wip
- commit
- a1d9a53ec2dc701265d5d76b2d637d9bcc27f0c4
- author
- bamen · · 2025-03-23 18:00 UTC
- parent
- dbdbc7c
- signature
- unsigned
101 files · +15924 -2963
this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.
M .goreleaser.yaml +2 -0
| @@ -3,6 +3,8 @@ version: 2 | |||||
| 3 | before: | 3 | before: | ||
| 4 | hooks: | 4 | hooks: | ||
| 5 | - go mod tidy | 5 | - go mod tidy | ||
| 6 | + | - templ generate | |||
| 7 | + | - bun run build:css | |||
| 6 | 8 | ||||
| 7 | release: | 9 | release: | ||
| 8 | draft: true | 10 | draft: true | ||
A .templ.config.json +4 -0
| @@ -0,0 +1,4 @@ | |||||
| 1 | + | { | |||
| 2 | + | "root": "internal/templating/models/templ", | |||
| 3 | + | "paths": ["internal/templating/models/templ"] | |||
| 4 | + | }⏎̸ | |||
A CLAUDE.md +28 -0
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | # Gordon Project Guidelines | |||
| 2 | + | ||||
| 3 | + | ## Build Commands | |||
| 4 | + | - Build project: `make build` | |||
| 5 | + | - Build CSS: `bun run build:css` or `make build-css` | |||
| 6 | + | - Watch CSS for changes: `bun run dev:css` | |||
| 7 | + | - Clean build artifacts: `make clean` | |||
| 8 | + | - Run rate limit test: `go run pkg/scripts/rate_limit_test/main.go --url URL` | |||
| 9 | + | ||||
| 10 | + | ## Code Style | |||
| 11 | + | ||||
| 12 | + | ### Go Guidelines | |||
| 13 | + | - Use tabs for indentation (4-space equivalent) | |||
| 14 | + | - Organize imports: standard lib first, then third-party packages | |||
| 15 | + | - Error handling: `if err != nil { return nil, fmt.Errorf("context: %w", err) }` | |||
| 16 | + | - Use structured logging with levels: `logger.Debug/Info/Warn/Error` | |||
| 17 | + | - Variable naming: camelCase for variables, PascalCase for exported items | |||
| 18 | + | - Functions should be focused and documented with comments | |||
| 19 | + | ||||
| 20 | + | ### JavaScript/CSS | |||
| 21 | + | - Use Prettier for formatting | |||
| 22 | + | - CSS is built with Tailwind | |||
| 23 | + | - Run Tailwind build with `bun run build:css` | |||
| 24 | + | ||||
| 25 | + | ## File Structure | |||
| 26 | + | - Package declaration first, followed by imports | |||
| 27 | + | - Type definitions before function implementations | |||
| 28 | + | - Methods for types should be grouped together⏎̸ | |||
M Dockerfile +17 -1
| @@ -1,8 +1,24 @@ | |||||
| 1 | FROM --platform=$TARGETPLATFORM alpine:latest | 1 | FROM --platform=$TARGETPLATFORM alpine:latest | ||
| 2 | 2 | ||||
| 3 | + | # Install diagnostic utilities | |||
| 4 | + | RUN apk add --no-cache \ | |||
| 5 | + | curl \ | |||
| 6 | + | wget \ | |||
| 7 | + | net-tools \ | |||
| 8 | + | bind-tools \ | |||
| 9 | + | busybox-extras | |||
| 10 | + | ||||
| 3 | COPY gordon /gordon | 11 | COPY gordon /gordon | ||
| 12 | + | COPY pkg/scripts/container_diagnostic/diagnostic.sh /diagnostic.sh | |||
| 13 | + | RUN chmod +x /diagnostic.sh | |||
| 4 | 14 | ||||
| 5 | RUN touch /.iscontainer | 15 | RUN touch /.iscontainer | ||
| 6 | 16 | ||||
| 17 | + | # Ensure we're running as root to bind to privileged ports | |||
| 18 | + | USER root | |||
| 19 | + | ||||
| 20 | + | # Expose the ports the server needs to listen on | |||
| 21 | + | EXPOSE 80 443 8080 | |||
| 22 | + | ||||
| 7 | ENTRYPOINT ["/gordon"] | 23 | ENTRYPOINT ["/gordon"] | ||
| 8 | - | CMD ["serve"] | 24 | + | CMD ["serve", "--use-fallback-binding", "--proxy-debug"] |
M Makefile +8 -2
| @@ -20,13 +20,13 @@ LDFLAGS := -s -w \ | |||||
| 20 | ARCHS := amd64 arm64 | 20 | ARCHS := amd64 arm64 | ||
| 21 | 21 | ||||
| 22 | # Phony targets | 22 | # Phony targets | ||
| 23 | - | .PHONY: all build build-push clean build-css | 23 | + | .PHONY: all build build-push clean build-css build-templ |
| 24 | 24 | ||||
| 25 | # Default target | 25 | # Default target | ||
| 26 | all: build | 26 | all: build | ||
| 27 | 27 | ||||
| 28 | # Build binaries | 28 | # Build binaries | ||
| 29 | - | build: build-css | 29 | + | build: build-css build-templ |
| 30 | @echo "Building Go binaries..." | 30 | @echo "Building Go binaries..." | ||
| 31 | @mkdir -p $(DIST_DIR) | 31 | @mkdir -p $(DIST_DIR) | ||
| 32 | @rm -f $(DIST_DIR)/* | 32 | @rm -f $(DIST_DIR)/* | ||
| @@ -41,6 +41,12 @@ build-css: | |||||
| 41 | @bun run build:css | 41 | @bun run build:css | ||
| 42 | @echo "CSS built successfully" | 42 | @echo "CSS built successfully" | ||
| 43 | 43 | ||||
| 44 | + | # Generate Go code from templ templates | |||
| 45 | + | build-templ: | |||
| 46 | + | @echo "Generating Go code from templ templates..." | |||
| 47 | + | @templ generate | |||
| 48 | + | @echo "templ generation completed successfully" | |||
| 49 | + | ||||
| 44 | # Build and push Docker images | 50 | # Build and push Docker images | ||
| 45 | build-push: build-css build | 51 | build-push: build-css build | ||
| 46 | @echo "Cleaning up dangling images..." | 52 | @echo "Cleaning up dangling images..." | ||
A TEMPL_MIGRATION.md +58 -0
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | # Gordon Templ Migration | |||
| 2 | + | ||||
| 3 | + | This project is being migrated from using Go's html/template to [templ](https://github.com/a-h/templ) for improved type safety, maintainability, and IDE support. | |||
| 4 | + | ||||
| 5 | + | ## Migration Status | |||
| 6 | + | ||||
| 7 | + | The following templates have been migrated: | |||
| 8 | + | ||||
| 9 | + | - [x] Login page (index.gohtml → login/index.templ) | |||
| 10 | + | - [x] Header component (header.gohtml → components/header.templ) | |||
| 11 | + | - [x] Footer component (footer.gohtml → components/footer.templ) | |||
| 12 | + | - [x] Menu component (menu.gohtml → components/menu.templ) | |||
| 13 | + | - [x] Container list component (containerlist.gohtml → components/containerlist.templ) | |||
| 14 | + | - [x] Success component (success.gohtml → components/success.templ) | |||
| 15 | + | - [x] Admin dashboard (dashboard.gohtml → pages/admin/dashboard.templ) | |||
| 16 | + | - [x] Admin manager (manager.gohtml → pages/admin/manager.templ) | |||
| 17 | + | - [x] Image list component (imagelist.gohtml → components/imagelist.templ) | |||
| 18 | + | - [x] Upload image form (uploadimage.gohtml → components/uploadimage.templ) | |||
| 19 | + | - [x] Create container form (createcontainer.gohtml → components/createcontainer.templ) | |||
| 20 | + | - [x] Install page (install.gohtml → pages/admin/install.templ) | |||
| 21 | + | - [x] Edit container form (editcontainer.gohtml → components/editcontainer.templ) | |||
| 22 | + | - [x] Full container creation (createcontainerfull.gohtml → pages/admin/createcontainerfull.templ) | |||
| 23 | + | - [x] Index page (index.gohtml → pages/admin/index.templ) | |||
| 24 | + | - [x] User page (user.gohtml → pages/admin/user.templ) | |||
| 25 | + | ||||
| 26 | + | All templates have been successfully migrated! 🎉 | |||
| 27 | + | ||||
| 28 | + | ## Build Process | |||
| 29 | + | ||||
| 30 | + | The build process has been updated to include templ generation: | |||
| 31 | + | ||||
| 32 | + | 1. Makefile includes a `build-templ` target that runs `templ generate` | |||
| 33 | + | 2. GoReleaser configuration includes `templ generate` in the before hooks | |||
| 34 | + | ||||
| 35 | + | ## Directory Structure | |||
| 36 | + | ||||
| 37 | + | - `internal/templating/models/templ/` - Root directory for templ templates | |||
| 38 | + | - `components/` - Reusable UI components | |||
| 39 | + | - `layouts/` - Page layouts like AdminLayout | |||
| 40 | + | - `pages/` - Full page templates | |||
| 41 | + | - `admin/` - Admin pages | |||
| 42 | + | - `login/` - Login pages | |||
| 43 | + | ||||
| 44 | + | ## Theme Configuration | |||
| 45 | + | ||||
| 46 | + | The default theme has been set to `gordon-dark`, which is defined in `internal/webui/public/assets/css/custom.css`. | |||
| 47 | + | ||||
| 48 | + | ## Handlers | |||
| 49 | + | ||||
| 50 | + | New handlers have been created in `internal/httpserve/handlers/templ_handlers.go` that use the templ renderer to render the migrated templates. | |||
| 51 | + | ||||
| 52 | + | ## Further Work | |||
| 53 | + | ||||
| 54 | + | 1. ✅ Complete the migration of all templates | |||
| 55 | + | 2. Update all handlers to use the new templ templates | |||
| 56 | + | 3. Add error handling and loading states to components | |||
| 57 | + | 4. Create more reusable components for forms, buttons, cards, etc. | |||
| 58 | + | 5. Consider integrating with Go's generics for more type safety⏎̸ | |||
M docker-compose.yml.example +6 -1
| @@ -44,9 +44,11 @@ services: | |||||
| 44 | # Reverse Proxy Configuration | 44 | # Reverse Proxy Configuration | ||
| 45 | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | 45 | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | ||
| 46 | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | 46 | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | ||
| 47 | + | - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container) | |||
| 47 | - GORDON_PROXY_CERT_DIR=/certs | 48 | - GORDON_PROXY_CERT_DIR=/certs | ||
| 48 | - GORDON_PROXY_EMAIL=admin@example.com | 49 | - GORDON_PROXY_EMAIL=admin@example.com | ||
| 49 | - | - GORDON_PROXY_ENABLE_LOGS=true # Enable HTTP request logging (set to false to disable) | 50 | + | - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable) |
| 51 | + | - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable) | |||
| 50 | 52 | ||||
| 51 | # Session Secret for security | 53 | # Session Secret for security | ||
| 52 | - SESSION_SECRET=replace_with_secure_random_string | 54 | - SESSION_SECRET=replace_with_secure_random_string | ||
| @@ -97,8 +99,11 @@ networks: | |||||
| 97 | # # Reverse Proxy Configuration | 99 | # # Reverse Proxy Configuration | ||
| 98 | # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | 100 | # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | ||
| 99 | # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | 101 | # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | ||
| 102 | + | # - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container) | |||
| 100 | # - GORDON_PROXY_CERT_DIR=/certs | 103 | # - GORDON_PROXY_CERT_DIR=/certs | ||
| 101 | # - GORDON_PROXY_EMAIL=admin@example.com | 104 | # - GORDON_PROXY_EMAIL=admin@example.com | ||
| 105 | + | # - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable) | |||
| 106 | + | # - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable) | |||
| 102 | # | 107 | # | ||
| 103 | # # Session Secret for security | 108 | # # Session Secret for security | ||
| 104 | # - SESSION_SECRET=replace_with_secure_random_string | 109 | # - SESSION_SECRET=replace_with_secure_random_string | ||
M go.mod +9 -0
| @@ -6,6 +6,7 @@ require ( | |||||
| 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | ||
| 7 | github.com/Masterminds/semver/v3 v3.3.1 | 7 | github.com/Masterminds/semver/v3 v3.3.1 | ||
| 8 | github.com/PuerkitoBio/goquery v1.10.2 | 8 | github.com/PuerkitoBio/goquery v1.10.2 | ||
| 9 | + | github.com/a-h/templ v0.3.833 | |||
| 9 | github.com/charmbracelet/bubbles v0.20.0 | 10 | github.com/charmbracelet/bubbles v0.20.0 | ||
| 10 | github.com/charmbracelet/lipgloss v1.0.0 | 11 | github.com/charmbracelet/lipgloss v1.0.0 | ||
| 11 | github.com/charmbracelet/log v0.4.0 | 12 | github.com/charmbracelet/log v0.4.0 | ||
| @@ -24,12 +25,20 @@ require ( | |||||
| 24 | ) | 25 | ) | ||
| 25 | 26 | ||||
| 26 | require ( | 27 | require ( | ||
| 28 | + | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect | |||
| 29 | + | github.com/andybalholm/brotli v1.1.0 // indirect | |||
| 30 | + | github.com/cenkalti/backoff/v4 v4.3.0 // indirect | |||
| 27 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 31 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 32 | + | github.com/cli/browser v1.3.0 // indirect | |||
| 33 | + | github.com/fsnotify/fsnotify v1.7.0 // indirect | |||
| 28 | github.com/klauspost/compress v1.18.0 // indirect | 34 | github.com/klauspost/compress v1.18.0 // indirect | ||
| 29 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect | 35 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect | ||
| 36 | + | github.com/natefinch/atomic v1.0.1 // indirect | |||
| 30 | github.com/zeebo/xxh3 v1.0.2 // indirect | 37 | github.com/zeebo/xxh3 v1.0.2 // indirect | ||
| 31 | go.mongodb.org/mongo-driver v1.17.3 // indirect | 38 | go.mongodb.org/mongo-driver v1.17.3 // indirect | ||
| 32 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect | 39 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect | ||
| 40 | + | golang.org/x/mod v0.24.0 // indirect | |||
| 41 | + | golang.org/x/tools v0.31.0 // indirect | |||
| 33 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect | 42 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect | ||
| 34 | ) | 43 | ) | ||
| 35 | 44 | ||||
M go.sum +14 -2 generated file: collapsed
M internal/cli/cmd/serve.go +68 -3
| @@ -4,6 +4,7 @@ package cmd | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "os" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/gordon/internal/cli/handler" | 9 | "github.com/bnema/gordon/internal/cli/handler" | ||
| 9 | "github.com/bnema/gordon/internal/common" | 10 | "github.com/bnema/gordon/internal/common" | ||
| @@ -17,6 +18,13 @@ import ( | |||||
| 17 | func NewServeCommand(a *server.App) *cobra.Command { | 18 | func NewServeCommand(a *server.App) *cobra.Command { | ||
| 18 | defaultport := "1323" | 19 | defaultport := "1323" | ||
| 19 | var port string | 20 | var port string | ||
| 21 | + | var proxyEnabled bool | |||
| 22 | + | var proxyPort string | |||
| 23 | + | var proxyHttpPort string | |||
| 24 | + | var skipCertificates bool | |||
| 25 | + | var proxyDebug bool | |||
| 26 | + | var useFallbackBinding bool | |||
| 27 | + | var detectUpstreamProxy bool | |||
| 20 | 28 | ||||
| 21 | // if the server is running in a docker container, the default port is 8080 | 29 | // if the server is running in a docker container, the default port is 8080 | ||
| 22 | // (changed from 80 to avoid conflict with the reverse proxy) | 30 | // (changed from 80 to avoid conflict with the reverse proxy) | ||
| @@ -36,9 +44,56 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||||
| 36 | fmt.Println("Continuing with Docker/Podman functionality disabled") | 44 | fmt.Println("Continuing with Docker/Podman functionality disabled") | ||
| 37 | } | 45 | } | ||
| 38 | 46 | ||||
| 39 | - | // handler.StartServer will use the value of port, which will be the default | 47 | + | // Only use the port from flag if it was explicitly set by the user |
| 40 | - | // value if no flag is specified | 48 | + | // otherwise keep the one from configuration (which has env vars applied) |
| 41 | - | a.Config.Http.Port = port | 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 | + | ||||
| 42 | err := handler.StartServer(a, port) | 97 | err := handler.StartServer(a, port) | ||
| 43 | if err != nil { | 98 | if err != nil { | ||
| 44 | logger.Error("Server error", "error", err) | 99 | logger.Error("Server error", "error", err) | ||
| @@ -53,5 +108,15 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||||
| 53 | 108 | ||||
| 54 | // Attach the flag to serveCmd and store its value in the variable port | 109 | // Attach the flag to serveCmd and store its value in the variable port | ||
| 55 | serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on") | 110 | serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on") | ||
| 111 | + | ||||
| 112 | + | // Add proxy-related flags | |||
| 113 | + | serveCmd.Flags().BoolVar(&proxyEnabled, "proxy-enabled", true, "enable or disable the reverse proxy") | |||
| 114 | + | serveCmd.Flags().StringVar(&proxyPort, "proxy-port", "443", "port for the reverse proxy HTTPS server") | |||
| 115 | + | serveCmd.Flags().StringVar(&proxyHttpPort, "proxy-http-port", "80", "port for the reverse proxy HTTP server") | |||
| 116 | + | serveCmd.Flags().BoolVar(&skipCertificates, "skip-certificates", false, "skip Let's Encrypt certificate acquisition") | |||
| 117 | + | serveCmd.Flags().BoolVar(&proxyDebug, "proxy-debug", false, "enable detailed debug logging for the proxy") | |||
| 118 | + | serveCmd.Flags().BoolVar(&useFallbackBinding, "use-fallback-binding", false, "use fallback binding method for HTTPS ports") | |||
| 119 | + | serveCmd.Flags().BoolVar(&detectUpstreamProxy, "detect-upstream-proxy", false, "detect and handle upstream TLS termination proxies") | |||
| 120 | + | ||||
| 56 | return serveCmd | 121 | return serveCmd | ||
| 57 | } | 122 | } | ||
M internal/cli/handler/start_server.go +55 -15
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/httpserve" | 12 | "github.com/bnema/gordon/internal/httpserve" | ||
| 13 | + | "github.com/bnema/gordon/internal/proxy" | |||
| 13 | "github.com/bnema/gordon/internal/server" | 14 | "github.com/bnema/gordon/internal/server" | ||
| 14 | "github.com/bnema/gordon/pkg/logger" | 15 | "github.com/bnema/gordon/pkg/logger" | ||
| 15 | "github.com/labstack/echo/v4" | 16 | "github.com/labstack/echo/v4" | ||
| @@ -19,6 +20,8 @@ import ( | |||||
| 19 | var ( | 20 | var ( | ||
| 20 | globalServer *server.App | 21 | globalServer *server.App | ||
| 21 | globalServerLock sync.Mutex | 22 | globalServerLock sync.Mutex | ||
| 23 | + | // Add a lock for server initialization | |||
| 24 | + | serverInitLock sync.Mutex | |||
| 22 | ) | 25 | ) | ||
| 23 | 26 | ||||
| 24 | // SetGlobalServer sets the global server instance for shutdown handling | 27 | // SetGlobalServer sets the global server instance for shutdown handling | ||
| @@ -38,6 +41,10 @@ func GetGlobalServer() *server.App { | |||||
| 38 | // execute cmd/srv/main.go main function | 41 | // execute cmd/srv/main.go main function | ||
| 39 | 42 | ||||
| 40 | func StartServer(a *server.App, port string) error { | 43 | func StartServer(a *server.App, port string) error { | ||
| 44 | + | // Use lock to ensure only one server initialization happens at a time | |||
| 45 | + | serverInitLock.Lock() | |||
| 46 | + | defer serverInitLock.Unlock() | |||
| 47 | + | ||||
| 41 | // Set the global server instance for shutdown handling | 48 | // Set the global server instance for shutdown handling | ||
| 42 | SetGlobalServer(a) | 49 | SetGlobalServer(a) | ||
| 43 | 50 | ||||
| @@ -54,27 +61,38 @@ func StartServer(a *server.App, port string) error { | |||||
| 54 | logger.Error("Token initialization error", "error", err) | 61 | logger.Error("Token initialization error", "error", err) | ||
| 55 | } | 62 | } | ||
| 56 | 63 | ||||
| 57 | - | // Initialize and start the reverse proxy using the already loaded configuration | 64 | + | // Create a channel to signal when the main server is ready |
| 58 | - | p, err := httpserve.InitializeProxy(a) | 65 | + | serverReady := make(chan struct{}) |
| 59 | - | if err != nil { | |||
| 60 | - | logger.Error("Failed to initialize reverse proxy", "error", err) | |||
| 61 | - | // Continue even if proxy fails, as the main server should still work | |||
| 62 | - | } | |||
| 63 | 66 | ||||
| 67 | + | logger.Info("Initializing main HTTP server...") | |||
| 64 | e := echo.New() | 68 | e := echo.New() | ||
| 65 | e.Server.ReadTimeout = 10 * time.Minute | 69 | e.Server.ReadTimeout = 10 * time.Minute | ||
| 66 | e.Server.WriteTimeout = 10 * time.Minute | 70 | e.Server.WriteTimeout = 10 * time.Minute | ||
| 67 | e.HideBanner = true | 71 | e.HideBanner = true | ||
| 68 | e.HidePort = true | 72 | e.HidePort = true | ||
| 73 | + | ||||
| 74 | + | logger.Debug("About to register routes to main HTTP server") | |||
| 69 | e = httpserve.RegisterRoutes(e, a) | 75 | e = httpserve.RegisterRoutes(e, a) | ||
| 76 | + | logger.Info("Routes registered to main HTTP server") | |||
| 70 | 77 | ||||
| 71 | // Setup a channel to capture termination signals | 78 | // Setup a channel to capture termination signals | ||
| 72 | sigs := make(chan os.Signal, 1) | 79 | sigs := make(chan os.Signal, 1) | ||
| 73 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | 80 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | ||
| 74 | 81 | ||||
| 75 | - | // Start server in a goroutine | 82 | + | // Start the server in a goroutine |
| 76 | go func() { | 83 | go func() { | ||
| 77 | - | logger.Info("Starting server", "port", port) | 84 | + | logger.Info("Starting main server on port", "port", a.Config.Http.Port) |
| 85 | + | ||||
| 86 | + | // Create a separate goroutine that will signal after a short delay | |||
| 87 | + | // This ensures the server has time to initialize and bind to the port | |||
| 88 | + | go func() { | |||
| 89 | + | // Wait for server to start binding | |||
| 90 | + | time.Sleep(1 * time.Second) | |||
| 91 | + | logger.Debug("Signaling that main server is ready") | |||
| 92 | + | close(serverReady) | |||
| 93 | + | }() | |||
| 94 | + | ||||
| 95 | + | // Attempt to start the server | |||
| 78 | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | 96 | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | ||
| 79 | if err.Error() != "http: Server closed" { | 97 | if err.Error() != "http: Server closed" { | ||
| 80 | logger.Error("Server error", "error", err) | 98 | logger.Error("Server error", "error", err) | ||
| @@ -82,13 +100,35 @@ func StartServer(a *server.App, port string) error { | |||||
| 82 | } | 100 | } | ||
| 83 | }() | 101 | }() | ||
| 84 | 102 | ||||
| 85 | - | // Test admin connections after the server has started (in a separate goroutine) | 103 | + | // Log information about accessing the admin WebUI |
| 86 | - | if p != nil { | 104 | + | adminPath := a.Config.Admin.Path |
| 87 | - | go func() { | 105 | + | if adminPath == "" { |
| 88 | - | // Add a delay to ensure the server has fully started | 106 | + | adminPath = "/admin" // Default if not set |
| 89 | - | time.Sleep(2 * time.Second) | 107 | + | } |
| 90 | - | p.TestAdminConnectionLater() | 108 | + | |
| 91 | - | }() | 109 | + | // Log access information |
| 110 | + | protocol := "https" | |||
| 111 | + | if !a.Config.Http.Https { | |||
| 112 | + | protocol = "http" | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | fullDomain := a.Config.Http.FullDomain() | |||
| 116 | + | logger.Info("Access the Admin WebUI at", "url", fmt.Sprintf("%s://%s%s", protocol, fullDomain, adminPath)) | |||
| 117 | + | ||||
| 118 | + | // Wait for the main server to be ready before initializing the proxy | |||
| 119 | + | <-serverReady | |||
| 120 | + | ||||
| 121 | + | // Initialize and start the reverse proxy after the main server is ready | |||
| 122 | + | var p *proxy.Proxy | |||
| 123 | + | logger.Info("Starting reverse proxy initialization...") | |||
| 124 | + | p, err = httpserve.InitializeProxy(a) | |||
| 125 | + | if err != nil { | |||
| 126 | + | logger.Error("Failed to initialize reverse proxy", "error", err, "solution", "Check logs for specific HTTPS binding errors and container capabilities") | |||
| 127 | + | // Alert the user that this is a critical issue | |||
| 128 | + | fmt.Println("[gordon] | ⚠️ WARNING: Reverse proxy failed to initialize. HTTPS server will not be available!") | |||
| 129 | + | fmt.Println("[gordon] | ⚠️ Check logs for details and verify container has correct permissions.") | |||
| 130 | + | } else { | |||
| 131 | + | logger.Info("Reverse proxy initialization complete") | |||
| 92 | } | 132 | } | ||
| 93 | 133 | ||||
| 94 | // Wait for interrupt signal | 134 | // Wait for interrupt signal | ||
M internal/common/init_config.go +170 -61
| @@ -47,14 +47,15 @@ type BuildConfig struct { | |||||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | type AdminConfig struct { | 49 | type AdminConfig struct { | ||
| 50 | - | Path string `yaml:"path"` | 50 | + | Path string `yaml:"path"` |
| 51 | + | AdminWebUI bool `yaml:"-"` | |||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | type GeneralConfig struct { | 54 | type GeneralConfig struct { | ||
| 54 | StorageDir string `yaml:"storageDir"` | 55 | StorageDir string `yaml:"storageDir"` | ||
| 55 | Token string `yaml:"token"` | 56 | Token string `yaml:"token"` | ||
| 56 | LogLevel string `yaml:"logLevel"` | 57 | LogLevel string `yaml:"logLevel"` | ||
| 57 | - | GordonContainerID string `yaml:"gordonContainerID"` | 58 | + | GordonContainerID string `yaml:"-"` |
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 60 | type HttpConfig struct { | 61 | type HttpConfig struct { | ||
| @@ -73,33 +74,39 @@ type ContainerEngineConfig struct { | |||||
| 73 | } | 74 | } | ||
| 74 | 75 | ||||
| 75 | type ReverseProxyConfig struct { | 76 | type ReverseProxyConfig struct { | ||
| 76 | - | Port string `yaml:"port"` // Port for the reverse proxy to listen on | 77 | + | Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled |
| 77 | - | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | 78 | + | Port string `yaml:"port"` // Port for the reverse proxy to listen on |
| 78 | - | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | 79 | + | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS |
| 79 | - | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | 80 | + | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates |
| 80 | - | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | 81 | + | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates |
| 81 | - | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | 82 | + | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates |
| 82 | - | Email string `yaml:"email"` // Email for Let's Encrypt | 83 | + | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" |
| 83 | - | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | 84 | + | Email string `yaml:"email"` // Email for Let's Encrypt |
| 84 | - | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | 85 | + | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache |
| 85 | - | EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true) | 86 | + | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds |
| 86 | - | DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false) | 87 | + | EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging (default: true) |
| 88 | + | EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware (default: false) | |||
| 89 | + | DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies (default: false) | |||
| 90 | + | SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy (default: false) | |||
| 87 | } | 91 | } | ||
| 88 | 92 | ||||
| 89 | // Default values | 93 | // Default values | ||
| 90 | var ( | 94 | var ( | ||
| 91 | - | sock = "/var/run/docker.sock" | 95 | + | sock = "/var/run/docker.sock" |
| 92 | - | podmansock = "/run/podman/podman.sock" | 96 | + | podmansock = "/run/podman/podman.sock" |
| 93 | - | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | 97 | + | reverseProxyPort = "443" // Changed to 443 for standard HTTPS |
| 94 | - | httpPort = "80" // Standard HTTP port | 98 | + | httpPort = "80" // Standard HTTP port |
| 95 | - | certDir = "/certs" | 99 | + | certDir = "/certs" |
| 96 | - | letsEncryptMode = "staging" | 100 | + | letsEncryptMode = "staging" |
| 97 | - | autoRenew = true | 101 | + | autoRenew = true |
| 98 | - | renewBefore = 30 // days | 102 | + | proxyEnabled = true // Default to enabled for backward compatibility |
| 99 | - | cacheSize = 1000 // entries | 103 | + | renewBefore = 30 // days |
| 100 | - | gracePeriod = 30 // seconds | 104 | + | cacheSize = 1000 // entries |
| 101 | - | defaultLogLevel = "info" // Default log level | 105 | + | gracePeriod = 30 // seconds |
| 102 | - | disableRateLimit = false | 106 | + | defaultLogLevel = "info" // Default log level |
| 107 | + | disableRateLimit = false | |||
| 108 | + | detectUpstreamProxy = false // Default to disabled | |||
| 109 | + | skipCertificates = false // Default to disabled | |||
| 103 | ) | 110 | ) | ||
| 104 | 111 | ||||
| 105 | // applyDefaultsToConfig applies default values to any fields that have zero values | 112 | // applyDefaultsToConfig applies default values to any fields that have zero values | ||
| @@ -115,6 +122,12 @@ func applyDefaultsToConfig(config *Config) bool { | |||||
| 115 | } | 122 | } | ||
| 116 | 123 | ||||
| 117 | // Apply defaults to ReverseProxy config | 124 | // Apply defaults to ReverseProxy config | ||
| 125 | + | // Set Enabled to true by default if not specified | |||
| 126 | + | if !config.ReverseProxy.Enabled { | |||
| 127 | + | config.ReverseProxy.Enabled = proxyEnabled | |||
| 128 | + | logger.Debug("Applied default value for ReverseProxy.Enabled", "value", proxyEnabled) | |||
| 129 | + | defaultsApplied = true | |||
| 130 | + | } | |||
| 118 | if config.ReverseProxy.Port == "" { | 131 | if config.ReverseProxy.Port == "" { | ||
| 119 | config.ReverseProxy.Port = reverseProxyPort | 132 | config.ReverseProxy.Port = reverseProxyPort | ||
| 120 | logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort) | 133 | logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort) | ||
| @@ -150,16 +163,30 @@ func applyDefaultsToConfig(config *Config) bool { | |||||
| 150 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | 163 | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | ||
| 151 | defaultsApplied = true | 164 | defaultsApplied = true | ||
| 152 | } | 165 | } | ||
| 153 | - | // Set EnableLogs to true by default if not specified | 166 | + | // Set EnableHttpLogs to true by default if not specified |
| 154 | - | if !config.ReverseProxy.EnableLogs { | 167 | + | if !config.ReverseProxy.EnableHttpLogs { |
| 155 | - | config.ReverseProxy.EnableLogs = true | 168 | + | config.ReverseProxy.EnableHttpLogs = true |
| 156 | - | logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true) | 169 | + | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", true) |
| 170 | + | defaultsApplied = true | |||
| 171 | + | } | |||
| 172 | + | // Set EnableRateLimit to false by default if not specified | |||
| 173 | + | if !config.ReverseProxy.EnableRateLimit { | |||
| 174 | + | config.ReverseProxy.EnableRateLimit = false | |||
| 175 | + | logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", false) | |||
| 176 | + | defaultsApplied = true | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // Set DetectUpstreamProxy to false by default if not specified | |||
| 180 | + | if !config.ReverseProxy.DetectUpstreamProxy { | |||
| 181 | + | config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy | |||
| 182 | + | logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy) | |||
| 157 | defaultsApplied = true | 183 | defaultsApplied = true | ||
| 158 | } | 184 | } | ||
| 159 | - | // Set DisableRateLimit to false by default if not specified | 185 | + | |
| 160 | - | if !config.ReverseProxy.DisableRateLimit { | 186 | + | // Set SkipCertificates to false by default if not specified |
| 161 | - | config.ReverseProxy.DisableRateLimit = false | 187 | + | if !config.ReverseProxy.SkipCertificates { |
| 162 | - | logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false) | 188 | + | config.ReverseProxy.SkipCertificates = skipCertificates |
| 189 | + | logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates) | |||
| 163 | defaultsApplied = true | 190 | defaultsApplied = true | ||
| 164 | } | 191 | } | ||
| 165 | 192 | ||||
| @@ -476,6 +503,25 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 476 | logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val) | 503 | logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val) | ||
| 477 | } | 504 | } | ||
| 478 | } | 505 | } | ||
| 506 | + | ||||
| 507 | + | // New environment variables for reverse proxy port control | |||
| 508 | + | if val := os.Getenv("GORDON_USE_FALLBACK_BINDING"); val != "" { | |||
| 509 | + | // This is handled at runtime in the proxy server but we log it here | |||
| 510 | + | if printLogs { | |||
| 511 | + | logger.Info("Found environment variable GORDON_USE_FALLBACK_BINDING", "value", val) | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | // Handle GORDON_PROXY_ENABLED environment variable | |||
| 516 | + | if val := os.Getenv("GORDON_PROXY_ENABLED"); val != "" { | |||
| 517 | + | enabled, err := strconv.ParseBool(val) | |||
| 518 | + | if err == nil { | |||
| 519 | + | config.ReverseProxy.Enabled = enabled | |||
| 520 | + | } | |||
| 521 | + | if printLogs { | |||
| 522 | + | logger.Info("Using environment variable GORDON_PROXY_ENABLED", "value", config.ReverseProxy.Enabled) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 479 | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | 525 | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | ||
| 480 | config.ReverseProxy.CertDir = val | 526 | config.ReverseProxy.CertDir = val | ||
| 481 | if printLogs { | 527 | if printLogs { | ||
| @@ -524,22 +570,59 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 524 | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | 570 | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | ||
| 525 | } | 571 | } | ||
| 526 | 572 | ||||
| 527 | - | // Handle GORDON_PROXY_ENABLE_LOGS environment variable | 573 | + | // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable |
| 528 | - | if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" { | 574 | + | if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" { |
| 529 | enableLogs, err := strconv.ParseBool(val) | 575 | enableLogs, err := strconv.ParseBool(val) | ||
| 530 | if err == nil { | 576 | if err == nil { | ||
| 531 | - | config.ReverseProxy.EnableLogs = enableLogs | 577 | + | config.ReverseProxy.EnableHttpLogs = enableLogs |
| 532 | } | 578 | } | ||
| 533 | - | logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs) | 579 | + | logger.Info("Using environment variable GORDON_PROXY_ENABLE_HTTP_LOGS", "value", config.ReverseProxy.EnableHttpLogs) |
| 580 | + | } else if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" { | |||
| 581 | + | // For backward compatibility | |||
| 582 | + | enableLogs, err := strconv.ParseBool(val) | |||
| 583 | + | if err == nil { | |||
| 584 | + | config.ReverseProxy.EnableHttpLogs = enableLogs | |||
| 585 | + | } | |||
| 586 | + | logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS (deprecated, use GORDON_PROXY_ENABLE_HTTP_LOGS)", "value", config.ReverseProxy.EnableHttpLogs) | |||
| 534 | } | 587 | } | ||
| 535 | 588 | ||||
| 536 | - | // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable | 589 | + | // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable |
| 537 | - | if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" { | 590 | + | if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" { |
| 591 | + | enableRateLimit, err := strconv.ParseBool(val) | |||
| 592 | + | if err == nil { | |||
| 593 | + | config.ReverseProxy.EnableRateLimit = enableRateLimit | |||
| 594 | + | } | |||
| 595 | + | logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit) | |||
| 596 | + | } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" { | |||
| 597 | + | // For backward compatibility | |||
| 538 | disableRateLimit, err := strconv.ParseBool(val) | 598 | disableRateLimit, err := strconv.ParseBool(val) | ||
| 539 | if err == nil { | 599 | if err == nil { | ||
| 540 | - | config.ReverseProxy.DisableRateLimit = 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) | |||
| 541 | } | 625 | } | ||
| 542 | - | logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit) | |||
| 543 | } | 626 | } | ||
| 544 | } | 627 | } | ||
| 545 | 628 | ||||
| @@ -587,16 +670,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 587 | Network: "gordon", | 670 | Network: "gordon", | ||
| 588 | }, | 671 | }, | ||
| 589 | ReverseProxy: ReverseProxyConfig{ | 672 | ReverseProxy: ReverseProxyConfig{ | ||
| 590 | - | Port: reverseProxyPort, | 673 | + | Port: reverseProxyPort, |
| 591 | - | HttpPort: httpPort, | 674 | + | HttpPort: httpPort, |
| 592 | - | CertDir: certDir, | 675 | + | CertDir: certDir, |
| 593 | - | AutoRenew: autoRenew, | 676 | + | AutoRenew: autoRenew, |
| 594 | - | RenewBefore: renewBefore, | 677 | + | RenewBefore: renewBefore, |
| 595 | - | LetsEncryptMode: letsEncryptMode, | 678 | + | LetsEncryptMode: letsEncryptMode, |
| 596 | - | CacheSize: cacheSize, | 679 | + | CacheSize: cacheSize, |
| 597 | - | GracePeriod: gracePeriod, | 680 | + | GracePeriod: gracePeriod, |
| 598 | - | EnableLogs: true, | 681 | + | EnableHttpLogs: true, |
| 599 | - | DisableRateLimit: false, | 682 | + | EnableRateLimit: false, |
| 600 | }, | 683 | }, | ||
| 601 | } | 684 | } | ||
| 602 | 685 | ||||
| @@ -726,16 +809,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 726 | Network: "gordon", | 809 | Network: "gordon", | ||
| 727 | }, | 810 | }, | ||
| 728 | ReverseProxy: ReverseProxyConfig{ | 811 | ReverseProxy: ReverseProxyConfig{ | ||
| 729 | - | Port: reverseProxyPort, | 812 | + | Port: reverseProxyPort, |
| 730 | - | HttpPort: httpPort, | 813 | + | HttpPort: httpPort, |
| 731 | - | CertDir: certDir, | 814 | + | CertDir: certDir, |
| 732 | - | AutoRenew: autoRenew, | 815 | + | AutoRenew: autoRenew, |
| 733 | - | RenewBefore: renewBefore, | 816 | + | RenewBefore: renewBefore, |
| 734 | - | LetsEncryptMode: letsEncryptMode, | 817 | + | LetsEncryptMode: letsEncryptMode, |
| 735 | - | CacheSize: cacheSize, | 818 | + | CacheSize: cacheSize, |
| 736 | - | GracePeriod: gracePeriod, | 819 | + | GracePeriod: gracePeriod, |
| 737 | - | EnableLogs: true, | 820 | + | EnableHttpLogs: true, |
| 738 | - | DisableRateLimit: false, | 821 | + | EnableRateLimit: false, |
| 739 | }, | 822 | }, | ||
| 740 | } | 823 | } | ||
| 741 | 824 | ||||
| @@ -808,6 +891,27 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 808 | logger.Info("Loaded configuration from", "path", configFilePath) | 891 | logger.Info("Loaded configuration from", "path", configFilePath) | ||
| 809 | } | 892 | } | ||
| 810 | 893 | ||||
| 894 | + | // After loading from file or environment, check if Admin.Path is empty | |||
| 895 | + | // This should be placed before returning the config | |||
| 896 | + | if config.Admin.Path == "" { | |||
| 897 | + | logger.Warn("Admin path is empty or not set, admin dashboard webui will be disabled") | |||
| 898 | + | config.Admin.AdminWebUI = false | |||
| 899 | + | } else { | |||
| 900 | + | config.Admin.AdminWebUI = true | |||
| 901 | + | } | |||
| 902 | + | ||||
| 903 | + | // Save config file if running in container and env vars were applied | |||
| 904 | + | // This ensures config.yml always reflects the environment variables | |||
| 905 | + | if docker.IsRunningInContainer() && configLogsPrinted { | |||
| 906 | + | logger.Info("Environment variables were applied, saving configuration to reflect current values...") | |||
| 907 | + | err = config.SaveConfig() | |||
| 908 | + | if err != nil { | |||
| 909 | + | logger.Error("Failed to save configuration with environment variables", "error", err) | |||
| 910 | + | } else { | |||
| 911 | + | logger.Info("Successfully updated config.yml with environment variable values") | |||
| 912 | + | } | |||
| 913 | + | } | |||
| 914 | + | ||||
| 811 | // Apply the log level from configuration | 915 | // Apply the log level from configuration | ||
| 812 | if config.General.LogLevel != "" { | 916 | if config.General.LogLevel != "" { | ||
| 813 | // Set the log level using our centralized logger | 917 | // Set the log level using our centralized logger | ||
| @@ -930,3 +1034,8 @@ func (c *HttpConfig) FullDomain() string { | |||||
| 930 | func (c *Config) GetVersion() string { | 1034 | func (c *Config) GetVersion() string { | ||
| 931 | return c.Build.BuildVersion | 1035 | return c.Build.BuildVersion | ||
| 932 | } | 1036 | } | ||
| 1037 | + | ||||
| 1038 | + | // IsAdminWebUIEnabled returns whether the admin dashboard is enabled | |||
| 1039 | + | func (c *AdminConfig) IsAdminWebUIEnabled() bool { | |||
| 1040 | + | return c.AdminWebUI && c.Path != "" | |||
| 1041 | + | } | |||
M internal/common/init_dockerclient.go +155 -41
| @@ -19,62 +19,176 @@ func DockerInit(cc *ContainerEngineConfig) error { | |||||
| 19 | cc.PodmanSock = cc.Sock | 19 | cc.PodmanSock = cc.Sock | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | - | config := NewDockerConfig(cc) | 22 | + | // If socket is empty, try to detect it automatically |
| 23 | - | err := docker.InitializeClient(config) | 23 | + | if cc.Sock == "" { |
| 24 | - | if err != nil { | 24 | + | // Try standard paths first |
| 25 | - | // Check if it's a "socket doesn't exist" error | 25 | + | standardPaths := []string{ |
| 26 | - | if strings.Contains(err.Error(), "no such file or directory") { | 26 | + | "/var/run/docker.sock", // Standard Docker socket in containers |
| 27 | - | // Log the error | 27 | + | "/run/podman/podman.sock", // Standard Podman path |
| 28 | - | log.Printf("Error initializing Docker client: %s", err) | 28 | + | "/var/run/podman/podman.sock", // Alternative standard path |
| 29 | - | 29 | + | } | |
| 30 | - | // Try alternative socket paths | |||
| 31 | - | socketPaths := []string{ | |||
| 32 | - | "/run/podman/podman.sock", // Standard path | |||
| 33 | - | "/var/run/podman/podman.sock", // Alternative standard path | |||
| 34 | - | os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros | |||
| 35 | - | "/run/user/1000/podman/podman.sock", // User-specific socket | |||
| 36 | - | } | |||
| 37 | 30 | ||||
| 38 | - | // Add Docker socket path as a fallback | 31 | + | // Add home directory based path |
| 39 | - | if !isPodmanSocket { | 32 | + | homePath := os.Getenv("HOME") |
| 40 | - | socketPaths = append(socketPaths, "/var/run/docker.sock") | 33 | + | if homePath != "" { |
| 41 | - | } | 34 | + | standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock") |
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Check standard paths first | |||
| 38 | + | for _, path := range standardPaths { | |||
| 39 | + | if _, err := os.Stat(path); err == nil { | |||
| 40 | + | log.Printf("Auto-detected container engine socket at %s", path) | |||
| 42 | 41 | ||||
| 43 | - | for _, path := range socketPaths { | 42 | + | // Detect if this is a Podman socket |
| 44 | - | // Skip if it's the path we already tried | 43 | + | isPodmanSocket = detectPodmanSocket(path) |
| 45 | - | if path == cc.Sock { | 44 | + | if isPodmanSocket { |
| 46 | - | continue | 45 | + | cc.Podman = true |
| 46 | + | cc.PodmanSock = path | |||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | // Check if this socket exists | 49 | + | cc.Sock = path |
| 50 | - | if _, err := os.Stat(path); err == nil { | 50 | + | break |
| 51 | - | log.Printf("Found alternative socket at %s", path) | 51 | + | } else { |
| 52 | + | log.Printf("Socket not found at: %s", path) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 52 | 55 | ||||
| 53 | - | // Detect if this is a Podman socket | 56 | + | // If not found in standard paths, try common user IDs |
| 54 | - | isPodmanSocket = detectPodmanSocket(path) | 57 | + | if cc.Sock == "" { |
| 55 | - | if isPodmanSocket { | 58 | + | log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...") |
| 59 | + | ||||
| 60 | + | // Try common user IDs from 1000 to 1100 (typical range for first users on Linux) | |||
| 61 | + | for uid := 1000; uid <= 1100; uid++ { | |||
| 62 | + | uidStr := fmt.Sprintf("%d", uid) | |||
| 63 | + | podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr) | |||
| 64 | + | dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr) | |||
| 65 | + | ||||
| 66 | + | // Check the Podman socket exists | |||
| 67 | + | if _, err := os.Stat(podmanSocketPath); err == nil { | |||
| 68 | + | // Check if the socket is accessible for reading/writing | |||
| 69 | + | if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil { | |||
| 70 | + | file.Close() | |||
| 71 | + | log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath) | |||
| 56 | cc.Podman = true | 72 | 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) | |||
| 58 | } | 78 | } | ||
| 79 | + | } | |||
| 59 | 80 | ||||
| 60 | - | cc.Sock = path | 81 | + | // Check Docker socket for this UID |
| 61 | - | 82 | + | if _, err := os.Stat(dockerSocketPath); err == nil { | |
| 62 | - | // Try again with the new path | 83 | + | if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil { |
| 63 | - | newConfig := NewDockerConfig(cc) | 84 | + | file.Close() |
| 64 | - | if err := docker.InitializeClient(newConfig); err == nil { | 85 | + | log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath) |
| 65 | - | return nil | 86 | + | cc.Sock = dockerSocketPath |
| 87 | + | break | |||
| 66 | } | 88 | } | ||
| 67 | } | 89 | } | ||
| 68 | } | 90 | } | ||
| 91 | + | } | |||
| 92 | + | } | |||
| 69 | 93 | ||||
| 70 | - | // Return without an error, as we want to continue even if Docker is not available | 94 | + | // If we found a socket, try to initialize Docker client |
| 71 | - | log.Printf("Docker/Podman functionality will be limited or unavailable") | 95 | + | if cc.Sock != "" { |
| 72 | - | return fmt.Errorf("could not connect to container engine: %w", err) | 96 | + | config := NewDockerConfig(cc) |
| 97 | + | err := docker.InitializeClient(config) | |||
| 98 | + | if err == nil { | |||
| 99 | + | log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock) | |||
| 100 | + | return nil | |||
| 73 | } | 101 | } | ||
| 74 | 102 | ||||
| 75 | - | return 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 | + | } | |||
| 76 | } | 130 | } | ||
| 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") | |||
| 78 | } | 192 | } | ||
| 79 | 193 | ||||
| 80 | // detectPodmanSocket checks if a socket path is likely a Podman socket | 194 | // detectPodmanSocket checks if a socket path is likely a Podman socket | ||
M internal/httpserve/handlers/container_crud.go +35 -37
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/gordon/internal/server" | 8 | "github.com/bnema/gordon/internal/server" | ||
| 9 | "github.com/bnema/gordon/internal/templating/cmdparams" | 9 | "github.com/bnema/gordon/internal/templating/cmdparams" | ||
| 10 | + | templcomponents "github.com/bnema/gordon/internal/templating/models/templ/components" | |||
| 11 | + | templadmin "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |||
| 10 | "github.com/bnema/gordon/internal/templating/render" | 12 | "github.com/bnema/gordon/internal/templating/render" | ||
| 11 | "github.com/bnema/gordon/pkg/docker" | 13 | "github.com/bnema/gordon/pkg/docker" | ||
| 12 | "github.com/bnema/gordon/pkg/sanitize" | 14 | "github.com/bnema/gordon/pkg/sanitize" | ||
| @@ -34,7 +36,7 @@ func CreateContainerGET(c echo.Context, a *server.App) error { | |||||
| 34 | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | 36 | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | ||
| 35 | if !exists { | 37 | if !exists { | ||
| 36 | log.Debug("No mapping found for short ID: %s", shortID) | 38 | log.Debug("No mapping found for short ID: %s", shortID) | ||
| 37 | - | return c.String(http.StatusNotFound, "Image ID not found") | 39 | + | return RenderNotFoundPage(c, a) |
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 40 | log.Debug("Found mapping: %s -> %s", shortID, fullID) | 42 | log.Debug("Found mapping: %s -> %s", shortID, fullID) | ||
| @@ -49,30 +51,23 @@ func CreateContainerGET(c echo.Context, a *server.App) error { | |||||
| 49 | var imageName string | 51 | var imageName string | ||
| 50 | if len(imageInfo.RepoTags) > 0 { | 52 | if len(imageInfo.RepoTags) > 0 { | ||
| 51 | imageName = imageInfo.RepoTags[0] | 53 | imageName = imageInfo.RepoTags[0] | ||
| 54 | + | } else { | |||
| 55 | + | imageName = imageInfo.ID | |||
| 52 | } | 56 | } | ||
| 53 | 57 | ||||
| 54 | - | data := map[string]interface{}{ | 58 | + | // Setup form data |
| 55 | - | "Title": "Create a new container", | 59 | + | formData := templcomponents.ContainerFormData{ |
| 56 | - | "ShortID": shortID, | 60 | + | ImageName: imageName, |
| 57 | - | "ImageID": fullID, | 61 | + | ImageID: fullID, |
| 58 | - | "ImageName": imageName, | 62 | + | ShortID: shortID, |
| 63 | + | ErrorMessage: "", | |||
| 59 | } | 64 | } | ||
| 60 | 65 | ||||
| 61 | - | // Render the create container page | 66 | + | // Get the renderer |
| 62 | - | rendererData, err := render.GetHTMLRenderer("html/fragments", "createcontainer.gohtml", a.TemplateFS, a) | 67 | + | renderer := render.NewTemplRenderer(a) |
| 63 | - | ||||
| 64 | - | if err != nil { | |||
| 65 | - | return sendError(c, err) | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | renderedHTML, err := rendererData.Render(data, a) | |||
| 69 | - | ||||
| 70 | - | if err != nil { | |||
| 71 | - | return sendError(c, err) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | return c.HTML(200, renderedHTML) | |||
| 75 | 68 | ||||
| 69 | + | // Render the component | |||
| 70 | + | return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData)) | |||
| 76 | } | 71 | } | ||
| 77 | 72 | ||||
| 78 | // CreateContainerFullGET handles the full HTML page for creating a new container | 73 | // CreateContainerFullGET handles the full HTML page for creating a new container | ||
| @@ -84,7 +79,7 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error { | |||||
| 84 | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | 79 | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | ||
| 85 | if !exists { | 80 | if !exists { | ||
| 86 | log.Debug("No mapping found for short ID: %s", shortID) | 81 | log.Debug("No mapping found for short ID: %s", shortID) | ||
| 87 | - | return c.String(http.StatusNotFound, "Image ID not found") | 82 | + | return RenderNotFoundPage(c, a) |
| 88 | } | 83 | } | ||
| 89 | 84 | ||||
| 90 | log.Debug("Found mapping: %s -> %s", shortID, fullID) | 85 | log.Debug("Found mapping: %s -> %s", shortID, fullID) | ||
| @@ -99,29 +94,32 @@ func CreateContainerFullGET(c echo.Context, a *server.App) error { | |||||
| 99 | var imageName string | 94 | var imageName string | ||
| 100 | if len(imageInfo.RepoTags) > 0 { | 95 | if len(imageInfo.RepoTags) > 0 { | ||
| 101 | imageName = imageInfo.RepoTags[0] | 96 | imageName = imageInfo.RepoTags[0] | ||
| 97 | + | } else { | |||
| 98 | + | imageName = imageInfo.ID | |||
| 102 | } | 99 | } | ||
| 103 | 100 | ||||
| 104 | - | data := map[string]interface{}{ | 101 | + | // Setup form data |
| 105 | - | "Title": "Create a new container", | 102 | + | formData := templcomponents.ContainerFormData{ |
| 106 | - | "ShortID": shortID, | 103 | + | ImageName: imageName, |
| 107 | - | "ImageID": fullID, | 104 | + | ImageID: fullID, |
| 108 | - | "ImageName": imageName, | 105 | + | ShortID: shortID, |
| 109 | - | "AdminPath": a.Config.Admin.Path, | 106 | + | ErrorMessage: "", |
| 110 | } | 107 | } | ||
| 111 | 108 | ||||
| 112 | - | // Specify both admin and fragments directories | 109 | + | // Get the renderer |
| 113 | - | rendererData, err := render.GetHTMLRenderer("html/admin", "createcontainerfull.gohtml", a.TemplateFS, a, "html/fragments") | 110 | + | renderer := render.NewTemplRenderer(a) |
| 114 | - | if err != nil { | |||
| 115 | - | return sendError(c, err) | |||
| 116 | - | } | |||
| 117 | 111 | ||||
| 118 | - | renderedHTML, err := rendererData.Render(data, a) | 112 | + | // Render the createcontainerfull template |
| 119 | - | if err != nil { | 113 | + | pageData := templadmin.CreateContainerFullData{ |
| 120 | - | log.Debug("Error rendering template: %v", err) | 114 | + | Title: "Create Container | Gordon", |
| 121 | - | return sendError(c, err) | 115 | + | BuildVersion: a.Config.Build.BuildVersion, |
| 116 | + | AdminPath: a.Config.Admin.Path, | |||
| 117 | + | UserSettings: "/user", | |||
| 118 | + | LogoutURL: a.Config.Admin.Path + "/logout", | |||
| 119 | + | ContainerData: formData, | |||
| 122 | } | 120 | } | ||
| 123 | 121 | ||||
| 124 | - | return c.HTML(200, renderedHTML) | 122 | + | return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData)) |
| 125 | } | 123 | } | ||
| 126 | 124 | ||||
| 127 | // CreateContainerPOST handles the create container form submission | 125 | // CreateContainerPOST handles the create container form submission | ||
M internal/httpserve/handlers/container_management.go +58 -2
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/templating/render" | 12 | "github.com/bnema/gordon/internal/templating/render" | ||
| 13 | "github.com/bnema/gordon/pkg/docker" | 13 | "github.com/bnema/gordon/pkg/docker" | ||
| 14 | "github.com/bnema/gordon/pkg/humanize" | 14 | "github.com/bnema/gordon/pkg/humanize" | ||
| 15 | + | "github.com/bnema/gordon/pkg/logger" | |||
| 15 | "github.com/docker/docker/api/types" | 16 | "github.com/docker/docker/api/types" | ||
| 16 | "github.com/labstack/echo/v4" | 17 | "github.com/labstack/echo/v4" | ||
| 17 | ) | 18 | ) | ||
| @@ -58,6 +59,10 @@ type HumanReadableContainerImage struct { | |||||
| 58 | SizeStr string | 59 | SizeStr string | ||
| 59 | RepoDigests []string | 60 | RepoDigests []string | ||
| 60 | RepoTags []string | 61 | RepoTags []string | ||
| 62 | + | Repository string | |||
| 63 | + | Tag string | |||
| 64 | + | Size string | |||
| 65 | + | Created string | |||
| 61 | } | 66 | } | ||
| 62 | 67 | ||||
| 63 | type HumanReadableContainer struct { | 68 | type HumanReadableContainer struct { | ||
| @@ -85,17 +90,33 @@ type ContainerDisplay struct { | |||||
| 85 | 90 | ||||
| 86 | // renderHTML is a generalized function to render HTML | 91 | // renderHTML is a generalized function to render HTML | ||
| 87 | func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error { | 92 | func renderHTML(c echo.Context, a *server.App, path, templateName string, data map[string]interface{}) error { | ||
| 93 | + | logger.Debug("Rendering HTML", "path", path, "template", templateName, "data_keys", getKeysFromMap(data)) | |||
| 94 | + | ||||
| 88 | rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a) | 95 | rendererData, err := render.GetHTMLRenderer(path, templateName, a.TemplateFS, a) | ||
| 89 | if err != nil { | 96 | if err != nil { | ||
| 97 | + | logger.Error("Error getting HTML renderer", "error", err) | |||
| 90 | return err | 98 | return err | ||
| 91 | } | 99 | } | ||
| 100 | + | ||||
| 92 | renderedHTML, err := rendererData.Render(data, a) | 101 | renderedHTML, err := rendererData.Render(data, a) | ||
| 93 | if err != nil { | 102 | if err != nil { | ||
| 103 | + | logger.Error("Error rendering HTML", "error", err) | |||
| 94 | return err | 104 | return err | ||
| 95 | } | 105 | } | ||
| 106 | + | ||||
| 107 | + | logger.Debug("HTML rendered successfully", "content_length", len(renderedHTML)) | |||
| 96 | return c.HTML(http.StatusOK, renderedHTML) | 108 | return c.HTML(http.StatusOK, renderedHTML) | ||
| 97 | } | 109 | } | ||
| 98 | 110 | ||||
| 111 | + | // getKeysFromMap returns a slice of keys from a map | |||
| 112 | + | func getKeysFromMap(data map[string]interface{}) []string { | |||
| 113 | + | keys := make([]string, 0, len(data)) | |||
| 114 | + | for k := range data { | |||
| 115 | + | keys = append(keys, k) | |||
| 116 | + | } | |||
| 117 | + | return keys | |||
| 118 | + | } | |||
| 119 | + | ||||
| 99 | // ActionSuccess returns the success HTML fragment | 120 | // ActionSuccess returns the success HTML fragment | ||
| 100 | func ActionSuccess(a *server.App) string { | 121 | func ActionSuccess(a *server.App) string { | ||
| 101 | // Create a data structure to pass to the template | 122 | // Create a data structure to pass to the template | ||
| @@ -114,11 +135,19 @@ func ActionSuccess(a *server.App) string { | |||||
| 114 | 135 | ||||
| 115 | // ImageManagerComponent handles the /image-manager route (HTMX route) | 136 | // ImageManagerComponent handles the /image-manager route (HTMX route) | ||
| 116 | func ImageManagerComponent(c echo.Context, a *server.App) error { | 137 | func ImageManagerComponent(c echo.Context, a *server.App) error { | ||
| 138 | + | logger.Debug("ImageManagerComponent called", "url", c.Request().URL.String()) | |||
| 139 | + | ||||
| 117 | images, err := docker.ListContainerImages() | 140 | images, err := docker.ListContainerImages() | ||
| 118 | if err != nil { | 141 | if err != nil { | ||
| 142 | + | logger.Error("Error listing container images:", "error", err) | |||
| 119 | return sendError(c, err) | 143 | return sendError(c, err) | ||
| 120 | } | 144 | } | ||
| 121 | 145 | ||||
| 146 | + | logger.Debug("Images found", "count", len(images)) | |||
| 147 | + | for i, img := range images { | |||
| 148 | + | logger.Debug("Image details", "index", i, "id", img.ID, "repoTags", img.RepoTags) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 122 | var humanReadableImages []HumanReadableContainerImage | 151 | var humanReadableImages []HumanReadableContainerImage | ||
| 123 | 152 | ||||
| 124 | for _, img := range images { | 153 | for _, img := range images { | ||
| @@ -129,6 +158,14 @@ func ImageManagerComponent(c echo.Context, a *server.App) error { | |||||
| 129 | sizeStr := humanize.BytesToReadableSize(img.Size) | 158 | sizeStr := humanize.BytesToReadableSize(img.Size) | ||
| 130 | 159 | ||||
| 131 | for _, repoTag := range img.RepoTags { | 160 | for _, repoTag := range img.RepoTags { | ||
| 161 | + | // Split the repo tag into repository and tag | |||
| 162 | + | parts := strings.Split(repoTag, ":") | |||
| 163 | + | repository := parts[0] | |||
| 164 | + | tag := "latest" | |||
| 165 | + | if len(parts) > 1 { | |||
| 166 | + | tag = parts[1] | |||
| 167 | + | } | |||
| 168 | + | ||||
| 132 | humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{ | 169 | humanReadableImages = append(humanReadableImages, HumanReadableContainerImage{ | ||
| 133 | ID: img.ID, | 170 | ID: img.ID, | ||
| 134 | ShortID: shortID, | 171 | ShortID: shortID, | ||
| @@ -137,6 +174,11 @@ func ImageManagerComponent(c echo.Context, a *server.App) error { | |||||
| 137 | Name: repoTag, | 174 | Name: repoTag, | ||
| 138 | RepoDigests: img.RepoDigests, | 175 | RepoDigests: img.RepoDigests, | ||
| 139 | RepoTags: img.RepoTags, | 176 | RepoTags: img.RepoTags, | ||
| 177 | + | // Add fields that match template expectations | |||
| 178 | + | Repository: repository, | |||
| 179 | + | Tag: tag, | |||
| 180 | + | Size: sizeStr, | |||
| 181 | + | Created: createdStr, | |||
| 140 | }) | 182 | }) | ||
| 141 | } | 183 | } | ||
| 142 | } | 184 | } | ||
| @@ -145,6 +187,7 @@ func ImageManagerComponent(c echo.Context, a *server.App) error { | |||||
| 145 | "Images": humanReadableImages, | 187 | "Images": humanReadableImages, | ||
| 146 | } | 188 | } | ||
| 147 | 189 | ||||
| 190 | + | logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images") | |||
| 148 | return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data) | 191 | return renderHTML(c, a, "html/fragments", "imagelist.gohtml", data) | ||
| 149 | } | 192 | } | ||
| 150 | 193 | ||||
| @@ -170,11 +213,19 @@ func ImageManagerDelete(c echo.Context, a *server.App) error { | |||||
| 170 | 213 | ||||
| 171 | // ContainerManagerComponent handles the /container-manager route | 214 | // ContainerManagerComponent handles the /container-manager route | ||
| 172 | func ContainerManagerComponent(c echo.Context, a *server.App) error { | 215 | func ContainerManagerComponent(c echo.Context, a *server.App) error { | ||
| 216 | + | logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String()) | |||
| 217 | + | ||||
| 173 | containers, err := docker.ListRunningContainers() | 218 | containers, err := docker.ListRunningContainers() | ||
| 174 | if err != nil { | 219 | if err != nil { | ||
| 220 | + | logger.Error("Error listing containers", "error", err) | |||
| 175 | return sendError(c, err) | 221 | return sendError(c, err) | ||
| 176 | } | 222 | } | ||
| 177 | 223 | ||||
| 224 | + | logger.Debug("Containers found", "count", len(containers)) | |||
| 225 | + | for i, container := range containers { | |||
| 226 | + | logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 178 | var humanReadableContainers []HumanReadableContainer | 229 | var humanReadableContainers []HumanReadableContainer | ||
| 179 | 230 | ||||
| 180 | for _, container := range containers { | 231 | for _, container := range containers { | ||
| @@ -212,10 +263,15 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error { | |||||
| 212 | return err | 263 | return err | ||
| 213 | } | 264 | } | ||
| 214 | data := map[string]interface{}{ | 265 | data := map[string]interface{}{ | ||
| 215 | - | "Lang": yamlData["Lang"], | 266 | + | "Lang": yamlData["Lang"], |
| 216 | - | "containers": humanReadableContainers, | 267 | + | "Containers": humanReadableContainers, |
| 268 | + | "NoContainer": len(humanReadableContainers) == 0, | |||
| 217 | } | 269 | } | ||
| 218 | 270 | ||||
| 271 | + | logger.Debug("Rendering containerlist template", | |||
| 272 | + | "humanReadableContainers", len(humanReadableContainers), | |||
| 273 | + | "noContainer", len(humanReadableContainers) == 0) | |||
| 274 | + | ||||
| 219 | return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data) | 275 | return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data) | ||
| 220 | } | 276 | } | ||
| 221 | 277 | ||||
M internal/httpserve/handlers/container_transactions.go +3 -3
| @@ -2,13 +2,13 @@ package handlers | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "log" | |||
| 6 | "strings" | 5 | "strings" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/gordon/internal/server" | 7 | "github.com/bnema/gordon/internal/server" | ||
| 9 | "github.com/bnema/gordon/internal/templating/cmdparams" | 8 | "github.com/bnema/gordon/internal/templating/cmdparams" | ||
| 10 | "github.com/bnema/gordon/internal/templating/render" | 9 | "github.com/bnema/gordon/internal/templating/render" | ||
| 11 | "github.com/bnema/gordon/pkg/docker" | 10 | "github.com/bnema/gordon/pkg/docker" | ||
| 11 | + | "github.com/bnema/gordon/pkg/logger" | |||
| 12 | "github.com/labstack/echo/v4" | 12 | "github.com/labstack/echo/v4" | ||
| 13 | "gopkg.in/yaml.v3" | 13 | "gopkg.in/yaml.v3" | ||
| 14 | ) | 14 | ) | ||
| @@ -132,13 +132,13 @@ func (tq *TransactionQueue) Execute() error { | |||||
| 132 | for i, step := range tq.steps { | 132 | for i, step := range tq.steps { | ||
| 133 | err := step() | 133 | err := step() | ||
| 134 | if err != nil { | 134 | if err != nil { | ||
| 135 | - | log.Printf("Error occurred at step index %d: %v", i, err) | 135 | + | logger.Error(fmt.Sprintf("Error occurred at step index %d: %v", i, err)) |
| 136 | transactionErrors = append(transactionErrors, err) | 136 | transactionErrors = append(transactionErrors, err) | ||
| 137 | for j := i - 1; j >= 0; j-- { | 137 | for j := i - 1; j >= 0; j-- { | ||
| 138 | rollback := tq.rollbacks[j] | 138 | rollback := tq.rollbacks[j] | ||
| 139 | if rollback != nil { | 139 | if rollback != nil { | ||
| 140 | if rollbackErr := rollback(); rollbackErr != nil { | 140 | if rollbackErr := rollback(); rollbackErr != nil { | ||
| 141 | - | log.Printf("Failed to execute rollback at index %d: %v", j, rollbackErr) | 141 | + | logger.Error(fmt.Sprintf("Failed to execute rollback at index %d: %v", j, rollbackErr)) |
| 142 | rollbackErrors = append(rollbackErrors, rollbackErr) | 142 | rollbackErrors = append(rollbackErrors, rollbackErr) | ||
| 143 | } | 143 | } | ||
| 144 | } | 144 | } | ||
M internal/httpserve/handlers/endpoint_deploy.go +34 -34
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/gordon/internal/proxy" | 17 | "github.com/bnema/gordon/internal/proxy" | ||
| 18 | "github.com/bnema/gordon/internal/server" | 18 | "github.com/bnema/gordon/internal/server" | ||
| 19 | "github.com/bnema/gordon/pkg/docker" | 19 | "github.com/bnema/gordon/pkg/docker" | ||
| 20 | - | "github.com/charmbracelet/log" | 20 | + | "github.com/bnema/gordon/pkg/logger" |
| 21 | "github.com/labstack/echo/v4" | 21 | "github.com/labstack/echo/v4" | ||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| @@ -26,7 +26,7 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||||
| 26 | payload, err := validateAndPrepareDeployPayload(c) | 26 | payload, err := validateAndPrepareDeployPayload(c) | ||
| 27 | if err != nil { | 27 | if err != nil { | ||
| 28 | // Debug | 28 | // Debug | ||
| 29 | - | log.Error("Failed to validate and prepare payload", "error", err) | 29 | + | logger.Error("Failed to validate and prepare payload", "error", err) |
| 30 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 30 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | ||
| 31 | Success: false, | 31 | Success: false, | ||
| 32 | Message: fmt.Sprintf("Invalid payload: %v", err), | 32 | Message: fmt.Sprintf("Invalid payload: %v", err), | ||
| @@ -37,7 +37,7 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||||
| 37 | _, err = saveAndImportImage(c, a, payload) | 37 | _, err = saveAndImportImage(c, a, payload) | ||
| 38 | if err != nil { | 38 | if err != nil { | ||
| 39 | // Debug | 39 | // Debug | ||
| 40 | - | log.Error("Failed to save and import image", "error", err) | 40 | + | logger.Error("Failed to save and import image", "error", err) |
| 41 | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 41 | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | ||
| 42 | Success: false, | 42 | Success: false, | ||
| 43 | Message: fmt.Sprintf("Failed to save or import image: %v", err), | 43 | Message: fmt.Sprintf("Failed to save or import image: %v", err), | ||
| @@ -48,14 +48,14 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||||
| 48 | containerID, containerName, err := createAndStartContainer(a, payload) | 48 | containerID, containerName, err := createAndStartContainer(a, payload) | ||
| 49 | if err != nil { | 49 | if err != nil { | ||
| 50 | if strings.Contains(err.Error(), "is already in use") { | 50 | if strings.Contains(err.Error(), "is already in use") { | ||
| 51 | - | log.Warn("Container already exists", "error", err) | 51 | + | logger.Warn("Container already exists", "error", err) |
| 52 | existingContainerID := extractContainerID(err.Error()) | 52 | existingContainerID := extractContainerID(err.Error()) | ||
| 53 | existingContainerName, _ := docker.GetContainerName(existingContainerID) | 53 | existingContainerName, _ := docker.GetContainerName(existingContainerID) | ||
| 54 | // Remove the / from the container name | 54 | // Remove the / from the container name | ||
| 55 | existingContainerName = strings.TrimLeft(existingContainerName, "/") | 55 | existingContainerName = strings.TrimLeft(existingContainerName, "/") | ||
| 56 | 56 | ||||
| 57 | if existingContainerID == "" || existingContainerName == "" { | 57 | if existingContainerID == "" || existingContainerName == "" { | ||
| 58 | - | log.Error("Failed to extract container ID or Name from error message") | 58 | + | logger.Error("Failed to extract container ID or Name from error message") |
| 59 | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | 59 | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | ||
| 60 | Success: false, | 60 | Success: false, | ||
| 61 | Message: "A container for this deploy already exists but could not be identified.", | 61 | Message: "A container for this deploy already exists but could not be identified.", | ||
| @@ -71,7 +71,7 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||||
| 71 | StatusCode: http.StatusConflict, | 71 | StatusCode: http.StatusConflict, | ||
| 72 | }) | 72 | }) | ||
| 73 | } | 73 | } | ||
| 74 | - | log.Error("Failed to create or start container", "error", err) | 74 | + | logger.Error("Failed to create or start container", "error", err) |
| 75 | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | 75 | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | ||
| 76 | Success: false, | 76 | Success: false, | ||
| 77 | Message: fmt.Sprintf("Failed to create or start container: %v", err), | 77 | Message: fmt.Sprintf("Failed to create or start container: %v", err), | ||
| @@ -90,11 +90,11 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||||
| 90 | containerIP := GetContainerIP(a, containerID, containerName) | 90 | containerIP := GetContainerIP(a, containerID, containerName) | ||
| 91 | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | 91 | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | ||
| 92 | if err != nil { | 92 | if err != nil { | ||
| 93 | - | log.Error("Failed to add proxy route", "error", err) | 93 | + | logger.Error("Failed to add proxy route", "error", err) |
| 94 | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | 94 | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | ||
| 95 | } | 95 | } | ||
| 96 | } else { | 96 | } else { | ||
| 97 | - | log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName) | 97 | + | logger.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName) |
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | // Arrived here means deployment was successful | 100 | // Arrived here means deployment was successful | ||
| @@ -124,7 +124,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||||
| 124 | 124 | ||||
| 125 | payload, err := validateAndPrepareDeployPayload(c) | 125 | payload, err := validateAndPrepareDeployPayload(c) | ||
| 126 | if err != nil { | 126 | if err != nil { | ||
| 127 | - | log.Error("Failed to validate and prepare payload", "error", err) | 127 | + | logger.Error("Failed to validate and prepare payload", "error", err) |
| 128 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 128 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | ||
| 129 | Success: false, | 129 | Success: false, | ||
| 130 | Message: fmt.Sprintf("Invalid payload: %v", err), | 130 | Message: fmt.Sprintf("Invalid payload: %v", err), | ||
| @@ -196,7 +196,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||||
| 196 | // Chunks validation steps | 196 | // Chunks validation steps | ||
| 197 | // | 197 | // | ||
| 198 | if len(chunkData) == 0 { | 198 | if len(chunkData) == 0 { | ||
| 199 | - | log.Error("Received empty chunk") | 199 | + | logger.Error("Received empty chunk") |
| 200 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 200 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | ||
| 201 | Success: false, | 201 | Success: false, | ||
| 202 | Message: "Received empty chunk", | 202 | Message: "Received empty chunk", | ||
| @@ -206,7 +206,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||||
| 206 | 206 | ||||
| 207 | // Verify chunk size matches metadata | 207 | // Verify chunk size matches metadata | ||
| 208 | if int64(len(chunkData)) != metadata.ChunkSize { | 208 | if int64(len(chunkData)) != metadata.ChunkSize { | ||
| 209 | - | log.Error("Chunk size mismatch", | 209 | + | logger.Error("Chunk size mismatch", |
| 210 | "expected", metadata.ChunkSize, | 210 | "expected", metadata.ChunkSize, | ||
| 211 | "received", len(chunkData)) | 211 | "received", len(chunkData)) | ||
| 212 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | 212 | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | ||
| @@ -236,7 +236,7 @@ func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) erro | |||||
| 236 | } | 236 | } | ||
| 237 | 237 | ||||
| 238 | func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error { | 238 | func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error { | ||
| 239 | - | log.Info("Starting complete deploy transfer processing", | 239 | + | logger.Info("Starting complete deploy transfer processing", |
| 240 | "transferID", transferID) | 240 | "transferID", transferID) | ||
| 241 | 241 | ||||
| 242 | // Get transfer data | 242 | // Get transfer data | ||
| @@ -296,21 +296,21 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||||
| 296 | // Check for existing container | 296 | // Check for existing container | ||
| 297 | existingContainerID := docker.GetContainerIDByName(containerName) | 297 | existingContainerID := docker.GetContainerIDByName(containerName) | ||
| 298 | if existingContainerID != "" { | 298 | if existingContainerID != "" { | ||
| 299 | - | log.Warn("Container already exists", | 299 | + | logger.Warn("Container already exists", |
| 300 | "name", containerName, | 300 | "name", containerName, | ||
| 301 | "id", existingContainerID) | 301 | "id", existingContainerID) | ||
| 302 | return sendDeployErrorResponse(c, "Container already exists", nil) | 302 | return sendDeployErrorResponse(c, "Container already exists", nil) | ||
| 303 | } | 303 | } | ||
| 304 | 304 | ||||
| 305 | // Import the image | 305 | // Import the image | ||
| 306 | - | log.Info("Importing image from file", | 306 | + | logger.Info("Importing image from file", |
| 307 | "path", tmpFile.Name(), | 307 | "path", tmpFile.Name(), | ||
| 308 | "imageName", imageName) | 308 | "imageName", imageName) | ||
| 309 | 309 | ||||
| 310 | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | 310 | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | ||
| 311 | if err != nil { | 311 | if err != nil { | ||
| 312 | if strings.Contains(err.Error(), "platform compatibility check failed") { | 312 | if strings.Contains(err.Error(), "platform compatibility check failed") { | ||
| 313 | - | log.Error("Platform compatibility check failed", | 313 | + | logger.Error("Platform compatibility check failed", |
| 314 | "error", err, | 314 | "error", err, | ||
| 315 | "imageName", imageName) | 315 | "imageName", imageName) | ||
| 316 | return sendDeployErrorResponse(c, "Platform compatibility check failed", err) | 316 | return sendDeployErrorResponse(c, "Platform compatibility check failed", err) | ||
| @@ -343,7 +343,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||||
| 343 | // Cleanup transfer data | 343 | // Cleanup transfer data | ||
| 344 | cleanupTransfer(transferID) | 344 | cleanupTransfer(transferID) | ||
| 345 | 345 | ||||
| 346 | - | log.Info("Deployment completed successfully", | 346 | + | logger.Info("Deployment completed successfully", |
| 347 | "containerID", containerID, | 347 | "containerID", containerID, | ||
| 348 | "containerName", containerName, | 348 | "containerName", containerName, | ||
| 349 | "domain", targetDomain) | 349 | "domain", targetDomain) | ||
| @@ -352,7 +352,7 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||||
| 352 | containerIP := GetContainerIP(a, containerID, containerName) | 352 | containerIP := GetContainerIP(a, containerID, containerName) | ||
| 353 | err = AddProxyRoute(a, containerID, containerIP, port, targetDomain) | 353 | err = AddProxyRoute(a, containerID, containerIP, port, targetDomain) | ||
| 354 | if err != nil { | 354 | if err != nil { | ||
| 355 | - | log.Error("Failed to add proxy route", "error", err) | 355 | + | logger.Error("Failed to add proxy route", "error", err) |
| 356 | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | 356 | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | ||
| 357 | } | 357 | } | ||
| 358 | 358 | ||||
| @@ -422,7 +422,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 422 | 422 | ||||
| 423 | // Double check that we have a valid container IP - critical for recreated containers | 423 | // Double check that we have a valid container IP - critical for recreated containers | ||
| 424 | if containerIP == "" || containerIP == containerID || containerIP == "localhost" { | 424 | if containerIP == "" || containerIP == containerID || containerIP == "localhost" { | ||
| 425 | - | log.Warn("Invalid container IP detected, attempting to get correct IP", | 425 | + | logger.Warn("Invalid container IP detected, attempting to get correct IP", |
| 426 | "containerID", containerID, | 426 | "containerID", containerID, | ||
| 427 | "invalid_ip", containerIP) | 427 | "invalid_ip", containerIP) | ||
| 428 | 428 | ||||
| @@ -437,7 +437,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 437 | // Get the correct container IP with retry logic | 437 | // Get the correct container IP with retry logic | ||
| 438 | newContainerIP := GetContainerIP(a, containerID, containerName) | 438 | newContainerIP := GetContainerIP(a, containerID, containerName) | ||
| 439 | if newContainerIP != containerIP && newContainerIP != containerName { | 439 | if newContainerIP != containerIP && newContainerIP != containerName { | ||
| 440 | - | log.Info("Updated container IP for proxy route", | 440 | + | logger.Info("Updated container IP for proxy route", |
| 441 | "containerID", containerID, | 441 | "containerID", containerID, | ||
| 442 | "old_ip", containerIP, | 442 | "old_ip", containerIP, | ||
| 443 | "new_ip", newContainerIP) | 443 | "new_ip", newContainerIP) | ||
| @@ -448,12 +448,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 448 | // Get container info to check labels | 448 | // Get container info to check labels | ||
| 449 | containerInfo, err := docker.GetContainerInfo(containerID) | 449 | containerInfo, err := docker.GetContainerInfo(containerID) | ||
| 450 | if err != nil { | 450 | if err != nil { | ||
| 451 | - | log.Warn("Failed to get container info", "error", err) | 451 | + | logger.Warn("Failed to get container info", "error", err) |
| 452 | } else { | 452 | } else { | ||
| 453 | // Check if the container has a gordon.proxy.port label that might override containerPort | 453 | // Check if the container has a gordon.proxy.port label that might override containerPort | ||
| 454 | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | 454 | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | ||
| 455 | if portLabel != containerPort { | 455 | if portLabel != containerPort { | ||
| 456 | - | log.Info("Using container label port instead of provided port", | 456 | + | logger.Info("Using container label port instead of provided port", |
| 457 | "label_port", portLabel, | 457 | "label_port", portLabel, | ||
| 458 | "provided_port", containerPort) | 458 | "provided_port", containerPort) | ||
| 459 | containerPort = portLabel | 459 | containerPort = portLabel | ||
| @@ -467,7 +467,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 467 | // Check if the target domain specifies HTTPS | 467 | // Check if the target domain specifies HTTPS | ||
| 468 | if strings.HasPrefix(strings.ToLower(targetDomain), "https://") { | 468 | if strings.HasPrefix(strings.ToLower(targetDomain), "https://") { | ||
| 469 | protocol = "https" | 469 | protocol = "https" | ||
| 470 | - | log.Debug("Using HTTPS protocol for proxy route based on target domain", | 470 | + | logger.Debug("Using HTTPS protocol for proxy route based on target domain", |
| 471 | "domain", targetDomain, | 471 | "domain", targetDomain, | ||
| 472 | "protocol", protocol) | 472 | "protocol", protocol) | ||
| 473 | } else { | 473 | } else { | ||
| @@ -477,7 +477,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 477 | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | 477 | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | ||
| 478 | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | 478 | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | ||
| 479 | protocol = "https" | 479 | protocol = "https" | ||
| 480 | - | log.Debug("Using HTTPS protocol for proxy route based on container labels", | 480 | + | logger.Debug("Using HTTPS protocol for proxy route based on container labels", |
| 481 | "domain", targetDomain, | 481 | "domain", targetDomain, | ||
| 482 | "containerID", containerID, | 482 | "containerID", containerID, | ||
| 483 | "label_value", sslValue, | 483 | "label_value", sslValue, | ||
| @@ -496,16 +496,16 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 496 | cleanDomain = strings.TrimPrefix(cleanDomain, ".") | 496 | cleanDomain = strings.TrimPrefix(cleanDomain, ".") | ||
| 497 | 497 | ||||
| 498 | // Log that we're adding the domain for Let's Encrypt | 498 | // Log that we're adding the domain for Let's Encrypt | ||
| 499 | - | log.Debug("Adding domain for Let's Encrypt verification", | 499 | + | logger.Debug("Adding domain for Let's Encrypt verification", |
| 500 | "domain", cleanDomain) | 500 | "domain", cleanDomain) | ||
| 501 | 501 | ||||
| 502 | // Get container name for better logging | 502 | // Get container name for better logging | ||
| 503 | containerName, err := docker.GetContainerName(containerID) | 503 | containerName, err := docker.GetContainerName(containerID) | ||
| 504 | if err != nil { | 504 | if err != nil { | ||
| 505 | - | log.Warn("Unable to get container name", "containerID", containerID, "error", err) | 505 | + | logger.Warn("Unable to get container name", "containerID", containerID, "error", err) |
| 506 | } else { | 506 | } else { | ||
| 507 | containerName = strings.TrimPrefix(containerName, "/") | 507 | containerName = strings.TrimPrefix(containerName, "/") | ||
| 508 | - | log.Debug("Container name for proxy route", "name", containerName) | 508 | + | logger.Debug("Container name for proxy route", "name", containerName) |
| 509 | } | 509 | } | ||
| 510 | 510 | ||||
| 511 | // Check the database for any existing routes with this domain, regardless of container ID | 511 | // Check the database for any existing routes with this domain, regardless of container ID | ||
| @@ -526,7 +526,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 526 | 526 | ||||
| 527 | if err == nil { | 527 | if err == nil { | ||
| 528 | // Domain exists in database, check if this is a recreated container | 528 | // Domain exists in database, check if this is a recreated container | ||
| 529 | - | log.Info("Found existing proxy route for domain", | 529 | + | logger.Info("Found existing proxy route for domain", |
| 530 | "domain", cleanDomain, | 530 | "domain", cleanDomain, | ||
| 531 | "old_container_id", existingContainerID, | 531 | "old_container_id", existingContainerID, | ||
| 532 | "new_container_id", containerID) | 532 | "new_container_id", containerID) | ||
| @@ -538,7 +538,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 538 | networkName := a.Config.ContainerEngine.Network | 538 | networkName := a.Config.ContainerEngine.Network | ||
| 539 | if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists && | 539 | if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists && | ||
| 540 | networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP { | 540 | networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP { | ||
| 541 | - | log.Info("Updating container IP before database update", | 541 | + | logger.Info("Updating container IP before database update", |
| 542 | "domain", cleanDomain, | 542 | "domain", cleanDomain, | ||
| 543 | "old_ip", containerIP, | 543 | "old_ip", containerIP, | ||
| 544 | "new_ip", networkSettings.IPAddress) | 544 | "new_ip", networkSettings.IPAddress) | ||
| @@ -559,7 +559,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 559 | return fmt.Errorf("failed to update proxy route: %w", err) | 559 | return fmt.Errorf("failed to update proxy route: %w", err) | ||
| 560 | } | 560 | } | ||
| 561 | 561 | ||||
| 562 | - | log.Info("Updated proxy route for recreated container", | 562 | + | logger.Info("Updated proxy route for recreated container", |
| 563 | "domain", cleanDomain, | 563 | "domain", cleanDomain, | ||
| 564 | "old_container_id", existingContainerID, | 564 | "old_container_id", existingContainerID, | ||
| 565 | "new_container_id", containerID, | 565 | "new_container_id", containerID, | ||
| @@ -579,11 +579,11 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 579 | WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP) | 579 | WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP) | ||
| 580 | 580 | ||||
| 581 | if err == nil && updatedContainerIP != containerIP { | 581 | if err == nil && updatedContainerIP != containerIP { | ||
| 582 | - | log.Warn("Database verification failed: container IP mismatch", | 582 | + | logger.Warn("Database verification failed: container IP mismatch", |
| 583 | "expected", containerIP, | 583 | "expected", containerIP, | ||
| 584 | "actual", updatedContainerIP) | 584 | "actual", updatedContainerIP) | ||
| 585 | } else { | 585 | } else { | ||
| 586 | - | log.Debug("Database verification passed: container IP updated correctly", | 586 | + | logger.Debug("Database verification passed: container IP updated correctly", |
| 587 | "container_ip", containerIP) | 587 | "container_ip", containerIP) | ||
| 588 | } | 588 | } | ||
| 589 | 589 | ||||
| @@ -596,12 +596,12 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 596 | routes := p.GetRoutes() | 596 | routes := p.GetRoutes() | ||
| 597 | if route, exists := routes[cleanDomain]; exists { | 597 | if route, exists := routes[cleanDomain]; exists { | ||
| 598 | if route.ContainerIP != containerIP { | 598 | if route.ContainerIP != containerIP { | ||
| 599 | - | log.Warn("Route reload verification failed: container IP mismatch", | 599 | + | logger.Warn("Route reload verification failed: container IP mismatch", |
| 600 | "domain", cleanDomain, | 600 | "domain", cleanDomain, | ||
| 601 | "expected", containerIP, | 601 | "expected", containerIP, | ||
| 602 | "actual", route.ContainerIP) | 602 | "actual", route.ContainerIP) | ||
| 603 | } else { | 603 | } else { | ||
| 604 | - | log.Debug("Route reload verification passed: container IP loaded correctly", | 604 | + | logger.Debug("Route reload verification passed: container IP loaded correctly", |
| 605 | "domain", cleanDomain, | 605 | "domain", cleanDomain, | ||
| 606 | "container_ip", containerIP) | 606 | "container_ip", containerIP) | ||
| 607 | } | 607 | } | ||
| @@ -623,7 +623,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 623 | // and avoid database locking conflicts | 623 | // and avoid database locking conflicts | ||
| 624 | p.RegisterNewlyCreatedContainer(containerID) | 624 | p.RegisterNewlyCreatedContainer(containerID) | ||
| 625 | 625 | ||||
| 626 | - | log.Debug("Added proxy route", | 626 | + | logger.Debug("Added proxy route", |
| 627 | "domain", targetDomain, | 627 | "domain", targetDomain, | ||
| 628 | "containerIP", containerIP, | 628 | "containerIP", containerIP, | ||
| 629 | "containerPort", containerPort, | 629 | "containerPort", containerPort, | ||
M internal/httpserve/handlers/endpoint_helpers.go +24 -24
| @@ -18,7 +18,7 @@ import ( | |||||
| 18 | "github.com/bnema/gordon/internal/templating/cmdparams" | 18 | "github.com/bnema/gordon/internal/templating/cmdparams" | ||
| 19 | "github.com/bnema/gordon/pkg/docker" | 19 | "github.com/bnema/gordon/pkg/docker" | ||
| 20 | "github.com/bnema/gordon/pkg/filestore" | 20 | "github.com/bnema/gordon/pkg/filestore" | ||
| 21 | - | "github.com/charmbracelet/log" | 21 | + | "github.com/bnema/gordon/pkg/logger" |
| 22 | "github.com/labstack/echo/v4" | 22 | "github.com/labstack/echo/v4" | ||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| @@ -71,7 +71,7 @@ func (info *InfoResponse) Populate(a *server.App) { | |||||
| 71 | func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error { | 71 | func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error { | ||
| 72 | err := c.JSON(statusCode, response) | 72 | err := c.JSON(statusCode, response) | ||
| 73 | if err != nil { | 73 | if err != nil { | ||
| 74 | - | log.Error("Failed to send JSON response", | 74 | + | logger.Error("Failed to send JSON response", |
| 75 | "error", err, | 75 | "error", err, | ||
| 76 | "statusCode", statusCode) | 76 | "statusCode", statusCode) | ||
| 77 | } | 77 | } | ||
| @@ -152,7 +152,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||||
| 152 | imageID, err = docker.ImportImageToEngine(imagePath) | 152 | imageID, err = docker.ImportImageToEngine(imagePath) | ||
| 153 | if err != nil { | 153 | if err != nil { | ||
| 154 | if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil { | 154 | if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil { | ||
| 155 | - | log.Error("Failed to remove temporary image file after import failure", | 155 | + | logger.Error("Failed to remove temporary image file after import failure", |
| 156 | "error", removeErr, | 156 | "error", removeErr, | ||
| 157 | "path", imagePath) | 157 | "path", imagePath) | ||
| 158 | } | 158 | } | ||
| @@ -161,7 +161,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||||
| 161 | 161 | ||||
| 162 | if imageID == "" { | 162 | if imageID == "" { | ||
| 163 | if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil { | 163 | if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil { | ||
| 164 | - | log.Error("Failed to remove temporary image file after empty ID", | 164 | + | logger.Error("Failed to remove temporary image file after empty ID", |
| 165 | "error", removeErr, | 165 | "error", removeErr, | ||
| 166 | "path", imagePath) | 166 | "path", imagePath) | ||
| 167 | } | 167 | } | ||
| @@ -170,7 +170,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||||
| 170 | 170 | ||||
| 171 | err = filestore.RemoveFromStorage(imagePath) | 171 | err = filestore.RemoveFromStorage(imagePath) | ||
| 172 | if err != nil { | 172 | if err != nil { | ||
| 173 | - | log.Error("Failed to remove temporary image file after successful import", | 173 | + | logger.Error("Failed to remove temporary image file after successful import", |
| 174 | "error", err, | 174 | "error", err, | ||
| 175 | "path", imagePath) | 175 | "path", imagePath) | ||
| 176 | return "", fmt.Errorf("failed to remove temporary image file: %v", err) | 176 | return "", fmt.Errorf("failed to remove temporary image file: %v", err) | ||
| @@ -181,7 +181,7 @@ func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (str | |||||
| 181 | 181 | ||||
| 182 | // createAndStartContainer creates a container from the image, starts it and returns the container ID | 182 | // createAndStartContainer creates a container from the image, starts it and returns the container ID | ||
| 183 | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | 183 | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | ||
| 184 | - | log.Info("Starting container creation process", | 184 | + | logger.Info("Starting container creation process", |
| 185 | "imageName", payload.ImageName, | 185 | "imageName", payload.ImageName, | ||
| 186 | "imageID", payload.ImageID, | 186 | "imageID", payload.ImageID, | ||
| 187 | "port", payload.Port) | 187 | "port", payload.Port) | ||
| @@ -191,21 +191,21 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri | |||||
| 191 | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | 191 | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
| 194 | - | log.Debug("Container parameters prepared", "params", params) | 194 | + | logger.Debug("Container parameters prepared", "params", params) |
| 195 | 195 | ||||
| 196 | containerID, err := docker.CreateContainer(params) | 196 | containerID, err := docker.CreateContainer(params) | ||
| 197 | if err != nil { | 197 | if err != nil { | ||
| 198 | return "", "", fmt.Errorf("failed to create container: %v", err) | 198 | return "", "", fmt.Errorf("failed to create container: %v", err) | ||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | - | log.Info("Container created successfully", "containerID", containerID) | 201 | + | logger.Info("Container created successfully", "containerID", containerID) |
| 202 | 202 | ||||
| 203 | containerName, err := docker.GetContainerName(containerID) | 203 | containerName, err := docker.GetContainerName(containerID) | ||
| 204 | if err != nil { | 204 | if err != nil { | ||
| 205 | return "", "", fmt.Errorf("failed to get container name: %v", err) | 205 | return "", "", fmt.Errorf("failed to get container name: %v", err) | ||
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| 208 | - | log.Info("Starting container", | 208 | + | logger.Info("Starting container", |
| 209 | "containerID", containerID, | 209 | "containerID", containerID, | ||
| 210 | "name", containerName) | 210 | "name", containerName) | ||
| 211 | 211 | ||||
| @@ -215,7 +215,7 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri | |||||
| 215 | // Get container logs if start failed | 215 | // Get container logs if start failed | ||
| 216 | logs, logErr := docker.GetContainerLogs(containerID) | 216 | logs, logErr := docker.GetContainerLogs(containerID) | ||
| 217 | if logErr == nil { | 217 | if logErr == nil { | ||
| 218 | - | log.Error("Container start failed - container logs", | 218 | + | logger.Error("Container start failed - container logs", |
| 219 | "logs", logs, | 219 | "logs", logs, | ||
| 220 | "containerID", containerID) | 220 | "containerID", containerID) | ||
| 221 | } | 221 | } | ||
| @@ -223,14 +223,14 @@ func createAndStartContainer(a *server.App, payload *common.DeployPayload) (stri | |||||
| 223 | // Cleanup the failed container | 223 | // Cleanup the failed container | ||
| 224 | cleanupErr := docker.RemoveContainer(containerID) | 224 | cleanupErr := docker.RemoveContainer(containerID) | ||
| 225 | if cleanupErr != nil { | 225 | if cleanupErr != nil { | ||
| 226 | - | log.Error("Failed to cleanup container after start failure", | 226 | + | logger.Error("Failed to cleanup container after start failure", |
| 227 | "containerID", containerID, | 227 | "containerID", containerID, | ||
| 228 | "error", cleanupErr) | 228 | "error", cleanupErr) | ||
| 229 | } | 229 | } | ||
| 230 | return "", "", fmt.Errorf("failed to start container: %v", err) | 230 | return "", "", fmt.Errorf("failed to start container: %v", err) | ||
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | - | log.Info("Container started successfully", | 233 | + | logger.Info("Container started successfully", |
| 234 | "containerID", containerID, | 234 | "containerID", containerID, | ||
| 235 | "name", containerName) | 235 | "name", containerName) | ||
| 236 | 236 | ||||
| @@ -292,14 +292,14 @@ func isTransferComplete(transferID string) bool { | |||||
| 292 | 292 | ||||
| 293 | metadata := chunkStore.metadata[transferID] | 293 | metadata := chunkStore.metadata[transferID] | ||
| 294 | if metadata == nil { | 294 | if metadata == nil { | ||
| 295 | - | log.Warn("No metadata found for transfer", "transferID", transferID) | 295 | + | logger.Warn("No metadata found for transfer", "transferID", transferID) |
| 296 | return false | 296 | return false | ||
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | chunksReceived := len(chunkStore.chunks[transferID]) | 299 | chunksReceived := len(chunkStore.chunks[transferID]) | ||
| 300 | isComplete := chunksReceived == metadata.TotalChunks | 300 | isComplete := chunksReceived == metadata.TotalChunks | ||
| 301 | 301 | ||||
| 302 | - | log.Debug("Transfer completion check", | 302 | + | logger.Debug("Transfer completion check", |
| 303 | "transferID", transferID, | 303 | "transferID", transferID, | ||
| 304 | "chunksReceived", chunksReceived, | 304 | "chunksReceived", chunksReceived, | ||
| 305 | "totalExpected", metadata.TotalChunks, | 305 | "totalExpected", metadata.TotalChunks, | ||
| @@ -328,7 +328,7 @@ func cleanupOldTransfers() { | |||||
| 328 | chunkStore.mu.Lock() | 328 | chunkStore.mu.Lock() | ||
| 329 | for id, startTime := range chunkStore.started { | 329 | for id, startTime := range chunkStore.started { | ||
| 330 | if now.Sub(startTime) > transferTimeout { | 330 | if now.Sub(startTime) > transferTimeout { | ||
| 331 | - | log.Debug("Cleaning up stale transfer", | 331 | + | logger.Debug("Cleaning up stale transfer", |
| 332 | "transferID", id, | 332 | "transferID", id, | ||
| 333 | "age", now.Sub(startTime)) | 333 | "age", now.Sub(startTime)) | ||
| 334 | cleanupTransfer(id) | 334 | cleanupTransfer(id) | ||
| @@ -376,7 +376,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er | |||||
| 376 | totalSize += int64(len(chunk)) | 376 | totalSize += int64(len(chunk)) | ||
| 377 | } | 377 | } | ||
| 378 | 378 | ||||
| 379 | - | log.Debug("Starting chunk assembly", | 379 | + | logger.Debug("Starting chunk assembly", |
| 380 | "totalChunks", totalChunks, | 380 | "totalChunks", totalChunks, | ||
| 381 | "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024))) | 381 | "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024))) | ||
| 382 | 382 | ||||
| @@ -435,7 +435,7 @@ func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) er | |||||
| 435 | return fmt.Errorf("failed to reset file pointer: %w", err) | 435 | return fmt.Errorf("failed to reset file pointer: %w", err) | ||
| 436 | } | 436 | } | ||
| 437 | 437 | ||||
| 438 | - | log.Debug("Chunk assembly completed successfully", | 438 | + | logger.Debug("Chunk assembly completed successfully", |
| 439 | "totalBytesWritten", written, | 439 | "totalBytesWritten", written, | ||
| 440 | "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024))) | 440 | "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024))) | ||
| 441 | 441 | ||||
| @@ -464,7 +464,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||||
| 464 | // If this is a retry, add an increasing delay | 464 | // If this is a retry, add an increasing delay | ||
| 465 | if retry > 0 { | 465 | if retry > 0 { | ||
| 466 | sleepTime := time.Duration(500*retry) * time.Millisecond | 466 | sleepTime := time.Duration(500*retry) * time.Millisecond | ||
| 467 | - | log.Debug("Retrying to get container IP", | 467 | + | logger.Debug("Retrying to get container IP", |
| 468 | "attempt", retry+1, | 468 | "attempt", retry+1, | ||
| 469 | "containerID", containerID, | 469 | "containerID", containerID, | ||
| 470 | "delay", sleepTime) | 470 | "delay", sleepTime) | ||
| @@ -486,7 +486,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||||
| 486 | refreshedInfo, err := docker.GetContainerInfo(containerID) | 486 | refreshedInfo, err := docker.GetContainerInfo(containerID) | ||
| 487 | if err == nil { | 487 | if err == nil { | ||
| 488 | containerInfo = refreshedInfo | 488 | containerInfo = refreshedInfo | ||
| 489 | - | log.Debug("Refreshed container info for IP retrieval", | 489 | + | logger.Debug("Refreshed container info for IP retrieval", |
| 490 | "containerName", containerName, | 490 | "containerName", containerName, | ||
| 491 | "containerID", containerID) | 491 | "containerID", containerID) | ||
| 492 | } else { | 492 | } else { | ||
| @@ -501,7 +501,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||||
| 501 | networkName := a.Config.ContainerEngine.Network | 501 | networkName := a.Config.ContainerEngine.Network | ||
| 502 | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | 502 | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | ||
| 503 | containerIP = networkSettings.IPAddress | 503 | containerIP = networkSettings.IPAddress | ||
| 504 | - | log.Debug("Found container IP", | 504 | + | logger.Debug("Found container IP", |
| 505 | "containerName", containerName, | 505 | "containerName", containerName, | ||
| 506 | "containerID", containerID, | 506 | "containerID", containerID, | ||
| 507 | "containerIP", containerIP, | 507 | "containerIP", containerIP, | ||
| @@ -513,7 +513,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||||
| 513 | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | 513 | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | ||
| 514 | if networkSettings.IPAddress != "" { | 514 | if networkSettings.IPAddress != "" { | ||
| 515 | containerIP = networkSettings.IPAddress | 515 | containerIP = networkSettings.IPAddress | ||
| 516 | - | log.Debug("Found container IP in alternative network", | 516 | + | logger.Debug("Found container IP in alternative network", |
| 517 | "containerName", containerName, | 517 | "containerName", containerName, | ||
| 518 | "containerID", containerID, | 518 | "containerID", containerID, | ||
| 519 | "containerIP", containerIP, | 519 | "containerIP", containerIP, | ||
| @@ -533,7 +533,7 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||||
| 533 | // Extract just the IP address from the CIDR format | 533 | // Extract just the IP address from the CIDR format | ||
| 534 | ipCIDR := endpoint.IPv4Address | 534 | ipCIDR := endpoint.IPv4Address | ||
| 535 | containerIP = strings.Split(ipCIDR, "/")[0] | 535 | containerIP = strings.Split(ipCIDR, "/")[0] | ||
| 536 | - | log.Debug("Found container IP via network inspection", | 536 | + | logger.Debug("Found container IP via network inspection", |
| 537 | "containerName", containerName, | 537 | "containerName", containerName, | ||
| 538 | "containerID", containerID, | 538 | "containerID", containerID, | ||
| 539 | "containerIP", containerIP, | 539 | "containerIP", containerIP, | ||
| @@ -546,12 +546,12 @@ func GetContainerIP(a *server.App, containerID, containerName string) string { | |||||
| 546 | 546 | ||||
| 547 | // If we've tried multiple times and couldn't get an IP | 547 | // If we've tried multiple times and couldn't get an IP | ||
| 548 | if lastErr != nil { | 548 | if lastErr != nil { | ||
| 549 | - | log.Warn("Failed to get container info after multiple attempts", | 549 | + | logger.Warn("Failed to get container info after multiple attempts", |
| 550 | "error", lastErr, | 550 | "error", lastErr, | ||
| 551 | "containerID", containerID) | 551 | "containerID", containerID) | ||
| 552 | } | 552 | } | ||
| 553 | 553 | ||||
| 554 | - | log.Warn("Could not find container IP, using container name as fallback", | 554 | + | logger.Warn("Could not find container IP, using container name as fallback", |
| 555 | "containerName", containerName, | 555 | "containerName", containerName, | ||
| 556 | "containerID", containerID) | 556 | "containerID", containerID) | ||
| 557 | 557 | ||||
A internal/httpserve/handlers/error_handlers.go +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/server" | |||
| 7 | + | "github.com/bnema/gordon/internal/templating/models/templ/pages/errors" | |||
| 8 | + | "github.com/bnema/gordon/internal/templating/render" | |||
| 9 | + | "github.com/labstack/echo/v4" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // RenderNotFoundPage renders a 404 error page using templ | |||
| 13 | + | func RenderNotFoundPage(c echo.Context, a *server.App) error { | |||
| 14 | + | data := errors.ErrorPageData{ | |||
| 15 | + | Title: "Page Not Found | Gordon", | |||
| 16 | + | AdminPath: a.Config.Admin.Path, | |||
| 17 | + | StatusCode: http.StatusNotFound, | |||
| 18 | + | Message: "Sorry, the page you are looking for does not exist.", | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | renderer := render.NewTemplRenderer(a) | |||
| 22 | + | return renderer.RenderTempl(c, errors.NotFoundPage(data)) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // RenderForbiddenPage renders a 403 error page using templ | |||
| 26 | + | func RenderForbiddenPage(c echo.Context, a *server.App) error { | |||
| 27 | + | data := errors.ErrorPageData{ | |||
| 28 | + | Title: "Access Forbidden | Gordon", | |||
| 29 | + | AdminPath: a.Config.Admin.Path, | |||
| 30 | + | StatusCode: http.StatusForbidden, | |||
| 31 | + | Message: "You don't have permission to access this resource.", | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | renderer := render.NewTemplRenderer(a) | |||
| 35 | + | return renderer.RenderTempl(c, errors.ForbiddenPage(data)) | |||
| 36 | + | } ⏎̸ | |||
A internal/httpserve/handlers/templ_handlers.go +212 -0
| @@ -0,0 +1,212 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/gordon/internal/server" | |||
| 5 | + | "github.com/bnema/gordon/internal/templating/models/templ/components" | |||
| 6 | + | "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |||
| 7 | + | "github.com/bnema/gordon/internal/templating/models/templ/pages/login" | |||
| 8 | + | "github.com/bnema/gordon/internal/templating/render" | |||
| 9 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 10 | + | "github.com/docker/docker/api/types" | |||
| 11 | + | "github.com/labstack/echo/v4" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | "github.com/bnema/gordon/pkg/humanize" | |||
| 15 | + | "net/http" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // RenderTemplLoginPage renders the login page using templ | |||
| 19 | + | func RenderTemplLoginPage(c echo.Context, a *server.App) error { | |||
| 20 | + | // Check for token in query param for backward compatibility | |||
| 21 | + | urlToken := c.QueryParam("token") | |||
| 22 | + | _ = urlToken // Not using this yet, but kept for compatibility | |||
| 23 | + | ||||
| 24 | + | // Check for error message | |||
| 25 | + | errorMsg := c.QueryParam("error") | |||
| 26 | + | ||||
| 27 | + | // Create a templ renderer | |||
| 28 | + | renderer := render.NewTemplRenderer(a) | |||
| 29 | + | ||||
| 30 | + | // Render the login page | |||
| 31 | + | return renderer.RenderTempl(c, login.LoginPage("Login", a.Config.Build.BuildVersion, errorMsg)) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // RenderTemplDashboardPage renders the dashboard page using templ | |||
| 35 | + | func RenderTemplDashboardPage(c echo.Context, a *server.App) error { | |||
| 36 | + | // Create a templ renderer | |||
| 37 | + | renderer := render.NewTemplRenderer(a) | |||
| 38 | + | ||||
| 39 | + | // Get user-specific URLs | |||
| 40 | + | adminPath := a.Config.Admin.Path | |||
| 41 | + | userSettingsURL := adminPath + "/settings" | |||
| 42 | + | logoutURL := adminPath + "/logout" | |||
| 43 | + | ||||
| 44 | + | // Render the dashboard page | |||
| 45 | + | return renderer.RenderTempl(c, admin.DashboardPage( | |||
| 46 | + | "Dashboard", | |||
| 47 | + | a.Config.Build.BuildVersion, | |||
| 48 | + | adminPath, | |||
| 49 | + | userSettingsURL, | |||
| 50 | + | logoutURL, | |||
| 51 | + | )) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // RenderTemplManagerPage renders the manager page using templ | |||
| 55 | + | func RenderTemplManagerPage(c echo.Context, a *server.App) error { | |||
| 56 | + | // Create a templ renderer | |||
| 57 | + | renderer := render.NewTemplRenderer(a) | |||
| 58 | + | ||||
| 59 | + | // Get container and image counts | |||
| 60 | + | containerCount, _ := GetContainerCount(a) | |||
| 61 | + | imageCount, _ := GetImageCount(a) | |||
| 62 | + | ||||
| 63 | + | // Get user-specific URLs | |||
| 64 | + | adminPath := a.Config.Admin.Path | |||
| 65 | + | userSettingsURL := adminPath + "/settings" | |||
| 66 | + | logoutURL := adminPath + "/logout" | |||
| 67 | + | ||||
| 68 | + | // Render the manager page | |||
| 69 | + | return renderer.RenderTempl(c, admin.ManagerPage( | |||
| 70 | + | "Container Manager", | |||
| 71 | + | a.Config.Build.BuildVersion, | |||
| 72 | + | adminPath, | |||
| 73 | + | userSettingsURL, | |||
| 74 | + | logoutURL, | |||
| 75 | + | containerCount, | |||
| 76 | + | imageCount, | |||
| 77 | + | )) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // RenderTemplContainerList renders the container list component | |||
| 81 | + | func RenderTemplContainerList(c echo.Context, a *server.App) error { | |||
| 82 | + | // Create a templ renderer | |||
| 83 | + | renderer := render.NewTemplRenderer(a) | |||
| 84 | + | ||||
| 85 | + | // Get the containers | |||
| 86 | + | containers, noContainer, err := GetContainers(a) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Render the container list component | |||
| 92 | + | return renderer.RenderTempl(c, components.ContainerList(containers, noContainer)) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // RenderTemplImageList renders the image list component | |||
| 96 | + | func RenderTemplImageList(c echo.Context, a *server.App) error { | |||
| 97 | + | // Create a templ renderer | |||
| 98 | + | renderer := render.NewTemplRenderer(a) | |||
| 99 | + | ||||
| 100 | + | // Get the images | |||
| 101 | + | images, err := docker.ListContainerImages() | |||
| 102 | + | if err != nil { | |||
| 103 | + | return err | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Convert to human-readable format | |||
| 107 | + | var humanReadableImages []components.HumanReadableImage | |||
| 108 | + | for _, img := range images { | |||
| 109 | + | shortID := img.ID[7:19] | |||
| 110 | + | ||||
| 111 | + | for _, repoTag := range img.RepoTags { | |||
| 112 | + | // Split the repo tag into repository and tag | |||
| 113 | + | parts := strings.Split(repoTag, ":") | |||
| 114 | + | repository := parts[0] | |||
| 115 | + | tag := "latest" | |||
| 116 | + | if len(parts) > 1 { | |||
| 117 | + | tag = parts[1] | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | humanReadableImages = append(humanReadableImages, components.HumanReadableImage{ | |||
| 121 | + | ID: img.ID, | |||
| 122 | + | ShortID: shortID, | |||
| 123 | + | Name: repoTag, | |||
| 124 | + | Repository: repository, | |||
| 125 | + | Tag: tag, | |||
| 126 | + | Size: humanize.BytesToReadableSize(img.Size), | |||
| 127 | + | Created: humanize.TimeAgo(time.Unix(img.Created, 0)), | |||
| 128 | + | RepoDigests: img.RepoDigests, | |||
| 129 | + | RepoTags: img.RepoTags, | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Render the image list component | |||
| 135 | + | return renderer.RenderTempl(c, components.ImageList(humanReadableImages)) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // RenderTemplUploadImageForm renders the upload image form component | |||
| 139 | + | func RenderTemplUploadImageForm(c echo.Context, a *server.App) error { | |||
| 140 | + | // Create a templ renderer | |||
| 141 | + | renderer := render.NewTemplRenderer(a) | |||
| 142 | + | ||||
| 143 | + | // Render the upload image form component | |||
| 144 | + | return renderer.RenderTempl(c, components.UploadImageForm()) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // RenderTemplCreateContainerForm renders the create container form component | |||
| 148 | + | func RenderTemplCreateContainerForm(c echo.Context, a *server.App) error { | |||
| 149 | + | // Create a templ renderer | |||
| 150 | + | renderer := render.NewTemplRenderer(a) | |||
| 151 | + | ||||
| 152 | + | // Get the image ID from the URL parameter | |||
| 153 | + | shortID := c.Param("ID") | |||
| 154 | + | ||||
| 155 | + | // Try to fetch the image info | |||
| 156 | + | imageID, exists := safelyInteractWithIDMap(Fetch, shortID) | |||
| 157 | + | if !exists { | |||
| 158 | + | return c.String(http.StatusBadRequest, "Invalid image ID") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // Get image info | |||
| 162 | + | image, err := docker.GetImageInfo(imageID) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // Prepare image name from repo tags | |||
| 168 | + | imageName := "unknown" | |||
| 169 | + | if len(image.RepoTags) > 0 { | |||
| 170 | + | imageName = image.RepoTags[0] | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // Prepare form data | |||
| 174 | + | formData := components.ContainerFormData{ | |||
| 175 | + | ImageName: imageName, | |||
| 176 | + | ImageID: imageID, | |||
| 177 | + | ShortID: shortID, | |||
| 178 | + | // Default values can be set here if needed | |||
| 179 | + | ProxyPort: "80", // Default proxy port | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Render the create container form component | |||
| 183 | + | return renderer.RenderTempl(c, components.CreateContainerForm(formData)) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // GetContainerCount returns the count of containers | |||
| 187 | + | func GetContainerCount(a *server.App) (int, error) { | |||
| 188 | + | containers, err := docker.ListRunningContainers() | |||
| 189 | + | if err != nil { | |||
| 190 | + | return 0, err | |||
| 191 | + | } | |||
| 192 | + | return len(containers), nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // GetImageCount returns the count of images | |||
| 196 | + | func GetImageCount(a *server.App) (int, error) { | |||
| 197 | + | images, err := docker.ListContainerImages() | |||
| 198 | + | if err != nil { | |||
| 199 | + | return 0, err | |||
| 200 | + | } | |||
| 201 | + | return len(images), nil | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // GetContainers returns the list of containers | |||
| 205 | + | func GetContainers(a *server.App) ([]types.Container, bool, error) { | |||
| 206 | + | containers, err := docker.ListRunningContainers() | |||
| 207 | + | if err != nil { | |||
| 208 | + | return nil, false, err | |||
| 209 | + | } | |||
| 210 | + | noContainer := len(containers) == 0 | |||
| 211 | + | return containers, noContainer, nil | |||
| 212 | + | }⏎̸ | |||
A internal/httpserve/middleware/error_middleware.go +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/httpserve/handlers" | |||
| 7 | + | "github.com/bnema/gordon/internal/server" | |||
| 8 | + | "github.com/labstack/echo/v4" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // CustomHTTPErrorHandler is a custom error handler that uses templ to render error pages | |||
| 12 | + | func CustomHTTPErrorHandler(a *server.App) echo.HTTPErrorHandler { | |||
| 13 | + | return func(err error, c echo.Context) { | |||
| 14 | + | code := http.StatusInternalServerError | |||
| 15 | + | if he, ok := err.(*echo.HTTPError); ok { | |||
| 16 | + | code = he.Code | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Handle specific error codes with our templates | |||
| 20 | + | switch code { | |||
| 21 | + | case http.StatusNotFound: | |||
| 22 | + | if err := handlers.RenderNotFoundPage(c, a); err != nil { | |||
| 23 | + | c.Logger().Error(err) | |||
| 24 | + | } | |||
| 25 | + | return | |||
| 26 | + | case http.StatusForbidden: | |||
| 27 | + | if err := handlers.RenderForbiddenPage(c, a); err != nil { | |||
| 28 | + | c.Logger().Error(err) | |||
| 29 | + | } | |||
| 30 | + | return | |||
| 31 | + | default: | |||
| 32 | + | // For other errors, use Echo's default error handler | |||
| 33 | + | c.Echo().DefaultHTTPErrorHandler(err, c) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } ⏎̸ | |||
M internal/httpserve/proxy_init.go +6 -0
| @@ -1,6 +1,8 @@ | |||||
| 1 | package httpserve | 1 | package httpserve | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "time" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/gordon/internal/proxy" | 6 | "github.com/bnema/gordon/internal/proxy" | ||
| 5 | "github.com/bnema/gordon/internal/server" | 7 | "github.com/bnema/gordon/internal/server" | ||
| 6 | "github.com/charmbracelet/log" | 8 | "github.com/charmbracelet/log" | ||
| @@ -21,6 +23,10 @@ func InitializeProxy(a *server.App) (*proxy.Proxy, error) { | |||||
| 21 | return nil, err | 23 | return nil, err | ||
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 26 | + | // Add a small delay to ensure router initialization is complete | |||
| 27 | + | // This helps prevent race conditions during startup | |||
| 28 | + | time.Sleep(500 * time.Millisecond) | |||
| 29 | + | ||||
| 24 | log.Info("Reverse proxy initialized and started") | 30 | log.Info("Reverse proxy initialized and started") | ||
| 25 | return p, nil | 31 | return p, nil | ||
| 26 | } | 32 | } | ||
M internal/httpserve/router.go +47 -8
| @@ -12,9 +12,13 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | // RegisterRoutes registers all routes and middlewares | 13 | // RegisterRoutes registers all routes and middlewares | ||
| 14 | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | 14 | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | ||
| 15 | - | // Add default Echo logger middleware | |||
| 16 | - | e.Use(echomid.Logger()) | |||
| 17 | e.Use(echomid.Recover()) | 15 | e.Use(echomid.Recover()) | ||
| 16 | + | ||||
| 17 | + | // Log admin status | |||
| 18 | + | log.Info("Admin WebUI status", | |||
| 19 | + | "enabled", a.Config.Admin.IsAdminWebUIEnabled(), | |||
| 20 | + | "path", a.Config.Admin.Path) | |||
| 21 | + | ||||
| 18 | // Initiate the session middleware | 22 | // Initiate the session middleware | ||
| 19 | e.Use(middleware.InitSessionMiddleware(a)) | 23 | e.Use(middleware.InitSessionMiddleware(a)) | ||
| 20 | 24 | ||||
| @@ -22,7 +26,10 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |||||
| 22 | e.Use(middleware.LanguageDetection) | 26 | e.Use(middleware.LanguageDetection) | ||
| 23 | 27 | ||||
| 24 | AdminPath := a.Config.Admin.Path | 28 | AdminPath := a.Config.Admin.Path | ||
| 29 | + | log.Debug("Binding login routes with AdminPath", "path", AdminPath) | |||
| 25 | bindLoginRoute(e, a, AdminPath) | 30 | bindLoginRoute(e, a, AdminPath) | ||
| 31 | + | ||||
| 32 | + | log.Debug("Binding API endpoints") | |||
| 26 | bindAPIEndpoints(e, a) | 33 | bindAPIEndpoints(e, a) | ||
| 27 | 34 | ||||
| 28 | // SetCommonDataMiddleware will pass data to the renderer | 35 | // SetCommonDataMiddleware will pass data to the renderer | ||
| @@ -37,15 +44,29 @@ func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |||||
| 37 | // Color scheme detection for dark/light mode | 44 | // Color scheme detection for dark/light mode | ||
| 38 | e.Use(middleware.ColorSchemeDetection) | 45 | e.Use(middleware.ColorSchemeDetection) | ||
| 39 | 46 | ||||
| 47 | + | // Set custom error handler | |||
| 48 | + | e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a) | |||
| 49 | + | ||||
| 50 | + | log.Debug("Binding admin routes with AdminPath", "path", AdminPath) | |||
| 40 | bindAdminRoute(e, a, AdminPath) | 51 | bindAdminRoute(e, a, AdminPath) | ||
| 52 | + | ||||
| 53 | + | log.Debug("Binding static routes") | |||
| 41 | bindStaticRoute(e, a, "/*") | 54 | bindStaticRoute(e, a, "/*") | ||
| 42 | - | bindHTMXEndpoints(e, a) | 55 | + | |
| 56 | + | // Only bind HTMX endpoints if Admin WebUI is enabled | |||
| 57 | + | if a.Config.Admin.IsAdminWebUIEnabled() { | |||
| 58 | + | log.Info("Admin WebUI is enabled, binding HTMX endpoints") | |||
| 59 | + | bindHTMXEndpoints(e, a) | |||
| 60 | + | } else { | |||
| 61 | + | log.Info("Admin WebUI is disabled, skipping HTMX endpoints") | |||
| 62 | + | } | |||
| 43 | 63 | ||||
| 44 | // Protect the root path with a 403 | 64 | // Protect the root path with a 403 | ||
| 45 | e.GET("/", func(c echo.Context) error { | 65 | e.GET("/", func(c echo.Context) error { | ||
| 46 | - | return c.String(403, "403 Forbidden") | 66 | + | return handlers.RenderForbiddenPage(c, a) |
| 47 | }) | 67 | }) | ||
| 48 | 68 | ||||
| 69 | + | log.Info("All routes registered successfully") | |||
| 49 | return e | 70 | return e | ||
| 50 | } | 71 | } | ||
| 51 | 72 | ||||
| @@ -101,8 +122,14 @@ func bindStaticRoute(e *echo.Echo, a *server.App, path string) { | |||||
| 101 | 122 | ||||
| 102 | // bindLoginRoute binds all login routes | 123 | // bindLoginRoute binds all login routes | ||
| 103 | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | 124 | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | ||
| 125 | + | // Skip binding admin login routes if admin webUI is disabled | |||
| 126 | + | if !a.Config.Admin.IsAdminWebUIEnabled() { | |||
| 127 | + | log.Debug("Admin WebUI is disabled, skipping admin login routes") | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | ||||
| 104 | e.GET(adminPath+"/login", func(c echo.Context) error { | 131 | e.GET(adminPath+"/login", func(c echo.Context) error { | ||
| 105 | - | return handlers.RenderLoginPage(c, a) | 132 | + | return handlers.RenderTemplLoginPage(c, a) |
| 106 | }) | 133 | }) | ||
| 107 | e.POST(adminPath+"/login/submit-token", func(c echo.Context) error { | 134 | e.POST(adminPath+"/login/submit-token", func(c echo.Context) error { | ||
| 108 | return handlers.HandleTokenSubmission(c, a) | 135 | return handlers.HandleTokenSubmission(c, a) | ||
| @@ -120,6 +147,12 @@ func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |||||
| 120 | 147 | ||||
| 121 | // bindAdminRoute binds all admin routes | 148 | // bindAdminRoute binds all admin routes | ||
| 122 | func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | 149 | func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | ||
| 150 | + | // Skip binding admin routes if admin webUI is disabled | |||
| 151 | + | if !a.Config.Admin.IsAdminWebUIEnabled() { | |||
| 152 | + | log.Debug("Admin WebUI is disabled, skipping admin routes") | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 155 | + | ||||
| 123 | adminGroup := e.Group(adminPath) | 156 | adminGroup := e.Group(adminPath) | ||
| 124 | // Since login is behind the admin path, we cannot use group middleware | 157 | // Since login is behind the admin path, we cannot use group middleware | ||
| 125 | adminGroup.GET("", func(c echo.Context) error { | 158 | adminGroup.GET("", func(c echo.Context) error { | ||
| @@ -127,7 +160,7 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |||||
| 127 | }, middleware.RequireLogin(a)) | 160 | }, middleware.RequireLogin(a)) | ||
| 128 | 161 | ||||
| 129 | adminGroup.GET("/manager", func(c echo.Context) error { | 162 | adminGroup.GET("/manager", func(c echo.Context) error { | ||
| 130 | - | return handlers.AdminManagerRoute(c, a) | 163 | + | return handlers.RenderTemplManagerPage(c, a) |
| 131 | }, middleware.RequireLogin(a)) | 164 | }, middleware.RequireLogin(a)) | ||
| 132 | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | 165 | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | ||
| 133 | return handlers.CreateContainerFullGET(c, a) | 166 | return handlers.CreateContainerFullGET(c, a) | ||
| @@ -146,7 +179,13 @@ func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |||||
| 146 | 179 | ||||
| 147 | // bindHTMXEndpoints binds all HTMX endpoints | 180 | // bindHTMXEndpoints binds all HTMX endpoints | ||
| 148 | func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | 181 | func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | ||
| 149 | - | // Create a group for /htmx 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 | |||
| 150 | htmxGroup := e.Group("/htmx") | 189 | htmxGroup := e.Group("/htmx") | ||
| 151 | 190 | ||||
| 152 | // Apply middleware to the group, so all endpoints are protected | 191 | // Apply middleware to the group, so all endpoints are protected | ||
| @@ -163,7 +202,7 @@ func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | |||||
| 163 | 202 | ||||
| 164 | // List all containers | 203 | // List all containers | ||
| 165 | htmxGroup.GET("/container-manager", func(c echo.Context) error { | 204 | htmxGroup.GET("/container-manager", func(c echo.Context) error { | ||
| 166 | - | return handlers.ContainerManagerComponent(c, a) | 205 | + | return handlers.RenderTemplContainerList(c, a) |
| 167 | }) | 206 | }) | ||
| 168 | // Stop a container | 207 | // Stop a container | ||
| 169 | htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error { | 208 | htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error { | ||
M internal/proxy/certificates.go +126 -0
| @@ -24,8 +24,116 @@ import ( | |||||
| 24 | "golang.org/x/crypto/acme/autocert" | 24 | "golang.org/x/crypto/acme/autocert" | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | // detectUpstreamProxy checks for signs of an upstream TLS-terminating proxy | |||
| 28 | + | func (p *Proxy) detectUpstreamProxy() bool { | |||
| 29 | + | // Check if detection is explicitly disabled | |||
| 30 | + | if !p.config.DetectUpstreamProxy { | |||
| 31 | + | return false | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // First check our cached detection status - if we already detected a proxy previously | |||
| 35 | + | p.upstreamProxyMu.RLock() | |||
| 36 | + | alreadyDetected := p.upstreamProxyDetected | |||
| 37 | + | p.upstreamProxyMu.RUnlock() | |||
| 38 | + | ||||
| 39 | + | if alreadyDetected { | |||
| 40 | + | return true | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // Check for known HTTP headers that indicate a TLS termination proxy | |||
| 44 | + | // This is a best-effort approach since we're analyzing HTTP headers from | |||
| 45 | + | // previous requests stored in our context (if available) | |||
| 46 | + | ||||
| 47 | + | // Look for common proxy headers in our recent requests | |||
| 48 | + | // This is a static check at startup; ideally we would check incoming | |||
| 49 | + | // requests dynamically, but for this implementation we're using | |||
| 50 | + | // known proxy environment indicators | |||
| 51 | + | ||||
| 52 | + | // Most common proxy headers: X-Forwarded-Proto, X-Forwarded-SSL, X-Forwarded-For | |||
| 53 | + | // Environment variables: HTTPS=on, HTTP_X_FORWARDED_PROTO=https | |||
| 54 | + | ||||
| 55 | + | // Check for common environment variables set by upstream proxies | |||
| 56 | + | if os.Getenv("HTTPS") == "on" || | |||
| 57 | + | os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" || | |||
| 58 | + | os.Getenv("HTTP_X_FORWARDED_SSL") == "on" { | |||
| 59 | + | logger.Info("Upstream TLS-terminating proxy detected via environment variables", | |||
| 60 | + | "detection_method", "environment_variables", | |||
| 61 | + | "https", os.Getenv("HTTPS"), | |||
| 62 | + | "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"), | |||
| 63 | + | "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL")) | |||
| 64 | + | ||||
| 65 | + | // Cache this result | |||
| 66 | + | p.upstreamProxyMu.Lock() | |||
| 67 | + | p.upstreamProxyDetected = true | |||
| 68 | + | p.upstreamProxyMu.Unlock() | |||
| 69 | + | ||||
| 70 | + | return true | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Check if Cloudflare headers are present in environment | |||
| 74 | + | // Cloudflare sets environment variables like HTTP_CF_VISITOR={"scheme":"https"} | |||
| 75 | + | if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" { | |||
| 76 | + | if strings.Contains(cfVisitor, "\"scheme\":\"https\"") { | |||
| 77 | + | logger.Info("Cloudflare proxy detected via environment variables", | |||
| 78 | + | "detection_method", "cloudflare_headers", | |||
| 79 | + | "cf_visitor", cfVisitor) | |||
| 80 | + | ||||
| 81 | + | // Cache this result | |||
| 82 | + | p.upstreamProxyMu.Lock() | |||
| 83 | + | p.upstreamProxyDetected = true | |||
| 84 | + | p.upstreamProxyMu.Unlock() | |||
| 85 | + | ||||
| 86 | + | return true | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Not detected, but log if explicit configuration is set | |||
| 91 | + | if p.config.SkipCertificates { | |||
| 92 | + | logger.Info("No upstream proxy detected automatically, but certificate acquisition will be skipped due to configuration", | |||
| 93 | + | "detection_result", "false", | |||
| 94 | + | "skip_certificates", "true (forced via config)") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | return false | |||
| 98 | + | } | |||
| 99 | + | ||||
| 27 | // setupCertManager configures the autocert manager for Let's Encrypt | 100 | // setupCertManager configures the autocert manager for Let's Encrypt | ||
| 28 | func (p *Proxy) setupCertManager() { | 101 | func (p *Proxy) setupCertManager() { | ||
| 102 | + | // First check for upstream TLS-terminating proxies if detection is enabled | |||
| 103 | + | isProxied := p.detectUpstreamProxy() | |||
| 104 | + | ||||
| 105 | + | // Skip certificate acquisition if we're behind a TLS-terminating proxy | |||
| 106 | + | // and the skip certificates option is enabled | |||
| 107 | + | if isProxied && p.config.SkipCertificates { | |||
| 108 | + | logger.Info("Upstream TLS-terminating proxy detected, skipping Let's Encrypt certificate acquisition", | |||
| 109 | + | "upstream_proxy", "detected", | |||
| 110 | + | "certificate_acquisition", "disabled") | |||
| 111 | + | // We still setup a fallback cert for the admin domain | |||
| 112 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 113 | + | rootDomain := p.app.GetConfig().Http.Domain | |||
| 114 | + | ||||
| 115 | + | var fallbackDomains []string | |||
| 116 | + | fallbackDomains = append(fallbackDomains, adminDomain) | |||
| 117 | + | ||||
| 118 | + | // Include root domain in fallback certificate if it's different from admin domain | |||
| 119 | + | if rootDomain != "" && rootDomain != adminDomain { | |||
| 120 | + | fallbackDomains = append(fallbackDomains, rootDomain) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | fallbackCert, err := generateFallbackCertificates(fallbackDomains) | |||
| 124 | + | if err != nil { | |||
| 125 | + | logger.Warn("Failed to generate fallback certificate", "error", err) | |||
| 126 | + | } else { | |||
| 127 | + | logger.Info("Generated fallback self-signed certificate for admin domain behind proxy", | |||
| 128 | + | "domain", adminDomain, | |||
| 129 | + | "valid_until", time.Now().Add(24*time.Hour).Format("2006-01-02 15:04:05")) | |||
| 130 | + | // Store the fallback certificate | |||
| 131 | + | p.fallbackCert = fallbackCert | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | ||||
| 29 | // Create a cache directory if it doesn't exist | 137 | // Create a cache directory if it doesn't exist | ||
| 30 | dir := p.config.CertDir | 138 | dir := p.config.CertDir | ||
| 31 | if dir == "" { | 139 | if dir == "" { | ||
| @@ -384,6 +492,15 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 384 | logger.Debug("HTTPS is disabled, skipping admin certificate request") | 492 | logger.Debug("HTTPS is disabled, skipping admin certificate request") | ||
| 385 | return | 493 | return | ||
| 386 | } | 494 | } | ||
| 495 | + | ||||
| 496 | + | // Check for upstream proxy with certificate skipping enabled | |||
| 497 | + | isProxied := p.detectUpstreamProxy() | |||
| 498 | + | if isProxied && p.config.SkipCertificates { | |||
| 499 | + | logger.Info("Upstream TLS-terminating proxy detected, skipping admin certificate request", | |||
| 500 | + | "domain", adminDomain, | |||
| 501 | + | "skip_reason", "behind TLS-terminating proxy") | |||
| 502 | + | return | |||
| 503 | + | } | |||
| 387 | 504 | ||||
| 388 | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | 505 | // Extract hostname from admin domain, resolving it to see if it's publicly accessible | ||
| 389 | host := adminDomain | 506 | host := adminDomain | ||
| @@ -539,6 +656,15 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 539 | logger.Warn("Empty domain provided to requestDomainCertificate") | 656 | logger.Warn("Empty domain provided to requestDomainCertificate") | ||
| 540 | return | 657 | return | ||
| 541 | } | 658 | } | ||
| 659 | + | ||||
| 660 | + | // Check for upstream proxy with certificate skipping enabled | |||
| 661 | + | isProxied := p.detectUpstreamProxy() | |||
| 662 | + | if isProxied && p.config.SkipCertificates { | |||
| 663 | + | logger.Info("Upstream TLS-terminating proxy detected, skipping domain certificate request", | |||
| 664 | + | "domain", domain, | |||
| 665 | + | "skip_reason", "behind TLS-terminating proxy") | |||
| 666 | + | return | |||
| 667 | + | } | |||
| 542 | 668 | ||||
| 543 | // Extract hostname from domain, resolving it to see if it's publicly accessible | 669 | // Extract hostname from domain, resolving it to see if it's publicly accessible | ||
| 544 | ips, err := net.LookupIP(domain) | 670 | ips, err := net.LookupIP(domain) | ||
M internal/proxy/containers.go +7 -5
| @@ -1,7 +1,6 @@ | |||||
| 1 | package proxy | 1 | package proxy | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "fmt" | |||
| 5 | "os" | 4 | "os" | ||
| 6 | "strings" | 5 | "strings" | ||
| 7 | 6 | ||||
| @@ -12,11 +11,11 @@ import ( | |||||
| 12 | // scanContainersAndCheckCertificates scans all containers from the Gordon network | 11 | // scanContainersAndCheckCertificates scans all containers from the Gordon network | ||
| 13 | // and checks if we have certificates for their domains and subdomains | 12 | // and checks if we have certificates for their domains and subdomains | ||
| 14 | func (p *Proxy) scanContainersAndCheckCertificates() error { | 13 | func (p *Proxy) scanContainersAndCheckCertificates() error { | ||
| 15 | - | // Get the network name from app config | |||
| 16 | networkName := p.app.GetConfig().ContainerEngine.Network | 14 | networkName := p.app.GetConfig().ContainerEngine.Network | ||
| 17 | - | ||||
| 18 | if networkName == "" { | 15 | if networkName == "" { | ||
| 19 | - | return fmt.Errorf("container network name is not configured") | 16 | + | // If no network is defined, skip container scanning |
| 17 | + | logger.Info("No container network defined, skipping certificate checks") | |||
| 18 | + | return nil | |||
| 20 | } | 19 | } | ||
| 21 | 20 | ||||
| 22 | logger.Info("Scanning containers in network for certificate checks", | 21 | logger.Info("Scanning containers in network for certificate checks", | ||
| @@ -25,7 +24,10 @@ func (p *Proxy) scanContainersAndCheckCertificates() error { | |||||
| 25 | // Try to get network info using the docker client | 24 | // Try to get network info using the docker client | ||
| 26 | networkInfo, err := docker.GetNetworkInfo(networkName) | 25 | networkInfo, err := docker.GetNetworkInfo(networkName) | ||
| 27 | if err != nil { | 26 | if err != nil { | ||
| 28 | - | return fmt.Errorf("failed to get network info: %w", err) | 27 | + | logger.Warn("Failed to get network info, skipping certificate checks", |
| 28 | + | "error", err, | |||
| 29 | + | "network", networkName) | |||
| 30 | + | return nil // Return nil instead of error to continue execution | |||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 31 | // Skip admin cert check if environment variable is set | 33 | // Skip admin cert check if environment variable is set | ||
M internal/proxy/core.go +34 -6
| @@ -22,7 +22,6 @@ import ( | |||||
| 22 | // - certificates.go - Certificate management functions | 22 | // - certificates.go - Certificate management functions | ||
| 23 | // - routes.go - Route management functions | 23 | // - routes.go - Route management functions | ||
| 24 | // - middleware.go - HTTP middleware functions | 24 | // - middleware.go - HTTP middleware functions | ||
| 25 | - | // - blacklist.go - IP blacklisting functionality | |||
| 26 | // - server.go - Server startup and shutdown | 25 | // - server.go - Server startup and shutdown | ||
| 27 | // - utils.go - Utility functions | 26 | // - utils.go - Utility functions | ||
| 28 | 27 | ||||
| @@ -58,6 +57,10 @@ type Proxy struct { | |||||
| 58 | recentContainersMu sync.RWMutex | 57 | recentContainersMu sync.RWMutex | ||
| 59 | containerCooldownPeriod time.Duration | 58 | containerCooldownPeriod time.Duration | ||
| 60 | 59 | ||||
| 60 | + | // Upstream proxy detection | |||
| 61 | + | upstreamProxyDetected bool | |||
| 62 | + | upstreamProxyMu sync.RWMutex | |||
| 63 | + | ||||
| 61 | // SQL queries | 64 | // SQL queries | ||
| 62 | queries *queries.ProxyQueries | 65 | queries *queries.ProxyQueries | ||
| 63 | } | 66 | } | ||
| @@ -66,8 +69,36 @@ type Proxy struct { | |||||
| 66 | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | 69 | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | ||
| 67 | logger.Debug("Initializing reverse proxy") | 70 | logger.Debug("Initializing reverse proxy") | ||
| 68 | 71 | ||||
| 69 | - | // Log important configuration information | 72 | + | // Set up the config |
| 70 | - | logger.Debug("All internal connections to containers will use HTTP protocol regardless of external protocol") | 73 | + | config := app.GetConfig().ReverseProxy |
| 74 | + | ||||
| 75 | + | // Check if proxy is disabled through configuration | |||
| 76 | + | if !config.Enabled { | |||
| 77 | + | logger.Info("Reverse proxy is disabled via configuration") | |||
| 78 | + | // Return a non-active proxy instance | |||
| 79 | + | return &Proxy{ | |||
| 80 | + | config: config, | |||
| 81 | + | app: app, | |||
| 82 | + | serverStarted: false, | |||
| 83 | + | shutdown: make(chan struct{}), | |||
| 84 | + | }, nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Check if running inside a container | |||
| 88 | + | isContainer := docker.IsRunningInContainer() | |||
| 89 | + | logger.Debug("Container environment check", "is_running_in_container", isContainer) | |||
| 90 | + | ||||
| 91 | + | if !isContainer { | |||
| 92 | + | logger.Info("Gordon is not running in a container, proxy will be disabled automatically") | |||
| 93 | + | // Override the enabled setting to false | |||
| 94 | + | config.Enabled = false | |||
| 95 | + | return &Proxy{ | |||
| 96 | + | config: config, | |||
| 97 | + | app: app, | |||
| 98 | + | serverStarted: false, | |||
| 99 | + | shutdown: make(chan struct{}), | |||
| 100 | + | }, nil | |||
| 101 | + | } | |||
| 71 | 102 | ||||
| 72 | // Set up the echo server for HTTPS traffic | 103 | // Set up the echo server for HTTPS traffic | ||
| 73 | httpsServer := echo.New() | 104 | httpsServer := echo.New() | ||
| @@ -77,9 +108,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||||
| 77 | httpServer := echo.New() | 108 | httpServer := echo.New() | ||
| 78 | httpServer.HideBanner = true | 109 | httpServer.HideBanner = true | ||
| 79 | 110 | ||||
| 80 | - | // Set up the config | |||
| 81 | - | config := app.GetConfig().ReverseProxy | |||
| 82 | - | ||||
| 83 | // Detect and store our container ID (if we're running in a container) | 111 | // Detect and store our container ID (if we're running in a container) | ||
| 84 | var ourContainerID string | 112 | var ourContainerID string | ||
| 85 | if docker.IsRunningInContainer() { | 113 | if docker.IsRunningInContainer() { | ||
M internal/proxy/middleware.go +90 -8
| @@ -2,6 +2,7 @@ package proxy | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "net/http" | 4 | "net/http" | ||
| 5 | + | "strings" | |||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/gordon/pkg/logger" | 8 | "github.com/bnema/gordon/pkg/logger" | ||
| @@ -30,8 +31,8 @@ func (p *Proxy) setupMiddleware() { | |||||
| 30 | p.httpsServer.Use(p.createRequestIDMiddleware()) | 31 | p.httpsServer.Use(p.createRequestIDMiddleware()) | ||
| 31 | p.httpServer.Use(p.createRequestIDMiddleware()) | 32 | p.httpServer.Use(p.createRequestIDMiddleware()) | ||
| 32 | 33 | ||||
| 33 | - | // Check if rate limiting is disabled via configuration | 34 | + | // Check if rate limiting is enabled via configuration |
| 34 | - | if !p.config.DisableRateLimit { | 35 | + | if p.config.EnableRateLimit { |
| 35 | // Create a new Starskey rate limiter store for HTTPS server | 36 | // Create a new Starskey rate limiter store for HTTPS server | ||
| 36 | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | 37 | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | ||
| 37 | "./data/ratelimiter/https", // Path to store rate limit data | 38 | "./data/ratelimiter/https", // Path to store rate limit data | ||
| @@ -98,12 +99,12 @@ func (p *Proxy) setupMiddleware() { | |||||
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 100 | // Add custom logger middleware with request ID if enabled in config | 101 | // Add custom logger middleware with request ID if enabled in config | ||
| 101 | - | if p.config.EnableLogs { | 102 | + | if p.config.EnableHttpLogs { |
| 102 | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | 103 | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | ||
| 103 | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n", | 104 | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n", | ||
| 104 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | 105 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | ||
| 105 | Skipper: func(c echo.Context) bool { | 106 | Skipper: func(c echo.Context) bool { | ||
| 106 | - | return !p.config.EnableLogs | 107 | + | return !p.config.EnableHttpLogs |
| 107 | }, | 108 | }, | ||
| 108 | })) | 109 | })) | ||
| 109 | logger.Debug("HTTP request logging enabled for HTTPS server") | 110 | logger.Debug("HTTP request logging enabled for HTTPS server") | ||
| @@ -111,8 +112,8 @@ func (p *Proxy) setupMiddleware() { | |||||
| 111 | logger.Debug("HTTP request logging disabled for HTTPS server") | 112 | logger.Debug("HTTP request logging disabled for HTTPS server") | ||
| 112 | } | 113 | } | ||
| 113 | 114 | ||||
| 114 | - | // Check if rate limiting is disabled via configuration | 115 | + | // Check if rate limiting is enabled via configuration |
| 115 | - | if !p.config.DisableRateLimit { | 116 | + | if p.config.EnableRateLimit { |
| 116 | // Create a new Starskey rate limiter store for HTTP server | 117 | // Create a new Starskey rate limiter store for HTTP server | ||
| 117 | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | 118 | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | ||
| 118 | "./data/ratelimiter/http", // Path to store rate limit data | 119 | "./data/ratelimiter/http", // Path to store rate limit data | ||
| @@ -179,12 +180,12 @@ func (p *Proxy) setupMiddleware() { | |||||
| 179 | } | 180 | } | ||
| 180 | 181 | ||||
| 181 | // Add custom logger middleware with request ID for HTTP server if enabled in config | 182 | // Add custom logger middleware with request ID for HTTP server if enabled in config | ||
| 182 | - | if p.config.EnableLogs { | 183 | + | if p.config.EnableHttpLogs { |
| 183 | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | 184 | p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | ||
| 184 | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n", | 185 | Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n", | ||
| 185 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | 186 | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | ||
| 186 | Skipper: func(c echo.Context) bool { | 187 | Skipper: func(c echo.Context) bool { | ||
| 187 | - | return !p.config.EnableLogs | 188 | + | return !p.config.EnableHttpLogs |
| 188 | }, | 189 | }, | ||
| 189 | })) | 190 | })) | ||
| 190 | logger.Debug("HTTP request logging enabled for HTTP server") | 191 | logger.Debug("HTTP request logging enabled for HTTP server") | ||
| @@ -206,8 +207,89 @@ func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | |||||
| 206 | // Add it as a response header | 207 | // Add it as a response header | ||
| 207 | c.Response().Header().Set(echo.HeaderXRequestID, requestID) | 208 | c.Response().Header().Set(echo.HeaderXRequestID, requestID) | ||
| 208 | 209 | ||||
| 210 | + | // If proxy detection is enabled, check for upstream proxy headers | |||
| 211 | + | if p.config.DetectUpstreamProxy { | |||
| 212 | + | p.detectUpstreamProxyFromRequest(c) | |||
| 213 | + | } | |||
| 214 | + | ||||
| 209 | // Continue processing | 215 | // Continue processing | ||
| 210 | return next(c) | 216 | return next(c) | ||
| 211 | } | 217 | } | ||
| 212 | } | 218 | } | ||
| 213 | } | 219 | } | ||
| 220 | + | ||||
| 221 | + | // detectUpstreamProxyFromRequest checks for headers that indicate TLS termination upstream | |||
| 222 | + | // This allows real-time detection as requests come in | |||
| 223 | + | func (p *Proxy) detectUpstreamProxyFromRequest(c echo.Context) { | |||
| 224 | + | // Only check if detection is enabled | |||
| 225 | + | if !p.config.DetectUpstreamProxy { | |||
| 226 | + | return | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // First check our cached detection status | |||
| 230 | + | p.upstreamProxyMu.RLock() | |||
| 231 | + | alreadyDetected := p.upstreamProxyDetected | |||
| 232 | + | p.upstreamProxyMu.RUnlock() | |||
| 233 | + | ||||
| 234 | + | // Skip if we already detected a proxy previously | |||
| 235 | + | if alreadyDetected { | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | req := c.Request() | |||
| 240 | + | headers := req.Header | |||
| 241 | + | proxyDetected := false | |||
| 242 | + | detectionMethod := "" | |||
| 243 | + | headerName := "" | |||
| 244 | + | headerValue := "" | |||
| 245 | + | ||||
| 246 | + | // Check for common TLS termination proxy headers | |||
| 247 | + | // X-Forwarded-Proto: https indicates an upstream proxy terminated TLS | |||
| 248 | + | if proto := headers.Get("X-Forwarded-Proto"); proto == "https" { | |||
| 249 | + | proxyDetected = true | |||
| 250 | + | detectionMethod = "request_header" | |||
| 251 | + | headerName = "X-Forwarded-Proto" | |||
| 252 | + | headerValue = proto | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // X-Forwarded-SSL: on indicates an upstream proxy terminated TLS | |||
| 256 | + | if !proxyDetected { | |||
| 257 | + | if ssl := headers.Get("X-Forwarded-SSL"); ssl == "on" { | |||
| 258 | + | proxyDetected = true | |||
| 259 | + | detectionMethod = "request_header" | |||
| 260 | + | headerName = "X-Forwarded-SSL" | |||
| 261 | + | headerValue = ssl | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // Cloudflare specific headers | |||
| 266 | + | if !proxyDetected { | |||
| 267 | + | if cf := headers.Get("CF-Visitor"); cf != "" && strings.Contains(cf, "\"scheme\":\"https\"") { | |||
| 268 | + | proxyDetected = true | |||
| 269 | + | detectionMethod = "cloudflare_header" | |||
| 270 | + | headerName = "CF-Visitor" | |||
| 271 | + | headerValue = cf | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // If we detected a proxy, store the detection and log it | |||
| 276 | + | if proxyDetected { | |||
| 277 | + | // Store the detection in our state with lock protection | |||
| 278 | + | p.upstreamProxyMu.Lock() | |||
| 279 | + | p.upstreamProxyDetected = true | |||
| 280 | + | p.upstreamProxyMu.Unlock() | |||
| 281 | + | ||||
| 282 | + | logger.Info("Upstream TLS-terminating proxy detected from request headers", | |||
| 283 | + | "detection_method", detectionMethod, | |||
| 284 | + | "header", headerName, | |||
| 285 | + | "value", headerValue, | |||
| 286 | + | "host", req.Host) | |||
| 287 | + | ||||
| 288 | + | // If certificate skipping is enabled, log it | |||
| 289 | + | if p.config.SkipCertificates { | |||
| 290 | + | logger.Info("Upstream TLS-terminating proxy detected during request, future certificate requests will be skipped", | |||
| 291 | + | "host", req.Host, | |||
| 292 | + | "certificate_acquisition", "will be skipped for new domains") | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | } | |||
M internal/proxy/routes.go +105 -732
| @@ -3,15 +3,12 @@ package proxy | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "database/sql" | 4 | "database/sql" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "net" | |||
| 7 | - | "net/http" | |||
| 8 | - | "net/http/httputil" | |||
| 9 | "net/url" | 6 | "net/url" | ||
| 10 | "os" | 7 | "os" | ||
| 11 | "strings" | 8 | "strings" | ||
| 12 | "time" | 9 | "time" | ||
| 13 | 10 | ||||
| 14 | - | "github.com/bnema/gordon/pkg/docker" | 11 | + | docker "github.com/bnema/gordon/pkg/docker" |
| 15 | "github.com/bnema/gordon/pkg/logger" | 12 | "github.com/bnema/gordon/pkg/logger" | ||
| 16 | "github.com/labstack/echo/v4" | 13 | "github.com/labstack/echo/v4" | ||
| 17 | ) | 14 | ) | ||
| @@ -42,12 +39,7 @@ func (p *Proxy) loadRoutes() error { | |||||
| 42 | } | 39 | } | ||
| 43 | 40 | ||||
| 44 | // Save the admin domain route if it exists | 41 | // Save the admin domain route if it exists | ||
| 45 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | 42 | + | adminRoute := p.saveAdminRoute() |
| 46 | - | var adminRoute *ProxyRouteInfo | |||
| 47 | - | if route, exists := p.routes[adminDomain]; exists { | |||
| 48 | - | adminRoute = route | |||
| 49 | - | logger.Debug("Preserving admin domain route", "domain", adminDomain) | |||
| 50 | - | } | |||
| 51 | 43 | ||||
| 52 | // Query the database for active proxy routes using retry mechanism | 44 | // Query the database for active proxy routes using retry mechanism | ||
| 53 | rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes) | 45 | rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes) | ||
| @@ -58,8 +50,8 @@ func (p *Proxy) loadRoutes() error { | |||||
| 58 | // Restore admin route if it was saved | 50 | // Restore admin route if it was saved | ||
| 59 | if adminRoute != nil { | 51 | if adminRoute != nil { | ||
| 60 | p.routes = make(map[string]*ProxyRouteInfo) | 52 | p.routes = make(map[string]*ProxyRouteInfo) | ||
| 61 | - | p.routes[adminDomain] = adminRoute | 53 | + | p.restoreAdminRoute(adminRoute, p.routes) |
| 62 | - | logger.Debug("Restored admin domain route after empty database query", "domain", adminDomain) | 54 | + | logger.Debug("Restored admin domain route after empty database query", "domain", p.app.GetConfig().Http.FullDomain()) |
| 63 | } | 55 | } | ||
| 64 | 56 | ||||
| 65 | return nil | 57 | return nil | ||
| @@ -72,10 +64,7 @@ func (p *Proxy) loadRoutes() error { | |||||
| 72 | p.routes = make(map[string]*ProxyRouteInfo) | 64 | p.routes = make(map[string]*ProxyRouteInfo) | ||
| 73 | 65 | ||||
| 74 | // Restore admin route if it was saved | 66 | // Restore admin route if it was saved | ||
| 75 | - | if adminRoute != nil { | 67 | + | p.restoreAdminRoute(adminRoute, p.routes) |
| 76 | - | p.routes[adminDomain] = adminRoute | |||
| 77 | - | logger.Debug("Restored admin domain route after clearing routes map", "domain", adminDomain) | |||
| 78 | - | } | |||
| 79 | 68 | ||||
| 80 | // Populate the routes map | 69 | // Populate the routes map | ||
| 81 | for rows.Next() { | 70 | for rows.Next() { | ||
| @@ -85,19 +74,11 @@ func (p *Proxy) loadRoutes() error { | |||||
| 85 | return fmt.Errorf("failed to scan row: %w", err) | 74 | return fmt.Errorf("failed to scan row: %w", err) | ||
| 86 | } | 75 | } | ||
| 87 | 76 | ||||
| 88 | - | // Normalize domain (lowercase, no trailing dot) | 77 | + | // Normalize domain |
| 89 | - | domain = strings.TrimSuffix(strings.ToLower(domain), ".") | 78 | + | domain = normalizeDomain(domain) |
| 90 | 79 | ||||
| 91 | // Add the route to the map | 80 | // Add the route to the map | ||
| 92 | - | p.routes[domain] = &ProxyRouteInfo{ | 81 | + | p.routes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active) |
| 93 | - | Domain: domain, | |||
| 94 | - | ContainerID: containerID, | |||
| 95 | - | ContainerIP: containerIP, | |||
| 96 | - | ContainerPort: containerPort, | |||
| 97 | - | Protocol: protocol, | |||
| 98 | - | Path: path, | |||
| 99 | - | Active: active, | |||
| 100 | - | } | |||
| 101 | 82 | ||||
| 102 | logger.Debug("Loaded proxy route", | 83 | logger.Debug("Loaded proxy route", | ||
| 103 | "domain", domain, | 84 | "domain", domain, | ||
| @@ -112,114 +93,29 @@ func (p *Proxy) loadRoutes() error { | |||||
| 112 | 93 | ||||
| 113 | // configureRoutes sets up the HTTP and HTTPS routes | 94 | // configureRoutes sets up the HTTP and HTTPS routes | ||
| 114 | func (p *Proxy) configureRoutes() { | 95 | func (p *Proxy) configureRoutes() { | ||
| 96 | + | // Ensure only one goroutine can configure routes at a time | |||
| 97 | + | p.mu.Lock() | |||
| 98 | + | defer p.mu.Unlock() | |||
| 99 | + | ||||
| 115 | // Get the admin domain from the config | 100 | // Get the admin domain from the config | ||
| 116 | adminDomain := p.app.GetConfig().Http.FullDomain() | 101 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 117 | logger.Debug("Configuring proxy routes", "admin_domain", adminDomain) | 102 | logger.Debug("Configuring proxy routes", "admin_domain", adminDomain) | ||
| 118 | 103 | ||||
| 119 | // Verify admin domain is in routes - if not, we need to add it | 104 | // Verify admin domain is in routes - if not, we need to add it | ||
| 120 | - | p.mu.RLock() | |||
| 121 | _, adminRouteExists := p.routes[adminDomain] | 105 | _, adminRouteExists := p.routes[adminDomain] | ||
| 122 | - | p.mu.RUnlock() | |||
| 123 | 106 | ||||
| 124 | if !adminRouteExists { | 107 | if !adminRouteExists { | ||
| 125 | - | logger.Warn("Admin domain route is missing, attempting to recreate it") | 108 | + | // Create new admin route |
| 126 | - | p.mu.Lock() | 109 | + | p.routes[adminDomain] = p.createAdminRoute() |
| 127 | - | // Auto-detect the Gordon container name | |||
| 128 | - | containerName := p.detectGordonContainer() | |||
| 129 | - | ||||
| 130 | - | // In a container environment, we need to use the host container IP | |||
| 131 | - | // instead of 127.0.0.1 because each container has its own localhost | |||
| 132 | - | containerIP := "localhost" // Default to localhost for most reliable connectivity | |||
| 133 | - | ||||
| 134 | - | // Fall back options for container IP | |||
| 135 | - | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |||
| 136 | - | // Allow explicit configuration via env var | |||
| 137 | - | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |||
| 138 | - | } else if os.Getenv("HOSTNAME") != "" { | |||
| 139 | - | // Use container's own hostname as they're on the same network | |||
| 140 | - | containerIP = os.Getenv("HOSTNAME") | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | p.routes[adminDomain] = &ProxyRouteInfo{ | |||
| 144 | - | Domain: adminDomain, | |||
| 145 | - | ContainerIP: containerIP, | |||
| 146 | - | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 147 | - | ContainerID: containerName, | |||
| 148 | - | Protocol: "http", // Gordon server uses HTTP internally | |||
| 149 | - | Path: "/", | |||
| 150 | - | Active: true, | |||
| 151 | - | } | |||
| 152 | logger.Info("Recreated admin domain route", | 110 | logger.Info("Recreated admin domain route", | ||
| 153 | "domain", adminDomain, | 111 | "domain", adminDomain, | ||
| 154 | - | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | 112 | + | "target", fmt.Sprintf("http://%s:%s", p.routes[adminDomain].ContainerIP, p.routes[adminDomain].ContainerPort)) |
| 155 | - | p.mu.Unlock() | |||
| 156 | } else { | 113 | } else { | ||
| 157 | logger.Debug("Admin domain route exists", "domain", adminDomain) | 114 | logger.Debug("Admin domain route exists", "domain", adminDomain) | ||
| 158 | } | 115 | } | ||
| 159 | 116 | ||||
| 160 | - | // Handler function - will be assigned to both HTTP and HTTPS servers | 117 | + | // Create handler function for both HTTP and HTTPS servers |
| 161 | - | handler := echo.HandlerFunc(func(c echo.Context) error { | 118 | + | handler := p.createProxyHandler() |
| 162 | - | host := c.Request().Host | |||
| 163 | - | ||||
| 164 | - | // Strip port if present in the host | |||
| 165 | - | if hostParts := strings.Split(host, ":"); len(hostParts) > 1 { | |||
| 166 | - | host = hostParts[0] | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | // Normalize host (trim trailing dot, convert to lowercase) | |||
| 170 | - | host = strings.TrimSuffix(strings.ToLower(host), ".") | |||
| 171 | - | ||||
| 172 | - | // Check if this is the admin domain | |||
| 173 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 174 | - | if strings.EqualFold(host, adminDomain) { | |||
| 175 | - | // Always prioritize admin domain | |||
| 176 | - | p.mu.RLock() | |||
| 177 | - | adminRoute, adminOk := p.routes[adminDomain] | |||
| 178 | - | p.mu.RUnlock() | |||
| 179 | - | ||||
| 180 | - | if adminOk { | |||
| 181 | - | return p.proxyRequest(c, adminRoute) | |||
| 182 | - | } | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | // Important: Get the route freshly from the map for every request | |||
| 186 | - | // This ensures we always use the most up-to-date IP | |||
| 187 | - | p.mu.RLock() | |||
| 188 | - | route, ok := p.routes[host] | |||
| 189 | - | p.mu.RUnlock() | |||
| 190 | - | ||||
| 191 | - | if !ok { | |||
| 192 | - | // Check if the host is an IP address - silently handle without logging warnings | |||
| 193 | - | if net.ParseIP(host) != nil { | |||
| 194 | - | // For IP-based requests, just return a 404 without logging warnings | |||
| 195 | - | return c.String(http.StatusNotFound, "Domain not found") | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // Create list of available domains for debugging | |||
| 199 | - | availableDomains := make([]string, 0, len(p.routes)) | |||
| 200 | - | p.mu.RLock() | |||
| 201 | - | for d := range p.routes { | |||
| 202 | - | availableDomains = append(availableDomains, d) | |||
| 203 | - | } | |||
| 204 | - | p.mu.RUnlock() | |||
| 205 | - | ||||
| 206 | - | // Log warning for non-IP hosts that aren't configured | |||
| 207 | - | logger.Warn("Request with unknown host", | |||
| 208 | - | "requested_host", host, | |||
| 209 | - | "client_ip", c.RealIP(), | |||
| 210 | - | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 211 | - | return c.String(http.StatusNotFound, "Domain not found") | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | // Check if the route is active | |||
| 215 | - | if !route.Active { | |||
| 216 | - | logger.Warn("Route is not active", | |||
| 217 | - | "domain", host, | |||
| 218 | - | "client_ip", c.RealIP()) | |||
| 219 | - | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 220 | - | } | |||
| 221 | - | return p.proxyRequest(c, route) | |||
| 222 | - | }) | |||
| 223 | 119 | ||||
| 224 | // Add the handler to the HTTPS server | 120 | // Add the handler to the HTTPS server | ||
| 225 | if p.httpsServer != nil { | 121 | if p.httpsServer != nil { | ||
| @@ -237,8 +133,8 @@ func (p *Proxy) configureRoutes() { | |||||
| 237 | // Note: Regardless of the external protocol, all internal connections to containers use HTTP. | 133 | // Note: Regardless of the external protocol, all internal connections to containers use HTTP. | ||
| 238 | // The HTTPS protocol is only used for external connections and certificates. | 134 | // The HTTPS protocol is only used for external connections and certificates. | ||
| 239 | func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error { | 135 | func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error { | ||
| 240 | - | // Normalize domain name (lowercase, no trailing dot) | 136 | + | // Normalize domain name |
| 241 | - | domainName = strings.TrimSuffix(strings.ToLower(domainName), ".") | 137 | + | domainName = normalizeDomain(domainName) |
| 242 | 138 | ||||
| 243 | logger.Debug("Adding proxy route", | 139 | logger.Debug("Adding proxy route", | ||
| 244 | "domain", domainName, | 140 | "domain", domainName, | ||
| @@ -256,15 +152,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 256 | 152 | ||||
| 257 | // Add the route to memory immediately for better UX | 153 | // Add the route to memory immediately for better UX | ||
| 258 | p.mu.Lock() | 154 | p.mu.Lock() | ||
| 259 | - | p.routes[domainName] = &ProxyRouteInfo{ | 155 | + | p.routes[domainName] = createRouteInfo(domainName, containerID, containerIP, containerPort, protocol, path, true) |
| 260 | - | Domain: domainName, | |||
| 261 | - | ContainerID: containerID, | |||
| 262 | - | ContainerIP: containerIP, | |||
| 263 | - | ContainerPort: containerPort, | |||
| 264 | - | Protocol: protocol, | |||
| 265 | - | Path: path, | |||
| 266 | - | Active: true, | |||
| 267 | - | } | |||
| 268 | p.mu.Unlock() | 156 | p.mu.Unlock() | ||
| 269 | 157 | ||||
| 270 | // Return success - the event listener will handle the database update | 158 | // Return success - the event listener will handle the database update | ||
| @@ -324,37 +212,8 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 324 | logger.Debug("Added domain to database for Let's Encrypt", | 212 | logger.Debug("Added domain to database for Let's Encrypt", | ||
| 325 | "domain", hostname) | 213 | "domain", hostname) | ||
| 326 | 214 | ||||
| 327 | - | // Also add the subdomain.domain format to the database if this is a domain with components | 215 | + | // Process subdomain if needed |
| 328 | - | hostParts := strings.Split(hostname, ".") | 216 | + | p.processNewSubdomain(tx, hostname, accountID, now) |
| 329 | - | if len(hostParts) >= 3 { | |||
| 330 | - | // This appears to be in format subdomain.domain.tld | |||
| 331 | - | // Extract subdomain and domain parts | |||
| 332 | - | subdomain := hostParts[0] | |||
| 333 | - | domainPart := strings.Join(hostParts[1:], ".") | |||
| 334 | - | ||||
| 335 | - | // Check if the domain part already exists | |||
| 336 | - | var domainPartID string | |||
| 337 | - | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID) | |||
| 338 | - | if err != nil { | |||
| 339 | - | if err == sql.ErrNoRows { | |||
| 340 | - | // Domain part doesn't exist, create it | |||
| 341 | - | domainPartID = proxyGenerateUUID() | |||
| 342 | - | _, err = txExecWithRetry(tx, | |||
| 343 | - | p.queries.InsertDomain, | |||
| 344 | - | domainPartID, domainPart, accountID, now, now, | |||
| 345 | - | ) | |||
| 346 | - | if err != nil { | |||
| 347 | - | logger.Warn("Failed to insert domain part, continuing anyway", | |||
| 348 | - | "domain_part", domainPart, | |||
| 349 | - | "error", err) | |||
| 350 | - | } else { | |||
| 351 | - | logger.Debug("Added domain part to database for Let's Encrypt", | |||
| 352 | - | "domain_part", domainPart, | |||
| 353 | - | "subdomain", subdomain) | |||
| 354 | - | } | |||
| 355 | - | } | |||
| 356 | - | } | |||
| 357 | - | } | |||
| 358 | } else { | 217 | } else { | ||
| 359 | logger.Error("Failed to query domain", | 218 | logger.Error("Failed to query domain", | ||
| 360 | "domain", hostname, | 219 | "domain", hostname, | ||
| @@ -439,27 +298,23 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 439 | // Register this container to prevent duplicate processing | 298 | // Register this container to prevent duplicate processing | ||
| 440 | p.RegisterNewlyCreatedContainer(containerID) | 299 | p.RegisterNewlyCreatedContainer(containerID) | ||
| 441 | 300 | ||||
| 442 | - | // Update the in-memory routes map immediately for better UX | 301 | + | // Lock to update the in-memory routes map and reload routes |
| 443 | p.mu.Lock() | 302 | p.mu.Lock() | ||
| 444 | - | p.routes[hostname] = &ProxyRouteInfo{ | 303 | + | |
| 445 | - | Domain: hostname, | 304 | + | // Update the in-memory routes map |
| 446 | - | ContainerID: containerID, | 305 | + | hostname = domainName |
| 447 | - | ContainerIP: containerIP, | 306 | + | p.routes[hostname] = createRouteInfo(hostname, containerID, containerIP, containerPort, protocol, path, true) |
| 448 | - | ContainerPort: containerPort, | 307 | + | |
| 449 | - | Protocol: protocol, | 308 | + | // Reload the routes directly while holding the lock |
| 450 | - | Path: path, | 309 | + | // This avoids the race condition from concurrent reload operations |
| 451 | - | Active: true, | 310 | + | if err := p.Reload(); err != nil { |
| 311 | + | logger.Error("Failed to reload routes after adding route", | |||
| 312 | + | "domain", hostname, | |||
| 313 | + | "error", err) | |||
| 452 | } | 314 | } | ||
| 453 | - | p.mu.Unlock() | |||
| 454 | 315 | ||||
| 455 | - | // Reload the routes in the background to avoid blocking | 316 | + | // Unlock before continuing with certificate operations |
| 456 | - | go func() { | 317 | + | p.mu.Unlock() |
| 457 | - | if err := p.Reload(); err != nil { | |||
| 458 | - | logger.Error("Failed to reload routes after adding route", | |||
| 459 | - | "domain", hostname, | |||
| 460 | - | "error", err) | |||
| 461 | - | } | |||
| 462 | - | }() | |||
| 463 | 318 | ||||
| 464 | logger.Info("Added proxy route", | 319 | logger.Info("Added proxy route", | ||
| 465 | "domain", hostname, | 320 | "domain", hostname, | ||
| @@ -488,10 +343,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 488 | // RemoveRoute removes a route from the database and reloads the proxy | 343 | // RemoveRoute removes a route from the database and reloads the proxy | ||
| 489 | func (p *Proxy) RemoveRoute(domainName string) error { | 344 | func (p *Proxy) RemoveRoute(domainName string) error { | ||
| 490 | // Prevent removing the admin domain | 345 | // Prevent removing the admin domain | ||
| 491 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | 346 | + | if err := p.checkAdminDomainProtection(domainName); err != nil { |
| 492 | - | if domainName == adminDomain { | |||
| 493 | - | logger.Warn("Attempt to remove admin domain route prevented", | |||
| 494 | - | "domain", domainName) | |||
| 495 | return fmt.Errorf("cannot remove admin domain route: %s", domainName) | 347 | return fmt.Errorf("cannot remove admin domain route: %s", domainName) | ||
| 496 | } | 348 | } | ||
| 497 | 349 | ||||
| @@ -512,16 +364,9 @@ func (p *Proxy) RemoveRoute(domainName string) error { | |||||
| 512 | return fmt.Errorf("failed to query domain: %w", err) | 364 | return fmt.Errorf("failed to query domain: %w", err) | ||
| 513 | } | 365 | } | ||
| 514 | 366 | ||||
| 515 | - | // Delete the route with retry | 367 | + | // Delete the route and domain from the database |
| 516 | - | _, err = txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID) | 368 | + | if err := p.deleteRouteWithTransaction(tx, domainID); err != nil { |
| 517 | - | if err != nil { | 369 | + | return err |
| 518 | - | return fmt.Errorf("failed to delete route: %w", err) | |||
| 519 | - | } | |||
| 520 | - | ||||
| 521 | - | // Delete the domain with retry | |||
| 522 | - | _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID) | |||
| 523 | - | if err != nil { | |||
| 524 | - | return fmt.Errorf("failed to delete domain: %w", err) | |||
| 525 | } | 370 | } | ||
| 526 | 371 | ||||
| 527 | // Commit the transaction | 372 | // Commit the transaction | ||
| @@ -556,27 +401,17 @@ func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |||||
| 556 | func (p *Proxy) Reload() error { | 401 | func (p *Proxy) Reload() error { | ||
| 557 | logger.Info("Reloading proxy routes from database") | 402 | logger.Info("Reloading proxy routes from database") | ||
| 558 | 403 | ||||
| 559 | - | // Get admin domain - this should be the full subdomain.domain format | 404 | + | // Get admin domain |
| 560 | adminDomain := p.app.GetConfig().Http.FullDomain() | 405 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 561 | - | // Normalize the admin domain (lowercase, no trailing dot) | 406 | + | // Normalize the admin domain |
| 562 | - | adminDomain = strings.TrimSuffix(strings.ToLower(adminDomain), ".") | 407 | + | adminDomain = normalizeDomain(adminDomain) |
| 408 | + | ||||
| 409 | + | // Lock for the entire reload operation to prevent concurrent modifications | |||
| 410 | + | p.mu.Lock() | |||
| 411 | + | defer p.mu.Unlock() | |||
| 563 | 412 | ||||
| 564 | // Save existing admin route if it exists | 413 | // Save existing admin route if it exists | ||
| 565 | - | p.mu.RLock() | 414 | + | adminRoute := p.saveAdminRoute() |
| 566 | - | var adminRoute *ProxyRouteInfo | |||
| 567 | - | if route, exists := p.routes[adminDomain]; exists { | |||
| 568 | - | adminRoute = &ProxyRouteInfo{ | |||
| 569 | - | Domain: route.Domain, | |||
| 570 | - | ContainerID: route.ContainerID, | |||
| 571 | - | ContainerIP: route.ContainerIP, | |||
| 572 | - | ContainerPort: route.ContainerPort, | |||
| 573 | - | Protocol: route.Protocol, | |||
| 574 | - | Path: route.Path, | |||
| 575 | - | Active: route.Active, | |||
| 576 | - | } | |||
| 577 | - | logger.Debug("Preserved admin domain route during reload", "domain", adminDomain) | |||
| 578 | - | } | |||
| 579 | - | p.mu.RUnlock() | |||
| 580 | 415 | ||||
| 581 | // Create a new route map instead of modifying the existing one | 416 | // Create a new route map instead of modifying the existing one | ||
| 582 | // This ensures a clean reload without any potential race conditions | 417 | // This ensures a clean reload without any potential race conditions | ||
| @@ -602,19 +437,11 @@ func (p *Proxy) Reload() error { | |||||
| 602 | return fmt.Errorf("failed to scan row during reload: %w", err) | 437 | return fmt.Errorf("failed to scan row during reload: %w", err) | ||
| 603 | } | 438 | } | ||
| 604 | 439 | ||||
| 605 | - | // Normalize domain (lowercase, no trailing dot) | 440 | + | // Normalize domain |
| 606 | - | domain = strings.TrimSuffix(strings.ToLower(domain), ".") | 441 | + | domain = normalizeDomain(domain) |
| 607 | 442 | ||||
| 608 | // Add the route to the new map | 443 | // Add the route to the new map | ||
| 609 | - | newRoutes[domain] = &ProxyRouteInfo{ | 444 | + | newRoutes[domain] = createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path, active) |
| 610 | - | Domain: domain, | |||
| 611 | - | ContainerID: containerID, | |||
| 612 | - | ContainerIP: containerIP, | |||
| 613 | - | ContainerPort: containerPort, | |||
| 614 | - | Protocol: protocol, | |||
| 615 | - | Path: path, | |||
| 616 | - | Active: active, | |||
| 617 | - | } | |||
| 618 | 445 | ||||
| 619 | logger.Debug("Loaded route during reload", | 446 | logger.Debug("Loaded route during reload", | ||
| 620 | "domain", domain, | 447 | "domain", domain, | ||
| @@ -654,38 +481,18 @@ func (p *Proxy) Reload() error { | |||||
| 654 | 481 | ||||
| 655 | // If admin domain is still missing, recreate it | 482 | // If admin domain is still missing, recreate it | ||
| 656 | if _, exists := newRoutes[adminDomain]; !exists { | 483 | if _, exists := newRoutes[adminDomain]; !exists { | ||
| 657 | - | logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain) | 484 | + | // Create a new admin route |
| 658 | - | 485 | + | adminRoute := p.createAdminRoute() | |
| 659 | - | // Auto-detect the Gordon container information | 486 | + | newRoutes[adminDomain] = adminRoute |
| 660 | - | containerName := p.detectGordonContainer() | |||
| 661 | - | containerIP := "localhost" // Default to localhost | |||
| 662 | - | ||||
| 663 | - | // Fall back options for container IP | |||
| 664 | - | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |||
| 665 | - | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |||
| 666 | - | } else if os.Getenv("HOSTNAME") != "" { | |||
| 667 | - | containerIP = os.Getenv("HOSTNAME") | |||
| 668 | - | } | |||
| 669 | - | ||||
| 670 | - | // Add to in-memory routes | |||
| 671 | - | newRoutes[adminDomain] = &ProxyRouteInfo{ | |||
| 672 | - | Domain: adminDomain, | |||
| 673 | - | ContainerID: containerName, | |||
| 674 | - | ContainerIP: containerIP, | |||
| 675 | - | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 676 | - | Protocol: "http", // Gordon server uses HTTP internally | |||
| 677 | - | Path: "/", | |||
| 678 | - | Active: true, | |||
| 679 | - | } | |||
| 680 | 487 | ||||
| 681 | // Add to database | 488 | // Add to database | ||
| 682 | err := p.AddRoute( | 489 | err := p.AddRoute( | ||
| 683 | adminDomain, | 490 | adminDomain, | ||
| 684 | - | containerName, | 491 | + | adminRoute.ContainerID, |
| 685 | - | containerIP, | 492 | + | adminRoute.ContainerIP, |
| 686 | - | p.app.GetConfig().Http.Port, | 493 | + | adminRoute.ContainerPort, |
| 687 | - | "http", | 494 | + | adminRoute.Protocol, |
| 688 | - | "/", | 495 | + | adminRoute.Path, |
| 689 | ) | 496 | ) | ||
| 690 | 497 | ||||
| 691 | if err != nil { | 498 | if err != nil { | ||
| @@ -697,24 +504,22 @@ func (p *Proxy) Reload() error { | |||||
| 697 | } | 504 | } | ||
| 698 | } | 505 | } | ||
| 699 | 506 | ||||
| 700 | - | // Atomically swap the new routes map with the old one | 507 | + | // Update the routes map with the new one |
| 701 | - | // This ensures that no requests use a partially updated map | |||
| 702 | - | p.mu.Lock() | |||
| 703 | p.routes = newRoutes | 508 | p.routes = newRoutes | ||
| 704 | - | p.mu.Unlock() | 509 | + | // Note: We don't need to call configureRoutes() here because it would acquire the same lock |
| 510 | + | // that we already hold. Instead, we do the work directly. | |||
| 705 | 511 | ||||
| 706 | - | // Rebuild route configuration | 512 | + | // Define the handler using our helper function |
| 707 | - | p.configureRoutes() | 513 | + | handler := p.createProxyHandler() |
| 708 | 514 | ||||
| 709 | - | // Force recreation of the route handlers if servers are initialized | 515 | + | // Add the handler to the HTTPS server |
| 710 | - | if p.httpServer != nil { | 516 | + | if p.httpsServer != nil { |
| 711 | - | logger.Debug("Refreshing HTTP routes") | 517 | + | p.httpsServer.Any("/*", handler) |
| 712 | - | p.httpServer.Routes() | |||
| 713 | } | 518 | } | ||
| 714 | 519 | ||||
| 715 | - | if p.httpsServer != nil { | 520 | + | // Also add the handler to the HTTP server for HTTP-01 challenges |
| 716 | - | logger.Debug("Refreshing HTTPS routes") | 521 | + | if p.httpServer != nil { |
| 717 | - | p.httpsServer.Routes() | 522 | + | p.httpServer.Any("/*", handler) |
| 718 | } | 523 | } | ||
| 719 | 524 | ||||
| 720 | return nil | 525 | return nil | ||
| @@ -743,91 +548,14 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error { | |||||
| 743 | "old_ip", route.ContainerIP, | 548 | "old_ip", route.ContainerIP, | ||
| 744 | "new_ip", newIP) | 549 | "new_ip", newIP) | ||
| 745 | 550 | ||||
| 746 | - | // Double-check that the container exists and is running | 551 | + | // Verify and normalize the container IP |
| 747 | - | containerInfo, err := docker.GetContainerInfo(containerID) | 552 | + | newIP = p.verifyAndNormalizeContainerIP(containerID, newIP) |
| 748 | - | if err != nil { | |||
| 749 | - | logger.Warn("Container not found when updating route IP, might have been recreated", | |||
| 750 | - | "domain", domain, | |||
| 751 | - | "container_id", containerID, | |||
| 752 | - | "error", err) | |||
| 753 | - | // Continue with the update anyway, as the container ID might still be valid | |||
| 754 | - | } else { | |||
| 755 | - | logger.Debug("Container exists when updating route IP", | |||
| 756 | - | "domain", domain, | |||
| 757 | - | "container_id", containerID, | |||
| 758 | - | "container_state", containerInfo.State.Status) | |||
| 759 | - | ||||
| 760 | - | // Verify the new IP matches what's in the container | |||
| 761 | - | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 762 | - | if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil { | |||
| 763 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && | |||
| 764 | - | networkSettings.IPAddress != "" && networkSettings.IPAddress != newIP { | |||
| 765 | - | logger.Warn("New IP doesn't match container network IP, using container's actual IP", | |||
| 766 | - | "domain", domain, | |||
| 767 | - | "provided_ip", newIP, | |||
| 768 | - | "container_actual_ip", networkSettings.IPAddress) | |||
| 769 | - | newIP = networkSettings.IPAddress | |||
| 770 | - | } | |||
| 771 | - | } | |||
| 772 | - | } | |||
| 773 | 553 | ||||
| 774 | // First, update the in-memory route immediately | 554 | // First, update the in-memory route immediately | ||
| 775 | - | p.mu.Lock() | 555 | + | p.forceUpdateRouteIPInMemory(domain, newIP) |
| 776 | - | if r, ok := p.routes[domain]; ok { | |||
| 777 | - | oldIP := r.ContainerIP | |||
| 778 | - | r.ContainerIP = newIP | |||
| 779 | - | // Make sure the route is also marked as active | |||
| 780 | - | r.Active = true | |||
| 781 | - | logger.Info("Force updated route IP in-memory", | |||
| 782 | - | "domain", domain, | |||
| 783 | - | "old_ip", oldIP, | |||
| 784 | - | "new_ip", newIP) | |||
| 785 | - | } | |||
| 786 | - | p.mu.Unlock() | |||
| 787 | 556 | ||||
| 788 | // Next, update the database | 557 | // Next, update the database | ||
| 789 | - | // Get the domain ID with retry | 558 | + | return p.updateRouteIPInDatabase(domain, containerID, newIP) |
| 790 | - | var domainID string | |||
| 791 | - | rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain) | |||
| 792 | - | if err != nil { | |||
| 793 | - | logger.Error("Failed to query domain for IP update", "domain", domain, "error", err) | |||
| 794 | - | return fmt.Errorf("failed to query domain: %w", err) | |||
| 795 | - | } | |||
| 796 | - | ||||
| 797 | - | if !rows.Next() { | |||
| 798 | - | rows.Close() | |||
| 799 | - | logger.Error("Domain not found in database when updating IP", "domain", domain) | |||
| 800 | - | return fmt.Errorf("domain not found: %s", domain) | |||
| 801 | - | } | |||
| 802 | - | ||||
| 803 | - | if err := rows.Scan(&domainID); err != nil { | |||
| 804 | - | rows.Close() | |||
| 805 | - | logger.Error("Failed to scan domain ID for IP update", "domain", domain, "error", err) | |||
| 806 | - | return fmt.Errorf("failed to scan domain ID: %w", err) | |||
| 807 | - | } | |||
| 808 | - | rows.Close() | |||
| 809 | - | ||||
| 810 | - | // Update the proxy_route record with retry and ensure it's marked as active | |||
| 811 | - | now := time.Now().Format(time.RFC3339) | |||
| 812 | - | result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID) | |||
| 813 | - | ||||
| 814 | - | if err != nil { | |||
| 815 | - | logger.Error("Failed to update IP in database", "domain", domain, "error", err) | |||
| 816 | - | return fmt.Errorf("failed to update database: %w", err) | |||
| 817 | - | } | |||
| 818 | - | ||||
| 819 | - | rowsAffected, _ := result.RowsAffected() | |||
| 820 | - | if rowsAffected == 0 { | |||
| 821 | - | logger.Warn("No rows affected when updating route IP in database", "domain", domain) | |||
| 822 | - | return fmt.Errorf("no rows affected when updating route in database") | |||
| 823 | - | } | |||
| 824 | - | ||||
| 825 | - | logger.Info("Force updated route IP in database", | |||
| 826 | - | "domain", domain, | |||
| 827 | - | "container_id", containerID, | |||
| 828 | - | "new_ip", newIP) | |||
| 829 | - | ||||
| 830 | - | return nil | |||
| 831 | } | 559 | } | ||
| 832 | 560 | ||||
| 833 | // FindRoutesByContainerName finds routes associated with a specific container name | 561 | // FindRoutesByContainerName finds routes associated with a specific container name | ||
| @@ -992,8 +720,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI | |||||
| 992 | 720 | ||||
| 993 | // First approach: check if there are any domains that should point to this container based on labels | 721 | // First approach: check if there are any domains that should point to this container based on labels | ||
| 994 | if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" { | 722 | if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" { | ||
| 995 | - | // Normalize domain (lowercase, no trailing dot) | 723 | + | // Normalize domain |
| 996 | - | domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".") | 724 | + | domainLabel = normalizeDomain(domainLabel) |
| 997 | 725 | ||||
| 998 | // Check if there's already a route for this domain | 726 | // Check if there's already a route for this domain | ||
| 999 | p.mu.RLock() | 727 | p.mu.RLock() | ||
| @@ -1008,28 +736,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI | |||||
| 1008 | "old_container_id", route.ContainerID, | 736 | "old_container_id", route.ContainerID, | ||
| 1009 | "new_container_id", containerID) | 737 | "new_container_id", containerID) | ||
| 1010 | 738 | ||||
| 1011 | - | // Get the port to use for the proxy from the container labels | 739 | + | // Update route for this container |
| 1012 | - | containerPort := "80" // Default | 740 | + | err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, route.Path) |
| 1013 | - | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 1014 | - | containerPort = portLabel | |||
| 1015 | - | } | |||
| 1016 | - | ||||
| 1017 | - | // Get the protocol to use | |||
| 1018 | - | protocol := "http" // Default | |||
| 1019 | - | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 1020 | - | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 1021 | - | protocol = "https" | |||
| 1022 | - | } | |||
| 1023 | - | ||||
| 1024 | - | // Update the route with the new container ID and IP | |||
| 1025 | - | err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, route.Path) | |||
| 1026 | if err != nil { | 741 | if err != nil { | ||
| 1027 | logger.Error("Failed to update route for recreated container", "error", err) | 742 | logger.Error("Failed to update route for recreated container", "error", err) | ||
| 1028 | - | } else { | |||
| 1029 | - | logger.Info("Updated route for recreated container", | |||
| 1030 | - | "domain", domainLabel, | |||
| 1031 | - | "container_id", containerID, | |||
| 1032 | - | "container_ip", containerIP) | |||
| 1033 | } | 743 | } | ||
| 1034 | } else { | 744 | } else { | ||
| 1035 | // Same container ID but maybe IP changed | 745 | // Same container ID but maybe IP changed | ||
| @@ -1053,27 +763,8 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI | |||||
| 1053 | "domain", domainLabel, | 763 | "domain", domainLabel, | ||
| 1054 | "container_id", containerID) | 764 | "container_id", containerID) | ||
| 1055 | 765 | ||||
| 1056 | - | // Get the port to use for the proxy from the container labels | |||
| 1057 | - | containerPort := "80" // Default | |||
| 1058 | - | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 1059 | - | containerPort = portLabel | |||
| 1060 | - | } | |||
| 1061 | - | ||||
| 1062 | - | // Get the protocol to use | |||
| 1063 | - | protocol := "http" // Default | |||
| 1064 | - | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 1065 | - | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 1066 | - | protocol = "https" | |||
| 1067 | - | } | |||
| 1068 | - | ||||
| 1069 | // Add a new route for this container | 766 | // Add a new route for this container | ||
| 1070 | - | logger.Info("Adding missing route for Gordon container", | 767 | + | err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "") |
| 1071 | - | "domain", domainLabel, | |||
| 1072 | - | "container_id", containerID, | |||
| 1073 | - | "container_ip", containerIP, | |||
| 1074 | - | "container_port", containerPort) | |||
| 1075 | - | ||||
| 1076 | - | err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/") | |||
| 1077 | if err != nil { | 768 | if err != nil { | ||
| 1078 | logger.Error("Failed to add missing route", "error", err) | 769 | logger.Error("Failed to add missing route", "error", err) | ||
| 1079 | } | 770 | } | ||
| @@ -1093,33 +784,10 @@ func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerI | |||||
| 1093 | "old_container_id", oldRoute.ContainerID, | 784 | "old_container_id", oldRoute.ContainerID, | ||
| 1094 | "new_container_id", containerID) | 785 | "new_container_id", containerID) | ||
| 1095 | 786 | ||||
| 1096 | - | // Get the port to use for the proxy from the container labels | 787 | + | // Update route for this container, preserving the old path |
| 1097 | - | containerPort := oldRoute.ContainerPort // Use the existing port by default | 788 | + | err = p.updateRouteForContainer(domain, containerID, containerIP, containerInfo, oldRoute.Path) |
| 1098 | - | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 1099 | - | // But prefer the container label if present | |||
| 1100 | - | containerPort = portLabel | |||
| 1101 | - | } | |||
| 1102 | - | ||||
| 1103 | - | // Get the protocol to use | |||
| 1104 | - | protocol := oldRoute.Protocol // Use existing protocol by default | |||
| 1105 | - | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists { | |||
| 1106 | - | // But prefer the container label if present | |||
| 1107 | - | if sslLabel == "true" || sslLabel == "1" || sslLabel == "yes" { | |||
| 1108 | - | protocol = "https" | |||
| 1109 | - | } else { | |||
| 1110 | - | protocol = "http" | |||
| 1111 | - | } | |||
| 1112 | - | } | |||
| 1113 | - | ||||
| 1114 | - | // Update the route | |||
| 1115 | - | err = p.AddRoute(domain, containerID, containerIP, containerPort, protocol, oldRoute.Path) | |||
| 1116 | if err != nil { | 789 | if err != nil { | ||
| 1117 | logger.Error("Failed to update route for recreated container", "error", err) | 790 | logger.Error("Failed to update route for recreated container", "error", err) | ||
| 1118 | - | } else { | |||
| 1119 | - | logger.Info("Updated route for recreated container", | |||
| 1120 | - | "domain", domain, | |||
| 1121 | - | "container_id", containerID, | |||
| 1122 | - | "container_ip", containerIP) | |||
| 1123 | } | 791 | } | ||
| 1124 | } | 792 | } | ||
| 1125 | } | 793 | } | ||
| @@ -1143,150 +811,13 @@ func (p *Proxy) StartContainerEventListener() error { | |||||
| 1143 | "container_name", containerName, | 811 | "container_name", containerName, | ||
| 1144 | "container_ip", containerIP) | 812 | "container_ip", containerIP) | ||
| 1145 | 813 | ||||
| 1146 | - | // Skip processing if container is in cooldown period | |||
| 1147 | - | if p.IsContainerInCooldownPeriod(containerID) { | |||
| 1148 | - | logger.Debug("Skipping container event processing - container in cooldown period", | |||
| 1149 | - | "container_id", containerID, | |||
| 1150 | - | "container_name", containerName) | |||
| 1151 | - | return | |||
| 1152 | - | } | |||
| 1153 | - | ||||
| 1154 | - | // Register this container as recently created to prevent duplicate processing | |||
| 1155 | - | p.RegisterNewlyCreatedContainer(containerID) | |||
| 1156 | - | ||||
| 1157 | // Use a goroutine to handle the event asynchronously to avoid blocking the event listener | 814 | // Use a goroutine to handle the event asynchronously to avoid blocking the event listener | ||
| 1158 | go func() { | 815 | go func() { | ||
| 1159 | // Add a small delay to allow any direct API calls to complete first | 816 | // Add a small delay to allow any direct API calls to complete first | ||
| 1160 | time.Sleep(500 * time.Millisecond) | 817 | time.Sleep(500 * time.Millisecond) | ||
| 1161 | 818 | ||||
| 1162 | - | // Check if this is the Gordon container (admin domain) | 819 | + | // Process the container event |
| 1163 | - | isAdminContainer := false | 820 | + | p.processContainerEvent(containerID, containerName, containerIP) |
| 1164 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1165 | - | ||||
| 1166 | - | p.mu.RLock() | |||
| 1167 | - | if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID { | |||
| 1168 | - | isAdminContainer = true | |||
| 1169 | - | logger.Debug("Admin container event detected", "container_id", containerID) | |||
| 1170 | - | } | |||
| 1171 | - | p.mu.RUnlock() | |||
| 1172 | - | ||||
| 1173 | - | // First, check if there are any existing routes for this container ID | |||
| 1174 | - | var existingRoutes = make(map[string]*ProxyRouteInfo) | |||
| 1175 | - | p.mu.RLock() | |||
| 1176 | - | for domain, route := range p.routes { | |||
| 1177 | - | if route.ContainerID == containerID { | |||
| 1178 | - | existingRoutes[domain] = route | |||
| 1179 | - | } | |||
| 1180 | - | } | |||
| 1181 | - | p.mu.RUnlock() | |||
| 1182 | - | ||||
| 1183 | - | // If we found existing routes, check if the IP has changed | |||
| 1184 | - | if len(existingRoutes) > 0 { | |||
| 1185 | - | logger.Debug("Found existing routes for container", | |||
| 1186 | - | "container_id", containerID, | |||
| 1187 | - | "route_count", len(existingRoutes)) | |||
| 1188 | - | ||||
| 1189 | - | // Verify the container IP with an additional check | |||
| 1190 | - | verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName) | |||
| 1191 | - | if err == nil && verifiedIP != "" && verifiedIP != containerIP { | |||
| 1192 | - | logger.Warn("Container IP mismatch from event vs. direct network check", | |||
| 1193 | - | "container_id", containerID, | |||
| 1194 | - | "event_ip", containerIP, | |||
| 1195 | - | "network_ip", verifiedIP) | |||
| 1196 | - | ||||
| 1197 | - | // Use the directly checked IP as it's more reliable | |||
| 1198 | - | containerIP = verifiedIP | |||
| 1199 | - | } | |||
| 1200 | - | ||||
| 1201 | - | // Check and update each route if IP has changed | |||
| 1202 | - | for domain, route := range existingRoutes { | |||
| 1203 | - | // Handle admin domain specially to avoid path issues | |||
| 1204 | - | if domain == adminDomain && isAdminContainer { | |||
| 1205 | - | // For admin domain, only update the IP if it has changed | |||
| 1206 | - | if route.ContainerIP != containerIP { | |||
| 1207 | - | logger.Info("Admin container restarted with new IP - updating route", | |||
| 1208 | - | "container_id", containerID, | |||
| 1209 | - | "container_name", containerName, | |||
| 1210 | - | "domain", domain, | |||
| 1211 | - | "old_ip", route.ContainerIP, | |||
| 1212 | - | "new_ip", containerIP) | |||
| 1213 | - | ||||
| 1214 | - | // Update IP in memory only first | |||
| 1215 | - | p.mu.Lock() | |||
| 1216 | - | if r, ok := p.routes[domain]; ok { | |||
| 1217 | - | r.ContainerIP = containerIP | |||
| 1218 | - | r.Active = true | |||
| 1219 | - | } | |||
| 1220 | - | p.mu.Unlock() | |||
| 1221 | - | ||||
| 1222 | - | // Update DB in background | |||
| 1223 | - | go func(domain, containerID, containerIP string) { | |||
| 1224 | - | // Get the domain ID | |||
| 1225 | - | var domainID string | |||
| 1226 | - | rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain) | |||
| 1227 | - | if err != nil { | |||
| 1228 | - | logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err) | |||
| 1229 | - | return | |||
| 1230 | - | } | |||
| 1231 | - | ||||
| 1232 | - | if !rows.Next() { | |||
| 1233 | - | rows.Close() | |||
| 1234 | - | logger.Error("Admin domain not found in database", "domain", domain) | |||
| 1235 | - | return | |||
| 1236 | - | } | |||
| 1237 | - | ||||
| 1238 | - | if err := rows.Scan(&domainID); err != nil { | |||
| 1239 | - | rows.Close() | |||
| 1240 | - | logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err) | |||
| 1241 | - | return | |||
| 1242 | - | } | |||
| 1243 | - | rows.Close() | |||
| 1244 | - | ||||
| 1245 | - | // Update the IP in the database directly | |||
| 1246 | - | now := time.Now().Format(time.RFC3339) | |||
| 1247 | - | _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP, | |||
| 1248 | - | containerIP, now, domainID, containerID) | |||
| 1249 | - | ||||
| 1250 | - | if err != nil { | |||
| 1251 | - | logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err) | |||
| 1252 | - | } else { | |||
| 1253 | - | logger.Info("Updated admin container IP in database", | |||
| 1254 | - | "domain", domain, | |||
| 1255 | - | "container_id", containerID, | |||
| 1256 | - | "new_ip", containerIP) | |||
| 1257 | - | } | |||
| 1258 | - | }(domain, containerID, containerIP) | |||
| 1259 | - | } | |||
| 1260 | - | } else if route.ContainerIP != containerIP { | |||
| 1261 | - | // For non-admin domains, use the standard route update | |||
| 1262 | - | logger.Info("Container restarted with new IP - updating route", | |||
| 1263 | - | "container_id", containerID, | |||
| 1264 | - | "container_name", containerName, | |||
| 1265 | - | "domain", domain, | |||
| 1266 | - | "old_ip", route.ContainerIP, | |||
| 1267 | - | "new_ip", containerIP) | |||
| 1268 | - | ||||
| 1269 | - | // Update the route IP | |||
| 1270 | - | err = p.ForceUpdateRouteIP(domain, containerIP) | |||
| 1271 | - | if err != nil { | |||
| 1272 | - | logger.Error("Failed to update IP for restarted container", | |||
| 1273 | - | "domain", domain, | |||
| 1274 | - | "error", err) | |||
| 1275 | - | } | |||
| 1276 | - | } else { | |||
| 1277 | - | // Even if IP is the same, make sure the route is active | |||
| 1278 | - | if !route.Active { | |||
| 1279 | - | logger.Info("Reactivating route for restarted container", | |||
| 1280 | - | "container_id", containerID, | |||
| 1281 | - | "container_name", containerName, | |||
| 1282 | - | "domain", domain) | |||
| 1283 | - | ||||
| 1284 | - | // Just reuse ForceUpdateRouteIP with the same IP to activate the route | |||
| 1285 | - | p.ForceUpdateRouteIP(domain, containerIP) | |||
| 1286 | - | } | |||
| 1287 | - | } | |||
| 1288 | - | } | |||
| 1289 | - | } | |||
| 1290 | 821 | ||||
| 1291 | // Call the standard recreation handler for additional checks | 822 | // Call the standard recreation handler for additional checks | ||
| 1292 | p.HandleContainerRecreation(containerID, containerName, containerIP) | 823 | p.HandleContainerRecreation(containerID, containerName, containerIP) | ||
| @@ -1339,60 +870,15 @@ func (p *Proxy) StartContainerEventListener() error { | |||||
| 1339 | 870 | ||||
| 1340 | // checkRoutesForIPConsistency verifies that all active routes have the correct container IP | 871 | // checkRoutesForIPConsistency verifies that all active routes have the correct container IP | ||
| 1341 | func (p *Proxy) checkRoutesForIPConsistency() { | 872 | func (p *Proxy) checkRoutesForIPConsistency() { | ||
| 873 | + | // Get the network name from app config | |||
| 1342 | networkName := p.app.GetConfig().ContainerEngine.Network | 874 | networkName := p.app.GetConfig().ContainerEngine.Network | ||
| 1343 | 875 | ||||
| 1344 | - | // Get a copy of the current routes | 876 | + | // Get a copy of all active routes |
| 1345 | - | p.mu.RLock() | 877 | + | routesToCheck := p.getActiveRoutes() |
| 1346 | - | routesToCheck := make(map[string]*ProxyRouteInfo) | |||
| 1347 | - | for domain, route := range p.routes { | |||
| 1348 | - | if route.Active { | |||
| 1349 | - | routesToCheck[domain] = &ProxyRouteInfo{ | |||
| 1350 | - | Domain: route.Domain, | |||
| 1351 | - | ContainerID: route.ContainerID, | |||
| 1352 | - | ContainerIP: route.ContainerIP, | |||
| 1353 | - | ContainerPort: route.ContainerPort, | |||
| 1354 | - | Protocol: route.Protocol, | |||
| 1355 | - | Path: route.Path, | |||
| 1356 | - | Active: route.Active, | |||
| 1357 | - | } | |||
| 1358 | - | } | |||
| 1359 | - | } | |||
| 1360 | - | p.mu.RUnlock() | |||
| 1361 | 878 | ||||
| 1362 | - | // Check each active route | 879 | + | // Check each active route for IP consistency |
| 1363 | for domain, route := range routesToCheck { | 880 | for domain, route := range routesToCheck { | ||
| 1364 | - | // Skip checking admin domain | 881 | + | p.checkAndUpdateIPIfNeeded(domain, route, networkName) |
| 1365 | - | if domain == p.app.GetConfig().Http.FullDomain() { | |||
| 1366 | - | continue | |||
| 1367 | - | } | |||
| 1368 | - | ||||
| 1369 | - | // Verify the current container IP | |||
| 1370 | - | currentIP, err := docker.GetContainerIPFromNetwork(route.ContainerID, networkName) | |||
| 1371 | - | if err != nil { | |||
| 1372 | - | // Container might not exist anymore | |||
| 1373 | - | logger.Warn("Failed to get current IP for container during consistency check", | |||
| 1374 | - | "domain", domain, | |||
| 1375 | - | "container_id", route.ContainerID, | |||
| 1376 | - | "error", err) | |||
| 1377 | - | continue | |||
| 1378 | - | } | |||
| 1379 | - | ||||
| 1380 | - | // If IP has changed, update the route | |||
| 1381 | - | if currentIP != "" && currentIP != route.ContainerIP { | |||
| 1382 | - | logger.Info("Detected IP mismatch during consistency check", | |||
| 1383 | - | "domain", domain, | |||
| 1384 | - | "container_id", route.ContainerID, | |||
| 1385 | - | "old_ip", route.ContainerIP, | |||
| 1386 | - | "current_ip", currentIP) | |||
| 1387 | - | ||||
| 1388 | - | // Update the route | |||
| 1389 | - | err = p.ForceUpdateRouteIP(domain, currentIP) | |||
| 1390 | - | if err != nil { | |||
| 1391 | - | logger.Error("Failed to update IP during consistency check", | |||
| 1392 | - | "domain", domain, | |||
| 1393 | - | "error", err) | |||
| 1394 | - | } | |||
| 1395 | - | } | |||
| 1396 | } | 882 | } | ||
| 1397 | } | 883 | } | ||
| 1398 | 884 | ||||
| @@ -1402,28 +888,14 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||||
| 1402 | return | 888 | return | ||
| 1403 | } | 889 | } | ||
| 1404 | 890 | ||||
| 1405 | - | // Get the admin domain to protect it | |||
| 1406 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1407 | - | ||||
| 1408 | // Filter out admin domain from domains to mark inactive | 891 | // Filter out admin domain from domains to mark inactive | ||
| 1409 | - | filteredDomains := make([]string, 0, len(domains)) | 892 | + | filteredDomains := p.filterOutAdminDomain(domains) |
| 1410 | - | for _, domain := range domains { | |||
| 1411 | - | if domain != adminDomain { | |||
| 1412 | - | filteredDomains = append(filteredDomains, domain) | |||
| 1413 | - | } else { | |||
| 1414 | - | logger.Warn("Prevented admin domain from being marked inactive", | |||
| 1415 | - | "domain", adminDomain) | |||
| 1416 | - | } | |||
| 1417 | - | } | |||
| 1418 | 893 | ||||
| 1419 | // If all domains were filtered out, nothing to do | 894 | // If all domains were filtered out, nothing to do | ||
| 1420 | if len(filteredDomains) == 0 { | 895 | if len(filteredDomains) == 0 { | ||
| 1421 | return | 896 | return | ||
| 1422 | } | 897 | } | ||
| 1423 | 898 | ||||
| 1424 | - | // Use the filtered domains from here on | |||
| 1425 | - | domains = filteredDomains | |||
| 1426 | - | ||||
| 1427 | // Begin a transaction | 899 | // Begin a transaction | ||
| 1428 | tx, err := p.app.GetDB().Begin() | 900 | tx, err := p.app.GetDB().Begin() | ||
| 1429 | if err != nil { | 901 | if err != nil { | ||
| @@ -1432,31 +904,17 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||||
| 1432 | } | 904 | } | ||
| 1433 | defer tx.Rollback() | 905 | defer tx.Rollback() | ||
| 1434 | 906 | ||||
| 1435 | - | // Update each route in the database | 907 | + | // Update each route in the database and memory |
| 1436 | - | for _, domain := range domains { | 908 | + | for _, domain := range filteredDomains { |
| 1437 | - | // First, get the domain ID | 909 | + | // Update in database |
| 1438 | - | var domainID string | 910 | + | err := p.markRouteInactiveInDatabase(tx, domain) |
| 1439 | - | err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID) | |||
| 1440 | - | if err != nil { | |||
| 1441 | - | logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err) | |||
| 1442 | - | continue | |||
| 1443 | - | } | |||
| 1444 | - | ||||
| 1445 | - | // Update the route to mark it as inactive | |||
| 1446 | - | now := time.Now().Format(time.RFC3339) | |||
| 1447 | - | _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID) | |||
| 1448 | if err != nil { | 911 | if err != nil { | ||
| 1449 | - | logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err) | 912 | + | // Skip to next domain if there was an error |
| 1450 | continue | 913 | continue | ||
| 1451 | } | 914 | } | ||
| 1452 | 915 | ||||
| 1453 | - | // Also update the in-memory route | 916 | + | // Update in memory |
| 1454 | - | p.mu.Lock() | 917 | + | p.markRouteInactiveInMemory(domain) |
| 1455 | - | if route, exists := p.routes[domain]; exists { | |||
| 1456 | - | route.Active = false | |||
| 1457 | - | logger.Info("Marked route as inactive", "domain", domain) | |||
| 1458 | - | } | |||
| 1459 | - | p.mu.Unlock() | |||
| 1460 | } | 918 | } | ||
| 1461 | 919 | ||||
| 1462 | // Commit the transaction | 920 | // Commit the transaction | ||
| @@ -1465,7 +923,7 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||||
| 1465 | return | 923 | return | ||
| 1466 | } | 924 | } | ||
| 1467 | 925 | ||||
| 1468 | - | logger.Info("Successfully marked orphaned routes as inactive", "count", len(domains)) | 926 | + | logger.Info("Successfully marked orphaned routes as inactive", "count", len(filteredDomains)) |
| 1469 | } | 927 | } | ||
| 1470 | 928 | ||||
| 1471 | // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet | 929 | // DiscoverMissingRoutes finds Gordon-managed containers that don't have routes yet | ||
| @@ -1531,8 +989,8 @@ func (p *Proxy) DiscoverMissingRoutes() { | |||||
| 1531 | continue | 989 | continue | ||
| 1532 | } | 990 | } | ||
| 1533 | 991 | ||||
| 1534 | - | // Normalize domain (lowercase, no trailing dot) | 992 | + | // Normalize domain |
| 1535 | - | domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".") | 993 | + | domainLabel = normalizeDomain(domainLabel) |
| 1536 | 994 | ||||
| 1537 | // Skip if this is the admin domain | 995 | // Skip if this is the admin domain | ||
| 1538 | if domainLabel == p.app.GetConfig().Http.FullDomain() { | 996 | if domainLabel == p.app.GetConfig().Http.FullDomain() { | ||
| @@ -1559,27 +1017,13 @@ func (p *Proxy) DiscoverMissingRoutes() { | |||||
| 1559 | // Extract just the IP part from the CIDR notation (e.g., "192.168.1.2/24" → "192.168.1.2") | 1017 | // Extract just the IP part from the CIDR notation (e.g., "192.168.1.2/24" → "192.168.1.2") | ||
| 1560 | containerIP = strings.Split(containerIP, "/")[0] | 1018 | containerIP = strings.Split(containerIP, "/")[0] | ||
| 1561 | 1019 | ||||
| 1562 | - | // Get the port to use for the proxy from the container labels | |||
| 1563 | - | containerPort := "80" // Default | |||
| 1564 | - | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 1565 | - | containerPort = portLabel | |||
| 1566 | - | } | |||
| 1567 | - | ||||
| 1568 | - | // Get the protocol to use | |||
| 1569 | - | protocol := "http" // Default | |||
| 1570 | - | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 1571 | - | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 1572 | - | protocol = "https" | |||
| 1573 | - | } | |||
| 1574 | - | ||||
| 1575 | // Add a new route for this container | 1020 | // Add a new route for this container | ||
| 1576 | logger.Info("Adding missing route for Gordon container", | 1021 | logger.Info("Adding missing route for Gordon container", | ||
| 1577 | "domain", domainLabel, | 1022 | "domain", domainLabel, | ||
| 1578 | "container_id", containerID, | 1023 | "container_id", containerID, | ||
| 1579 | - | "container_ip", containerIP, | 1024 | + | "container_ip", containerIP) |
| 1580 | - | "container_port", containerPort) | |||
| 1581 | 1025 | ||||
| 1582 | - | err = p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, "/") | 1026 | + | err = p.updateRouteForContainer(domainLabel, containerID, containerIP, containerInfo, "") |
| 1583 | if err != nil { | 1027 | if err != nil { | ||
| 1584 | logger.Error("Failed to add missing route", "error", err) | 1028 | logger.Error("Failed to add missing route", "error", err) | ||
| 1585 | } | 1029 | } | ||
| @@ -1737,6 +1181,7 @@ func (p *Proxy) LogGordonIdentity() { | |||||
| 1737 | } | 1181 | } | ||
| 1738 | } | 1182 | } | ||
| 1739 | 1183 | ||||
| 1184 | + | // detectGordonContainer attempts to find the Gordon container automatically | |||
| 1740 | // detectGordonContainer attempts to find the Gordon container automatically | 1185 | // detectGordonContainer attempts to find the Gordon container automatically | ||
| 1741 | func (p *Proxy) detectGordonContainer() string { | 1186 | func (p *Proxy) detectGordonContainer() string { | ||
| 1742 | logger.Debug("Starting Gordon container detection") | 1187 | logger.Debug("Starting Gordon container detection") | ||
| @@ -1880,83 +1325,11 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1880 | // Create the target URL | 1325 | // Create the target URL | ||
| 1881 | targetURL := &url.URL{ | 1326 | targetURL := &url.URL{ | ||
| 1882 | Scheme: targetProtocol, | 1327 | Scheme: targetProtocol, | ||
| 1328 | + | Host: fmt.Sprintf("%s:%s", formatIPAddress(containerIP), containerPort), | |||
| 1883 | } | 1329 | } | ||
| 1884 | 1330 | ||||
| 1885 | - | // Format host properly for both IPv4 and IPv6 | 1331 | + | // Create a reverse proxy using the helper function |
| 1886 | - | if strings.Contains(containerIP, ":") { | 1332 | + | proxy := p.createReverseProxy(targetURL, host, containerID, isAdminDomain) |
| 1887 | - | // IPv6 address needs to be wrapped in brackets | |||
| 1888 | - | containerIP = "[" + containerIP + "]" | |||
| 1889 | - | } | |||
| 1890 | - | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |||
| 1891 | - | ||||
| 1892 | - | // Create a reverse proxy | |||
| 1893 | - | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |||
| 1894 | - | ||||
| 1895 | - | // Use our custom transport with longer timeouts | |||
| 1896 | - | if p.reverseProxyClient != nil { | |||
| 1897 | - | proxy.Transport = p.reverseProxyClient.Transport | |||
| 1898 | - | } | |||
| 1899 | - | ||||
| 1900 | - | // Set up request director to modify the request before it's sent | |||
| 1901 | - | originalDirector := proxy.Director | |||
| 1902 | - | proxy.Director = func(req *http.Request) { | |||
| 1903 | - | // Call the original director first | |||
| 1904 | - | originalDirector(req) | |||
| 1905 | - | ||||
| 1906 | - | // Keep the original host in the Host header | |||
| 1907 | - | req.Host = host | |||
| 1908 | - | ||||
| 1909 | - | // Set X-Forwarded headers to preserve client information | |||
| 1910 | - | if clientIP := c.RealIP(); clientIP != "" { | |||
| 1911 | - | req.Header.Set("X-Forwarded-For", clientIP) | |||
| 1912 | - | req.Header.Set("X-Real-IP", clientIP) | |||
| 1913 | - | } | |||
| 1914 | - | ||||
| 1915 | - | // Set X-Forwarded-Proto to the original protocol | |||
| 1916 | - | req.Header.Set("X-Forwarded-Proto", c.Scheme()) | |||
| 1917 | - | ||||
| 1918 | - | // Set X-Forwarded-Host to the original host | |||
| 1919 | - | req.Header.Set("X-Forwarded-Host", host) | |||
| 1920 | - | } | |||
| 1921 | - | ||||
| 1922 | - | // Log errors that occur during proxying | |||
| 1923 | - | proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) { | |||
| 1924 | - | logger.Error("Proxy error", | |||
| 1925 | - | "host", host, | |||
| 1926 | - | "target", targetURL.String(), | |||
| 1927 | - | "error", err) | |||
| 1928 | - | ||||
| 1929 | - | // Skip container down detection for admin domain | |||
| 1930 | - | if !isAdminDomain { | |||
| 1931 | - | // Check if this is a connection refused error | |||
| 1932 | - | if strings.Contains(err.Error(), "connection refused") || | |||
| 1933 | - | strings.Contains(err.Error(), "no route to host") || | |||
| 1934 | - | strings.Contains(err.Error(), "i/o timeout") { | |||
| 1935 | - | // Container might be down or unreachable - mark the route as inactive | |||
| 1936 | - | logger.Warn("Container appears to be down, marking route as inactive", | |||
| 1937 | - | "domain", host, | |||
| 1938 | - | "container_id", containerID) | |||
| 1939 | - | ||||
| 1940 | - | // Find domains for this container and mark them inactive | |||
| 1941 | - | var domains []string | |||
| 1942 | - | p.mu.RLock() | |||
| 1943 | - | for d, r := range p.routes { | |||
| 1944 | - | if r.ContainerID == containerID { | |||
| 1945 | - | domains = append(domains, d) | |||
| 1946 | - | } | |||
| 1947 | - | } | |||
| 1948 | - | p.mu.RUnlock() | |||
| 1949 | - | ||||
| 1950 | - | if len(domains) > 0 { | |||
| 1951 | - | go p.markRoutesInactive(domains) | |||
| 1952 | - | } | |||
| 1953 | - | } | |||
| 1954 | - | } | |||
| 1955 | - | ||||
| 1956 | - | // Return a 502 Bad Gateway error | |||
| 1957 | - | c.Response().WriteHeader(http.StatusBadGateway) | |||
| 1958 | - | c.Response().Write([]byte("Bad Gateway: Container is unreachable")) | |||
| 1959 | - | } | |||
| 1960 | 1333 | ||||
| 1961 | // Serve the request using the reverse proxy | 1334 | // Serve the request using the reverse proxy | ||
| 1962 | proxy.ServeHTTP(c.Response(), c.Request()) | 1335 | proxy.ServeHTTP(c.Response(), c.Request()) | ||
A internal/proxy/routes_helpers.go +723 -0
| @@ -0,0 +1,723 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httputil" | |||
| 10 | + | "net/url" | |||
| 11 | + | "os" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/pkg/docker" | |||
| 16 | + | "github.com/bnema/gordon/pkg/logger" | |||
| 17 | + | "github.com/docker/docker/api/types" | |||
| 18 | + | "github.com/google/uuid" | |||
| 19 | + | "github.com/labstack/echo/v4" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // proxyGenerateUUID generates a random UUID for use in various identifiers | |||
| 23 | + | // This is a renamed version to avoid conflicts until we clean up proxy.go | |||
| 24 | + | func proxyGenerateUUID() string { | |||
| 25 | + | id, err := uuid.NewRandom() | |||
| 26 | + | if err != nil { | |||
| 27 | + | // If we can't generate a UUID using the standard library, | |||
| 28 | + | // fall back to our own implementation | |||
| 29 | + | b := make([]byte, 16) | |||
| 30 | + | _, err := rand.Read(b) | |||
| 31 | + | if err != nil { | |||
| 32 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 33 | + | } | |||
| 34 | + | return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) | |||
| 35 | + | } | |||
| 36 | + | return id.String() | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // normalizeDomain converts a domain to lowercase and removes trailing dot | |||
| 40 | + | func normalizeDomain(domain string) string { | |||
| 41 | + | return strings.TrimSuffix(strings.ToLower(domain), ".") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // createProxyHandler creates a standard Echo handler function for both HTTP and HTTPS servers | |||
| 45 | + | func (p *Proxy) createProxyHandler() echo.HandlerFunc { | |||
| 46 | + | return func(c echo.Context) error { | |||
| 47 | + | host := c.Request().Host | |||
| 48 | + | ||||
| 49 | + | // Strip port if present in the host | |||
| 50 | + | if hostParts := strings.Split(host, ":"); len(hostParts) > 1 { | |||
| 51 | + | host = hostParts[0] | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Normalize host (trim trailing dot, convert to lowercase) | |||
| 55 | + | host = normalizeDomain(host) | |||
| 56 | + | ||||
| 57 | + | // Check if this is the admin domain | |||
| 58 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 59 | + | if strings.EqualFold(host, adminDomain) { | |||
| 60 | + | // Always prioritize admin domain | |||
| 61 | + | adminRoute, adminOk := p.routes[adminDomain] | |||
| 62 | + | ||||
| 63 | + | if adminOk { | |||
| 64 | + | return p.proxyRequest(c, adminRoute) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Important: Get the route freshly from the map for every request | |||
| 69 | + | // This ensures we always use the most up-to-date IP | |||
| 70 | + | route, ok := p.routes[host] | |||
| 71 | + | ||||
| 72 | + | if !ok { | |||
| 73 | + | // Check if the host is an IP address - silently handle without logging warnings | |||
| 74 | + | if net.ParseIP(host) != nil { | |||
| 75 | + | // For IP-based requests, just return a 404 without logging warnings | |||
| 76 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Create list of available domains for debugging | |||
| 80 | + | availableDomains := make([]string, 0, len(p.routes)) | |||
| 81 | + | for d := range p.routes { | |||
| 82 | + | availableDomains = append(availableDomains, d) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Log warning for non-IP hosts that aren't configured | |||
| 86 | + | logger.Warn("Request with unknown host", | |||
| 87 | + | "requested_host", host, | |||
| 88 | + | "client_ip", c.RealIP(), | |||
| 89 | + | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 90 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Check if the route is active | |||
| 94 | + | if !route.Active { | |||
| 95 | + | logger.Warn("Route is not active", | |||
| 96 | + | "domain", host, | |||
| 97 | + | "client_ip", c.RealIP()) | |||
| 98 | + | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 99 | + | } | |||
| 100 | + | return p.proxyRequest(c, route) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // createReverseProxy creates a reverse proxy for a given target URL | |||
| 105 | + | func (p *Proxy) createReverseProxy(targetURL *url.URL, host string, containerID string, isAdminDomain bool) *httputil.ReverseProxy { | |||
| 106 | + | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |||
| 107 | + | ||||
| 108 | + | // Use our custom transport with longer timeouts | |||
| 109 | + | if p.reverseProxyClient != nil { | |||
| 110 | + | proxy.Transport = p.reverseProxyClient.Transport | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // Set up request director to modify the request before it's sent | |||
| 114 | + | originalDirector := proxy.Director | |||
| 115 | + | proxy.Director = func(req *http.Request) { | |||
| 116 | + | // Call the original director first | |||
| 117 | + | originalDirector(req) | |||
| 118 | + | ||||
| 119 | + | // Keep the original host in the Host header | |||
| 120 | + | req.Host = host | |||
| 121 | + | ||||
| 122 | + | // Set X-Forwarded headers to preserve client information | |||
| 123 | + | if clientIP := req.Header.Get("X-Real-IP"); clientIP != "" { | |||
| 124 | + | req.Header.Set("X-Forwarded-For", clientIP) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // Set X-Forwarded-Proto based on the request scheme | |||
| 128 | + | if req.TLS != nil { | |||
| 129 | + | req.Header.Set("X-Forwarded-Proto", "https") | |||
| 130 | + | } else { | |||
| 131 | + | req.Header.Set("X-Forwarded-Proto", "http") | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Set X-Forwarded-Host to the original host | |||
| 135 | + | req.Header.Set("X-Forwarded-Host", host) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // Create error handler for proxy errors | |||
| 139 | + | proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) { | |||
| 140 | + | logger.Error("Proxy error", | |||
| 141 | + | "host", host, | |||
| 142 | + | "target", targetURL.String(), | |||
| 143 | + | "error", err) | |||
| 144 | + | ||||
| 145 | + | // Skip container down detection for admin domain | |||
| 146 | + | if !isAdminDomain { | |||
| 147 | + | // Check if this is a connection refused error | |||
| 148 | + | if strings.Contains(err.Error(), "connection refused") || | |||
| 149 | + | strings.Contains(err.Error(), "no route to host") || | |||
| 150 | + | strings.Contains(err.Error(), "i/o timeout") { | |||
| 151 | + | // Container might be down or unreachable - mark the route as inactive | |||
| 152 | + | logger.Warn("Container appears to be down, marking route as inactive", | |||
| 153 | + | "domain", host, | |||
| 154 | + | "container_id", containerID) | |||
| 155 | + | ||||
| 156 | + | // Find domains for this container and mark them inactive | |||
| 157 | + | var domains []string | |||
| 158 | + | p.mu.RLock() | |||
| 159 | + | for d, r := range p.routes { | |||
| 160 | + | if r.ContainerID == containerID { | |||
| 161 | + | domains = append(domains, d) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | p.mu.RUnlock() | |||
| 165 | + | ||||
| 166 | + | if len(domains) > 0 { | |||
| 167 | + | go p.markRoutesInactive(domains) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Return a 502 Bad Gateway error | |||
| 173 | + | rw.WriteHeader(http.StatusBadGateway) | |||
| 174 | + | rw.Write([]byte("Bad Gateway: Container is unreachable")) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | return proxy | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // saveAdminRoute preserves the admin route during route operations | |||
| 181 | + | func (p *Proxy) saveAdminRoute() *ProxyRouteInfo { | |||
| 182 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 183 | + | if route, exists := p.routes[adminDomain]; exists { | |||
| 184 | + | return &ProxyRouteInfo{ | |||
| 185 | + | Domain: route.Domain, | |||
| 186 | + | ContainerID: route.ContainerID, | |||
| 187 | + | ContainerIP: route.ContainerIP, | |||
| 188 | + | ContainerPort: route.ContainerPort, | |||
| 189 | + | Protocol: route.Protocol, | |||
| 190 | + | Path: route.Path, | |||
| 191 | + | Active: route.Active, | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // restoreAdminRoute adds back the admin route to the routes map | |||
| 198 | + | func (p *Proxy) restoreAdminRoute(adminRoute *ProxyRouteInfo, routes map[string]*ProxyRouteInfo) { | |||
| 199 | + | if adminRoute != nil { | |||
| 200 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 201 | + | routes[adminDomain] = adminRoute | |||
| 202 | + | logger.Debug("Restored admin domain route", "domain", adminDomain) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // createAdminRoute creates a new admin route with default settings | |||
| 207 | + | func (p *Proxy) createAdminRoute() *ProxyRouteInfo { | |||
| 208 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 209 | + | logger.Warn("Admin domain route is missing, recreating it", "domain", adminDomain) | |||
| 210 | + | ||||
| 211 | + | // Auto-detect the Gordon container information | |||
| 212 | + | containerName := p.detectGordonContainer() | |||
| 213 | + | containerIP := "localhost" // Default to localhost | |||
| 214 | + | ||||
| 215 | + | // Fall back options for container IP | |||
| 216 | + | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |||
| 217 | + | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |||
| 218 | + | } else if os.Getenv("HOSTNAME") != "" { | |||
| 219 | + | containerIP = os.Getenv("HOSTNAME") | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | return &ProxyRouteInfo{ | |||
| 223 | + | Domain: adminDomain, | |||
| 224 | + | ContainerID: containerName, | |||
| 225 | + | ContainerIP: containerIP, | |||
| 226 | + | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 227 | + | Protocol: "http", // Gordon server uses HTTP internally | |||
| 228 | + | Path: "/", | |||
| 229 | + | Active: true, | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // processNewSubdomain extracts subdomain and parent domain and adds them to the database | |||
| 234 | + | func (p *Proxy) processNewSubdomain(tx *sql.Tx, hostname, accountID string, now string) error { | |||
| 235 | + | hostParts := strings.Split(hostname, ".") | |||
| 236 | + | if len(hostParts) >= 3 { | |||
| 237 | + | // This appears to be in format subdomain.domain.tld | |||
| 238 | + | // Extract subdomain and domain parts | |||
| 239 | + | subdomain := hostParts[0] | |||
| 240 | + | domainPart := strings.Join(hostParts[1:], ".") | |||
| 241 | + | ||||
| 242 | + | // Check if the domain part already exists | |||
| 243 | + | var domainPartID string | |||
| 244 | + | err := tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainPart).Scan(&domainPartID) | |||
| 245 | + | if err != nil { | |||
| 246 | + | if err == sql.ErrNoRows { | |||
| 247 | + | // Domain part doesn't exist, create it | |||
| 248 | + | domainPartID = proxyGenerateUUID() | |||
| 249 | + | _, err = txExecWithRetry(tx, | |||
| 250 | + | p.queries.InsertDomain, | |||
| 251 | + | domainPartID, domainPart, accountID, now, now, | |||
| 252 | + | ) | |||
| 253 | + | if err != nil { | |||
| 254 | + | logger.Warn("Failed to insert domain part, continuing anyway", | |||
| 255 | + | "domain_part", domainPart, | |||
| 256 | + | "error", err) | |||
| 257 | + | } else { | |||
| 258 | + | logger.Debug("Added domain part to database for Let's Encrypt", | |||
| 259 | + | "domain_part", domainPart, | |||
| 260 | + | "subdomain", subdomain) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | return nil | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // getContainerInfoFromLabels extracts container configuration from Docker labels | |||
| 269 | + | func getContainerInfoFromLabels(containerInfo types.ContainerJSON) (string, string, string) { | |||
| 270 | + | // Get the port to use for the proxy from the container labels | |||
| 271 | + | containerPort := "80" // Default | |||
| 272 | + | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |||
| 273 | + | containerPort = portLabel | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // Get the protocol to use | |||
| 277 | + | protocol := "http" // Default | |||
| 278 | + | if sslLabel, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |||
| 279 | + | (sslLabel == "true" || sslLabel == "1" || sslLabel == "yes") { | |||
| 280 | + | protocol = "https" | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // Get path if specified | |||
| 284 | + | path := "/" // Default | |||
| 285 | + | if pathLabel, exists := containerInfo.Config.Labels["gordon.proxy.path"]; exists && pathLabel != "" { | |||
| 286 | + | path = pathLabel | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | return containerPort, protocol, path | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // updateRouteForContainer updates or creates a route for a container using label information | |||
| 293 | + | func (p *Proxy) updateRouteForContainer( | |||
| 294 | + | domainLabel, containerID, containerIP string, | |||
| 295 | + | containerInfo types.ContainerJSON, | |||
| 296 | + | existingPath string) error { | |||
| 297 | + | ||||
| 298 | + | // Get container configuration from labels | |||
| 299 | + | containerPort, protocol, path := getContainerInfoFromLabels(containerInfo) | |||
| 300 | + | ||||
| 301 | + | // Use existing path if provided and path from labels is default | |||
| 302 | + | if existingPath != "" && path == "/" { | |||
| 303 | + | path = existingPath | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // Add or update the route | |||
| 307 | + | logger.Info("Updating route for container", | |||
| 308 | + | "domain", domainLabel, | |||
| 309 | + | "container_id", containerID, | |||
| 310 | + | "container_ip", containerIP) | |||
| 311 | + | ||||
| 312 | + | return p.AddRoute(domainLabel, containerID, containerIP, containerPort, protocol, path) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // processContainerEvent handles a container start/restart event | |||
| 316 | + | func (p *Proxy) processContainerEvent(containerID, containerName, containerIP string) { | |||
| 317 | + | logger.Debug("Processing container event", | |||
| 318 | + | "container_id", containerID, | |||
| 319 | + | "container_name", containerName, | |||
| 320 | + | "container_ip", containerIP) | |||
| 321 | + | ||||
| 322 | + | // Skip processing if container is in cooldown period | |||
| 323 | + | if p.IsContainerInCooldownPeriod(containerID) { | |||
| 324 | + | logger.Debug("Skipping container event processing - container in cooldown period", | |||
| 325 | + | "container_id", containerID, | |||
| 326 | + | "container_name", containerName) | |||
| 327 | + | return | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // Register this container as recently created to prevent duplicate processing | |||
| 331 | + | p.RegisterNewlyCreatedContainer(containerID) | |||
| 332 | + | ||||
| 333 | + | // Check if this is the admin domain | |||
| 334 | + | isAdminContainer := false | |||
| 335 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 336 | + | ||||
| 337 | + | p.mu.RLock() | |||
| 338 | + | if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID { | |||
| 339 | + | isAdminContainer = true | |||
| 340 | + | logger.Debug("Admin container event detected", "container_id", containerID) | |||
| 341 | + | } | |||
| 342 | + | p.mu.RUnlock() | |||
| 343 | + | ||||
| 344 | + | // First, check if there are any existing routes for this container ID | |||
| 345 | + | var existingRoutes = make(map[string]*ProxyRouteInfo) | |||
| 346 | + | p.mu.RLock() | |||
| 347 | + | for domain, route := range p.routes { | |||
| 348 | + | if route.ContainerID == containerID { | |||
| 349 | + | existingRoutes[domain] = route | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | p.mu.RUnlock() | |||
| 353 | + | ||||
| 354 | + | // If we found existing routes, check if the IP has changed | |||
| 355 | + | if len(existingRoutes) > 0 { | |||
| 356 | + | logger.Debug("Found existing routes for container", | |||
| 357 | + | "container_id", containerID, | |||
| 358 | + | "route_count", len(existingRoutes)) | |||
| 359 | + | ||||
| 360 | + | // Verify the container IP with an additional check | |||
| 361 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 362 | + | verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName) | |||
| 363 | + | if err == nil && verifiedIP != "" && verifiedIP != containerIP { | |||
| 364 | + | logger.Warn("Container IP mismatch from event vs. direct network check", | |||
| 365 | + | "container_id", containerID, | |||
| 366 | + | "event_ip", containerIP, | |||
| 367 | + | "network_ip", verifiedIP) | |||
| 368 | + | ||||
| 369 | + | // Use the directly checked IP as it's more reliable | |||
| 370 | + | containerIP = verifiedIP | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // Check and update each route if IP has changed | |||
| 374 | + | for domain, route := range existingRoutes { | |||
| 375 | + | // Handle admin domain specially to avoid path issues | |||
| 376 | + | if domain == adminDomain && isAdminContainer { | |||
| 377 | + | // For admin domain, only update the IP if it has changed | |||
| 378 | + | if route.ContainerIP != containerIP { | |||
| 379 | + | p.updateAdminContainerIP(domain, containerID, containerIP) | |||
| 380 | + | } | |||
| 381 | + | } else if route.ContainerIP != containerIP { | |||
| 382 | + | // For non-admin domains, use the standard route update | |||
| 383 | + | logger.Info("Container restarted with new IP - updating route", | |||
| 384 | + | "container_id", containerID, | |||
| 385 | + | "container_name", containerName, | |||
| 386 | + | "domain", domain, | |||
| 387 | + | "old_ip", route.ContainerIP, | |||
| 388 | + | "new_ip", containerIP) | |||
| 389 | + | ||||
| 390 | + | // Update the route IP | |||
| 391 | + | err := p.ForceUpdateRouteIP(domain, containerIP) | |||
| 392 | + | if err != nil { | |||
| 393 | + | logger.Error("Failed to update IP for restarted container", | |||
| 394 | + | "domain", domain, | |||
| 395 | + | "error", err) | |||
| 396 | + | } | |||
| 397 | + | } else { | |||
| 398 | + | // Even if IP is the same, make sure the route is active | |||
| 399 | + | if !route.Active { | |||
| 400 | + | logger.Info("Reactivating route for restarted container", | |||
| 401 | + | "container_id", containerID, | |||
| 402 | + | "container_name", containerName, | |||
| 403 | + | "domain", domain) | |||
| 404 | + | ||||
| 405 | + | // Just reuse ForceUpdateRouteIP with the same IP to activate the route | |||
| 406 | + | p.ForceUpdateRouteIP(domain, containerIP) | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | // updateAdminContainerIP updates the admin container's IP in memory and in the database | |||
| 414 | + | func (p *Proxy) updateAdminContainerIP(domain, containerID, containerIP string) { | |||
| 415 | + | logger.Info("Admin container restarted with new IP - updating route", | |||
| 416 | + | "container_id", containerID, | |||
| 417 | + | "domain", domain, | |||
| 418 | + | "new_ip", containerIP) | |||
| 419 | + | ||||
| 420 | + | // Update IP in memory only first | |||
| 421 | + | p.mu.Lock() | |||
| 422 | + | if r, ok := p.routes[domain]; ok { | |||
| 423 | + | r.ContainerIP = containerIP | |||
| 424 | + | r.Active = true | |||
| 425 | + | } | |||
| 426 | + | p.mu.Unlock() | |||
| 427 | + | ||||
| 428 | + | // Update DB in background | |||
| 429 | + | go func(domain, containerID, containerIP string) { | |||
| 430 | + | // Get the domain ID | |||
| 431 | + | var domainID string | |||
| 432 | + | rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain) | |||
| 433 | + | if err != nil { | |||
| 434 | + | logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err) | |||
| 435 | + | return | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | if !rows.Next() { | |||
| 439 | + | rows.Close() | |||
| 440 | + | logger.Error("Admin domain not found in database", "domain", domain) | |||
| 441 | + | return | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | if err := rows.Scan(&domainID); err != nil { | |||
| 445 | + | rows.Close() | |||
| 446 | + | logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err) | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 449 | + | rows.Close() | |||
| 450 | + | ||||
| 451 | + | // Update the IP in the database directly | |||
| 452 | + | now := time.Now().Format(time.RFC3339) | |||
| 453 | + | _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP, | |||
| 454 | + | containerIP, now, domainID, containerID) | |||
| 455 | + | ||||
| 456 | + | if err != nil { | |||
| 457 | + | logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err) | |||
| 458 | + | } else { | |||
| 459 | + | logger.Info("Updated admin container IP in database", | |||
| 460 | + | "domain", domain, | |||
| 461 | + | "container_id", containerID, | |||
| 462 | + | "new_ip", containerIP) | |||
| 463 | + | } | |||
| 464 | + | }(domain, containerID, containerIP) | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // formatIPAddress handles proper formatting for both IPv4 and IPv6 addresses | |||
| 468 | + | func formatIPAddress(ip string) string { | |||
| 469 | + | if strings.Contains(ip, ":") { | |||
| 470 | + | // IPv6 address needs to be wrapped in brackets | |||
| 471 | + | return "[" + ip + "]" | |||
| 472 | + | } | |||
| 473 | + | return ip | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // createRouteInfo creates a new ProxyRouteInfo struct from parameters | |||
| 477 | + | func createRouteInfo(domain, containerID, containerIP, containerPort, protocol, path string, active bool) *ProxyRouteInfo { | |||
| 478 | + | return &ProxyRouteInfo{ | |||
| 479 | + | Domain: domain, | |||
| 480 | + | ContainerID: containerID, | |||
| 481 | + | ContainerIP: containerIP, | |||
| 482 | + | ContainerPort: containerPort, | |||
| 483 | + | Protocol: protocol, | |||
| 484 | + | Path: path, | |||
| 485 | + | Active: active, | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // forceUpdateRouteIPInMemory updates a route's IP address in the in-memory routes map | |||
| 490 | + | func (p *Proxy) forceUpdateRouteIPInMemory(domain, newIP string) { | |||
| 491 | + | p.mu.Lock() | |||
| 492 | + | defer p.mu.Unlock() | |||
| 493 | + | ||||
| 494 | + | if r, ok := p.routes[domain]; ok { | |||
| 495 | + | oldIP := r.ContainerIP | |||
| 496 | + | r.ContainerIP = newIP | |||
| 497 | + | // Make sure the route is also marked as active | |||
| 498 | + | r.Active = true | |||
| 499 | + | logger.Info("Force updated route IP in-memory", | |||
| 500 | + | "domain", domain, | |||
| 501 | + | "old_ip", oldIP, | |||
| 502 | + | "new_ip", newIP) | |||
| 503 | + | } | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | // updateRouteIPInDatabase updates a route's IP address in the database | |||
| 507 | + | func (p *Proxy) updateRouteIPInDatabase(domain, containerID, newIP string) error { | |||
| 508 | + | // Get the domain ID with retry | |||
| 509 | + | var domainID string | |||
| 510 | + | rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain) | |||
| 511 | + | if err != nil { | |||
| 512 | + | logger.Error("Failed to query domain for IP update", "domain", domain, "error", err) | |||
| 513 | + | return fmt.Errorf("failed to query domain: %w", err) | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | if !rows.Next() { | |||
| 517 | + | rows.Close() | |||
| 518 | + | logger.Error("Domain not found in database when updating IP", "domain", domain) | |||
| 519 | + | return fmt.Errorf("domain not found: %s", domain) | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | if err := rows.Scan(&domainID); err != nil { | |||
| 523 | + | rows.Close() | |||
| 524 | + | logger.Error("Failed to scan domain ID for IP update", "domain", domain, "error", err) | |||
| 525 | + | return fmt.Errorf("failed to scan domain ID: %w", err) | |||
| 526 | + | } | |||
| 527 | + | rows.Close() | |||
| 528 | + | ||||
| 529 | + | // Update the proxy_route record with retry and ensure it's marked as active | |||
| 530 | + | now := time.Now().Format(time.RFC3339) | |||
| 531 | + | result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID) | |||
| 532 | + | ||||
| 533 | + | if err != nil { | |||
| 534 | + | logger.Error("Failed to update IP in database", "domain", domain, "error", err) | |||
| 535 | + | return fmt.Errorf("failed to update database: %w", err) | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | rowsAffected, _ := result.RowsAffected() | |||
| 539 | + | if rowsAffected == 0 { | |||
| 540 | + | logger.Warn("No rows affected when updating route IP in database", "domain", domain) | |||
| 541 | + | return fmt.Errorf("no rows affected when updating route in database") | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | logger.Info("Force updated route IP in database", | |||
| 545 | + | "domain", domain, | |||
| 546 | + | "container_id", containerID, | |||
| 547 | + | "new_ip", newIP) | |||
| 548 | + | ||||
| 549 | + | return nil | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // verifyAndNormalizeContainerIP verifies that a container's IP address is correct | |||
| 553 | + | // and returns the normalized IP address | |||
| 554 | + | func (p *Proxy) verifyAndNormalizeContainerIP(containerID, providedIP string) string { | |||
| 555 | + | // Double-check that the container exists and is running | |||
| 556 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |||
| 557 | + | if err != nil { | |||
| 558 | + | logger.Warn("Container not found when updating route IP, might have been recreated", | |||
| 559 | + | "container_id", containerID, | |||
| 560 | + | "error", err) | |||
| 561 | + | // Continue with the update anyway, as the container ID might still be valid | |||
| 562 | + | return providedIP | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | logger.Debug("Container exists when updating route IP", | |||
| 566 | + | "container_id", containerID, | |||
| 567 | + | "container_state", containerInfo.State.Status) | |||
| 568 | + | ||||
| 569 | + | // Verify the new IP matches what's in the container | |||
| 570 | + | networkName := p.app.GetConfig().ContainerEngine.Network | |||
| 571 | + | if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil { | |||
| 572 | + | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && | |||
| 573 | + | networkSettings.IPAddress != "" && networkSettings.IPAddress != providedIP { | |||
| 574 | + | logger.Warn("New IP doesn't match container network IP, using container's actual IP", | |||
| 575 | + | "provided_ip", providedIP, | |||
| 576 | + | "container_actual_ip", networkSettings.IPAddress) | |||
| 577 | + | return networkSettings.IPAddress | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | return providedIP | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | // filterOutAdminDomain removes the admin domain from a list of domains | |||
| 585 | + | func (p *Proxy) filterOutAdminDomain(domains []string) []string { | |||
| 586 | + | if len(domains) == 0 { | |||
| 587 | + | return domains | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | // Get the admin domain to protect it | |||
| 591 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 592 | + | ||||
| 593 | + | // Filter out admin domain from domains | |||
| 594 | + | filteredDomains := make([]string, 0, len(domains)) | |||
| 595 | + | for _, domain := range domains { | |||
| 596 | + | if domain != adminDomain { | |||
| 597 | + | filteredDomains = append(filteredDomains, domain) | |||
| 598 | + | } else { | |||
| 599 | + | logger.Warn("Prevented admin domain from being modified", | |||
| 600 | + | "domain", adminDomain) | |||
| 601 | + | } | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | return filteredDomains | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | // markRouteInactiveInDatabase marks a route as inactive in the database | |||
| 608 | + | func (p *Proxy) markRouteInactiveInDatabase(tx *sql.Tx, domain string) error { | |||
| 609 | + | // Get the domain ID | |||
| 610 | + | var domainID string | |||
| 611 | + | err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID) | |||
| 612 | + | if err != nil { | |||
| 613 | + | logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err) | |||
| 614 | + | return err | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | // Update the route to mark it as inactive | |||
| 618 | + | now := time.Now().Format(time.RFC3339) | |||
| 619 | + | _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID) | |||
| 620 | + | if err != nil { | |||
| 621 | + | logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err) | |||
| 622 | + | return err | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | return nil | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | // markRouteInactiveInMemory marks a route as inactive in the in-memory routes map | |||
| 629 | + | func (p *Proxy) markRouteInactiveInMemory(domain string) { | |||
| 630 | + | p.mu.Lock() | |||
| 631 | + | defer p.mu.Unlock() | |||
| 632 | + | ||||
| 633 | + | if route, exists := p.routes[domain]; exists { | |||
| 634 | + | route.Active = false | |||
| 635 | + | logger.Info("Marked route as inactive", "domain", domain) | |||
| 636 | + | } | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | // getActiveRoutes returns a copy of all active routes in the routes map | |||
| 640 | + | func (p *Proxy) getActiveRoutes() map[string]*ProxyRouteInfo { | |||
| 641 | + | p.mu.RLock() | |||
| 642 | + | defer p.mu.RUnlock() | |||
| 643 | + | ||||
| 644 | + | routesToCheck := make(map[string]*ProxyRouteInfo) | |||
| 645 | + | for domain, route := range p.routes { | |||
| 646 | + | if route.Active { | |||
| 647 | + | routesToCheck[domain] = &ProxyRouteInfo{ | |||
| 648 | + | Domain: route.Domain, | |||
| 649 | + | ContainerID: route.ContainerID, | |||
| 650 | + | ContainerIP: route.ContainerIP, | |||
| 651 | + | ContainerPort: route.ContainerPort, | |||
| 652 | + | Protocol: route.Protocol, | |||
| 653 | + | Path: route.Path, | |||
| 654 | + | Active: route.Active, | |||
| 655 | + | } | |||
| 656 | + | } | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | return routesToCheck | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | // checkAndUpdateIPIfNeeded checks if a container's IP address has changed and updates it if needed | |||
| 663 | + | func (p *Proxy) checkAndUpdateIPIfNeeded(domain string, route *ProxyRouteInfo, networkName string) { | |||
| 664 | + | // Skip checking admin domain | |||
| 665 | + | if domain == p.app.GetConfig().Http.FullDomain() { | |||
| 666 | + | return | |||
| 667 | + | } | |||
| 668 | + | ||||
| 669 | + | // Verify the current container IP | |||
| 670 | + | currentIP, err := docker.GetContainerIPFromNetwork(route.ContainerID, networkName) | |||
| 671 | + | if err != nil { | |||
| 672 | + | // Container might not exist anymore | |||
| 673 | + | logger.Warn("Failed to get current IP for container during consistency check", | |||
| 674 | + | "domain", domain, | |||
| 675 | + | "container_id", route.ContainerID, | |||
| 676 | + | "error", err) | |||
| 677 | + | return | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | // If IP has changed, update the route | |||
| 681 | + | if currentIP != "" && currentIP != route.ContainerIP { | |||
| 682 | + | logger.Info("Detected IP mismatch during consistency check", | |||
| 683 | + | "domain", domain, | |||
| 684 | + | "container_id", route.ContainerID, | |||
| 685 | + | "old_ip", route.ContainerIP, | |||
| 686 | + | "current_ip", currentIP) | |||
| 687 | + | ||||
| 688 | + | // Update the route | |||
| 689 | + | err = p.ForceUpdateRouteIP(domain, currentIP) | |||
| 690 | + | if err != nil { | |||
| 691 | + | logger.Error("Failed to update IP during consistency check", | |||
| 692 | + | "domain", domain, | |||
| 693 | + | "error", err) | |||
| 694 | + | } | |||
| 695 | + | } | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | // checkAdminDomainProtection checks if a domain is the admin domain and returns an error if it is | |||
| 699 | + | func (p *Proxy) checkAdminDomainProtection(domain string) error { | |||
| 700 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 701 | + | if domain == adminDomain { | |||
| 702 | + | logger.Warn("Attempt to modify admin domain prevented", "domain", domain) | |||
| 703 | + | return fmt.Errorf("cannot modify admin domain: %s", domain) | |||
| 704 | + | } | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | // deleteRouteWithTransaction deletes a route and domain from the database within a transaction | |||
| 709 | + | func (p *Proxy) deleteRouteWithTransaction(tx *sql.Tx, domainID string) error { | |||
| 710 | + | // Delete the route with retry | |||
| 711 | + | _, err := txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID) | |||
| 712 | + | if err != nil { | |||
| 713 | + | return fmt.Errorf("failed to delete route: %w", err) | |||
| 714 | + | } | |||
| 715 | + | ||||
| 716 | + | // Delete the domain with retry | |||
| 717 | + | _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID) | |||
| 718 | + | if err != nil { | |||
| 719 | + | return fmt.Errorf("failed to delete domain: %w", err) | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | return nil | |||
| 723 | + | } | |||
M internal/proxy/server.go +513 -186
| @@ -4,82 +4,152 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/tls" | 5 | "crypto/tls" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "log" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 9 | + | "os" | |||
| 10 | + | "os/exec" | |||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync" | |||
| 8 | "time" | 14 | "time" | ||
| 9 | 15 | ||||
| 16 | + | "net" | |||
| 17 | + | ||||
| 10 | "github.com/bnema/gordon/pkg/docker" | 18 | "github.com/bnema/gordon/pkg/docker" | ||
| 11 | "github.com/bnema/gordon/pkg/logger" | 19 | "github.com/bnema/gordon/pkg/logger" | ||
| 12 | "github.com/labstack/echo/v4" | 20 | "github.com/labstack/echo/v4" | ||
| 13 | "golang.org/x/crypto/acme" | 21 | "golang.org/x/crypto/acme" | ||
| 14 | ) | 22 | ) | ||
| 15 | 23 | ||||
| 24 | + | // Global mutex for admin connection testing to prevent concurrent executions | |||
| 25 | + | var adminTestMutex sync.Mutex | |||
| 26 | + | ||||
| 16 | // This file contains server operations functions for the proxy. | 27 | // This file contains server operations functions for the proxy. | ||
| 17 | // These methods may need to be deleted from proxy.go after validation. | 28 | // These methods may need to be deleted from proxy.go after validation. | ||
| 18 | 29 | ||||
| 19 | // Start loads the routes from the database and starts the proxy server | 30 | // Start loads the routes from the database and starts the proxy server | ||
| 20 | func (p *Proxy) Start() error { | 31 | func (p *Proxy) Start() error { | ||
| 21 | - | // First, load routes from the 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 | |||
| 22 | if err := p.loadRoutes(); err != nil { | 42 | if err := p.loadRoutes(); err != nil { | ||
| 23 | return fmt.Errorf("failed to load routes: %w", err) | 43 | return fmt.Errorf("failed to load routes: %w", err) | ||
| 24 | } | 44 | } | ||
| 25 | 45 | ||||
| 26 | - | // Now log Gordon container identity for debugging (after routes are loaded) | 46 | + | // Log Gordon container identity and prepare admin route |
| 27 | p.LogGordonIdentity() | 47 | p.LogGordonIdentity() | ||
| 48 | + | p.setupAdminRoute() | |||
| 28 | 49 | ||||
| 29 | - | // Scan Gordon network containers and check for certificates | 50 | + | // Scan containers for certificates |
| 30 | if err := p.scanContainersAndCheckCertificates(); err != nil { | 51 | if err := p.scanContainersAndCheckCertificates(); err != nil { | ||
| 31 | logger.Warn("Failed to scan containers for certificate checks", "error", err) | 52 | logger.Warn("Failed to scan containers for certificate checks", "error", err) | ||
| 32 | } | 53 | } | ||
| 33 | 54 | ||||
| 34 | - | // Run an immediate route verification to ensure all routes are valid on startup | 55 | + | // Discover missing routes and log active containers |
| 35 | - | logger.Info("Performing initial route verification on startup") | |||
| 36 | - | ||||
| 37 | - | // Discover any containers that should have routes but don't | |||
| 38 | p.DiscoverMissingRoutes() | 56 | p.DiscoverMissingRoutes() | ||
| 39 | - | ||||
| 40 | - | // Log all active containers and their routes for visibility | |||
| 41 | p.LogActiveContainersAndRoutes() | 57 | p.LogActiveContainersAndRoutes() | ||
| 42 | 58 | ||||
| 43 | - | // Start the container event listener for real-time updates | 59 | + | // Start container event listener |
| 44 | if err := p.StartContainerEventListener(); err != nil { | 60 | if err := p.StartContainerEventListener(); err != nil { | ||
| 45 | logger.Warn("Failed to start container event listener", "error", err) | 61 | logger.Warn("Failed to start container event listener", "error", err) | ||
| 46 | - | // Continue anyway, this is non-critical | |||
| 47 | } | 62 | } | ||
| 48 | 63 | ||||
| 49 | - | // Check if there might be port conflicts with the main server | 64 | + | // Start the admin route verification in a separate goroutine |
| 50 | - | mainServerPort := p.app.GetConfig().Http.Port | 65 | + | go p.StartAdminRouteVerification() |
| 51 | 66 | ||||
| 52 | - | // Check HTTP port conflict | 67 | + | // Check for port conflicts with the main server |
| 53 | - | if p.config.HttpPort == mainServerPort { | 68 | + | if p.checkForPortConflicts() { |
| 54 | - | logger.Warn("HTTP port for reverse proxy conflicts with main server port", | |||
| 55 | - | "port", p.config.HttpPort, | |||
| 56 | - | "solution", "reverse proxy HTTP server will be disabled") | |||
| 57 | return nil | 69 | return nil | ||
| 58 | } | 70 | } | ||
| 59 | 71 | ||||
| 60 | - | // Check HTTPS port conflict (less common, but still possible) | 72 | + | // Set up middleware and configure routes |
| 61 | - | if p.config.Port == mainServerPort { | 73 | + | p.setupMiddleware() |
| 62 | - | logger.Warn("HTTPS port for reverse proxy conflicts with main server port", | 74 | + | p.configureRoutes() |
| 63 | - | "port", p.config.Port, | 75 | + | |
| 64 | - | "solution", "reverse proxy HTTPS server will be disabled") | 76 | + | // Configure HTTP server for Let's Encrypt challenges |
| 65 | - | return nil | 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 | + | } | |||
| 66 | } | 131 | } | ||
| 67 | 132 | ||||
| 68 | - | // Add a special route for the admin domain (Gordon itself) | 133 | + | // Log final port configuration |
| 134 | + | logger.Info("Final proxy port configuration", | |||
| 135 | + | "http_port", p.config.HttpPort, | |||
| 136 | + | "https_port", p.config.Port, | |||
| 137 | + | "in_container", isContainer) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // setupAdminRoute adds a special route for the admin domain (Gordon itself) | |||
| 141 | + | func (p *Proxy) setupAdminRoute() { | |||
| 69 | adminDomain := p.app.GetConfig().Http.FullDomain() | 142 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 143 | + | logger.Info("Setting up admin domain route", "domain", adminDomain) | |||
| 70 | 144 | ||||
| 71 | // Auto-detect the Gordon container name and ID | 145 | // Auto-detect the Gordon container name and ID | ||
| 72 | containerName := p.detectGordonContainer() | 146 | containerName := p.detectGordonContainer() | ||
| 73 | - | ||||
| 74 | - | // Get the network name from config | |||
| 75 | networkName := p.app.GetConfig().ContainerEngine.Network | 147 | networkName := p.app.GetConfig().ContainerEngine.Network | ||
| 76 | 148 | ||||
| 77 | - | // Initialize container IP | 149 | + | // Get the IP address from the container's network |
| 78 | - | var containerIP string | |||
| 79 | - | ||||
| 80 | - | // Try to get the actual container IP from the network | |||
| 81 | if p.gordonContainerID == "" { | 150 | if p.gordonContainerID == "" { | ||
| 82 | - | return fmt.Errorf("failed to detect Gordon container ID, cannot add admin route") | 151 | + | logger.Error("Failed to detect Gordon container ID, cannot add admin route") |
| 152 | + | return | |||
| 83 | } | 153 | } | ||
| 84 | 154 | ||||
| 85 | // Get the IP address from the container's network | 155 | // Get the IP address from the container's network | ||
| @@ -89,147 +159,171 @@ func (p *Proxy) Start() error { | |||||
| 89 | "container_id", p.gordonContainerID, | 159 | "container_id", p.gordonContainerID, | ||
| 90 | "network", networkName, | 160 | "network", networkName, | ||
| 91 | "error", err) | 161 | "error", err) | ||
| 92 | - | return fmt.Errorf("failed to get Gordon container IP from network %s: %w", networkName, err) | 162 | + | return |
| 93 | } | 163 | } | ||
| 94 | 164 | ||||
| 95 | - | containerIP = ip | 165 | + | // Add the admin route to the database |
| 96 | - | logger.Info("Using Gordon container IP from network", | |||
| 97 | - | "container_id", p.gordonContainerID, | |||
| 98 | - | "network", networkName, | |||
| 99 | - | "ip", containerIP) | |||
| 100 | - | ||||
| 101 | - | // Check if admin route exists in database and create/update it | |||
| 102 | err = p.AddRoute( | 166 | err = p.AddRoute( | ||
| 103 | adminDomain, | 167 | adminDomain, | ||
| 104 | containerName, | 168 | containerName, | ||
| 105 | - | containerIP, | 169 | + | ip, |
| 106 | p.app.GetConfig().Http.Port, | 170 | p.app.GetConfig().Http.Port, | ||
| 107 | "http", // Gordon server uses HTTP internally | 171 | "http", // Gordon server uses HTTP internally | ||
| 108 | "/", | 172 | "/", | ||
| 109 | ) | 173 | ) | ||
| 110 | 174 | ||||
| 111 | if err != nil { | 175 | if err != nil { | ||
| 112 | - | logger.Error("Failed to add admin route to database", | 176 | + | logger.Error("Failed to add admin route to database", "error", err, "domain", adminDomain) |
| 113 | - | "error", err, | |||
| 114 | - | "domain", adminDomain) | |||
| 115 | } else { | 177 | } else { | ||
| 116 | - | logger.Info("Ensured admin domain route is in database", | 178 | + | logger.Info("Ensured admin domain route is in database", "domain", adminDomain) |
| 117 | - | "domain", adminDomain, | |||
| 118 | - | "container", containerName, | |||
| 119 | - | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |||
| 120 | } | 179 | } | ||
| 121 | 180 | ||||
| 181 | + | // Add to in-memory routes if it doesn't already exist | |||
| 122 | p.mu.Lock() | 182 | p.mu.Lock() | ||
| 123 | - | // Only add if it doesn't already exist in memory | |||
| 124 | if _, exists := p.routes[adminDomain]; !exists { | 183 | if _, exists := p.routes[adminDomain]; !exists { | ||
| 125 | p.routes[adminDomain] = &ProxyRouteInfo{ | 184 | p.routes[adminDomain] = &ProxyRouteInfo{ | ||
| 126 | Domain: adminDomain, | 185 | Domain: adminDomain, | ||
| 127 | - | ContainerIP: containerIP, | 186 | + | ContainerIP: ip, |
| 128 | ContainerPort: p.app.GetConfig().Http.Port, | 187 | ContainerPort: p.app.GetConfig().Http.Port, | ||
| 129 | - | ContainerID: containerName, // Use auto-detected container name | 188 | + | ContainerID: containerName, |
| 130 | - | Protocol: "http", // Gordon server uses HTTP internally | 189 | + | Protocol: "http", // Gordon server uses HTTP internally |
| 131 | Path: "/", | 190 | Path: "/", | ||
| 132 | Active: true, | 191 | Active: true, | ||
| 133 | } | 192 | } | ||
| 134 | - | logger.Info("Added special route for admin domain", | 193 | + | logger.Info("Added special route for admin domain", "domain", adminDomain) |
| 135 | - | "domain", adminDomain, | |||
| 136 | - | "container", containerName, | |||
| 137 | - | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |||
| 138 | - | } else { | |||
| 139 | - | logger.Debug("Admin domain route already exists", | |||
| 140 | - | "domain", adminDomain, | |||
| 141 | - | "route", fmt.Sprintf("%s://%s:%s", | |||
| 142 | - | p.routes[adminDomain].Protocol, | |||
| 143 | - | p.routes[adminDomain].ContainerIP, | |||
| 144 | - | p.routes[adminDomain].ContainerPort)) | |||
| 145 | } | 194 | } | ||
| 146 | p.mu.Unlock() | 195 | p.mu.Unlock() | ||
| 196 | + | } | |||
| 147 | 197 | ||||
| 148 | - | // Set up middleware | 198 | + | // checkForPortConflicts checks if there are port conflicts with the main server |
| 149 | - | p.setupMiddleware() | 199 | + | // Returns true if conflicts are found and the proxy should not be started |
| 200 | + | func (p *Proxy) checkForPortConflicts() bool { | |||
| 201 | + | mainServerPort := p.app.GetConfig().Http.Port | |||
| 150 | 202 | ||||
| 151 | - | // Configure routes | 203 | + | // Check HTTP port conflict |
| 152 | - | p.configureRoutes() | 204 | + | if p.config.HttpPort == mainServerPort { |
| 205 | + | logger.Warn("HTTP port for reverse proxy conflicts with main server port", | |||
| 206 | + | "port", p.config.HttpPort, | |||
| 207 | + | "solution", "reverse proxy HTTP server will be disabled") | |||
| 208 | + | return true | |||
| 209 | + | } | |||
| 153 | 210 | ||||
| 154 | - | // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges | 211 | + | // Check HTTPS port conflict |
| 155 | - | // and redirect everything else to HTTPS | 212 | + | if p.config.Port == mainServerPort { |
| 156 | - | p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil))) | 213 | + | logger.Warn("HTTPS port for reverse proxy conflicts with main server port", |
| 214 | + | "port", p.config.Port, | |||
| 215 | + | "solution", "reverse proxy HTTPS server will be disabled") | |||
| 216 | + | return true | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | return false | |||
| 220 | + | } | |||
| 157 | 221 | ||||
| 158 | - | // Create a custom HTTPS server with proper TLS config | 222 | + | // startHTTPSServer starts the HTTPS server |
| 223 | + | func (p *Proxy) startHTTPSServer() { | |||
| 224 | + | // Create HTTPS server | |||
| 159 | httpsServer := &http.Server{ | 225 | httpsServer := &http.Server{ | ||
| 160 | Addr: fmt.Sprintf(":%s", p.config.Port), | 226 | Addr: fmt.Sprintf(":%s", p.config.Port), | ||
| 161 | Handler: p.httpsServer, | 227 | Handler: p.httpsServer, | ||
| 162 | TLSConfig: &tls.Config{ | 228 | TLSConfig: &tls.Config{ | ||
| 163 | GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | 229 | GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | ||
| 164 | - | // Log SNI information in debug mode | |||
| 165 | - | if hello.ServerName != "" { | |||
| 166 | - | logger.Debug("TLS handshake with SNI", | |||
| 167 | - | "server_name", hello.ServerName) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | // Try to get the certificate from the autocert manager | 230 | // Try to get the certificate from the autocert manager | ||
| 171 | cert, err := p.certManager.GetCertificate(hello) | 231 | cert, err := p.certManager.GetCertificate(hello) | ||
| 172 | 232 | ||||
| 173 | - | // If we can't get a certificate and we have a fallback, use it for the admin domain | 233 | + | // If no cert available and we have a fallback, use it for admin domain |
| 234 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 174 | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | 235 | if (err != nil || cert == nil) && hello.ServerName == adminDomain && p.fallbackCert != nil { | ||
| 175 | - | logger.Debug("Using fallback certificate for admin domain", | 236 | + | logger.Info("Using fallback certificate for admin domain", "domain", adminDomain) |
| 176 | - | "domain", adminDomain, | |||
| 177 | - | "error", err) | |||
| 178 | return p.fallbackCert, nil | 237 | return p.fallbackCert, nil | ||
| 179 | } | 238 | } | ||
| 180 | 239 | ||||
| 181 | return cert, err | 240 | return cert, err | ||
| 182 | }, | 241 | }, | ||
| 183 | MinVersion: tls.VersionTLS12, | 242 | MinVersion: tls.VersionTLS12, | ||
| 184 | - | // Add server name to use when client doesn't send SNI | |||
| 185 | ServerName: p.app.GetConfig().Http.FullDomain(), | 243 | ServerName: p.app.GetConfig().Http.FullDomain(), | ||
| 186 | - | // Add support for TLS-ALPN-01 challenges | |||
| 187 | NextProtos: []string{acme.ALPNProto}, | 244 | NextProtos: []string{acme.ALPNProto}, | ||
| 188 | }, | 245 | }, | ||
| 189 | - | // Add timeouts to prevent hanging connections | |||
| 190 | ReadTimeout: 30 * time.Second, | 246 | ReadTimeout: 30 * time.Second, | ||
| 191 | WriteTimeout: 30 * time.Second, | 247 | WriteTimeout: 30 * time.Second, | ||
| 192 | IdleTimeout: 120 * time.Second, | 248 | IdleTimeout: 120 * time.Second, | ||
| 249 | + | ErrorLog: log.New(os.Stderr, "[HTTPS] ", log.LstdFlags), | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // Try to start the server with different approaches depending on environment | |||
| 253 | + | logger.Info("Starting HTTPS server", "port", p.config.Port) | |||
| 254 | + | ||||
| 255 | + | // First try explicit IPv4 binding | |||
| 256 | + | httpsAddr := fmt.Sprintf("0.0.0.0:%s", p.config.Port) | |||
| 257 | + | tcpListener, err := net.Listen("tcp4", httpsAddr) | |||
| 258 | + | ||||
| 259 | + | if err != nil { | |||
| 260 | + | logger.Warn("Failed to create HTTPS listener with explicit IPv4 binding", | |||
| 261 | + | "error", err, "port", p.config.Port) | |||
| 262 | + | ||||
| 263 | + | // Try simple binding as fallback | |||
| 264 | + | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |||
| 265 | + | logger.Error("HTTPS server failed", "error", err, "port", p.config.Port) | |||
| 266 | + | } | |||
| 267 | + | return | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // Create TLS listener | |||
| 271 | + | tlsListener := tls.NewListener(tcpListener, httpsServer.TLSConfig) | |||
| 272 | + | logger.Info("HTTPS server is now accepting connections", "port", p.config.Port) | |||
| 273 | + | ||||
| 274 | + | // Start server with explicit listener | |||
| 275 | + | if err := httpsServer.Serve(tlsListener); err != nil && err != http.ErrServerClosed { | |||
| 276 | + | logger.Error("HTTPS server failed", "error", err, "port", p.config.Port) | |||
| 193 | } | 277 | } | ||
| 278 | + | } | |||
| 194 | 279 | ||||
| 195 | - | // Create a custom HTTP server | 280 | + | // startHTTPServer starts the HTTP server |
| 281 | + | func (p *Proxy) startHTTPServer() { | |||
| 282 | + | // Create HTTP server | |||
| 196 | httpServer := &http.Server{ | 283 | httpServer := &http.Server{ | ||
| 197 | Addr: fmt.Sprintf(":%s", p.config.HttpPort), | 284 | Addr: fmt.Sprintf(":%s", p.config.HttpPort), | ||
| 198 | Handler: p.httpServer, | 285 | Handler: p.httpServer, | ||
| 199 | ReadTimeout: 30 * time.Second, | 286 | ReadTimeout: 30 * time.Second, | ||
| 200 | WriteTimeout: 30 * time.Second, | 287 | WriteTimeout: 30 * time.Second, | ||
| 201 | IdleTimeout: 120 * time.Second, | 288 | IdleTimeout: 120 * time.Second, | ||
| 289 | + | ErrorLog: log.New(os.Stderr, "[HTTP] ", log.LstdFlags), | |||
| 202 | } | 290 | } | ||
| 203 | 291 | ||||
| 204 | - | p.serverStarted = true | 292 | + | // Try to start the server with different approaches depending on environment |
| 293 | + | logger.Info("Starting HTTP server", "port", p.config.HttpPort) | |||
| 205 | 294 | ||||
| 206 | - | // Start HTTPS server | 295 | + | // First try explicit IPv4 binding |
| 207 | - | go func() { | 296 | + | httpAddr := fmt.Sprintf("0.0.0.0:%s", p.config.HttpPort) |
| 208 | - | logger.Info("Starting HTTPS server", "address", httpsServer.Addr) | 297 | + | httpListener, err := net.Listen("tcp4", httpAddr) |
| 209 | - | // Using empty strings for cert and key files since we're using GetCertificate | |||
| 210 | - | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |||
| 211 | - | logger.Error("HTTPS server failed", "error", err) | |||
| 212 | - | } | |||
| 213 | - | }() | |||
| 214 | 298 | ||||
| 215 | - | // Start HTTP server | 299 | + | if err != nil { |
| 216 | - | go func() { | 300 | + | logger.Warn("Failed to create HTTP listener with explicit IPv4 binding", |
| 217 | - | logger.Info("Starting HTTP server", "address", httpServer.Addr) | 301 | + | "error", err, "port", p.config.HttpPort) |
| 302 | + | ||||
| 303 | + | // Try simple binding as fallback | |||
| 218 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | 304 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | ||
| 219 | - | logger.Error("HTTP server failed", "error", err) | 305 | + | logger.Error("HTTP server failed", "error", err, "port", p.config.HttpPort) |
| 220 | } | 306 | } | ||
| 221 | - | }() | 307 | + | return |
| 222 | - | 308 | + | } | |
| 223 | - | // Test admin connection after server starts | |||
| 224 | - | go p.TestAdminConnectionLater() | |||
| 225 | 309 | ||||
| 226 | - | return 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 | + | } | |||
| 227 | } | 315 | } | ||
| 228 | 316 | ||||
| 229 | // Stop gracefully shuts down the proxy server | 317 | // Stop gracefully shuts down the proxy server | ||
| 230 | func (p *Proxy) Stop() error { | 318 | func (p *Proxy) Stop() error { | ||
| 231 | logger.Info("Stopping reverse proxy") | 319 | logger.Info("Stopping reverse proxy") | ||
| 232 | 320 | ||||
| 321 | + | // Check if proxy is disabled via configuration | |||
| 322 | + | if !p.config.Enabled { | |||
| 323 | + | logger.Info("Proxy server is disabled via configuration, nothing to stop") | |||
| 324 | + | return nil | |||
| 325 | + | } | |||
| 326 | + | ||||
| 233 | // Run any rate limiter cleanup functions | 327 | // Run any rate limiter cleanup functions | ||
| 234 | for _, cleanup := range rateLimiterCleanup { | 328 | for _, cleanup := range rateLimiterCleanup { | ||
| 235 | cleanup() | 329 | cleanup() | ||
| @@ -325,21 +419,178 @@ func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||||
| 325 | 419 | ||||
| 326 | // TestAdminConnectionLater tests the admin connection after a short delay | 420 | // TestAdminConnectionLater tests the admin connection after a short delay | ||
| 327 | func (p *Proxy) TestAdminConnectionLater() { | 421 | func (p *Proxy) TestAdminConnectionLater() { | ||
| 328 | - | // Wait for servers to start | 422 | + | // Try to acquire the lock, return immediately if another test is in progress |
| 329 | - | time.Sleep(2 * time.Second) | 423 | + | if !adminTestMutex.TryLock() { |
| 424 | + | logger.Debug("Admin connection test already in progress, skipping") | |||
| 425 | + | return | |||
| 426 | + | } | |||
| 427 | + | defer adminTestMutex.Unlock() | |||
| 428 | + | ||||
| 429 | + | // Wait for servers to start - give plenty of time for binding to complete | |||
| 430 | + | time.Sleep(10 * time.Second) | |||
| 431 | + | ||||
| 432 | + | // Log that we're beginning the connection test | |||
| 433 | + | logger.Info("Beginning admin connection test after server startup") | |||
| 434 | + | ||||
| 435 | + | // First verify if ports 80 and 443 are actually bound locally | |||
| 436 | + | verifyLocalPortBinding := func(port string) bool { | |||
| 437 | + | // Try a local connection to the port | |||
| 438 | + | conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 2*time.Second) | |||
| 439 | + | if err != nil { | |||
| 440 | + | logger.Error(fmt.Sprintf("Failed to connect to localhost:%s - port may not be bound", port), | |||
| 441 | + | "error", err) | |||
| 442 | + | return false | |||
| 443 | + | } | |||
| 444 | + | conn.Close() | |||
| 445 | + | logger.Info(fmt.Sprintf("Successfully verified port %s is bound locally", port)) | |||
| 446 | + | return true | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // Check if we can connect to ports 80 and 443 locally | |||
| 450 | + | httpBound := verifyLocalPortBinding(p.config.HttpPort) | |||
| 451 | + | httpsBound := verifyLocalPortBinding(p.config.Port) | |||
| 452 | + | ||||
| 453 | + | if !httpBound || !httpsBound { | |||
| 454 | + | logger.Error("One or more ports are not bound locally - proxy may not be functioning", | |||
| 455 | + | "http_port_ok", httpBound, | |||
| 456 | + | "https_port_ok", httpsBound) | |||
| 457 | + | } | |||
| 330 | 458 | ||||
| 331 | logger.Debug("Testing admin connections after server startup") | 459 | logger.Debug("Testing admin connections after server startup") | ||
| 332 | 460 | ||||
| 333 | // Get domain and current route | 461 | // Get domain and current route | ||
| 334 | adminDomain := p.app.GetConfig().Http.FullDomain() | 462 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 335 | 463 | ||||
| 464 | + | // First, check if the route exists in memory | |||
| 336 | p.mu.RLock() | 465 | p.mu.RLock() | ||
| 337 | route, exists := p.routes[adminDomain] | 466 | route, exists := p.routes[adminDomain] | ||
| 338 | p.mu.RUnlock() | 467 | p.mu.RUnlock() | ||
| 339 | 468 | ||||
| 469 | + | // Add debug logging for admin route configuration | |||
| 470 | + | if exists { | |||
| 471 | + | logger.Debug("Current admin route configuration", | |||
| 472 | + | "domain", adminDomain, | |||
| 473 | + | "container_ip", route.ContainerIP, | |||
| 474 | + | "container_port", route.ContainerPort, | |||
| 475 | + | "protocol", route.Protocol) | |||
| 476 | + | ||||
| 477 | + | // Get the container's own IP by checking network interfaces | |||
| 478 | + | containerIP := p.getOwnContainerIP() | |||
| 479 | + | adminPath := p.app.GetConfig().Admin.Path | |||
| 480 | + | url := fmt.Sprintf("http://%s:%s%s/ping", containerIP, route.ContainerPort, adminPath) | |||
| 481 | + | ||||
| 482 | + | logger.Debug("Testing direct IP connection", "url", url) | |||
| 483 | + | client := &http.Client{ | |||
| 484 | + | Timeout: 5 * time.Second, | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | resp, err := client.Get(url) | |||
| 488 | + | if err != nil { | |||
| 489 | + | logger.Error("Failed to connect to admin with direct IP", | |||
| 490 | + | "url", url, | |||
| 491 | + | "error", err) | |||
| 492 | + | } else { | |||
| 493 | + | resp.Body.Close() | |||
| 494 | + | logger.Info("Successfully connected to admin with direct IP", | |||
| 495 | + | "url", url, | |||
| 496 | + | "status", resp.Status) | |||
| 497 | + | ||||
| 498 | + | // If localhost is being used, update the route to use the container IP | |||
| 499 | + | if route.ContainerIP == "localhost" || route.ContainerIP == "127.0.0.1" { | |||
| 500 | + | logger.Info("Updating admin route from localhost to container IP", | |||
| 501 | + | "old_ip", route.ContainerIP, | |||
| 502 | + | "new_ip", containerIP) | |||
| 503 | + | ||||
| 504 | + | p.mu.Lock() | |||
| 505 | + | if r, ok := p.routes[adminDomain]; ok { | |||
| 506 | + | r.ContainerIP = containerIP | |||
| 507 | + | } | |||
| 508 | + | p.mu.Unlock() | |||
| 509 | + | ||||
| 510 | + | // Reconfigure routes with the new IP | |||
| 511 | + | p.configureRoutes() | |||
| 512 | + | ||||
| 513 | + | // Also update in database if possible | |||
| 514 | + | err := p.ForceUpdateRouteIP(adminDomain, containerIP) | |||
| 515 | + | if err != nil { | |||
| 516 | + | logger.Warn("Failed to update admin route IP in database", | |||
| 517 | + | "error", err, | |||
| 518 | + | "domain", adminDomain) | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | } | |||
| 522 | + | } | |||
| 523 | + | ||||
| 340 | if !exists { | 524 | if !exists { | ||
| 341 | - | logger.Warn("Admin route not found") | 525 | + | logger.Warn("Admin route not found in memory, creating it now") |
| 342 | - | return | 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 | + | } | |||
| 343 | } | 594 | } | ||
| 344 | 595 | ||||
| 345 | // Test connection and update if needed | 596 | // Test connection and update if needed | ||
| @@ -351,14 +602,43 @@ func (p *Proxy) TestAdminConnectionLater() { | |||||
| 351 | logger.Info("Updated admin route with working connection", | 602 | logger.Info("Updated admin route with working connection", | ||
| 352 | "domain", adminDomain, | 603 | "domain", adminDomain, | ||
| 353 | "ip", testedIP) | 604 | "ip", testedIP) | ||
| 605 | + | ||||
| 606 | + | // Update the route in database as well | |||
| 607 | + | err := p.ForceUpdateRouteIP(adminDomain, testedIP) | |||
| 608 | + | if err != nil { | |||
| 609 | + | logger.Warn("Failed to update admin route IP in database", | |||
| 610 | + | "error", err, | |||
| 611 | + | "domain", adminDomain) | |||
| 612 | + | } | |||
| 354 | } | 613 | } | ||
| 355 | p.mu.Unlock() | 614 | p.mu.Unlock() | ||
| 615 | + | ||||
| 616 | + | // Reconfigure routes to apply the IP change | |||
| 617 | + | p.configureRoutes() | |||
| 356 | } | 618 | } | ||
| 357 | 619 | ||||
| 358 | // Check external port access | 620 | // Check external port access | ||
| 359 | p.checkExternalPortAccess() | 621 | p.checkExternalPortAccess() | ||
| 360 | } | 622 | } | ||
| 361 | 623 | ||||
| 624 | + | // checkPorts is a helper function to diagnose what might be using a port | |||
| 625 | + | func checkPorts(protocol, port string) { | |||
| 626 | + | // This is just a debugging helper - errors are ignored | |||
| 627 | + | // Try using netstat to check port usage | |||
| 628 | + | output, err := exec.Command("netstat", "-tuln").CombinedOutput() | |||
| 629 | + | if err == nil { | |||
| 630 | + | logger.Debug("Network port status", "output", string(output)) | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | // Try using lsof to check port usage | |||
| 634 | + | output, err = exec.Command("lsof", "-i", fmt.Sprintf("%s:%s", protocol, port)).CombinedOutput() | |||
| 635 | + | if err == nil && len(output) > 0 { | |||
| 636 | + | logger.Debug("Processes using port", "port", port, "output", string(output)) | |||
| 637 | + | } else { | |||
| 638 | + | logger.Debug("No process found using port with lsof", "port", port) | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | ||||
| 362 | // checkExternalPortAccess verifies if ports 80 and 443 are accessible | 642 | // checkExternalPortAccess verifies if ports 80 and 443 are accessible | ||
| 363 | func (p *Proxy) checkExternalPortAccess() { | 643 | func (p *Proxy) checkExternalPortAccess() { | ||
| 364 | adminDomain := p.app.GetConfig().Http.FullDomain() | 644 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| @@ -406,82 +686,129 @@ func (p *Proxy) checkExternalPortAccess() { | |||||
| 406 | } | 686 | } | ||
| 407 | } | 687 | } | ||
| 408 | 688 | ||||
| 409 | - | // // detectGordonContainer attempts to find the Gordon container automatically | 689 | + | // getOwnContainerIP attempts to determine the container's own IP address |
| 410 | - | // func (p *Proxy) detectGordonContainer() string { | 690 | + | // from network interfaces, preferring non-loopback interfaces |
| 411 | - | // // Check if we're running inside a container | 691 | + | func (p *Proxy) getOwnContainerIP() string { |
| 412 | - | // if docker.IsRunningInContainer() { | 692 | + | // Default fallback IP based on common container network range |
| 413 | - | // logger.Debug("Detected we're running in a container") | 693 | + | defaultIP := "10.89.0.118" // Updated to match the observed IP in logs |
| 414 | - | 694 | + | ||
| 415 | - | // // Get our hostname which should be the container ID in Docker/Podman | 695 | + | // Use environment variable if available (can be set in container config) |
| 416 | - | // hostname := os.Getenv("HOSTNAME") | 696 | + | if envIP := os.Getenv("GORDON_CONTAINER_IP"); envIP != "" { |
| 417 | - | // if hostname != "" { | 697 | + | logger.Debug("Using container IP from environment variable", "ip", envIP) |
| 418 | - | // // Check if there's a container with this hostname | 698 | + | return envIP |
| 419 | - | // containers, err := docker.ListRunningContainers() | 699 | + | } |
| 420 | - | // if err == nil { | 700 | + | |
| 421 | - | // for _, container := range containers { | 701 | + | // Try to get the IP from the Gordon network - this is the most reliable method |
| 422 | - | // // Check if this container is us by comparing hostname with container ID | 702 | + | networkName := p.app.GetConfig().ContainerEngine.Network |
| 423 | - | // if strings.HasPrefix(hostname, container.ID) { | 703 | + | if p.gordonContainerID != "" { |
| 424 | - | // // This is our container | 704 | + | ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName) |
| 425 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | 705 | + | if err == nil && ip != "" { |
| 426 | - | // logger.Debug("Auto-detected Gordon container name from hostname", "container_name", containerName) | 706 | + | logger.Info("Using container IP from Docker network", "ip", ip, "network", networkName) |
| 427 | - | // return containerName | 707 | + | return ip |
| 428 | - | // } | 708 | + | } else { |
| 429 | - | // } | 709 | + | logger.Error("Failed to get container IP from Docker network", |
| 430 | - | // } else { | 710 | + | "error", err, |
| 431 | - | // logger.Debug("Could not list containers when checking hostname", "error", err) | 711 | + | "container_id", p.gordonContainerID, |
| 432 | - | // } | 712 | + | "network", networkName) |
| 433 | - | // } | 713 | + | } |
| 434 | - | // } | 714 | + | } else { |
| 435 | - | 715 | + | logger.Error("Gordon container ID not set, cannot get container IP from Docker network") | |
| 436 | - | // // Try to find Gordon by examining container processes | 716 | + | } |
| 437 | - | // containers, err := docker.ListRunningContainers() | 717 | + | |
| 438 | - | // if err == nil { | 718 | + | // Try to detect by listing network interfaces |
| 439 | - | // for _, container := range containers { | 719 | + | ifaces, err := net.Interfaces() |
| 440 | - | // containerID := container.ID | 720 | + | if err != nil { |
| 441 | - | 721 | + | logger.Error("Failed to list network interfaces", "error", err) | |
| 442 | - | // // Check container processes for Gordon | 722 | + | return defaultIP |
| 443 | - | // containerLogs, err := docker.GetContainerLogs(containerID) | 723 | + | } |
| 444 | - | // if err == nil && (strings.Contains(containerLogs, "/gordon") || strings.Contains(containerLogs, "serve")) { | 724 | + | |
| 445 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | 725 | + | // First, look for eth0 or similar standard interfaces |
| 446 | - | // logger.Debug("Auto-detected Gordon container by process", "container_name", containerName) | 726 | + | for _, iface := range ifaces { |
| 447 | - | // return containerName | 727 | + | // Skip loopback |
| 448 | - | // } else if err != nil { | 728 | + | if iface.Flags&net.FlagLoopback != 0 { |
| 449 | - | // logger.Debug("Could not get container logs", "container_id", containerID, "error", err) | 729 | + | continue |
| 450 | - | // } | 730 | + | } |
| 451 | - | 731 | + | ||
| 452 | - | // // Check container command for Gordon | 732 | + | // Prefer eth0 or similar |
| 453 | - | // if len(container.Command) > 0 && strings.Contains(container.Command, "gordon") { | 733 | + | if strings.HasPrefix(iface.Name, "eth") || strings.HasPrefix(iface.Name, "en") { |
| 454 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | 734 | + | addrs, err := iface.Addrs() |
| 455 | - | // logger.Debug("Auto-detected Gordon container by command", "container_name", containerName) | 735 | + | if err != nil { |
| 456 | - | // return containerName | 736 | + | continue |
| 457 | - | // } | 737 | + | } |
| 458 | - | // } | 738 | + | |
| 459 | - | // } else { | 739 | + | for _, addr := range addrs { |
| 460 | - | // logger.Debug("Could not list containers when checking processes", "error", err) | 740 | + | ip, _, err := net.ParseCIDR(addr.String()) |
| 461 | - | // } | 741 | + | if err != nil { |
| 462 | - | 742 | + | continue | |
| 463 | - | // // If we couldn't detect from processes, try to find a container with gordon in the name or image | 743 | + | } |
| 464 | - | // if err == nil { | 744 | + | |
| 465 | - | // for _, container := range containers { | 745 | + | // Only use IPv4 addresses |
| 466 | - | // // Check container names for "gordon" | 746 | + | if ipv4 := ip.To4(); ipv4 != nil { |
| 467 | - | // for _, name := range container.Names { | 747 | + | logger.Debug("Found container IP from network interface", |
| 468 | - | // if strings.Contains(strings.ToLower(name), "gordon") { | 748 | + | "interface", iface.Name, |
| 469 | - | // containerName := strings.TrimLeft(name, "/") | 749 | + | "ip", ipv4.String()) |
| 470 | - | // logger.Debug("Auto-detected Gordon container by name", "container_name", containerName) | 750 | + | return ipv4.String() |
| 471 | - | // return containerName | 751 | + | } |
| 472 | - | // } | 752 | + | } |
| 473 | - | // } | 753 | + | } |
| 474 | - | 754 | + | } | |
| 475 | - | // // Check image name for "gordon" | 755 | + | |
| 476 | - | // if strings.Contains(strings.ToLower(container.Image), "gordon") { | 756 | + | // If no preferred interface found, use any non-loopback interface |
| 477 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | 757 | + | for _, iface := range ifaces { |
| 478 | - | // logger.Debug("Auto-detected Gordon container by image", "container_name", containerName, "image", container.Image) | 758 | + | if iface.Flags&net.FlagLoopback != 0 { |
| 479 | - | // return containerName | 759 | + | continue |
| 480 | - | // } | 760 | + | } |
| 481 | - | // } | 761 | + | |
| 482 | - | // } | 762 | + | addrs, err := iface.Addrs() |
| 483 | - | 763 | + | if err != nil { | |
| 484 | - | // // If all else fails, default to "gordon" | 764 | + | continue |
| 485 | - | // logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon") | 765 | + | } |
| 486 | - | // return "gordon" | 766 | + | |
| 487 | - | // } | 767 | + | for _, addr := range addrs { |
| 768 | + | ip, _, err := net.ParseCIDR(addr.String()) | |||
| 769 | + | if err != nil { | |||
| 770 | + | continue | |||
| 771 | + | } | |||
| 772 | + | ||||
| 773 | + | if ipv4 := ip.To4(); ipv4 != nil { | |||
| 774 | + | logger.Debug("Found container IP from fallback interface", | |||
| 775 | + | "interface", iface.Name, | |||
| 776 | + | "ip", ipv4.String()) | |||
| 777 | + | return ipv4.String() | |||
| 778 | + | } | |||
| 779 | + | } | |||
| 780 | + | } | |||
| 781 | + | ||||
| 782 | + | logger.Warn("Could not determine container IP, using default", "ip", defaultIP) | |||
| 783 | + | return defaultIP | |||
| 784 | + | } | |||
| 785 | + | ||||
| 786 | + | // StartAdminRouteVerification starts a periodic verification of admin routes | |||
| 787 | + | // to ensure they are correctly registered and functioning | |||
| 788 | + | func (p *Proxy) StartAdminRouteVerification() { | |||
| 789 | + | ticker := time.NewTicker(30 * time.Second) | |||
| 790 | + | defer ticker.Stop() | |||
| 791 | + | ||||
| 792 | + | logger.Debug("Starting admin route verification service") | |||
| 793 | + | ||||
| 794 | + | for { | |||
| 795 | + | select { | |||
| 796 | + | case <-ticker.C: | |||
| 797 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 798 | + | ||||
| 799 | + | // Check if the admin route exists in the routes map | |||
| 800 | + | p.mu.RLock() | |||
| 801 | + | _, exists := p.routes[adminDomain] | |||
| 802 | + | p.mu.RUnlock() | |||
| 803 | + | ||||
| 804 | + | if !exists { | |||
| 805 | + | logger.Warn("Admin route missing during periodic check, recreating it") | |||
| 806 | + | p.TestAdminConnectionLater() | |||
| 807 | + | continue | |||
| 808 | + | } | |||
| 809 | + | ||||
| 810 | + | // Test the existing admin connection | |||
| 811 | + | p.TestAdminConnectionLater() | |||
| 812 | + | } | |||
| 813 | + | } | |||
| 814 | + | } | |||
D internal/proxy/utils.go +0 -26
| @@ -1,26 +0,0 @@ | |||||
| 1 | - | package proxy | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "crypto/rand" | |||
| 5 | - | "fmt" | |||
| 6 | - | "time" | |||
| 7 | - | ||||
| 8 | - | "github.com/google/uuid" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // proxyGenerateUUID generates a random UUID for use in various identifiers | |||
| 12 | - | // This is a renamed version to avoid conflicts until we clean up proxy.go | |||
| 13 | - | func proxyGenerateUUID() string { | |||
| 14 | - | id, err := uuid.NewRandom() | |||
| 15 | - | if err != nil { | |||
| 16 | - | // If we can't generate a UUID using the standard library, | |||
| 17 | - | // fall back to our own implementation | |||
| 18 | - | b := make([]byte, 16) | |||
| 19 | - | _, err := rand.Read(b) | |||
| 20 | - | if err != nil { | |||
| 21 | - | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 22 | - | } | |||
| 23 | - | return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) | |||
| 24 | - | } | |||
| 25 | - | return id.String() | |||
| 26 | - | } | |||
M internal/templating/models/html/admin/createcontainerfull.gohtml +3 -3
| @@ -2,13 +2,13 @@ | |||||
| 2 | <html lang="en"> | 2 | <html lang="en"> | ||
| 3 | {{template "header.gohtml" .}} | 3 | {{template "header.gohtml" .}} | ||
| 4 | 4 | ||||
| 5 | - | <body class="bg-zinc-200 flex flex-col lg:flex-row p-0"> | 5 | + | <body class="bg-app flex flex-col lg:flex-row p-0"> |
| 6 | {{template "menu.gohtml" .}} | 6 | {{template "menu.gohtml" .}} | ||
| 7 | <!-- Main content here --> | 7 | <!-- Main content here --> | ||
| 8 | <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6"> | 8 | <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6"> | ||
| 9 | <div class="flex flex-col md:flex-row w-full flex-grow"> | 9 | <div class="flex flex-col md:flex-row w-full flex-grow"> | ||
| 10 | - | <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4"> | 10 | + | <div class="card flex-grow"> |
| 11 | - | <h1 class="text-2xl font-bold mb-4">Edit Container</h1> | 11 | + | <h1 class="card-header mb-4">Edit Container</h1> |
| 12 | <!-- Include the existing edit container fragment here --> | 12 | <!-- Include the existing edit container fragment here --> | ||
| 13 | {{template "createcontainer.gohtml" .}} | 13 | {{template "createcontainer.gohtml" .}} | ||
| 14 | </div> | 14 | </div> | ||
M internal/templating/models/html/admin/dashboard.gohtml +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | <div id="dashboard"> | 1 | <div id="dashboard"> | ||
| 2 | <h1 | 2 | <h1 | ||
| 3 | - | class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s" | 3 | + | class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s" |
| 4 | > | 4 | > | ||
| 5 | Hello World from dashboard.html | 5 | Hello World from dashboard.html | ||
| 6 | </h1> | 6 | </h1> | ||
M internal/templating/models/html/admin/index.gohtml +9 -9
| @@ -2,23 +2,23 @@ | |||||
| 2 | <html lang="en"> | 2 | <html lang="en"> | ||
| 3 | {{template "header.gohtml" .}} | 3 | {{template "header.gohtml" .}} | ||
| 4 | 4 | ||||
| 5 | - | <body class="bg-zinc-200 flex flex-col md:flex-row p-4 md:p-0"> | 5 | + | <body class="bg-app flex flex-col md:flex-row p-4 md:p-0"> |
| 6 | {{template "menu.gohtml" .}} | 6 | {{template "menu.gohtml" .}} | ||
| 7 | <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4"> | 7 | <div class="flex-1 flex flex-col md:pt-10 space-y-4 p-4 md:ml-4"> | ||
| 8 | <div | 8 | <div | ||
| 9 | class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> | 9 | class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> | ||
| 10 | - | <div class="flex-grow bg-white p-4 rounded-md shadow mb-4"> | 10 | + | <div class="card flex-grow"> |
| 11 | - | <h2>{{index .Lang "dashboard" "content" "title"}}</h2> | 11 | + | <h2 class="card-header">{{index .Lang "dashboard" "content" "title"}}</h2> |
| 12 | </div> | 12 | </div> | ||
| 13 | <div class="w-full md:w-2/6 flex flex-col space-y-4"> | 13 | <div class="w-full md:w-2/6 flex flex-col space-y-4"> | ||
| 14 | - | <div class="bg-white p-4 rounded-md shadow mb-4"> | 14 | + | <div class="card"> |
| 15 | - | <div class="w-full h-44 rounded-sm bg-gray-300"></div> | 15 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> |
| 16 | </div> | 16 | </div> | ||
| 17 | - | <div class="bg-white p-4 rounded-md shadow mb-4"> | 17 | + | <div class="card"> |
| 18 | - | <div class="w-full h-44 rounded-sm bg-gray-300"></div> | 18 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> |
| 19 | </div> | 19 | </div> | ||
| 20 | - | <div class="bg-white p-4 rounded-md shadow mb-4"> | 20 | + | <div class="card"> |
| 21 | - | <div class="w-full h-44 rounded-sm bg-gray-300"></div> | 21 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> |
| 22 | </div> | 22 | </div> | ||
| 23 | </div> | 23 | </div> | ||
| 24 | </div> | 24 | </div> | ||
M internal/templating/models/html/admin/install.gohtml +7 -7
| @@ -21,7 +21,7 @@ | |||||
| 21 | </head> | 21 | </head> | ||
| 22 | 22 | ||||
| 23 | <body | 23 | <body | ||
| 24 | - | class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center" | 24 | + | class="IBMPlexSans bg-app md:p-8 flex flex-col items-center" |
| 25 | hx-boost="true" | 25 | hx-boost="true" | ||
| 26 | > | 26 | > | ||
| 27 | <!-- Logo and Title --> | 27 | <!-- Logo and Title --> | ||
| @@ -38,28 +38,28 @@ | |||||
| 38 | </div> | 38 | </div> | ||
| 39 | <div class="flex justify-center mt-4"> | 39 | <div class="flex justify-center mt-4"> | ||
| 40 | <h1 | 40 | <h1 | ||
| 41 | - | class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter" | 41 | + | class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter" |
| 42 | > | 42 | > | ||
| 43 | Gordon | 43 | Gordon | ||
| 44 | </h1> | 44 | </h1> | ||
| 45 | - | <span class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2" | 45 | + | <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2" |
| 46 | >v0.0.1</span | 46 | >v0.0.1</span | ||
| 47 | > | 47 | > | ||
| 48 | </div> | 48 | </div> | ||
| 49 | </div> | 49 | </div> | ||
| 50 | 50 | ||||
| 51 | <!-- Main Content --> | 51 | <!-- Main Content --> | ||
| 52 | - | <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3"> | 52 | + | <div class="card w-2/3"> |
| 53 | <!-- Built-in Proxy --> | 53 | <!-- Built-in Proxy --> | ||
| 54 | <div id="built-in-proxy" class="mt-8"> | 54 | <div id="built-in-proxy" class="mt-8"> | ||
| 55 | <div class="flex items-center mb-4"> | 55 | <div class="flex items-center mb-4"> | ||
| 56 | - | <div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center"> | 56 | + | <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center"> |
| 57 | <span class="text-white font-bold">✓</span> | 57 | <span class="text-white font-bold">✓</span> | ||
| 58 | </div> | 58 | </div> | ||
| 59 | - | <h2 class="text-lg ml-2">Reverse Proxy</h2> | 59 | + | <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2> |
| 60 | </div> | 60 | </div> | ||
| 61 | 61 | ||||
| 62 | - | <div class="bg-gray-100 p-4 rounded-md"> | 62 | + | <div class="bg-indigo-50 p-4 rounded-md"> |
| 63 | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | 63 | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | ||
| 64 | <p>The proxy automatically:</p> | 64 | <p>The proxy automatically:</p> | ||
| 65 | <ul class="list-disc ml-6 mb-4"> | 65 | <ul class="list-disc ml-6 mb-4"> | ||
M internal/templating/models/html/admin/manager.gohtml +65 -23
| @@ -1,34 +1,76 @@ | |||||
| 1 | <!-- manager.gohtml --> | 1 | <!-- manager.gohtml --> | ||
| 2 | <!DOCTYPE html> | 2 | <!DOCTYPE html> | ||
| 3 | - | <html lang="en"> | 3 | + | <html lang="en" data-theme="gordon"> |
| 4 | {{template "header.gohtml" .}} | 4 | {{template "header.gohtml" .}} | ||
| 5 | 5 | ||||
| 6 | - | <body class="bg-zinc-200 flex flex-col lg:flex-row p-0"> | 6 | + | <body class="min-h-screen bg-base-200"> |
| 7 | {{template "menu.gohtml" .}} | 7 | {{template "menu.gohtml" .}} | ||
| 8 | - | <!-- Main content here --> | 8 | + | |
| 9 | - | <div class="flex-1 flex flex-col space-y-4 pt-16 md:pt-32 md:px-6"> | 9 | + | <!-- Main content --> |
| 10 | - | <div class="flex flex-col md:flex-row w-full flex-grow"> | 10 | + | <div class="container mx-auto py-6 px-4 mt-16"> |
| 11 | - | <div class="flex-grow bg-white p-4 md:rounded-md md:shadow mb-4"> | 11 | + | <div class="text-sm breadcrumbs mb-4"> |
| 12 | - | <!-- List all the containers --> | 12 | + | <ul> |
| 13 | - | <div | 13 | + | <li><a href="{{ .AdminPath }}">Gordon</a></li> |
| 14 | - | id="container-manager" | 14 | + | <li>Manager</li> |
| 15 | - | hx-get="/htmx/container-manager" | 15 | + | </ul> |
| 16 | - | hx-trigger="load" | |||
| 17 | - | hx-swap="innerHTML" | |||
| 18 | - | hx-swap-oob="true" | |||
| 19 | - | ></div> | |||
| 20 | - | <!-- List all the images --> | |||
| 21 | - | <div | |||
| 22 | - | id="image-manager" | |||
| 23 | - | hx-get="/htmx/image-manager" | |||
| 24 | - | hx-trigger="load" | |||
| 25 | - | hx-swap="innerHTML" | |||
| 26 | - | hx-swap-oob="true" | |||
| 27 | - | ></div> | |||
| 28 | </div> | 16 | </div> | ||
| 17 | + | ||||
| 18 | + | <!-- Stats overview --> | |||
| 19 | + | <div class="stats shadow mb-6 w-full"> | |||
| 20 | + | <div class="stat"> | |||
| 21 | + | <div class="stat-figure text-primary"> | |||
| 22 | + | <span class="iconf text-3xl"></span> | |||
| 23 | + | </div> | |||
| 24 | + | <div class="stat-title">Containers</div> | |||
| 25 | + | <div class="stat-value" id="container-count">{{.ContainerCount}}</div> | |||
| 26 | + | <div class="stat-desc">Docker containers managed by Gordon</div> | |||
| 27 | + | </div> | |||
| 28 | + | ||||
| 29 | + | <div class="stat"> | |||
| 30 | + | <div class="stat-figure text-secondary"> | |||
| 31 | + | <span class="iconf text-3xl"></span> | |||
| 32 | + | </div> | |||
| 33 | + | <div class="stat-title">Images</div> | |||
| 34 | + | <div class="stat-value" id="image-count">{{.ImageCount}}</div> | |||
| 35 | + | <div class="stat-desc">Docker images available</div> | |||
| 36 | + | </div> | |||
| 37 | + | </div> | |||
| 38 | + | ||||
| 39 | + | <!-- Tabs for containers and images --> | |||
| 40 | + | <div role="tablist" class="tabs tabs-boxed mb-4"> | |||
| 41 | + | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked /> | |||
| 42 | + | <div role="tabpanel" class="tab-content p-0"> | |||
| 43 | + | <!-- Container manager section --> | |||
| 44 | + | <div id="container-manager" | |||
| 45 | + | hx-get="/htmx/container-manager" | |||
| 46 | + | hx-trigger="load" | |||
| 47 | + | hx-swap="innerHTML"> | |||
| 48 | + | <!-- Container content loaded via HTMX --> | |||
| 49 | + | <div class="text-center p-4"> | |||
| 50 | + | <span class="iconf text-3xl animate-spin inline-block"></span> | |||
| 51 | + | Loading containers... | |||
| 52 | + | </div> | |||
| 53 | + | </div> | |||
| 54 | + | </div> | |||
| 55 | + | ||||
| 56 | + | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" /> | |||
| 57 | + | <div role="tabpanel" class="tab-content p-0"> | |||
| 58 | + | <!-- Image manager section --> | |||
| 59 | + | <div id="image-manager" | |||
| 60 | + | hx-get="/htmx/image-manager" | |||
| 61 | + | hx-trigger="load" | |||
| 62 | + | hx-swap="innerHTML"> | |||
| 63 | + | <!-- Image content loaded via HTMX --> | |||
| 64 | + | <div class="text-center p-4"> | |||
| 65 | + | <span class="iconf text-3xl animate-spin inline-block"></span> | |||
| 66 | + | Loading images... | |||
| 67 | + | </div> | |||
| 68 | + | </div> | |||
| 69 | + | </div> | |||
| 70 | + | </div> | |||
| 71 | + | ||||
| 29 | </div> | 72 | </div> | ||
| 30 | 73 | ||||
| 31 | - | <!-- footer --> | |||
| 32 | {{template "footer.gohtml" .}} | 74 | {{template "footer.gohtml" .}} | ||
| 33 | </body> | 75 | </body> | ||
| 34 | </html> | 76 | </html> | ||
M internal/templating/models/html/fragments/components.gohtml +55 -12
| @@ -1,20 +1,24 @@ | |||||
| 1 | <!-- @format --> | 1 | <!-- @format --> | ||
| 2 | 2 | ||||
| 3 | - | <div id="header"> | 3 | + | <div id="header" class="hero bg-base-200"> |
| 4 | - | <h1 | 4 | + | <div class="hero-content text-center"> |
| 5 | - | class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s" | 5 | + | <div class="max-w-md"> |
| 6 | - | > | 6 | + | <h1 class="text-5xl font-bold gordon-title"> |
| 7 | - | {{ .body.header.helloworld }} | 7 | + | {{ .body.header.helloworld }} |
| 8 | - | </h1> | 8 | + | </h1> |
| 9 | + | <p class="py-6 IBMPlexSans">Welcome to Gordon Container Management system.</p> | |||
| 10 | + | <button class="btn btn-primary">Get Started</button> | |||
| 11 | + | </div> | |||
| 12 | + | </div> | |||
| 9 | </div> | 13 | </div> | ||
| 10 | 14 | ||||
| 11 | - | <div id="hello"> | 15 | + | <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4"> |
| 12 | - | <h1 | 16 | + | <div> |
| 13 | - | class="text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s" | 17 | + | <span class="iconfb"></span> |
| 14 | - | > | 18 | + | <span class="IBMPlexSans">{{ .body.div.hello }}</span> |
| 15 | - | {{ .body.div.hello }} | 19 | + | </div> |
| 16 | - | </h1> | |||
| 17 | </div> | 20 | </div> | ||
| 21 | + | ||||
| 18 | <div | 22 | <div | ||
| 19 | id="header" | 23 | id="header" | ||
| 20 | hx-get="/htmx" | 24 | hx-get="/htmx" | ||
| @@ -31,3 +35,42 @@ | |||||
| 31 | hx-trigger="load" | 35 | hx-trigger="load" | ||
| 32 | hx-swap="outerHTML" | 36 | hx-swap="outerHTML" | ||
| 33 | ></div> | 37 | ></div> | ||
| 38 | + | ||||
| 39 | + | <!-- Stats Cards --> | |||
| 40 | + | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4"> | |||
| 41 | + | <div class="stat bg-base-100 shadow"> | |||
| 42 | + | <div class="stat-figure text-primary"> | |||
| 43 | + | <span class="iconfb text-3xl"></span> | |||
| 44 | + | </div> | |||
| 45 | + | <div class="stat-title">Containers</div> | |||
| 46 | + | <div class="stat-value">31</div> | |||
| 47 | + | <div class="stat-desc">Running containers</div> | |||
| 48 | + | </div> | |||
| 49 | + | ||||
| 50 | + | <div class="stat bg-base-100 shadow"> | |||
| 51 | + | <div class="stat-figure text-secondary"> | |||
| 52 | + | <span class="iconfb text-3xl"></span> | |||
| 53 | + | </div> | |||
| 54 | + | <div class="stat-title">Images</div> | |||
| 55 | + | <div class="stat-value">42</div> | |||
| 56 | + | <div class="stat-desc">Available images</div> | |||
| 57 | + | </div> | |||
| 58 | + | ||||
| 59 | + | <div class="stat bg-base-100 shadow"> | |||
| 60 | + | <div class="stat-figure text-accent"> | |||
| 61 | + | <span class="iconfb text-3xl"></span> | |||
| 62 | + | </div> | |||
| 63 | + | <div class="stat-title">Networks</div> | |||
| 64 | + | <div class="stat-value">12</div> | |||
| 65 | + | <div class="stat-desc">Container networks</div> | |||
| 66 | + | </div> | |||
| 67 | + | </div> | |||
| 68 | + | ||||
| 69 | + | <!-- Example of tabs --> | |||
| 70 | + | <div class="tabs tabs-boxed my-4"> | |||
| 71 | + | <a class="tab tab-active">Overview</a> | |||
| 72 | + | <a class="tab">Containers</a> | |||
| 73 | + | <a class="tab">Images</a> | |||
| 74 | + | <a class="tab">Networks</a> | |||
| 75 | + | <a class="tab">Volumes</a> | |||
| 76 | + | </div> | |||
M internal/templating/models/html/fragments/containerlist.gohtml +120 -110
| @@ -1,112 +1,122 @@ | |||||
| 1 | - | <div class="flex flex-col p-4" hx-ext="response-targets"> | 1 | + | <!-- Container list using daisyUI components --> |
| 2 | - | <div class="flex justify-between items-center mb-1"> | 2 | + | <div class="card w-full bg-base-300"> |
| 3 | - | <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue"> | 3 | + | <div class="card-body p-4"> |
| 4 | - | {{index .Lang "manager" "content" "h1-containers"}} | 4 | + | |
| 5 | - | </h1> | 5 | + | <!-- Action buttons --> |
| 6 | - | <button | 6 | + | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> |
| 7 | - | id="refresh-containers-button" | 7 | + | <h3 class="font-semibold text-base-content">Available Containers: {{ len .Containers }}</h3> |
| 8 | - | class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded" | 8 | + | <div class="btn-group"> |
| 9 | - | title="Refresh container list" | 9 | + | <button |
| 10 | - | > | 10 | + | class="btn btn-sm btn-primary" |
| 11 | - | <span class="iconf text-xl align-middle"> </span> | 11 | + | hx-get="/htmx/container-manager" |
| 12 | - | Refresh | 12 | + | hx-target="#container-manager" |
| 13 | - | </button> | 13 | + | hx-swap="innerHTML"> |
| 14 | - | </div> | 14 | + | <span class="iconf"></span> Refresh |
| 15 | - | <table class="p-4 border-t-2 table-auto"> | 15 | + | </button> |
| 16 | - | <thead> | 16 | + | </div> |
| 17 | - | <tr class="text-center text-gray-400"> | 17 | + | </div> |
| 18 | - | <th>{{index .Lang "manager" "content" "thName"}}</th> | 18 | + | |
| 19 | - | <th>{{index .Lang "manager" "content" "thPorts"}}</th> | 19 | + | <!-- Container list --> |
| 20 | - | <th>{{index .Lang "manager" "content" "thEntryPoint"}}</th> | 20 | + | <table class="table"> |
| 21 | - | <th>{{index .Lang "manager" "content" "thStatus"}}</th> | 21 | + | <!-- head --> |
| 22 | - | <th>{{index .Lang "manager" "content" "thState"}}</th> | 22 | + | <thead> |
| 23 | - | <th>{{index .Lang "manager" "content" "thActions"}}</th> | 23 | + | <tr> |
| 24 | - | </tr> | 24 | + | <th>Name</th> |
| 25 | - | </thead> | 25 | + | <th>Image</th> |
| 26 | - | <tbody> | 26 | + | <th>Status</th> |
| 27 | - | {{ $Lang := .Lang }} | 27 | + | <th>Created</th> |
| 28 | - | {{ | 28 | + | <th>Actions</th> |
| 29 | - | range.containers | 29 | + | </tr> |
| 30 | - | }} | 30 | + | </thead> |
| 31 | - | <tr id="container-{{.ID}}" class="text-center hover:bg-slate-100"> | 31 | + | <tbody> |
| 32 | - | <td> | 32 | + | {{ if or .NoContainer (eq (len .Containers) 0) }} |
| 33 | - | <span | 33 | + | <tr> |
| 34 | - | title="{{.Container.ID}}" | 34 | + | <td colspan="5" class="text-center py-4"> |
| 35 | - | class="font-bold" | 35 | + | <div class="alert alert-info"> |
| 36 | - | >{{.Name}}</span | 36 | + | <span class="iconf">ℹ️</span> |
| 37 | - | > | 37 | + | <span>No containers found. Upload an image first, then create a container.</span> |
| 38 | - | </td> | 38 | + | </div> |
| 39 | - | <td class="port-container">{{ .Ports }}</td> | 39 | + | </td> |
| 40 | - | <td class="entrypoint-container">{{.ProxyPort}}</td> | |||
| 41 | - | <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td> | |||
| 42 | - | <td style="color: {{.StateColor}};">{{.State}}</td> | |||
| 43 | - | <td> | |||
| 44 | - | <button | |||
| 45 | - | id="start-button-{{.ID}}" | |||
| 46 | - | hx-post="/htmx/container-manager/start/{{.ID}}" | |||
| 47 | - | hx-target="#actionResponse-{{.ID}}" | |||
| 48 | - | hx-target-*="#error-message-{{.ID}}" | |||
| 49 | - | hx-swap="outerHTML" | |||
| 50 | - | class="py-2 px-4" | |||
| 51 | - | > | |||
| 52 | - | <span title="Start the container" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue"></span> | |||
| 53 | - | </button> | |||
| 54 | - | <!-- EDIT --> | |||
| 55 | - | <button | |||
| 56 | - | id="edit-button-{{.ID}}" | |||
| 57 | - | hx-get="/htmx/container-manager/edit/{{.ID}}" | |||
| 58 | - | hx-target="#actionResponse-{{.ID}}" | |||
| 59 | - | hx-target-*="#error-message-{{.ID}}" | |||
| 60 | - | hx-swap="innerHTML" | |||
| 61 | - | class="py-2 px-4" | |||
| 62 | - | data-active="false" | |||
| 63 | - | > | |||
| 64 | - | <span title="Edit the container" class="iconf text-2xl text-blue-600 hover:text-gordonmascot-hblue"></span> | |||
| 65 | - | </button> | |||
| 66 | - | <!-- stop --> | |||
| 67 | - | <button | |||
| 68 | - | id="stop-button-{{.ID}}" | |||
| 69 | - | hx-post="/htmx/container-manager/stop/{{.ID}}" | |||
| 70 | - | hx-target="#actionResponse-{{.ID}}" | |||
| 71 | - | hx-target-*="#error-message-{{.ID}}" | |||
| 72 | - | hx-swap="innerHTML" | |||
| 73 | - | hx-confirm="{{index $Lang "manager" "content" "confirm-container-stop"}}" | |||
| 74 | - | class="py-2 px-4" | |||
| 75 | - | > | |||
| 76 | - | <span title="Stop the container" class="iconf text-xl text-red-600 hover:text-red-900"></span> | |||
| 77 | - | </button> | |||
| 78 | - | <!-- REMOVE --> | |||
| 79 | - | <button | |||
| 80 | - | id="remove-button-img-{{.ID}}" | |||
| 81 | - | hx-delete="/htmx/container-manager/delete/{{.ID}}" | |||
| 82 | - | hx-target="#actionResponse-{{.ID}}" | |||
| 83 | - | hx-target-*="#error-message-{{.ID}}" | |||
| 84 | - | hx-swap="innerHTML" | |||
| 85 | - | hx-confirm="{{index $Lang "manager" "content" "confirm-delete"}}" | |||
| 86 | - | class="py-2 px-4" | |||
| 87 | - | > | |||
| 88 | - | <!-- iconoir trash --> | |||
| 89 | - | <span title="Remove the container" class="iconf text-xl text-red-600 hover:text-red-900"></span> | |||
| 90 | - | </button> | |||
| 91 | - | </td> | |||
| 92 | </tr> | 40 | </tr> | ||
| 93 | - | <tr> | 41 | + | {{ else }} |
| 94 | - | <td colspan="7"> | 42 | + | {{ range .Containers }} |
| 95 | - | <div id="actionResponse-{{.ID}}"></div> | 43 | + | <tr class="hover"> |
| 96 | - | <div id="error-message-{{.ID}}" class="text-red-500"></div> | 44 | + | <td> |
| 97 | - | </td> | 45 | + | <div class="flex items-center gap-3"> |
| 98 | - | </tr> | 46 | + | <div> |
| 99 | - | {{ else }} | 47 | + | <div class="font-bold">{{ .Names }}</div> |
| 100 | - | <tr> | 48 | + | <div class="text-sm opacity-50">{{ .ID }}</div> |
| 101 | - | <td colspan="7" class="mt-2"> | 49 | + | </div> |
| 102 | - | <p class="text-center text-gray-400 text-xl"> | 50 | + | </div> |
| 103 | - | {{index $Lang "manager" "content" "no-containers"}} | 51 | + | </td> |
| 104 | - | </p> | 52 | + | <td>{{ .Image }}</td> |
| 105 | - | </td> | 53 | + | <td> |
| 106 | - | </tr> | 54 | + | {{ if eq .State "running" }} |
| 107 | - | {{ | 55 | + | <div class="badge badge-success badge-sm">Running</div> |
| 108 | - | end | 56 | + | {{ else if eq .State "exited" }} |
| 109 | - | }} | 57 | + | <div class="badge badge-warning badge-sm">Stopped</div> |
| 110 | - | </tbody> | 58 | + | {{ else }} |
| 111 | - | </table> | 59 | + | <div class="badge badge-ghost badge-sm">{{ .State }}</div> |
| 60 | + | {{ end }} | |||
| 61 | + | </td> | |||
| 62 | + | <td>{{ .Created }}</td> | |||
| 63 | + | <th> | |||
| 64 | + | <div class="dropdown dropdown-end"> | |||
| 65 | + | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |||
| 66 | + | <span class="iconf"></span> | |||
| 67 | + | </div> | |||
| 68 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40"> | |||
| 69 | + | {{ if eq .State "running" }} | |||
| 70 | + | <li> | |||
| 71 | + | <button | |||
| 72 | + | class="text-warning flex items-center" | |||
| 73 | + | hx-post="/htmx/container-manager/stop/{{ .ID }}" | |||
| 74 | + | hx-target="#container-manager" | |||
| 75 | + | hx-swap="innerHTML"> | |||
| 76 | + | <span class="iconf mr-2"></span> Stop | |||
| 77 | + | </button> | |||
| 78 | + | </li> | |||
| 79 | + | {{ else if eq .State "exited" }} | |||
| 80 | + | <li> | |||
| 81 | + | <button | |||
| 82 | + | class="text-success flex items-center" | |||
| 83 | + | hx-post="/htmx/container-manager/start/{{ .ID }}" | |||
| 84 | + | hx-target="#container-manager" | |||
| 85 | + | hx-swap="innerHTML"> | |||
| 86 | + | <span class="iconf mr-2"></span> Start | |||
| 87 | + | </button> | |||
| 88 | + | </li> | |||
| 89 | + | {{ end }} | |||
| 90 | + | <li> | |||
| 91 | + | <button | |||
| 92 | + | class="text-info flex items-center" | |||
| 93 | + | hx-get="/htmx/container-manager/edit/{{ .ID }}" | |||
| 94 | + | hx-target="#container-manager" | |||
| 95 | + | hx-swap="innerHTML"> | |||
| 96 | + | <span class="iconf mr-2"></span> Edit | |||
| 97 | + | </button> | |||
| 98 | + | </li> | |||
| 99 | + | <li> | |||
| 100 | + | <button | |||
| 101 | + | class="text-error flex items-center" | |||
| 102 | + | hx-delete="/htmx/container-manager/delete/{{ .ID }}" | |||
| 103 | + | hx-target="#container-manager" | |||
| 104 | + | hx-swap="innerHTML" | |||
| 105 | + | hx-confirm="Are you sure you want to delete this container?"> | |||
| 106 | + | <span class="iconf mr-2"></span> Delete | |||
| 107 | + | </button> | |||
| 108 | + | </li> | |||
| 109 | + | </ul> | |||
| 110 | + | </div> | |||
| 111 | + | </th> | |||
| 112 | + | </tr> | |||
| 113 | + | {{ end }} | |||
| 114 | + | {{ end }} | |||
| 115 | + | </tbody> | |||
| 116 | + | </table> | |||
| 117 | + | </div> | |||
| 112 | </div> | 118 | </div> | ||
| 119 | + | ||||
| 120 | + | <script> | |||
| 121 | + | console.debug('Container list rendered'); | |||
| 122 | + | </script> | |||
M internal/templating/models/html/fragments/createcontainer.gohtml +79 -54
| @@ -1,9 +1,12 @@ | |||||
| 1 | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets"> | 1 | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets"> | ||
| 2 | <!-- Left Side: Docker Run Options --> | 2 | <!-- Left Side: Docker Run Options --> | ||
| 3 | - | <div class="flex-grow bg-slate-100 p-4 rounded-md"> | 3 | + | <div class="flex-grow card p-6"> |
| 4 | - | <h2 class="font-bold pb-4">Deploy a container based on {{.ImageName}}</h2> | 4 | + | <h2 class="card-title pb-4">Deploy a container based on {{.ImageName}}</h2> |
| 5 | {{if .ErrorMessage}} | 5 | {{if .ErrorMessage}} | ||
| 6 | - | <div class="text-red-500">{{.ErrorMessage}}</div> | 6 | + | <div class="alert alert-error"> |
| 7 | + | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |||
| 8 | + | <span>{{.ErrorMessage}}</span> | |||
| 9 | + | </div> | |||
| 7 | {{ end }} | 10 | {{ end }} | ||
| 8 | <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post" | 11 | <form id="create-container-form-{{.ShortID}}" action="/create-container/{{.ShortID}}" method="post" | ||
| 9 | hx-post="/htmx/create-container/{{.ShortID}}" | 12 | hx-post="/htmx/create-container/{{.ShortID}}" | ||
| @@ -11,83 +14,104 @@ | |||||
| 11 | hx-target="#create-container-{{.ShortID}}" | 14 | hx-target="#create-container-{{.ShortID}}" | ||
| 12 | hx-target-*="#error-message-create-container-{{.ShortID}}"> | 15 | hx-target-*="#error-message-create-container-{{.ShortID}}"> | ||
| 13 | <!-- Image Name --> | 16 | <!-- Image Name --> | ||
| 14 | - | <div class="flex space-x-4 pb-4"> | 17 | + | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> |
| 15 | - | <div class="w-1/3"> | 18 | + | <div class="md:col-span-1"> |
| 16 | - | <label class="block">Image name</label> | 19 | + | <label class="label"> |
| 17 | - | <input type="text" name="image_name" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2" | 20 | + | <span class="label-text">Image name</span> |
| 21 | + | </label> | |||
| 22 | + | <input type="text" name="image_name" class="input input-bordered input-disabled w-full" | |||
| 18 | value="{{.ImageName}}" readonly /> | 23 | value="{{.ImageName}}" readonly /> | ||
| 19 | </div> | 24 | </div> | ||
| 20 | 25 | ||||
| 21 | - | <div class="w-2/3"> | 26 | + | <div class="md:col-span-2"> |
| 22 | - | <label class="block">Image ID</label> | 27 | + | <label class="label"> |
| 23 | - | <input type="text" name="image_id" class="w-full p-4 rounded-md bg-gray-100 border-gray-300 border-2" | 28 | + | <span class="label-text">Image ID</span> |
| 29 | + | </label> | |||
| 30 | + | <input type="text" name="image_id" class="input input-bordered input-disabled w-full" | |||
| 24 | value="{{.ImageID}}" readonly /> | 31 | value="{{.ImageID}}" readonly /> | ||
| 25 | </div> | 32 | </div> | ||
| 26 | </div> | 33 | </div> | ||
| 27 | 34 | ||||
| 28 | <!-- Container Name --> | 35 | <!-- Container Name --> | ||
| 29 | - | <div class="pb-4"> | 36 | + | <div class="form-control pb-4"> |
| 30 | - | <label class="block">Container name</label> | 37 | + | <label class="label"> |
| 31 | - | <input type="text" name="container_name" class="w-full p-4 rounded-md" value="{{.ContainerName}}" /> | 38 | + | <span class="label-text">Container name</span> |
| 39 | + | </label> | |||
| 40 | + | <input type="text" name="container_name" class="input input-bordered w-full" value="{{.ContainerName}}" /> | |||
| 32 | </div> | 41 | </div> | ||
| 42 | + | ||||
| 33 | <!-- Http(s) / Subdomain / Domain --> | 43 | <!-- Http(s) / Subdomain / Domain --> | ||
| 34 | - | <div class="flex pb-4 space-x-4"> | 44 | + | <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4"> |
| 35 | <!-- Protocol --> | 45 | <!-- Protocol --> | ||
| 36 | - | <div class="w-1/4"> | 46 | + | <div class="md:col-span-3"> |
| 37 | - | <label class="block">Protocol</label> | 47 | + | <label class="label"> |
| 38 | - | <select name="container_protocol" class="w-full p-4 rounded-md border-2 bg-slate-200 border-gray-300"> | 48 | + | <span class="label-text">Protocol</span> |
| 49 | + | </label> | |||
| 50 | + | <select name="container_protocol" class="select select-bordered w-full"> | |||
| 39 | <option value="https">https</option> | 51 | <option value="https">https</option> | ||
| 40 | <option value="http">http</option> | 52 | <option value="http">http</option> | ||
| 41 | </select> | 53 | </select> | ||
| 42 | </div> | 54 | </div> | ||
| 43 | 55 | ||||
| 44 | <!-- Subdomain --> | 56 | <!-- Subdomain --> | ||
| 45 | - | <div class="w-1/3"> | 57 | + | <div class="md:col-span-4"> |
| 46 | - | <label class="block">Subdomain</label> | 58 | + | <label class="label"> |
| 47 | - | <input type="text" name="container_subdomain" class="w-full p-4 rounded-md" value="{{.ContainerSubdomain}}" /> | 59 | + | <span class="label-text">Subdomain</span> |
| 60 | + | </label> | |||
| 61 | + | <input type="text" name="container_subdomain" class="input input-bordered w-full" value="{{.ContainerSubdomain}}" /> | |||
| 48 | </div> | 62 | </div> | ||
| 49 | 63 | ||||
| 50 | <!-- Domain --> | 64 | <!-- Domain --> | ||
| 51 | - | <div class="w-5/12"> | 65 | + | <div class="md:col-span-5"> |
| 52 | - | <label class="block">Domain</label> | 66 | + | <label class="label"> |
| 53 | - | <input type="text" name="container_domain" class="w-full p-4 rounded-md" value="{{.ContainerDomain}}" /> | 67 | + | <span class="label-text">Domain</span> |
| 68 | + | </label> | |||
| 69 | + | <input type="text" name="container_domain" class="input input-bordered w-full" value="{{.ContainerDomain}}" /> | |||
| 54 | </div> | 70 | </div> | ||
| 55 | </div> | 71 | </div> | ||
| 72 | + | ||||
| 56 | <!-- Exposed Ports --> | 73 | <!-- Exposed Ports --> | ||
| 57 | - | <div class="pb-4"> | 74 | + | <div class="form-control pb-4"> |
| 58 | - | <label class="block">Exposed Ports (Optional)</label> | 75 | + | <label class="label"> |
| 59 | - | <span class="text-gray-500"> | 76 | + | <span class="label-text">Exposed Ports (Optional)</span> |
| 60 | - | eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma) | 77 | + | <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span> |
| 61 | - | </span> | 78 | + | </label> |
| 62 | - | <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" /> | 79 | + | <input type="text" name="ports" class="input input-bordered w-full" value="{{.Ports}}" /> |
| 63 | </div> | 80 | </div> | ||
| 64 | 81 | ||||
| 65 | <!-- Proxy Port --> | 82 | <!-- Proxy Port --> | ||
| 66 | - | <div class="mb-6"> | 83 | + | <div class="form-control pb-4"> |
| 67 | - | <label class="block">Container Proxy Port (Required)</label> | 84 | + | <label class="label"> |
| 68 | - | <div class="text-xs text-gray-500 mb-2"> | 85 | + | <span class="label-text">Container Proxy Port (Required)</span> |
| 69 | - | Specify the internal container port that the proxy will route traffic to | 86 | + | <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span> |
| 70 | - | </div> | 87 | + | </label> |
| 71 | - | <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required /> | 88 | + | <input type="text" name="container_port" class="input input-bordered w-full" value="{{.ProxyPort}}" required /> |
| 72 | </div> | 89 | </div> | ||
| 73 | 90 | ||||
| 74 | <!-- Volumes --> | 91 | <!-- Volumes --> | ||
| 75 | - | <div class="pb-4"> | 92 | + | <div class="form-control pb-4"> |
| 76 | - | <label class="block">Volumes</label> | 93 | + | <label class="label"> |
| 77 | - | <span class="text-gray-500">e.g. ./data:/container/data </span> | 94 | + | <span class="label-text">Volumes</span> |
| 78 | - | <input type="text" name="volumes" class="w-full p-4 rounded-md" value="{{.Volumes}}" /> | 95 | + | <span class="label-text-alt">e.g. ./data:/container/data</span> |
| 96 | + | </label> | |||
| 97 | + | <input type="text" name="volumes" class="input input-bordered w-full" value="{{.Volumes}}" /> | |||
| 79 | </div> | 98 | </div> | ||
| 80 | 99 | ||||
| 81 | - | <div class="pb-4"> | 100 | + | <div class="form-control pb-4"> |
| 82 | - | <label class="block">Environment variables</label> | 101 | + | <label class="label"> |
| 83 | - | <span class="text-gray-500">e.g. KEY=VALUE </span> | 102 | + | <span class="label-text">Environment variables</span> |
| 84 | - | <textarea name="environment_variables" class="w-full p-4 rounded-md" | 103 | + | <span class="label-text-alt">e.g. KEY=VALUE</span> |
| 104 | + | </label> | |||
| 105 | + | <textarea name="environment_variables" class="textarea textarea-bordered w-full" | |||
| 85 | rows="4">{{.EnvironmentVariables}}</textarea> | 106 | rows="4">{{.EnvironmentVariables}}</textarea> | ||
| 86 | </div> | 107 | </div> | ||
| 108 | + | ||||
| 87 | <!-- Restart: always/on-failure/only when stopped/never --> | 109 | <!-- Restart: always/on-failure/only when stopped/never --> | ||
| 88 | - | <div class="pb-4"> | 110 | + | <div class="form-control pb-4"> |
| 89 | - | <label class="block">Restart</label> | 111 | + | <label class="label"> |
| 90 | - | <select name="restart" class="w-full p-4 rounded-md border-2 bg-gray-100 border-gray-300"> | 112 | + | <span class="label-text">Restart</span> |
| 113 | + | </label> | |||
| 114 | + | <select name="restart" class="select select-bordered w-full"> | |||
| 91 | <option value="always">Always</option> | 115 | <option value="always">Always</option> | ||
| 92 | <option value="on-failure">On failure</option> | 116 | <option value="on-failure">On failure</option> | ||
| 93 | <option value="unless-stopped">Unless stopped</option> | 117 | <option value="unless-stopped">Unless stopped</option> | ||
| @@ -96,20 +120,21 @@ | |||||
| 96 | </div> | 120 | </div> | ||
| 97 | 121 | ||||
| 98 | <!-- Skip Proxy Setup Option --> | 122 | <!-- Skip Proxy Setup Option --> | ||
| 99 | - | <div class="pb-4"> | 123 | + | <div class="form-control pb-4"> |
| 100 | - | <label class="flex items-center space-x-2"> | 124 | + | <label class="label cursor-pointer justify-start gap-4"> |
| 101 | - | <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" /> | 125 | + | <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" /> |
| 102 | <span>Skip proxy setup (create container only, don't set up domain/certificates)</span> | 126 | <span>Skip proxy setup (create container only, don't set up domain/certificates)</span> | ||
| 103 | </label> | 127 | </label> | ||
| 104 | - | <p class="text-sm text-gray-500 ml-7"> | 128 | + | <div class="text-sm opacity-70 ml-10"> |
| 105 | Use this option if you only want to create the container without triggering proxy setup, | 129 | Use this option if you only want to create the container without triggering proxy setup, | ||
| 106 | certificate generation, and other related processes. | 130 | certificate generation, and other related processes. | ||
| 107 | - | </p> | 131 | + | </div> |
| 108 | </div> | 132 | </div> | ||
| 109 | 133 | ||||
| 110 | - | <input type="submit" value="Create" id="create-container-submit" | 134 | + | <button type="submit" id="create-container-submit" class="btn btn-primary"> |
| 111 | - | class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" /> | 135 | + | Create Container |
| 136 | + | </button> | |||
| 112 | </form> | 137 | </form> | ||
| 113 | - | <div id="error-message-create-container-{{.ShortID}}" class="text-red-500"></div> | 138 | + | <div id="error-message-create-container-{{.ShortID}}" class="text-error mt-4"></div> |
| 114 | </div> | 139 | </div> | ||
| 115 | </div>⏎̸ | 140 | </div>⏎̸ | ||
M internal/templating/models/html/fragments/editcontainer.gohtml +12 -10
| @@ -1,9 +1,9 @@ | |||||
| 1 | <div | 1 | <div | ||
| 2 | id="edit-container-{{.ID}}" | 2 | id="edit-container-{{.ID}}" | ||
| 3 | - | class="h-fit-content" | 3 | + | class="card bg-base-100 shadow-xl" |
| 4 | hx-ext="response-targets" | 4 | hx-ext="response-targets" | ||
| 5 | > | 5 | > | ||
| 6 | - | <div id="editor"> | 6 | + | <div id="editor" class="card-body"> |
| 7 | <form | 7 | <form | ||
| 8 | method="post" | 8 | method="post" | ||
| 9 | action="/htmx/container-manager/edit/{{.ID}}" | 9 | action="/htmx/container-manager/edit/{{.ID}}" | ||
| @@ -14,17 +14,19 @@ | |||||
| 14 | <textarea | 14 | <textarea | ||
| 15 | name="container_config" | 15 | name="container_config" | ||
| 16 | id="container_config" | 16 | id="container_config" | ||
| 17 | - | class="w-full p-4 rounded-md bg-slate-100 border-gray-300 border-2" | 17 | + | class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF" |
| 18 | rows="20" | 18 | rows="20" | ||
| 19 | >{{.ContainerInfo}}</textarea | 19 | >{{.ContainerInfo}}</textarea | ||
| 20 | > | 20 | > | ||
| 21 | - | <input | 21 | + | <div class="card-actions justify-end mt-4"> |
| 22 | - | type="submit" | 22 | + | <input |
| 23 | - | value="Save" | 23 | + | type="submit" |
| 24 | - | id="save-button-{{.ID}}" | 24 | + | value="Save" |
| 25 | - | class="bg-gordonmascot-lblue text-white font-bold px-8 py-1 rounded-md hover:bg-gordonmascot-mblue cursor-pointer" | 25 | + | id="save-button-{{.ID}}" |
| 26 | - | /> | 26 | + | class="btn btn-primary" |
| 27 | + | /> | |||
| 28 | + | </div> | |||
| 27 | </form> | 29 | </form> | ||
| 28 | - | <div id="error-message-edit-container-{{.ID}}" class="text-red-500"></div> | 30 | + | <div id="error-message-edit-container-{{.ID}}" class="text-error mt-2"></div> |
| 29 | </div> | 31 | </div> | ||
| 30 | </div> | 32 | </div> | ||
M internal/templating/models/html/fragments/footer.gohtml +33 -0
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | <footer class="footer p-10 bg-base-200 text-base-content"> | |||
| 2 | + | <div> | |||
| 3 | + | <span class="footer-title">Gordon</span> | |||
| 4 | + | <a class="link link-hover">About us</a> | |||
| 5 | + | <a class="link link-hover">Documentation</a> | |||
| 6 | + | <a class="link link-hover">Github</a> | |||
| 7 | + | </div> | |||
| 8 | + | <div> | |||
| 9 | + | <span class="footer-title">Container Management</span> | |||
| 10 | + | <a class="link link-hover">Dashboard</a> | |||
| 11 | + | <a class="link link-hover">Settings</a> | |||
| 12 | + | <a class="link link-hover">Stats</a> | |||
| 13 | + | </div> | |||
| 14 | + | <div> | |||
| 15 | + | <span class="footer-title">Legal</span> | |||
| 16 | + | <a class="link link-hover">Terms of use</a> | |||
| 17 | + | <a class="link link-hover">Privacy policy</a> | |||
| 18 | + | <a class="link link-hover">License</a> | |||
| 19 | + | </div> | |||
| 20 | + | </footer> | |||
| 21 | + | <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300"> | |||
| 22 | + | <div class="items-center grid-flow-col"> | |||
| 23 | + | <span class="iconfb text-2xl"></span> | |||
| 24 | + | <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p> | |||
| 25 | + | </div> | |||
| 26 | + | <div class="md:place-self-center md:justify-self-end"> | |||
| 27 | + | <div class="grid grid-flow-col gap-4"> | |||
| 28 | + | <a class="iconfb text-2xl"></a> | |||
| 29 | + | <a class="iconfb text-2xl"></a> | |||
| 30 | + | <a class="iconfb text-2xl"></a> | |||
| 31 | + | </div> | |||
| 32 | + | </div> | |||
| 33 | + | </footer> | |||
M internal/templating/models/html/fragments/header.gohtml +10 -0
| @@ -11,4 +11,14 @@ | |||||
| 11 | <script src="/libs/htmx-sse.js"></script> | 11 | <script src="/libs/htmx-sse.js"></script> | ||
| 12 | <script defer src="/libs/hyperscript.min.js"></script> | 12 | <script defer src="/libs/hyperscript.min.js"></script> | ||
| 13 | <script defer src="/assets/js/custom.js"></script> | 13 | <script defer src="/assets/js/custom.js"></script> | ||
| 14 | + | ||||
| 15 | + | <!-- daisyUI theme configuration --> | |||
| 16 | + | <script> | |||
| 17 | + | // Check for saved theme preference or use preferred color scheme | |||
| 18 | + | const savedTheme = localStorage.getItem('theme') || 'gordon'; | |||
| 19 | + | document.documentElement.setAttribute('data-theme', savedTheme); | |||
| 20 | + | ||||
| 21 | + | // Log theme loading for debugging | |||
| 22 | + | console.debug('Theme loaded:', savedTheme); | |||
| 23 | + | </script> | |||
| 14 | </head> | 24 | </head> | ||
M internal/templating/models/html/fragments/imagelist.gohtml +95 -79
| @@ -1,82 +1,98 @@ | |||||
| 1 | - | <div class="flex flex-col p-4"> | 1 | + | <!-- Image list using daisyUI components --> |
| 2 | - | <!-- Upload button aligned right of Images --> | 2 | + | <div class="card w-full bg-base-300"> |
| 3 | - | <div class="flex justify-between items-center mb-1"> | 3 | + | <div class="card-body p-4"> |
| 4 | - | <h1 class="text-lg md:text-xl font-bold text-gordonmascot-hblue">Images</h1> | 4 | + | |
| 5 | - | <div class="flex space-x-2"> | 5 | + | <!-- Action buttons --> |
| 6 | - | <button | 6 | + | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> |
| 7 | - | id="refresh-images-button" | 7 | + | <h3 class="font-semibold text-base-content">Available Images: {{ len .Images }}</h3> |
| 8 | - | class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded" | 8 | + | <div class="flex gap-2"> |
| 9 | - | title="Refresh image list" | 9 | + | <button |
| 10 | - | > | 10 | + | class="btn btn-sm btn-secondary" |
| 11 | - | <span class="iconf text-xl align-middle"> </span> | 11 | + | hx-get="/htmx/upload-image" |
| 12 | - | Refresh | 12 | + | hx-target="#image-manager" |
| 13 | - | </button> | 13 | + | hx-swap="innerHTML"> |
| 14 | - | <button | 14 | + | <span class="iconf"></span> Upload |
| 15 | - | class="py-1 px-2 bg-blue-500 hover:bg-blue-700 text-white font-bold rounded" | 15 | + | </button> |
| 16 | - | id="upload-button" | 16 | + | <button |
| 17 | - | hx-get="/htmx/upload-image" | 17 | + | class="btn btn-sm btn-primary" |
| 18 | - | hx-target="#upload-image" | 18 | + | hx-get="/htmx/image-manager" |
| 19 | - | hx-swap="innerHTML" | 19 | + | hx-target="#image-manager" |
| 20 | - | title="Manually upload a .tar container image" | 20 | + | hx-swap="innerHTML"> |
| 21 | - | > | 21 | + | <span class="iconf"></span> Refresh |
| 22 | - | <span class="iconf text-xl align-middle"> </span> | 22 | + | </button> |
| 23 | - | Upload | 23 | + | </div> |
| 24 | - | </button> | |||
| 25 | </div> | 24 | </div> | ||
| 26 | - | </div> | |||
| 27 | - | <div id="upload-image"></div> | |||
| 28 | - | <table class="p-4 border-t-2"> | |||
| 29 | - | <tbody> | |||
| 30 | - | {{ | |||
| 31 | - | range.Images | |||
| 32 | - | }} | |||
| 33 | - | <tr id="img-{{.ShortID}}" hx-ext="response-targets" class=" hover:bg-slate-100"> | |||
| 34 | - | <td> | |||
| 35 | - | <span title="{{.ShortID}}">{{.Name}}</span> | |||
| 36 | - | </td> | |||
| 37 | - | <td> | |||
| 38 | - | <span title="Image creation date">{{.CreatedStr}}</span> | |||
| 39 | - | </td> | |||
| 40 | - | <td>{{.SizeStr}}</td> | |||
| 41 | - | <td align="right"> | |||
| 42 | - | ||||
| 43 | - | <button | |||
| 44 | - | id="add-button-img-{{.ShortID}}" | |||
| 45 | - | hx-get="/htmx/create-container/{{.ShortID}}" | |||
| 46 | - | hx-target="#create-container-{{.ShortID}}" | |||
| 47 | - | hx-target-*="#error-message-div-{{.ShortID}}" | |||
| 48 | - | hx-swap="innerHTML" | |||
| 49 | - | class="py-2 px-4" | |||
| 50 | - | > | |||
| 51 | - | <span title="Deploy a container using this image" class="iconf text-xl text-blue-600 hover:text-gordonmascot-hblue"></span> | |||
| 52 | - | </button> | |||
| 53 | 25 | ||||
| 54 | - | <button | 26 | + | <!-- Image list --> |
| 55 | - | id="remove-button-img-{{.ShortID}}" | 27 | + | <table class="table"> |
| 56 | - | hx-delete="/htmx/image-manager/delete/{{.ShortID}}" | 28 | + | <!-- head --> |
| 57 | - | hx-target="#img-{{.ShortID}}" | 29 | + | <thead> |
| 58 | - | hx-target-*="#error-message-div-{{.ShortID}}" | 30 | + | <tr> |
| 59 | - | hx-swap="innerHTML" | 31 | + | <th>Repository</th> |
| 60 | - | hx-confirm="Are you sure you want to delete this container image?" | 32 | + | <th>Tag</th> |
| 61 | - | class="py-2 px-4" | 33 | + | <th>Size</th> |
| 62 | - | > | 34 | + | <th>Created</th> |
| 63 | - | <span title="Remove this image" class="iconf text-xl text-red-600 hover:text-red-900"></span> | 35 | + | <th>Actions</th> |
| 64 | - | </button> | 36 | + | </tr> |
| 65 | - | </td> | 37 | + | </thead> |
| 66 | - | </tr> | 38 | + | <tbody> |
| 67 | - | <t> | 39 | + | {{ if .NoImage }} |
| 68 | - | <td colspan="4"> | 40 | + | <tr> |
| 69 | - | <div id="error-message-div-{{.ShortID}}" class="text-red-500"></div> | 41 | + | <td colspan="5" class="text-center py-4"> |
| 70 | - | <div class="create-container" id="create-container-{{.ShortID}}"></div> | 42 | + | <div class="alert alert-info"> |
| 71 | - | </td> | 43 | + | <span class="iconf">ℹ️</span> |
| 72 | - | </tr> | 44 | + | <span>No images found. Upload an image first.</span> |
| 73 | - | {{ else }} | 45 | + | </div> |
| 74 | - | <tr> | 46 | + | </td> |
| 75 | - | <td colspan="4" class="mt-2">No images found.</td> | 47 | + | </tr> |
| 76 | - | </tr> | 48 | + | {{ else }} |
| 77 | - | {{ | 49 | + | {{ range .Images }} |
| 78 | - | end | 50 | + | <tr class="hover"> |
| 79 | - | }} | 51 | + | <td> |
| 80 | - | </tbody> | 52 | + | <div class="flex items-center gap-3"> |
| 81 | - | </table> | 53 | + | <div> |
| 54 | + | <div class="font-bold">{{ .Repository }}</div> | |||
| 55 | + | <div class="text-sm opacity-50">{{ .ID }}</div> | |||
| 56 | + | </div> | |||
| 57 | + | </div> | |||
| 58 | + | </td> | |||
| 59 | + | <td>{{ .Tag }}</td> | |||
| 60 | + | <td>{{ .Size }}</td> | |||
| 61 | + | <td>{{ .Created }}</td> | |||
| 62 | + | <th> | |||
| 63 | + | <div class="dropdown dropdown-end"> | |||
| 64 | + | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |||
| 65 | + | <span class="iconf"></span> | |||
| 66 | + | </div> | |||
| 67 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | |||
| 68 | + | <li> | |||
| 69 | + | <a | |||
| 70 | + | href="/htmx/create-container/{{ if gt (len .ID) 12 }}{{ slice .ID 0 12 }}{{ else }}{{ .ID }}{{ end }}" | |||
| 71 | + | class="text-success flex items-center"> | |||
| 72 | + | <span class="iconf mr-2"></span> Create Container | |||
| 73 | + | </a> | |||
| 74 | + | </li> | |||
| 75 | + | <li> | |||
| 76 | + | <button | |||
| 77 | + | class="text-error flex items-center" | |||
| 78 | + | hx-delete="/htmx/image-manager/delete/{{ .ID }}" | |||
| 79 | + | hx-target="#image-manager" | |||
| 80 | + | hx-swap="innerHTML" | |||
| 81 | + | hx-confirm="Are you sure you want to delete this image?"> | |||
| 82 | + | <span class="iconf mr-2"></span> Delete | |||
| 83 | + | </button> | |||
| 84 | + | </li> | |||
| 85 | + | </ul> | |||
| 86 | + | </div> | |||
| 87 | + | </th> | |||
| 88 | + | </tr> | |||
| 89 | + | {{ end }} | |||
| 90 | + | {{ end }} | |||
| 91 | + | </tbody> | |||
| 92 | + | </table> | |||
| 93 | + | </div> | |||
| 82 | </div> | 94 | </div> | ||
| 95 | + | ||||
| 96 | + | <script> | |||
| 97 | + | console.debug('Image list rendered'); | |||
| 98 | + | </script> | |||
M internal/templating/models/html/fragments/menu.gohtml +61 -39
| @@ -1,45 +1,67 @@ | |||||
| 1 | - | <div | 1 | + | <!-- DaisyUI Navbar --> |
| 2 | - | id="menubar" | 2 | + | <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50"> |
| 3 | - | class="w-full h-12 md:h-16 fixed top-0 left-0 bg-gordonmascot-lblue flex flex-row justify-evenly items-center px-4 IBMPlexSans"> | 3 | + | <div class="navbar-start"> |
| 4 | - | <div class="w-full flex justify-start"> | 4 | + | <div class="flex items-center"> |
| 5 | - | <h1 | 5 | + | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> |
| 6 | - | class="gordon-title text-3xl md:text-4xl text-gordonmascot-hblue tracking-tighter"> | 6 | + | Gordon |
| 7 | - | Gordon | 7 | + | </h1> |
| 8 | - | </h1> | 8 | + | <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70"> |
| 9 | - | <span | 9 | + | {{ .BuildVersion }} |
| 10 | - | id="actual-version" | 10 | + | </span> |
| 11 | - | class="text-gordonmascot-mblue text-xs md:text-sm pr-1 JetBrainsMonoNF"> | 11 | + | <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title=""> |
| 12 | - | {{ .BuildVersion }} | 12 | + | <a href="https://github.com/bnema/gordon/releases" class="link"></a> |
| 13 | - | </span> | 13 | + | </span> |
| 14 | - | <span | 14 | + | </div> |
| 15 | - | hidden | |||
| 16 | - | id="update-available" | |||
| 17 | - | class="text-green-300 text-xs md:text-sm JetBrainsMonoNF hidden" | |||
| 18 | - | title="" | |||
| 19 | - | ><a href="https://github.com/bnema/gordon/releases"></a> | |||
| 20 | - | </span> | |||
| 21 | </div> | 15 | </div> | ||
| 22 | - | <div class="w-full"> | 16 | + | <div class="navbar-center hidden lg:flex"> |
| 23 | - | <ul | 17 | + | <ul class="menu menu-horizontal px-1"> |
| 24 | - | class="flex flex-row space-y-6 font-semibold text-gray-100 text-lg md:text-xl justify-center"> | 18 | + | <li> |
| 25 | - | <li class=""> | 19 | + | <a href="{{ .AdminPath }}/manager" class="font-semibold"> |
| 26 | - | <span class="JetBrainsMonoBoldNF pr-1"> </span> | 20 | + | <span class="JetBrainsMonoBoldNF pr-1"></span> Manager |
| 27 | - | <a href="{{ .AdminPath }}/manager">Manager</a> | 21 | + | </a> |
| 28 | </li> | 22 | </li> | ||
| 29 | </ul> | 23 | </ul> | ||
| 30 | </div> | 24 | </div> | ||
| 31 | - | <div class="w-full flex space-x-4 justify-end"> | 25 | + | <div class="navbar-end"> |
| 32 | - | <a | 26 | + | <!-- Theme toggle --> |
| 33 | - | href="{{ .UserSettingsURL }}" | 27 | + | <div class="dropdown dropdown-end"> |
| 34 | - | title="User settings" | 28 | + | <label tabindex="0" class="btn btn-ghost btn-circle"> |
| 35 | - | class="iconf text-gordonmascot-mblue text-lg md:text-xl" | 29 | + | <span class="iconf text-lg md:text-xl"></span> |
| 36 | - | ></a | 30 | + | </label> |
| 37 | - | > | 31 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> |
| 38 | - | <a | 32 | + | <li><button onclick="setTheme('gordon')">Gordon Light</button></li> |
| 39 | - | href="{{ .LogoutURL }}" | 33 | + | <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li> |
| 40 | - | title="Logout" | 34 | + | <li><button onclick="setTheme('light')">Light</button></li> |
| 41 | - | class="iconf text-gordonmascot-mblue text-lg md:text-xl" | 35 | + | <li><button onclick="setTheme('dark')">Dark</button></li> |
| 42 | - | ></a | 36 | + | </ul> |
| 43 | - | > | 37 | + | </div> |
| 38 | + | ||||
| 39 | + | <!-- User menu --> | |||
| 40 | + | <div class="dropdown dropdown-end"> | |||
| 41 | + | <label tabindex="0" class="btn btn-ghost btn-circle"> | |||
| 42 | + | <span class="iconf text-lg md:text-xl"></span> | |||
| 43 | + | </label> | |||
| 44 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="{{ .UserSettingsURL }}" title="User settings"> | |||
| 47 | + | <span class="iconf"></span> Settings | |||
| 48 | + | </a> | |||
| 49 | + | </li> | |||
| 50 | + | <li> | |||
| 51 | + | <a href="{{ .LogoutURL }}" title="Logout"> | |||
| 52 | + | <span class="iconf"></span> Logout | |||
| 53 | + | </a> | |||
| 54 | + | </li> | |||
| 55 | + | </ul> | |||
| 56 | + | </div> | |||
| 44 | </div> | 57 | </div> | ||
| 45 | </div> | 58 | </div> | ||
| 59 | + | ||||
| 60 | + | <!-- Theme toggle script --> | |||
| 61 | + | <script> | |||
| 62 | + | function setTheme(theme) { | |||
| 63 | + | document.documentElement.setAttribute('data-theme', theme); | |||
| 64 | + | localStorage.setItem('theme', theme); | |||
| 65 | + | console.debug('Theme changed to:', theme); | |||
| 66 | + | } | |||
| 67 | + | </script> | |||
M internal/templating/models/html/fragments/success.gohtml +1 -1
| @@ -1,5 +1,5 @@ | |||||
| 1 | <div | 1 | <div | ||
| 2 | id="success" | 2 | id="success" | ||
| 3 | - | class="flex text-blue-800 content-center justify-center items-center w-full h-full"> | 3 | + | class="flex text-indigo-600 content-center justify-center items-center w-full h-full"> |
| 4 | <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p> | 4 | <p class="text-2xl text-center">{{if .Message}}{{.Message}}{{else}}Success!{{end}}</p> | ||
| 5 | </div> | 5 | </div> | ||
M internal/templating/models/html/fragments/uploadimage.gohtml +53 -27
| @@ -1,35 +1,61 @@ | |||||
| 1 | - | <!-- Form to upload a container image (.tar format) --> | 1 | + | <!-- Upload image form using DaisyUI components --> |
| 2 | - | <div | 2 | + | <div class="card bg-base-100 shadow"> |
| 3 | - | class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" | 3 | + | <div class="card-body"> |
| 4 | - | hx-ext="response-targets" | 4 | + | <div class="flex justify-between items-center"> |
| 5 | - | hx-boost="true"> | 5 | + | <h2 class="card-title text-primary">Upload Image</h2> |
| 6 | - | <div class="flex-grow bg-slate-100 p-4 rounded-md"> | 6 | + | <button |
| 7 | - | <h2>Upload Image Containers</h2> | 7 | + | class="btn btn-sm btn-circle btn-ghost" |
| 8 | - | <!-- Upload Form --> | 8 | + | hx-get="/htmx/image-manager" |
| 9 | + | hx-target="#image-manager" | |||
| 10 | + | hx-swap="innerHTML"> | |||
| 11 | + | <span class="iconf text-lg">✕</span> | |||
| 12 | + | </button> | |||
| 13 | + | </div> | |||
| 14 | + | <div class="divider"></div> | |||
| 15 | + | ||||
| 9 | <form | 16 | <form | ||
| 10 | - | action="/upload" | 17 | + | id="upload-form" |
| 11 | - | method="post" | 18 | + | hx-encoding="multipart/form-data" |
| 12 | - | enctype="multipart/form-data" | |||
| 13 | hx-post="/htmx/upload-image" | 19 | hx-post="/htmx/upload-image" | ||
| 14 | - | hx-swap="innerHTML" | |||
| 15 | hx-target="#image-manager" | 20 | hx-target="#image-manager" | ||
| 16 | - | hx-target-*="#error-message-div"> | 21 | + | hx-swap="innerHTML" |
| 17 | - | <div | 22 | + | class="space-y-4"> |
| 18 | - | class="upload-zone w-full h-44 bg-slate-200 rounded-md p-4" | 23 | + | |
| 19 | - | hx-drop="true"> | 24 | + | <div class="alert alert-info shadow-lg mb-4"> |
| 20 | - | Drag & Drop your file here or | 25 | + | <span class="iconf">ℹ️</span> |
| 21 | - | <input | 26 | + | <div> |
| 22 | - | type="file" | 27 | + | <p>This form allows you to upload a container image (.tar file) to Gordon.</p> |
| 23 | - | name="file" | 28 | + | <p>After uploading, you can create and manage containers from this image.</p> |
| 29 | + | </div> | |||
| 30 | + | </div> | |||
| 31 | + | ||||
| 32 | + | <div class="form-control w-full"> | |||
| 33 | + | <label class="label"> | |||
| 34 | + | <span class="label-text">Select a container image file (.tar)</span> | |||
| 35 | + | </label> | |||
| 36 | + | <input | |||
| 37 | + | type="file" | |||
| 38 | + | name="imageFile" | |||
| 39 | + | class="file-input file-input-bordered w-full" | |||
| 24 | accept=".tar" | 40 | accept=".tar" | ||
| 25 | - | class="bg-transparent outline-none" /> | 41 | + | required /> |
| 42 | + | </div> | |||
| 43 | + | ||||
| 44 | + | <div class="card-actions justify-end mt-4"> | |||
| 45 | + | <button | |||
| 46 | + | class="btn btn-ghost" | |||
| 47 | + | hx-get="/htmx/image-manager" | |||
| 48 | + | hx-target="#image-manager" | |||
| 49 | + | hx-swap="innerHTML" | |||
| 50 | + | type="button"> | |||
| 51 | + | Cancel | |||
| 52 | + | </button> | |||
| 53 | + | <button | |||
| 54 | + | class="btn btn-primary" | |||
| 55 | + | type="submit"> | |||
| 56 | + | <span class="iconf mr-1"></span> Upload | |||
| 57 | + | </button> | |||
| 26 | </div> | 58 | </div> | ||
| 27 | - | <input | |||
| 28 | - | type="submit" | |||
| 29 | - | value="Upload" | |||
| 30 | - | id="upload-submit-button" | |||
| 31 | - | class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" /> | |||
| 32 | </form> | 59 | </form> | ||
| 33 | - | <div id="error-message-div" class="text-red-500"></div> | |||
| 34 | </div> | 60 | </div> | ||
| 35 | </div> | 61 | </div> | ||
M internal/templating/models/html/login/index.gohtml +79 -63
| @@ -1,5 +1,5 @@ | |||||
| 1 | <!doctype html> | 1 | <!doctype html> | ||
| 2 | - | <html lang="en"> | 2 | + | <html lang="en" data-theme="corporate"> |
| 3 | <head> | 3 | <head> | ||
| 4 | <meta charset="UTF-8" /> | 4 | <meta charset="UTF-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| @@ -12,95 +12,111 @@ | |||||
| 12 | <script src="/libs/htmx.min.js"></script> | 12 | <script src="/libs/htmx.min.js"></script> | ||
| 13 | <!-- HTMX SSE --> | 13 | <!-- HTMX SSE --> | ||
| 14 | <script src="/libs/htmx-sse.js"></script> | 14 | <script src="/libs/htmx-sse.js"></script> | ||
| 15 | + | <!-- DaisyUI --> | |||
| 16 | + | <script src="/libs/daisyui.js"></script> | |||
| 15 | <!-- Custom JS from assets/js --> | 17 | <!-- Custom JS from assets/js --> | ||
| 16 | <script src="/assets/js/custom.js"></script> | 18 | <script src="/assets/js/custom.js"></script> | ||
| 19 | + | ||||
| 20 | + | <!-- daisyUI theme configuration --> | |||
| 21 | + | <script> | |||
| 22 | + | // Check for saved theme preference or use preferred color scheme | |||
| 23 | + | const savedTheme = localStorage.getItem('theme') || 'corporate'; | |||
| 24 | + | document.documentElement.setAttribute('data-theme', savedTheme); | |||
| 25 | + | console.debug('Theme loaded:', savedTheme); | |||
| 26 | + | </script> | |||
| 17 | </head> | 27 | </head> | ||
| 18 | 28 | ||||
| 19 | - | <body class="IBMPlexSans bg-zinc-200 md:p-8 flex flex-col items-center"> | 29 | + | <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4"> |
| 20 | <!-- Logo and Title --> | 30 | <!-- Logo and Title --> | ||
| 21 | <div | 31 | <div | ||
| 22 | id="logo-mascot" | 32 | id="logo-mascot" | ||
| 23 | class="flex flex-col justify-center items-center p-4 mb-4"> | 33 | class="flex flex-col justify-center items-center p-4 mb-4"> | ||
| 24 | - | <div class="circle-shadow flex justify-center w-100"> | 34 | + | <div class="flex justify-center w-100"> |
| 25 | <img | 35 | <img | ||
| 26 | src="/assets/imgs/gordon-mascot-mq-trsp.png" | 36 | src="/assets/imgs/gordon-mascot-mq-trsp.png" | ||
| 27 | alt="logo gordon" | 37 | alt="logo gordon" | ||
| 28 | - | class="sm:h-24 h-32 xl:h-48" /> | 38 | + | class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" /> |
| 29 | </div> | 39 | </div> | ||
| 30 | <div class="flex justify-center mt-4"> | 40 | <div class="flex justify-center mt-4"> | ||
| 31 | <h1 | 41 | <h1 | ||
| 32 | - | class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-gordonmascot-hblue tracking-tighter"> | 42 | + | class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter"> |
| 33 | Gordon | 43 | Gordon | ||
| 34 | </h1> | 44 | </h1> | ||
| 35 | <span | 45 | <span | ||
| 36 | - | class="gordon-v-subtitle text-gordonmascot-mblue text-sm ml-2" | 46 | + | class="gordon-v-subtitle text-secondary text-sm ml-2" |
| 37 | >{{ .BuildVersion }}</span | 47 | >{{ .BuildVersion }}</span | ||
| 38 | > | 48 | > | ||
| 39 | </div> | 49 | </div> | ||
| 40 | </div> | 50 | </div> | ||
| 41 | 51 | ||||
| 42 | <!-- Main Content --> | 52 | <!-- Main Content --> | ||
| 43 | - | <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3"> | 53 | + | <div class="card bg-base-100 shadow-xl w-full max-w-md"> |
| 44 | - | <!-- Login Section --> | 54 | + | <div class="card-body"> |
| 45 | - | <div id="login-section"> | 55 | + | <!-- Login Section --> |
| 46 | - | <div class="flex flex-col items-center"> | 56 | + | <div id="login-section"> |
| 47 | - | <h2 class="text-2xl font-semibold text-gray-800">Login</h2> | 57 | + | <div class="flex flex-col items-center"> |
| 48 | - | 58 | + | <h2 class="card-title text-center mb-4">Login</h2> | |
| 49 | - | <!-- Error Message Display --> | 59 | + | |
| 50 | - | {{ if .Error }} | 60 | + | <!-- Error Message Display --> |
| 51 | - | <div class="mt-4 w-full max-w-md bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert"> | 61 | + | {{ if .Error }} |
| 52 | - | <span class="block sm:inline"> | 62 | + | <div class="alert alert-error mb-4"> |
| 53 | - | {{ if eq .Error "empty_token" }} | 63 | + | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> |
| 54 | - | Please enter a token. | 64 | + | <span> |
| 55 | - | {{ else if eq .Error "invalid_token" }} | 65 | + | {{ if eq .Error "empty_token" }} |
| 56 | - | Invalid token. Please try again. | 66 | + | Please enter a token. |
| 57 | - | {{ else if eq .Error "server_error" }} | 67 | + | {{ else if eq .Error "invalid_token" }} |
| 58 | - | A server error occurred. Please try again later. | 68 | + | Invalid token. Please try again. |
| 59 | - | {{ else }} | 69 | + | {{ else if eq .Error "server_error" }} |
| 60 | - | An error occurred. Please try again. | 70 | + | A server error occurred. Please try again later. |
| 61 | - | {{ end }} | 71 | + | {{ else }} |
| 62 | - | </span> | 72 | + | An error occurred. Please try again. |
| 63 | - | </div> | 73 | + | {{ end }} |
| 64 | - | {{ end }} | 74 | + | </span> |
| 65 | - | 75 | + | </div> | |
| 66 | - | <!-- Token Input Form --> | 76 | + | {{ end }} |
| 67 | - | <div class="mt-4 w-full max-w-md"> | 77 | + | |
| 68 | - | <form action="login/submit-token" method="POST" class="mb-4"> | 78 | + | <!-- Token Input Form --> |
| 69 | - | <div class="mb-2"> | 79 | + | <div class="w-full"> |
| 70 | - | <label for="token" class="block text-sm font-medium text-gray-700">Admin Token</label> | 80 | + | <form action="login/submit-token" method="POST" class="mb-4"> |
| 71 | - | <input | 81 | + | <div class="form-control mb-4"> |
| 72 | - | type="text" | 82 | + | <label for="token" class="label"> |
| 73 | - | id="token" | 83 | + | <span class="label-text">Admin Token</span> |
| 74 | - | name="token" | 84 | + | </label> |
| 75 | - | placeholder="Enter your admin token" | 85 | + | <input |
| 76 | - | class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-gordonmascot-hblue focus:border-gordonmascot-hblue" | 86 | + | type="text" |
| 77 | - | required | 87 | + | id="token" |
| 78 | - | /> | 88 | + | name="token" |
| 79 | - | </div> | 89 | + | placeholder="Enter your admin token" |
| 80 | - | <button | 90 | + | class="input input-bordered w-full" |
| 81 | - | type="submit" | 91 | + | required |
| 82 | - | class="w-full bg-gordonmascot-mblue hover:bg-gordonmascot-hblue text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-150 ease-in-out" | 92 | + | /> |
| 93 | + | </div> | |||
| 94 | + | <button | |||
| 95 | + | type="submit" | |||
| 96 | + | class="btn btn-primary w-full" | |||
| 97 | + | > | |||
| 98 | + | Continue with Token | |||
| 99 | + | </button> | |||
| 100 | + | </form> | |||
| 101 | + | <div class="divider">OR</div> | |||
| 102 | + | </div> | |||
| 103 | + | ||||
| 104 | + | <!-- GitHub OAuth --> | |||
| 105 | + | <div class="w-full"> | |||
| 106 | + | <a | |||
| 107 | + | href="login/oauth/github" | |||
| 108 | + | class="btn btn-secondary w-full" | |||
| 83 | > | 109 | > | ||
| 84 | - | Continue with Token | 110 | + | <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> |
| 85 | - | </button> | 111 | + | <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" /> |
| 86 | - | </form> | 112 | + | </svg> |
| 87 | - | <div class="text-center text-sm text-gray-500 mb-4">- OR -</div> | 113 | + | Sign in with GitHub |
| 88 | - | </div> | 114 | + | </a> |
| 89 | - | 115 | + | </div> | |
| 90 | - | <!-- GitHub OAuth --> | |||
| 91 | - | <div | |||
| 92 | - | class="mt-2 p-2 bg-gordonmascot-hblue text-white rounded-md flex justify-left"> | |||
| 93 | - | <a href="login/oauth/github" class="flex items-center"> | |||
| 94 | - | <img | |||
| 95 | - | src="/assets/imgs/github/github-mark-white.png" | |||
| 96 | - | alt="logo github" | |||
| 97 | - | class="h-8 xl:h-12" /> | |||
| 98 | - | <span class="w-full p-2 text-white">Login with GitHub</span> | |||
| 99 | - | </a> | |||
| 100 | </div> | 116 | </div> | ||
| 101 | </div> | 117 | </div> | ||
| 118 | + | <!-- End Login Section --> | |||
| 102 | </div> | 119 | </div> | ||
| 103 | - | <!-- End Login Section --> | |||
| 104 | </div> | 120 | </div> | ||
| 105 | </body> | 121 | </body> | ||
| 106 | </html> | 122 | </html> | ||
A internal/templating/models/templ/README.md +48 -0
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | # Templ Templates for Gordon | |||
| 2 | + | ||||
| 3 | + | This directory contains [templ](https://github.com/a-h/templ) templates for the Gordon application. We're migrating from Go's html/template to templ for improved type safety and maintainability. | |||
| 4 | + | ||||
| 5 | + | ## Directory Structure | |||
| 6 | + | ||||
| 7 | + | - `components/` - Reusable UI components | |||
| 8 | + | - `layouts/` - Page layouts and base templates | |||
| 9 | + | - `pages/` - Full page templates | |||
| 10 | + | - `admin/` - Admin pages | |||
| 11 | + | - `login/` - Login pages | |||
| 12 | + | ||||
| 13 | + | ## Working with Templates | |||
| 14 | + | ||||
| 15 | + | To create or modify templates: | |||
| 16 | + | ||||
| 17 | + | 1. Edit the `.templ` files in the appropriate directory | |||
| 18 | + | 2. Run `templ generate` or `make build-templ` to generate Go code | |||
| 19 | + | 3. Use the templates in your handlers with the `TemplRenderer` | |||
| 20 | + | ||||
| 21 | + | ## Converting from gohtml | |||
| 22 | + | ||||
| 23 | + | When converting from gohtml: | |||
| 24 | + | ||||
| 25 | + | 1. Create a new `.templ` file | |||
| 26 | + | 2. Copy HTML structure from gohtml | |||
| 27 | + | 3. Replace `{{ .varName }}` with `{ varName }` | |||
| 28 | + | 4. Use templ's control flow syntax like `if`, `for`, etc. | |||
| 29 | + | 5. Use `@` to invoke other components | |||
| 30 | + | ||||
| 31 | + | ## Example: | |||
| 32 | + | ||||
| 33 | + | ```templ | |||
| 34 | + | package components | |||
| 35 | + | ||||
| 36 | + | templ Button(label string, classes string) { | |||
| 37 | + | <button class={classes}>{ label }</button> | |||
| 38 | + | } | |||
| 39 | + | ``` | |||
| 40 | + | ||||
| 41 | + | ## Usage in Handlers | |||
| 42 | + | ||||
| 43 | + | ```go | |||
| 44 | + | func RenderMyPage(c echo.Context, a *server.App) error { | |||
| 45 | + | renderer := render.NewTemplRenderer(a) | |||
| 46 | + | return renderer.RenderTempl(c, mypage.PageTemplate("Page Title", someData)) | |||
| 47 | + | } | |||
| 48 | + | ```⏎̸ | |||
A internal/templating/models/templ/components/containerlist.templ +132 -0
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/docker/docker/api/types" | |||
| 5 | + | "strconv" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // ContainerList displays a list of containers | |||
| 9 | + | templ ContainerList(containers []types.Container, noContainer bool) { | |||
| 10 | + | <!-- Container list using daisyUI components --> | |||
| 11 | + | <div class="card w-full bg-base-300"> | |||
| 12 | + | <div class="card-body p-4"> | |||
| 13 | + | ||||
| 14 | + | <!-- Action buttons --> | |||
| 15 | + | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | |||
| 16 | + | <h3 class="font-semibold text-base-content">Available Containers: { strconv.Itoa(len(containers)) }</h3> | |||
| 17 | + | <div class="btn-group"> | |||
| 18 | + | <button | |||
| 19 | + | class="btn btn-sm btn-primary" | |||
| 20 | + | hx-get="/htmx/container-manager" | |||
| 21 | + | hx-target="#container-manager" | |||
| 22 | + | hx-swap="innerHTML"> | |||
| 23 | + | <span class="iconf"></span> Refresh | |||
| 24 | + | </button> | |||
| 25 | + | </div> | |||
| 26 | + | </div> | |||
| 27 | + | ||||
| 28 | + | <!-- Container list --> | |||
| 29 | + | <table class="table"> | |||
| 30 | + | <!-- head --> | |||
| 31 | + | <thead> | |||
| 32 | + | <tr> | |||
| 33 | + | <th>Name</th> | |||
| 34 | + | <th>Image</th> | |||
| 35 | + | <th>Status</th> | |||
| 36 | + | <th>Created</th> | |||
| 37 | + | <th>Actions</th> | |||
| 38 | + | </tr> | |||
| 39 | + | </thead> | |||
| 40 | + | <tbody> | |||
| 41 | + | if noContainer || len(containers) == 0 { | |||
| 42 | + | <tr> | |||
| 43 | + | <td colspan="5" class="text-center py-4"> | |||
| 44 | + | <div class="alert alert-info"> | |||
| 45 | + | <span class="iconf">ℹ️</span> | |||
| 46 | + | <span>No containers found. Upload an image first, then create a container.</span> | |||
| 47 | + | </div> | |||
| 48 | + | </td> | |||
| 49 | + | </tr> | |||
| 50 | + | } else { | |||
| 51 | + | for _, container := range containers { | |||
| 52 | + | <tr class="hover"> | |||
| 53 | + | <td> | |||
| 54 | + | <div class="flex items-center gap-3"> | |||
| 55 | + | <div> | |||
| 56 | + | <div class="font-bold">{ container.Names[0] }</div> | |||
| 57 | + | <div class="text-sm opacity-50">{ container.ID }</div> | |||
| 58 | + | </div> | |||
| 59 | + | </div> | |||
| 60 | + | </td> | |||
| 61 | + | <td>{ container.Image }</td> | |||
| 62 | + | <td> | |||
| 63 | + | if container.State == "running" { | |||
| 64 | + | <div class="badge badge-success badge-sm">Running</div> | |||
| 65 | + | } else if container.State == "exited" { | |||
| 66 | + | <div class="badge badge-warning badge-sm">Stopped</div> | |||
| 67 | + | } else { | |||
| 68 | + | <div class="badge badge-ghost badge-sm">{ container.State }</div> | |||
| 69 | + | } | |||
| 70 | + | </td> | |||
| 71 | + | <td>{ strconv.FormatInt(container.Created, 10) }</td> | |||
| 72 | + | <th> | |||
| 73 | + | <div class="dropdown dropdown-end"> | |||
| 74 | + | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |||
| 75 | + | <span class="iconf"></span> | |||
| 76 | + | </div> | |||
| 77 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40"> | |||
| 78 | + | if container.State == "running" { | |||
| 79 | + | <li> | |||
| 80 | + | <button | |||
| 81 | + | class="text-warning flex items-center" | |||
| 82 | + | hx-post={ "/htmx/container-manager/stop/" + container.ID } | |||
| 83 | + | hx-target="#container-manager" | |||
| 84 | + | hx-swap="innerHTML"> | |||
| 85 | + | <span class="iconf mr-2"></span> Stop | |||
| 86 | + | </button> | |||
| 87 | + | </li> | |||
| 88 | + | } else if container.State == "exited" { | |||
| 89 | + | <li> | |||
| 90 | + | <button | |||
| 91 | + | class="text-success flex items-center" | |||
| 92 | + | hx-post={ "/htmx/container-manager/start/" + container.ID } | |||
| 93 | + | hx-target="#container-manager" | |||
| 94 | + | hx-swap="innerHTML"> | |||
| 95 | + | <span class="iconf mr-2"></span> Start | |||
| 96 | + | </button> | |||
| 97 | + | </li> | |||
| 98 | + | } | |||
| 99 | + | <li> | |||
| 100 | + | <button | |||
| 101 | + | class="text-info flex items-center" | |||
| 102 | + | hx-get={ "/htmx/container-manager/edit/" + container.ID } | |||
| 103 | + | hx-target="#container-manager" | |||
| 104 | + | hx-swap="innerHTML"> | |||
| 105 | + | <span class="iconf mr-2"></span> Edit | |||
| 106 | + | </button> | |||
| 107 | + | </li> | |||
| 108 | + | <li> | |||
| 109 | + | <button | |||
| 110 | + | class="text-error flex items-center" | |||
| 111 | + | hx-delete={ "/htmx/container-manager/delete/" + container.ID } | |||
| 112 | + | hx-target="#container-manager" | |||
| 113 | + | hx-swap="innerHTML" | |||
| 114 | + | hx-confirm="Are you sure you want to delete this container?"> | |||
| 115 | + | <span class="iconf mr-2"></span> Delete | |||
| 116 | + | </button> | |||
| 117 | + | </li> | |||
| 118 | + | </ul> | |||
| 119 | + | </div> | |||
| 120 | + | </th> | |||
| 121 | + | </tr> | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | </tbody> | |||
| 125 | + | </table> | |||
| 126 | + | </div> | |||
| 127 | + | </div> | |||
| 128 | + | ||||
| 129 | + | <script> | |||
| 130 | + | console.debug('Container list rendered'); | |||
| 131 | + | </script> | |||
| 132 | + | }⏎̸ | |||
A internal/templating/models/templ/components/containerlist_templ.go +228 -0 generated file: collapsed
A internal/templating/models/templ/components/createcontainer.templ +160 -0
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // ContainerFormData holds the data needed for the container creation form | |||
| 4 | + | type ContainerFormData struct { | |||
| 5 | + | ImageName string | |||
| 6 | + | ImageID string | |||
| 7 | + | ShortID string | |||
| 8 | + | ContainerName string | |||
| 9 | + | ContainerSubdomain string | |||
| 10 | + | ContainerDomain string | |||
| 11 | + | Ports string | |||
| 12 | + | ProxyPort string | |||
| 13 | + | Volumes string | |||
| 14 | + | EnvironmentVariables string | |||
| 15 | + | ErrorMessage string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // CreateContainerForm displays a form for creating a new container | |||
| 19 | + | templ CreateContainerForm(data ContainerFormData) { | |||
| 20 | + | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow" hx-ext="response-targets"> | |||
| 21 | + | <!-- Left Side: Docker Run Options --> | |||
| 22 | + | <div class="flex-grow card p-6"> | |||
| 23 | + | <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2> | |||
| 24 | + | if data.ErrorMessage != "" { | |||
| 25 | + | <div class="alert alert-error"> | |||
| 26 | + | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |||
| 27 | + | <span>{ data.ErrorMessage }</span> | |||
| 28 | + | </div> | |||
| 29 | + | } | |||
| 30 | + | <form id={ "create-container-form-" + data.ShortID } action={ templ.SafeURL("/create-container/" + data.ShortID) } method="post" | |||
| 31 | + | hx-post={ "/htmx/create-container/" + data.ShortID } | |||
| 32 | + | hx-swap="innerHTML" | |||
| 33 | + | hx-target={ "#create-container-" + data.ShortID } | |||
| 34 | + | hx-target-*={ "#error-message-create-container-" + data.ShortID }> | |||
| 35 | + | <!-- Image Name --> | |||
| 36 | + | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> | |||
| 37 | + | <div class="md:col-span-1"> | |||
| 38 | + | <label class="label"> | |||
| 39 | + | <span class="label-text">Image name</span> | |||
| 40 | + | </label> | |||
| 41 | + | <input type="text" name="image_name" class="input input-bordered input-disabled w-full" | |||
| 42 | + | value={ data.ImageName } readonly /> | |||
| 43 | + | </div> | |||
| 44 | + | ||||
| 45 | + | <div class="md:col-span-2"> | |||
| 46 | + | <label class="label"> | |||
| 47 | + | <span class="label-text">Image ID</span> | |||
| 48 | + | </label> | |||
| 49 | + | <input type="text" name="image_id" class="input input-bordered input-disabled w-full" | |||
| 50 | + | value={ data.ImageID } readonly /> | |||
| 51 | + | </div> | |||
| 52 | + | </div> | |||
| 53 | + | ||||
| 54 | + | <!-- Container Name --> | |||
| 55 | + | <div class="form-control pb-4"> | |||
| 56 | + | <label class="label"> | |||
| 57 | + | <span class="label-text">Container name</span> | |||
| 58 | + | </label> | |||
| 59 | + | <input type="text" name="container_name" class="input input-bordered w-full" value={ data.ContainerName } /> | |||
| 60 | + | </div> | |||
| 61 | + | ||||
| 62 | + | <!-- Http(s) / Subdomain / Domain --> | |||
| 63 | + | <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4"> | |||
| 64 | + | <!-- Protocol --> | |||
| 65 | + | <div class="md:col-span-3"> | |||
| 66 | + | <label class="label"> | |||
| 67 | + | <span class="label-text">Protocol</span> | |||
| 68 | + | </label> | |||
| 69 | + | <select name="container_protocol" class="select select-bordered w-full"> | |||
| 70 | + | <option value="https">https</option> | |||
| 71 | + | <option value="http">http</option> | |||
| 72 | + | </select> | |||
| 73 | + | </div> | |||
| 74 | + | ||||
| 75 | + | <!-- Subdomain --> | |||
| 76 | + | <div class="md:col-span-4"> | |||
| 77 | + | <label class="label"> | |||
| 78 | + | <span class="label-text">Subdomain</span> | |||
| 79 | + | </label> | |||
| 80 | + | <input type="text" name="container_subdomain" class="input input-bordered w-full" value={ data.ContainerSubdomain } /> | |||
| 81 | + | </div> | |||
| 82 | + | ||||
| 83 | + | <!-- Domain --> | |||
| 84 | + | <div class="md:col-span-5"> | |||
| 85 | + | <label class="label"> | |||
| 86 | + | <span class="label-text">Domain</span> | |||
| 87 | + | </label> | |||
| 88 | + | <input type="text" name="container_domain" class="input input-bordered w-full" value={ data.ContainerDomain } /> | |||
| 89 | + | </div> | |||
| 90 | + | </div> | |||
| 91 | + | ||||
| 92 | + | <!-- Exposed Ports --> | |||
| 93 | + | <div class="form-control pb-4"> | |||
| 94 | + | <label class="label"> | |||
| 95 | + | <span class="label-text">Exposed Ports (Optional)</span> | |||
| 96 | + | <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span> | |||
| 97 | + | </label> | |||
| 98 | + | <input type="text" name="ports" class="input input-bordered w-full" value={ data.Ports } /> | |||
| 99 | + | </div> | |||
| 100 | + | ||||
| 101 | + | <!-- Proxy Port --> | |||
| 102 | + | <div class="form-control pb-4"> | |||
| 103 | + | <label class="label"> | |||
| 104 | + | <span class="label-text">Container Proxy Port (Required)</span> | |||
| 105 | + | <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span> | |||
| 106 | + | </label> | |||
| 107 | + | <input type="text" name="container_port" class="input input-bordered w-full" value={ data.ProxyPort } required /> | |||
| 108 | + | </div> | |||
| 109 | + | ||||
| 110 | + | <!-- Volumes --> | |||
| 111 | + | <div class="form-control pb-4"> | |||
| 112 | + | <label class="label"> | |||
| 113 | + | <span class="label-text">Volumes</span> | |||
| 114 | + | <span class="label-text-alt">e.g. ./data:/container/data</span> | |||
| 115 | + | </label> | |||
| 116 | + | <input type="text" name="volumes" class="input input-bordered w-full" value={ data.Volumes } /> | |||
| 117 | + | </div> | |||
| 118 | + | ||||
| 119 | + | <div class="form-control pb-4"> | |||
| 120 | + | <label class="label"> | |||
| 121 | + | <span class="label-text">Environment variables</span> | |||
| 122 | + | <span class="label-text-alt">e.g. KEY=VALUE</span> | |||
| 123 | + | </label> | |||
| 124 | + | <textarea name="environment_variables" class="textarea textarea-bordered w-full" | |||
| 125 | + | rows="4">{ data.EnvironmentVariables }</textarea> | |||
| 126 | + | </div> | |||
| 127 | + | ||||
| 128 | + | <!-- Restart: always/on-failure/only when stopped/never --> | |||
| 129 | + | <div class="form-control pb-4"> | |||
| 130 | + | <label class="label"> | |||
| 131 | + | <span class="label-text">Restart</span> | |||
| 132 | + | </label> | |||
| 133 | + | <select name="restart" class="select select-bordered w-full"> | |||
| 134 | + | <option value="always">Always</option> | |||
| 135 | + | <option value="on-failure">On failure</option> | |||
| 136 | + | <option value="unless-stopped">Unless stopped</option> | |||
| 137 | + | <option value="never">Never</option> | |||
| 138 | + | </select> | |||
| 139 | + | </div> | |||
| 140 | + | ||||
| 141 | + | <!-- Skip Proxy Setup Option --> | |||
| 142 | + | <div class="form-control pb-4"> | |||
| 143 | + | <label class="label cursor-pointer justify-start gap-4"> | |||
| 144 | + | <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" /> | |||
| 145 | + | <span>Skip proxy setup (create container only, don't set up domain/certificates)</span> | |||
| 146 | + | </label> | |||
| 147 | + | <div class="text-sm opacity-70 ml-10"> | |||
| 148 | + | Use this option if you only want to create the container without triggering proxy setup, | |||
| 149 | + | certificate generation, and other related processes. | |||
| 150 | + | </div> | |||
| 151 | + | </div> | |||
| 152 | + | ||||
| 153 | + | <button type="submit" id="create-container-submit" class="btn btn-primary"> | |||
| 154 | + | Create Container | |||
| 155 | + | </button> | |||
| 156 | + | </form> | |||
| 157 | + | <div id={ "error-message-create-container-" + data.ShortID } class="text-error mt-4"></div> | |||
| 158 | + | </div> | |||
| 159 | + | </div> | |||
| 160 | + | } ⏎̸ | |||
A internal/templating/models/templ/components/createcontainer_templ.go +283 -0 generated file: collapsed
A internal/templating/models/templ/components/editcontainer.templ +39 -0
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import t "github.com/a-h/templ" | |||
| 4 | + | ||||
| 5 | + | // EditContainer replaces editcontainer.gohtml | |||
| 6 | + | // It renders a form to edit a container's configuration | |||
| 7 | + | templ EditContainer(id string, containerInfo string) { | |||
| 8 | + | <div | |||
| 9 | + | id={ "edit-container-" + id } | |||
| 10 | + | class="card bg-base-100 shadow-xl" | |||
| 11 | + | hx-ext="response-targets" | |||
| 12 | + | > | |||
| 13 | + | <div id="editor" class="card-body"> | |||
| 14 | + | <form | |||
| 15 | + | method="post" | |||
| 16 | + | action={ t.URL("/htmx/container-manager/edit/" + id) } | |||
| 17 | + | hx-post={ string(t.URL("/htmx/container-manager/edit/" + id)) } | |||
| 18 | + | hx-swap="innerHTML" | |||
| 19 | + | hx-target-*={ "#error-message-edit-container-" + id } | |||
| 20 | + | > | |||
| 21 | + | <textarea | |||
| 22 | + | name="container_config" | |||
| 23 | + | id="container_config" | |||
| 24 | + | class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF" | |||
| 25 | + | rows="20" | |||
| 26 | + | >{ containerInfo }</textarea> | |||
| 27 | + | <div class="card-actions justify-end mt-4"> | |||
| 28 | + | <input | |||
| 29 | + | type="submit" | |||
| 30 | + | value="Save" | |||
| 31 | + | id={ "save-button-" + id } | |||
| 32 | + | class="btn btn-primary" | |||
| 33 | + | /> | |||
| 34 | + | </div> | |||
| 35 | + | </form> | |||
| 36 | + | <div id={ "error-message-edit-container-" + id } class="text-error mt-2"></div> | |||
| 37 | + | </div> | |||
| 38 | + | </div> | |||
| 39 | + | } ⏎̸ | |||
A internal/templating/models/templ/components/editcontainer_templ.go +131 -0 generated file: collapsed
A internal/templating/models/templ/components/footer.templ +39 -0
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // Footer is a templ component for the page footer | |||
| 4 | + | // It replaces footer.gohtml | |||
| 5 | + | templ Footer() { | |||
| 6 | + | <footer class="footer p-10 bg-base-200 text-base-content"> | |||
| 7 | + | <div> | |||
| 8 | + | <span class="footer-title">Gordon</span> | |||
| 9 | + | <a class="link link-hover">About us</a> | |||
| 10 | + | <a class="link link-hover">Documentation</a> | |||
| 11 | + | <a class="link link-hover">Github</a> | |||
| 12 | + | </div> | |||
| 13 | + | <div> | |||
| 14 | + | <span class="footer-title">Container Management</span> | |||
| 15 | + | <a class="link link-hover">Dashboard</a> | |||
| 16 | + | <a class="link link-hover">Settings</a> | |||
| 17 | + | <a class="link link-hover">Stats</a> | |||
| 18 | + | </div> | |||
| 19 | + | <div> | |||
| 20 | + | <span class="footer-title">Legal</span> | |||
| 21 | + | <a class="link link-hover">Terms of use</a> | |||
| 22 | + | <a class="link link-hover">Privacy policy</a> | |||
| 23 | + | <a class="link link-hover">License</a> | |||
| 24 | + | </div> | |||
| 25 | + | </footer> | |||
| 26 | + | <footer class="footer px-10 py-4 border-t bg-base-300 text-base-content border-base-300"> | |||
| 27 | + | <div class="items-center grid-flow-col"> | |||
| 28 | + | <span class="iconfb text-2xl"></span> | |||
| 29 | + | <p class="IBMPlexSans">Gordon Container Manager<br/>Providing reliable container management since 2023</p> | |||
| 30 | + | </div> | |||
| 31 | + | <div class="md:place-self-center md:justify-self-end"> | |||
| 32 | + | <div class="grid grid-flow-col gap-4"> | |||
| 33 | + | <a class="iconfb text-2xl"></a> | |||
| 34 | + | <a class="iconfb text-2xl"></a> | |||
| 35 | + | <a class="iconfb text-2xl"></a> | |||
| 36 | + | </div> | |||
| 37 | + | </div> | |||
| 38 | + | </footer> | |||
| 39 | + | }⏎̸ | |||
A internal/templating/models/templ/components/footer_templ.go +42 -0 generated file: collapsed
A internal/templating/models/templ/components/header.templ +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // Header is a templ component for the page header | |||
| 4 | + | // It replaces header.gohtml | |||
| 5 | + | templ Header(title string) { | |||
| 6 | + | <head> | |||
| 7 | + | <meta charset="UTF-8" /> | |||
| 8 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |||
| 9 | + | <title>{ title }</title> | |||
| 10 | + | <link rel="stylesheet" href="/assets/css/tailwind.css" /> | |||
| 11 | + | ||||
| 12 | + | <script src="/libs/jquery.min.js"></script> | |||
| 13 | + | <script src="/libs/htmx.min.js"></script> | |||
| 14 | + | <script src="/libs/htmx-response-targets.js"></script> | |||
| 15 | + | <script src="/libs/htmx-debug.js"></script> | |||
| 16 | + | <script src="/libs/htmx-sse.js"></script> | |||
| 17 | + | <script defer src="/libs/hyperscript.min.js"></script> | |||
| 18 | + | <script defer src="/assets/js/custom.js"></script> | |||
| 19 | + | ||||
| 20 | + | <!-- daisyUI theme configuration --> | |||
| 21 | + | <script> | |||
| 22 | + | // Check for saved theme preference or use preferred color scheme | |||
| 23 | + | const savedTheme = localStorage.getItem('theme') || 'gordon-dark'; | |||
| 24 | + | document.documentElement.setAttribute('data-theme', savedTheme); | |||
| 25 | + | ||||
| 26 | + | // Log theme loading for debugging | |||
| 27 | + | console.debug('Theme loaded:', savedTheme); | |||
| 28 | + | </script> | |||
| 29 | + | </head> | |||
| 30 | + | }⏎̸ | |||
A internal/templating/models/templ/components/header_templ.go +55 -0 generated file: collapsed
A internal/templating/models/templ/components/htmx_components.templ +80 -0
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // HTMXHeader creates the hero header with HTMX attributes | |||
| 4 | + | templ HTMXHeader(title string, subtitle string) { | |||
| 5 | + | <div id="header" class="hero bg-base-200"> | |||
| 6 | + | <div class="hero-content text-center"> | |||
| 7 | + | <div class="max-w-md"> | |||
| 8 | + | <h1 class="text-5xl font-bold gordon-title"> | |||
| 9 | + | { title } | |||
| 10 | + | </h1> | |||
| 11 | + | <p class="py-6 IBMPlexSans">{ subtitle }</p> | |||
| 12 | + | <button class="btn btn-primary">Get Started</button> | |||
| 13 | + | </div> | |||
| 14 | + | </div> | |||
| 15 | + | </div> | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // HTMXAlert creates an alert box that can be loaded with HTMX | |||
| 19 | + | templ HTMXAlert(message string) { | |||
| 20 | + | <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4"> | |||
| 21 | + | <div> | |||
| 22 | + | <span class="iconfb"></span> | |||
| 23 | + | <span class="IBMPlexSans">{ message }</span> | |||
| 24 | + | </div> | |||
| 25 | + | </div> | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // HTMXHeaderContainer creates a container to load the HTMX header | |||
| 29 | + | templ HTMXHeaderContainer() { | |||
| 30 | + | <div | |||
| 31 | + | id="header" | |||
| 32 | + | hx-get="/htmx" | |||
| 33 | + | hx-headers='{"HX-Fragment": "header"}' | |||
| 34 | + | hx-trigger="load" | |||
| 35 | + | hx-swap="outerHTML" | |||
| 36 | + | ></div> | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // HTMXAppContainer creates a container to load the HTMX app | |||
| 40 | + | templ HTMXAppContainer() { | |||
| 41 | + | <div | |||
| 42 | + | id="app" | |||
| 43 | + | hx-get="/htmx" | |||
| 44 | + | hx-headers='{"HX-Fragment": "hello"}' | |||
| 45 | + | hx-trigger="load" | |||
| 46 | + | hx-swap="outerHTML" | |||
| 47 | + | ></div> | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // StatsCard creates individual stat cards for the dashboard | |||
| 51 | + | templ StatsCard(title string, value string, description string, icon string) { | |||
| 52 | + | <div class="stat bg-base-100 shadow"> | |||
| 53 | + | <div class="stat-figure text-primary"> | |||
| 54 | + | <span class="iconfb text-3xl">{ icon }</span> | |||
| 55 | + | </div> | |||
| 56 | + | <div class="stat-title">{ title }</div> | |||
| 57 | + | <div class="stat-value">{ value }</div> | |||
| 58 | + | <div class="stat-desc">{ description }</div> | |||
| 59 | + | </div> | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // StatsCards creates a set of stats cards | |||
| 63 | + | templ StatsCards() { | |||
| 64 | + | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4"> | |||
| 65 | + | @StatsCard("Containers", "31", "Running containers", "") | |||
| 66 | + | @StatsCard("Images", "42", "Available images", "") | |||
| 67 | + | @StatsCard("Networks", "12", "Container networks", "") | |||
| 68 | + | </div> | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // TabsNav creates a navigation tabs component | |||
| 72 | + | templ TabsNav(activeTab string) { | |||
| 73 | + | <div class="tabs tabs-boxed my-4"> | |||
| 74 | + | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Overview")}>Overview</a> | |||
| 75 | + | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Containers")}>Containers</a> | |||
| 76 | + | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Images")}>Images</a> | |||
| 77 | + | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Networks")}>Networks</a> | |||
| 78 | + | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Volumes")}>Volumes</a> | |||
| 79 | + | </div> | |||
| 80 | + | }⏎̸ | |||
A internal/templating/models/templ/components/htmx_components_templ.go +438 -0 generated file: collapsed
A internal/templating/models/templ/components/imagelist.templ +122 -0
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strconv" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | // HumanReadableImage represents an image with human-readable attributes | |||
| 8 | + | type HumanReadableImage struct { | |||
| 9 | + | ID string | |||
| 10 | + | ShortID string | |||
| 11 | + | Name string | |||
| 12 | + | Repository string | |||
| 13 | + | Tag string | |||
| 14 | + | Size string | |||
| 15 | + | Created string | |||
| 16 | + | RepoDigests []string | |||
| 17 | + | RepoTags []string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // ImageList displays a list of images | |||
| 21 | + | templ ImageList(images []HumanReadableImage) { | |||
| 22 | + | <!-- Image list using daisyUI components --> | |||
| 23 | + | <div class="card w-full bg-base-300"> | |||
| 24 | + | <div class="card-body p-4"> | |||
| 25 | + | ||||
| 26 | + | <!-- Action buttons --> | |||
| 27 | + | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | |||
| 28 | + | <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3> | |||
| 29 | + | <div class="flex gap-2"> | |||
| 30 | + | <button | |||
| 31 | + | class="btn btn-sm btn-secondary" | |||
| 32 | + | hx-get="/htmx/upload-image" | |||
| 33 | + | hx-target="#image-manager" | |||
| 34 | + | hx-swap="innerHTML"> | |||
| 35 | + | <span class="iconf"></span> Upload | |||
| 36 | + | </button> | |||
| 37 | + | <button | |||
| 38 | + | class="btn btn-sm btn-primary" | |||
| 39 | + | hx-get="/htmx/image-manager" | |||
| 40 | + | hx-target="#image-manager" | |||
| 41 | + | hx-swap="innerHTML"> | |||
| 42 | + | <span class="iconf"></span> Refresh | |||
| 43 | + | </button> | |||
| 44 | + | </div> | |||
| 45 | + | </div> | |||
| 46 | + | ||||
| 47 | + | <!-- Image list --> | |||
| 48 | + | <table class="table"> | |||
| 49 | + | <!-- head --> | |||
| 50 | + | <thead> | |||
| 51 | + | <tr> | |||
| 52 | + | <th>Repository</th> | |||
| 53 | + | <th>Tag</th> | |||
| 54 | + | <th>Size</th> | |||
| 55 | + | <th>Created</th> | |||
| 56 | + | <th>Actions</th> | |||
| 57 | + | </tr> | |||
| 58 | + | </thead> | |||
| 59 | + | <tbody> | |||
| 60 | + | /* Check if there are no images */ | |||
| 61 | + | if len(images) == 0 { | |||
| 62 | + | <tr> | |||
| 63 | + | <td colspan="5" class="text-center py-4"> | |||
| 64 | + | <div class="alert alert-info"> | |||
| 65 | + | <span class="iconf">ℹ️</span> | |||
| 66 | + | <span>No images found. Upload an image first.</span> | |||
| 67 | + | </div> | |||
| 68 | + | </td> | |||
| 69 | + | </tr> | |||
| 70 | + | } else { | |||
| 71 | + | /* List all available images */ | |||
| 72 | + | for _, img := range images { | |||
| 73 | + | <tr class="hover"> | |||
| 74 | + | <td> | |||
| 75 | + | <div class="flex items-center gap-3"> | |||
| 76 | + | <div> | |||
| 77 | + | <div class="font-bold">{ img.Repository }</div> | |||
| 78 | + | <div class="text-sm opacity-50">{ img.ID }</div> | |||
| 79 | + | </div> | |||
| 80 | + | </div> | |||
| 81 | + | </td> | |||
| 82 | + | <td>{ img.Tag }</td> | |||
| 83 | + | <td>{ img.Size }</td> | |||
| 84 | + | <td>{ img.Created }</td> | |||
| 85 | + | <th> | |||
| 86 | + | <div class="dropdown dropdown-end"> | |||
| 87 | + | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |||
| 88 | + | <span class="iconf"></span> | |||
| 89 | + | </div> | |||
| 90 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | |||
| 91 | + | <li> | |||
| 92 | + | <a | |||
| 93 | + | href={ templ.SafeURL("/htmx/create-container/" + img.ShortID) } | |||
| 94 | + | class="text-success flex items-center"> | |||
| 95 | + | <span class="iconf mr-2"></span> Create Container | |||
| 96 | + | </a> | |||
| 97 | + | </li> | |||
| 98 | + | <li> | |||
| 99 | + | <button | |||
| 100 | + | class="text-error flex items-center" | |||
| 101 | + | hx-delete={ "/htmx/image-manager/delete/" + img.ShortID } | |||
| 102 | + | hx-target="#image-manager" | |||
| 103 | + | hx-swap="innerHTML" | |||
| 104 | + | hx-confirm="Are you sure you want to delete this image?"> | |||
| 105 | + | <span class="iconf mr-2"></span> Delete | |||
| 106 | + | </button> | |||
| 107 | + | </li> | |||
| 108 | + | </ul> | |||
| 109 | + | </div> | |||
| 110 | + | </th> | |||
| 111 | + | </tr> | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | </tbody> | |||
| 115 | + | </table> | |||
| 116 | + | </div> | |||
| 117 | + | </div> | |||
| 118 | + | ||||
| 119 | + | <script> | |||
| 120 | + | console.debug('Image list rendered'); | |||
| 121 | + | </script> | |||
| 122 | + | } ⏎̸ | |||
A internal/templating/models/templ/components/imagelist_templ.go +175 -0 generated file: collapsed
A internal/templating/models/templ/components/navbar.templ +72 -0
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // Navbar is a menu component for navigation | |||
| 4 | + | templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |||
| 5 | + | <!-- DaisyUI Navbar --> | |||
| 6 | + | <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50"> | |||
| 7 | + | <div class="navbar-start"> | |||
| 8 | + | <div class="flex items-center"> | |||
| 9 | + | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> | |||
| 10 | + | Gordon | |||
| 11 | + | </h1> | |||
| 12 | + | <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70"> | |||
| 13 | + | { buildVersion } | |||
| 14 | + | </span> | |||
| 15 | + | <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title=""> | |||
| 16 | + | <a href="https://github.com/bnema/gordon/releases" class="link"></a> | |||
| 17 | + | </span> | |||
| 18 | + | </div> | |||
| 19 | + | </div> | |||
| 20 | + | <div class="navbar-center hidden lg:flex"> | |||
| 21 | + | <ul class="menu menu-horizontal px-1"> | |||
| 22 | + | <li> | |||
| 23 | + | <a href={ templ.URL(adminPath + "/manager") } class="font-semibold"> | |||
| 24 | + | <span class="JetBrainsMonoBoldNF pr-1"></span> Manager | |||
| 25 | + | </a> | |||
| 26 | + | </li> | |||
| 27 | + | </ul> | |||
| 28 | + | </div> | |||
| 29 | + | <div class="navbar-end"> | |||
| 30 | + | <!-- Theme toggle --> | |||
| 31 | + | <div class="dropdown dropdown-end"> | |||
| 32 | + | <label tabindex="0" class="btn btn-ghost btn-circle"> | |||
| 33 | + | <span class="iconf text-lg md:text-xl"></span> | |||
| 34 | + | </label> | |||
| 35 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |||
| 36 | + | <li><button onclick="setTheme('gordon')">Gordon Light</button></li> | |||
| 37 | + | <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li> | |||
| 38 | + | <li><button onclick="setTheme('light')">Light</button></li> | |||
| 39 | + | <li><button onclick="setTheme('dark')">Dark</button></li> | |||
| 40 | + | </ul> | |||
| 41 | + | </div> | |||
| 42 | + | ||||
| 43 | + | <!-- User menu --> | |||
| 44 | + | <div class="dropdown dropdown-end"> | |||
| 45 | + | <label tabindex="0" class="btn btn-ghost btn-circle"> | |||
| 46 | + | <span class="iconf text-lg md:text-xl"></span> | |||
| 47 | + | </label> | |||
| 48 | + | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |||
| 49 | + | <li> | |||
| 50 | + | <a href={ templ.URL(userSettingsURL) } title="User settings"> | |||
| 51 | + | <span class="iconf"></span> Settings | |||
| 52 | + | </a> | |||
| 53 | + | </li> | |||
| 54 | + | <li> | |||
| 55 | + | <a href={ templ.URL(logoutURL) } title="Logout"> | |||
| 56 | + | <span class="iconf"></span> Logout | |||
| 57 | + | </a> | |||
| 58 | + | </li> | |||
| 59 | + | </ul> | |||
| 60 | + | </div> | |||
| 61 | + | </div> | |||
| 62 | + | </div> | |||
| 63 | + | ||||
| 64 | + | <!-- Theme toggle script --> | |||
| 65 | + | <script> | |||
| 66 | + | function setTheme(theme) { | |||
| 67 | + | document.documentElement.setAttribute('data-theme', theme); | |||
| 68 | + | localStorage.setItem('theme', theme); | |||
| 69 | + | console.debug('Theme changed to:', theme); | |||
| 70 | + | } | |||
| 71 | + | </script> | |||
| 72 | + | }⏎̸ | |||
A internal/templating/models/templ/components/navbar_templ.go +81 -0 generated file: collapsed
A internal/templating/models/templ/components/success.templ +16 -0
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // Success displays a success message | |||
| 4 | + | templ Success(message string) { | |||
| 5 | + | <div | |||
| 6 | + | id="success" | |||
| 7 | + | class="flex text-indigo-600 content-center justify-center items-center w-full h-full"> | |||
| 8 | + | <p class="text-2xl text-center"> | |||
| 9 | + | if message != "" { | |||
| 10 | + | { message } | |||
| 11 | + | } else { | |||
| 12 | + | Success! | |||
| 13 | + | } | |||
| 14 | + | </p> | |||
| 15 | + | </div> | |||
| 16 | + | }⏎̸ | |||
A internal/templating/models/templ/components/success_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/components/uploadimage.templ +66 -0
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | // UploadImageForm displays a form for uploading Docker images | |||
| 4 | + | templ UploadImageForm() { | |||
| 5 | + | <!-- Upload image form using DaisyUI components --> | |||
| 6 | + | <div class="card bg-base-100 shadow"> | |||
| 7 | + | <div class="card-body"> | |||
| 8 | + | <div class="flex justify-between items-center"> | |||
| 9 | + | <h2 class="card-title text-primary">Upload Image</h2> | |||
| 10 | + | <button | |||
| 11 | + | class="btn btn-sm btn-circle btn-ghost" | |||
| 12 | + | hx-get="/htmx/image-manager" | |||
| 13 | + | hx-target="#image-manager" | |||
| 14 | + | hx-swap="innerHTML"> | |||
| 15 | + | <span class="iconf text-lg">✕</span> | |||
| 16 | + | </button> | |||
| 17 | + | </div> | |||
| 18 | + | <div class="divider"></div> | |||
| 19 | + | ||||
| 20 | + | <form | |||
| 21 | + | id="upload-form" | |||
| 22 | + | hx-encoding="multipart/form-data" | |||
| 23 | + | hx-post="/htmx/upload-image" | |||
| 24 | + | hx-target="#image-manager" | |||
| 25 | + | hx-swap="innerHTML" | |||
| 26 | + | class="space-y-4"> | |||
| 27 | + | ||||
| 28 | + | <div class="alert alert-info shadow-lg mb-4"> | |||
| 29 | + | <span class="iconf">ℹ️</span> | |||
| 30 | + | <div> | |||
| 31 | + | <p>This form allows you to upload a container image (.tar file) to Gordon.</p> | |||
| 32 | + | <p>After uploading, you can create and manage containers from this image.</p> | |||
| 33 | + | </div> | |||
| 34 | + | </div> | |||
| 35 | + | ||||
| 36 | + | <div class="form-control w-full"> | |||
| 37 | + | <label class="label"> | |||
| 38 | + | <span class="label-text">Select a container image file (.tar)</span> | |||
| 39 | + | </label> | |||
| 40 | + | <input | |||
| 41 | + | type="file" | |||
| 42 | + | name="imageFile" | |||
| 43 | + | class="file-input file-input-bordered w-full" | |||
| 44 | + | accept=".tar" | |||
| 45 | + | required /> | |||
| 46 | + | </div> | |||
| 47 | + | ||||
| 48 | + | <div class="card-actions justify-end mt-4"> | |||
| 49 | + | <button | |||
| 50 | + | class="btn btn-ghost" | |||
| 51 | + | hx-get="/htmx/image-manager" | |||
| 52 | + | hx-target="#image-manager" | |||
| 53 | + | hx-swap="innerHTML" | |||
| 54 | + | type="button"> | |||
| 55 | + | Cancel | |||
| 56 | + | </button> | |||
| 57 | + | <button | |||
| 58 | + | class="btn btn-primary" | |||
| 59 | + | type="submit"> | |||
| 60 | + | <span class="iconf mr-1"></span> Upload | |||
| 61 | + | </button> | |||
| 62 | + | </div> | |||
| 63 | + | </form> | |||
| 64 | + | </div> | |||
| 65 | + | </div> | |||
| 66 | + | } ⏎̸ | |||
A internal/templating/models/templ/components/uploadimage_templ.go +41 -0 generated file: collapsed
A internal/templating/models/templ/layouts/admin_layout.templ +21 -0
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package layouts | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/components" | |||
| 4 | + | ||||
| 5 | + | // AdminLayout provides the base layout for admin pages | |||
| 6 | + | templ AdminLayout(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |||
| 7 | + | <!DOCTYPE html> | |||
| 8 | + | <html lang="en" data-theme="gordon-dark"> | |||
| 9 | + | @components.Header(title) | |||
| 10 | + | <body class="min-h-screen bg-base-100"> | |||
| 11 | + | @components.Navbar(buildVersion, adminPath, userSettingsURL, logoutURL) | |||
| 12 | + | ||||
| 13 | + | <!-- Main content starts after navbar height --> | |||
| 14 | + | <div class="container mx-auto pt-20 px-4"> | |||
| 15 | + | { children... } | |||
| 16 | + | </div> | |||
| 17 | + | ||||
| 18 | + | @components.Footer() | |||
| 19 | + | </body> | |||
| 20 | + | </html> | |||
| 21 | + | }⏎̸ | |||
A internal/templating/models/templ/layouts/admin_layout_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/layouts/base.templ +16 -0
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package layouts | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/components" | |||
| 4 | + | ||||
| 5 | + | // Base layout template that provides the HTML structure | |||
| 6 | + | // All pages will use this layout template | |||
| 7 | + | templ Base(title string) { | |||
| 8 | + | <!DOCTYPE html> | |||
| 9 | + | <html lang="en"> | |||
| 10 | + | @components.Header(title) | |||
| 11 | + | <body class="min-h-screen bg-base-100"> | |||
| 12 | + | { children... } | |||
| 13 | + | @components.Footer() | |||
| 14 | + | </body> | |||
| 15 | + | </html> | |||
| 16 | + | }⏎̸ | |||
A internal/templating/models/templ/layouts/base_templ.go +64 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/createcontainerfull.templ +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 5 | + | "github.com/bnema/gordon/internal/templating/models/templ/components" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // CreateContainerFullData represents the data needed for the create container full page | |||
| 9 | + | type CreateContainerFullData struct { | |||
| 10 | + | Title string | |||
| 11 | + | BuildVersion string | |||
| 12 | + | AdminPath string | |||
| 13 | + | UserSettings string | |||
| 14 | + | LogoutURL string | |||
| 15 | + | ContainerData components.ContainerFormData | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // CreateContainerFullPage replaces createcontainerfull.gohtml | |||
| 19 | + | // It renders the full page for creating a container | |||
| 20 | + | templ CreateContainerFullPage(data CreateContainerFullData) { | |||
| 21 | + | @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) { | |||
| 22 | + | <div class="flex flex-col md:flex-row w-full flex-grow"> | |||
| 23 | + | <div class="card flex-grow"> | |||
| 24 | + | <h1 class="card-header mb-4">Create Container</h1> | |||
| 25 | + | <!-- Include the container creation form component --> | |||
| 26 | + | @components.CreateContainerForm(data.ContainerData) | |||
| 27 | + | </div> | |||
| 28 | + | </div> | |||
| 29 | + | } | |||
| 30 | + | } ⏎̸ | |||
A internal/templating/models/templ/pages/admin/createcontainerfull_templ.go +83 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/dashboard.templ +18 -0
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 4 | + | ||||
| 5 | + | // DashboardPage implements the admin dashboard | |||
| 6 | + | templ DashboardPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |||
| 7 | + | @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) { | |||
| 8 | + | <div id="dashboard"> | |||
| 9 | + | <h1 | |||
| 10 | + | class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s" | |||
| 11 | + | > | |||
| 12 | + | Hello World from dashboard.html | |||
| 13 | + | </h1> | |||
| 14 | + | <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded --> | |||
| 15 | + | <script src="libs/chart.js"></script> | |||
| 16 | + | </div> | |||
| 17 | + | } | |||
| 18 | + | }⏎̸ | |||
A internal/templating/models/templ/pages/admin/dashboard_templ.go +61 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/index.templ +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 4 | + | ||||
| 5 | + | // IndexPageData represents data needed for the index page | |||
| 6 | + | type IndexPageData struct { | |||
| 7 | + | Title string | |||
| 8 | + | BuildVersion string | |||
| 9 | + | AdminPath string | |||
| 10 | + | UserSettings string | |||
| 11 | + | LogoutURL string | |||
| 12 | + | LangContent map[string]string // For storing language translations | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // IndexPage replaces admin/index.gohtml | |||
| 16 | + | // It renders the admin dashboard index page | |||
| 17 | + | templ IndexPage(data IndexPageData) { | |||
| 18 | + | @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) { | |||
| 19 | + | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> | |||
| 20 | + | <div class="card flex-grow"> | |||
| 21 | + | <h2 class="card-header">{ data.LangContent["title"] }</h2> | |||
| 22 | + | </div> | |||
| 23 | + | <div class="w-full md:w-2/6 flex flex-col space-y-4"> | |||
| 24 | + | <div class="card"> | |||
| 25 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |||
| 26 | + | </div> | |||
| 27 | + | <div class="card"> | |||
| 28 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |||
| 29 | + | </div> | |||
| 30 | + | <div class="card"> | |||
| 31 | + | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |||
| 32 | + | </div> | |||
| 33 | + | </div> | |||
| 34 | + | </div> | |||
| 35 | + | } | |||
| 36 | + | } ⏎̸ | |||
A internal/templating/models/templ/pages/admin/index_templ.go +85 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/install.templ +59 -0
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 4 | + | ||||
| 5 | + | // InstallPage replaces install.gohtml | |||
| 6 | + | // It renders the installation page | |||
| 7 | + | templ InstallPage(title string, version string) { | |||
| 8 | + | @layouts.Base(title) { | |||
| 9 | + | <!-- Logo and Title --> | |||
| 10 | + | <div | |||
| 11 | + | id="logo-mascot" | |||
| 12 | + | class="flex flex-col justify-center items-center p-4 mb-4" | |||
| 13 | + | > | |||
| 14 | + | <div class="circle-shadow flex justify-center w-100"> | |||
| 15 | + | <img | |||
| 16 | + | src="/assets/imgs/gordon-mascot-hq-trsp.png" | |||
| 17 | + | alt="logo" | |||
| 18 | + | class="sm:h-24 h-32 xl:h-48" | |||
| 19 | + | /> | |||
| 20 | + | </div> | |||
| 21 | + | <div class="flex justify-center mt-4"> | |||
| 22 | + | <h1 | |||
| 23 | + | class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter" | |||
| 24 | + | > | |||
| 25 | + | Gordon | |||
| 26 | + | </h1> | |||
| 27 | + | <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"> | |||
| 28 | + | v{ version } | |||
| 29 | + | </span> | |||
| 30 | + | </div> | |||
| 31 | + | </div> | |||
| 32 | + | ||||
| 33 | + | <!-- Main Content --> | |||
| 34 | + | <div class="card w-2/3"> | |||
| 35 | + | <!-- Built-in Proxy --> | |||
| 36 | + | <div id="built-in-proxy" class="mt-8"> | |||
| 37 | + | <div class="flex items-center mb-4"> | |||
| 38 | + | <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center"> | |||
| 39 | + | <span class="text-white font-bold">✓</span> | |||
| 40 | + | </div> | |||
| 41 | + | <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2> | |||
| 42 | + | </div> | |||
| 43 | + | ||||
| 44 | + | <div class="bg-indigo-50 p-4 rounded-md"> | |||
| 45 | + | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | |||
| 46 | + | <p>The proxy automatically:</p> | |||
| 47 | + | <ul class="list-disc ml-6 mb-4"> | |||
| 48 | + | <li>Manages Let's Encrypt certificates</li> | |||
| 49 | + | <li>Routes domains to the appropriate containers</li> | |||
| 50 | + | <li>Handles HTTP to HTTPS redirects</li> | |||
| 51 | + | <li>Provides proper headers for forwarded requests</li> | |||
| 52 | + | </ul> | |||
| 53 | + | <p>No additional configuration is required.</p> | |||
| 54 | + | </div> | |||
| 55 | + | </div> | |||
| 56 | + | <!-- End Built-in Proxy --> | |||
| 57 | + | </div> | |||
| 58 | + | } | |||
| 59 | + | } ⏎̸ | |||
A internal/templating/models/templ/pages/admin/install_templ.go +75 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/manager.templ +73 -0
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 5 | + | "strconv" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // ManagerPage implements the container and image manager page | |||
| 9 | + | templ ManagerPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string, containerCount int, imageCount int) { | |||
| 10 | + | @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) { | |||
| 11 | + | <!-- Main content --> | |||
| 12 | + | <div class="text-sm breadcrumbs mb-4"> | |||
| 13 | + | <ul> | |||
| 14 | + | <li><a href={ templ.URL(adminPath) }>Gordon</a></li> | |||
| 15 | + | <li>Manager</li> | |||
| 16 | + | </ul> | |||
| 17 | + | </div> | |||
| 18 | + | ||||
| 19 | + | <!-- Stats overview --> | |||
| 20 | + | <div class="stats shadow mb-6 w-full"> | |||
| 21 | + | <div class="stat"> | |||
| 22 | + | <div class="stat-figure text-primary"> | |||
| 23 | + | <span class="iconf text-3xl"></span> | |||
| 24 | + | </div> | |||
| 25 | + | <div class="stat-title">Containers</div> | |||
| 26 | + | <div class="stat-value" id="container-count">{ strconv.Itoa(containerCount) }</div> | |||
| 27 | + | <div class="stat-desc">Docker containers managed by Gordon</div> | |||
| 28 | + | </div> | |||
| 29 | + | ||||
| 30 | + | <div class="stat"> | |||
| 31 | + | <div class="stat-figure text-secondary"> | |||
| 32 | + | <span class="iconf text-3xl"></span> | |||
| 33 | + | </div> | |||
| 34 | + | <div class="stat-title">Images</div> | |||
| 35 | + | <div class="stat-value" id="image-count">{ strconv.Itoa(imageCount) }</div> | |||
| 36 | + | <div class="stat-desc">Docker images available</div> | |||
| 37 | + | </div> | |||
| 38 | + | </div> | |||
| 39 | + | ||||
| 40 | + | <!-- Tabs for containers and images --> | |||
| 41 | + | <div role="tablist" class="tabs tabs-boxed mb-4"> | |||
| 42 | + | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked /> | |||
| 43 | + | <div role="tabpanel" class="tab-content p-0"> | |||
| 44 | + | <!-- Container manager section --> | |||
| 45 | + | <div id="container-manager" | |||
| 46 | + | hx-get="/htmx/container-manager" | |||
| 47 | + | hx-trigger="load" | |||
| 48 | + | hx-swap="innerHTML"> | |||
| 49 | + | <!-- Container content loaded via HTMX --> | |||
| 50 | + | <div class="text-center p-4"> | |||
| 51 | + | <span class="iconf text-3xl animate-spin inline-block"></span> | |||
| 52 | + | Loading containers... | |||
| 53 | + | </div> | |||
| 54 | + | </div> | |||
| 55 | + | </div> | |||
| 56 | + | ||||
| 57 | + | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" /> | |||
| 58 | + | <div role="tabpanel" class="tab-content p-0"> | |||
| 59 | + | <!-- Image manager section --> | |||
| 60 | + | <div id="image-manager" | |||
| 61 | + | hx-get="/htmx/image-manager" | |||
| 62 | + | hx-trigger="load" | |||
| 63 | + | hx-swap="innerHTML"> | |||
| 64 | + | <!-- Image content loaded via HTMX --> | |||
| 65 | + | <div class="text-center p-4"> | |||
| 66 | + | <span class="iconf text-3xl animate-spin inline-block"></span> | |||
| 67 | + | Loading images... | |||
| 68 | + | </div> | |||
| 69 | + | </div> | |||
| 70 | + | </div> | |||
| 71 | + | </div> | |||
| 72 | + | } | |||
| 73 | + | }⏎̸ | |||
A internal/templating/models/templ/pages/admin/manager_templ.go +99 -0 generated file: collapsed
A internal/templating/models/templ/pages/admin/user.templ +58 -0
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 4 | + | ||||
| 5 | + | // UserPageData represents data needed for the user page | |||
| 6 | + | type UserPageData struct { | |||
| 7 | + | Title string | |||
| 8 | + | BuildVersion string | |||
| 9 | + | AdminPath string | |||
| 10 | + | UserSettings string | |||
| 11 | + | LogoutURL string | |||
| 12 | + | Username string | |||
| 13 | + | Email string | |||
| 14 | + | Role string | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // UserPage replaces admin/user.gohtml | |||
| 18 | + | // It renders the user profile page | |||
| 19 | + | templ UserPage(data UserPageData) { | |||
| 20 | + | @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) { | |||
| 21 | + | <div class="text-sm breadcrumbs mb-4"> | |||
| 22 | + | <ul> | |||
| 23 | + | <li><a href={ templ.URL(data.AdminPath) }>Gordon</a></li> | |||
| 24 | + | <li>User Profile</li> | |||
| 25 | + | </ul> | |||
| 26 | + | </div> | |||
| 27 | + | ||||
| 28 | + | <div class="card bg-base-100 shadow-xl"> | |||
| 29 | + | <div class="card-body"> | |||
| 30 | + | <h2 class="card-title">User Profile</h2> | |||
| 31 | + | <div class="divider"></div> | |||
| 32 | + | ||||
| 33 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |||
| 34 | + | <div class="form-control"> | |||
| 35 | + | <label class="label"> | |||
| 36 | + | <span class="label-text">Username</span> | |||
| 37 | + | </label> | |||
| 38 | + | <input type="text" class="input input-bordered" value={ data.Username } readonly /> | |||
| 39 | + | </div> | |||
| 40 | + | ||||
| 41 | + | <div class="form-control"> | |||
| 42 | + | <label class="label"> | |||
| 43 | + | <span class="label-text">Email</span> | |||
| 44 | + | </label> | |||
| 45 | + | <input type="email" class="input input-bordered" value={ data.Email } readonly /> | |||
| 46 | + | </div> | |||
| 47 | + | ||||
| 48 | + | <div class="form-control"> | |||
| 49 | + | <label class="label"> | |||
| 50 | + | <span class="label-text">Role</span> | |||
| 51 | + | </label> | |||
| 52 | + | <input type="text" class="input input-bordered" value={ data.Role } readonly /> | |||
| 53 | + | </div> | |||
| 54 | + | </div> | |||
| 55 | + | </div> | |||
| 56 | + | </div> | |||
| 57 | + | } | |||
| 58 | + | } ⏎̸ | |||
A internal/templating/models/templ/pages/admin/user_templ.go +122 -0 generated file: collapsed
A internal/templating/models/templ/pages/errors/errors.templ +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package errors | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 4 | + | ||||
| 5 | + | // ErrorPageData represents data needed for error pages | |||
| 6 | + | type ErrorPageData struct { | |||
| 7 | + | Title string | |||
| 8 | + | BuildVersion string | |||
| 9 | + | AdminPath string | |||
| 10 | + | StatusCode int | |||
| 11 | + | Message string | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // NotFoundPage renders a 404 error page | |||
| 15 | + | templ NotFoundPage(data ErrorPageData) { | |||
| 16 | + | @layouts.Base(data.Title) { | |||
| 17 | + | <div class="flex flex-col items-center justify-center min-h-screen p-4"> | |||
| 18 | + | <div class="flex flex-col items-center mb-8"> | |||
| 19 | + | <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" /> | |||
| 20 | + | <h1 class="text-4xl font-bold text-error">404</h1> | |||
| 21 | + | <h2 class="text-2xl mb-2">Page Not Found</h2> | |||
| 22 | + | <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p> | |||
| 23 | + | <a href={ templ.URL(data.AdminPath) } class="btn btn-primary"> | |||
| 24 | + | <span class="iconf mr-2">🏠</span> Go Home | |||
| 25 | + | </a> | |||
| 26 | + | </div> | |||
| 27 | + | </div> | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // ForbiddenPage renders a 403 error page | |||
| 32 | + | templ ForbiddenPage(data ErrorPageData) { | |||
| 33 | + | @layouts.Base(data.Title) { | |||
| 34 | + | <div class="flex flex-col items-center justify-center min-h-screen p-4"> | |||
| 35 | + | <div class="flex flex-col items-center mb-8"> | |||
| 36 | + | <img src="/assets/imgs/gordon-mascot-hq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" /> | |||
| 37 | + | <h1 class="text-4xl font-bold text-error">403</h1> | |||
| 38 | + | <h2 class="text-2xl mb-2">Access Forbidden</h2> | |||
| 39 | + | <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p> | |||
| 40 | + | <a href={ templ.URL(data.AdminPath) } class="btn btn-primary"> | |||
| 41 | + | <span class="iconf mr-2">🏠</span> Go Home | |||
| 42 | + | </a> | |||
| 43 | + | </div> | |||
| 44 | + | </div> | |||
| 45 | + | } | |||
| 46 | + | } ⏎̸ | |||
A internal/templating/models/templ/pages/errors/errors_templ.go +162 -0 generated file: collapsed
A internal/templating/models/templ/pages/login/index.templ +126 -0
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | package login | |||
| 2 | + | ||||
| 3 | + | // LoginPage implements the login page with token and GitHub authentication | |||
| 4 | + | templ LoginPage(title string, buildVersion string, errorMsg string) { | |||
| 5 | + | <!doctype html> | |||
| 6 | + | <html lang="en" data-theme="gordon-dark"> | |||
| 7 | + | <head> | |||
| 8 | + | <meta charset="UTF-8" /> | |||
| 9 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |||
| 10 | + | <title>{ title }</title> | |||
| 11 | + | <meta name="description" content="Gordon Container Management System" /> | |||
| 12 | + | <meta name="keywords" content="container, management, docker, podman" /> | |||
| 13 | + | <!-- Custom CSS + Tailwind --> | |||
| 14 | + | <link rel="stylesheet" href="/assets/css/tailwind.css" /> | |||
| 15 | + | <!-- HTMX min js --> | |||
| 16 | + | <script src="/libs/htmx.min.js"></script> | |||
| 17 | + | <!-- HTMX SSE --> | |||
| 18 | + | <script src="/libs/htmx-sse.js"></script> | |||
| 19 | + | <!-- DaisyUI --> | |||
| 20 | + | <script src="/libs/daisyui.js"></script> | |||
| 21 | + | <!-- Custom JS from assets/js --> | |||
| 22 | + | <script src="/assets/js/custom.js"></script> | |||
| 23 | + | ||||
| 24 | + | <!-- daisyUI theme configuration --> | |||
| 25 | + | <script> | |||
| 26 | + | // Check for saved theme preference or use preferred color scheme | |||
| 27 | + | const savedTheme = localStorage.getItem('theme') || 'gordon-dark'; | |||
| 28 | + | document.documentElement.setAttribute('data-theme', savedTheme); | |||
| 29 | + | console.debug('Theme loaded:', savedTheme); | |||
| 30 | + | </script> | |||
| 31 | + | </head> | |||
| 32 | + | ||||
| 33 | + | <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4"> | |||
| 34 | + | <!-- Logo and Title --> | |||
| 35 | + | <div | |||
| 36 | + | id="logo-mascot" | |||
| 37 | + | class="flex flex-col justify-center items-center p-4 mb-4"> | |||
| 38 | + | <div class="flex justify-center w-100"> | |||
| 39 | + | <img | |||
| 40 | + | src="/assets/imgs/gordon-mascot-mq-trsp.png" | |||
| 41 | + | alt="logo gordon" | |||
| 42 | + | class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" /> | |||
| 43 | + | </div> | |||
| 44 | + | <div class="flex justify-center mt-4"> | |||
| 45 | + | <h1 | |||
| 46 | + | class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter"> | |||
| 47 | + | Gordon | |||
| 48 | + | </h1> | |||
| 49 | + | <span | |||
| 50 | + | class="gordon-v-subtitle text-secondary text-sm ml-2" | |||
| 51 | + | >{ buildVersion }</span> | |||
| 52 | + | </div> | |||
| 53 | + | </div> | |||
| 54 | + | ||||
| 55 | + | <!-- Main Content --> | |||
| 56 | + | <div class="card bg-base-100 shadow-xl w-full max-w-md"> | |||
| 57 | + | <div class="card-body"> | |||
| 58 | + | <!-- Login Section --> | |||
| 59 | + | <div id="login-section"> | |||
| 60 | + | <div class="flex flex-col items-center"> | |||
| 61 | + | <h2 class="card-title text-center mb-4">Login</h2> | |||
| 62 | + | ||||
| 63 | + | <!-- Error Message Display --> | |||
| 64 | + | if errorMsg != "" { | |||
| 65 | + | <div class="alert alert-error mb-4"> | |||
| 66 | + | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |||
| 67 | + | <span> | |||
| 68 | + | if errorMsg == "empty_token" { | |||
| 69 | + | Please enter a token. | |||
| 70 | + | } else if errorMsg == "invalid_token" { | |||
| 71 | + | Invalid token. Please try again. | |||
| 72 | + | } else if errorMsg == "server_error" { | |||
| 73 | + | A server error occurred. Please try again later. | |||
| 74 | + | } else { | |||
| 75 | + | An error occurred. Please try again. | |||
| 76 | + | } | |||
| 77 | + | </span> | |||
| 78 | + | </div> | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | <!-- Token Input Form --> | |||
| 82 | + | <div class="w-full"> | |||
| 83 | + | <form action="login/submit-token" method="POST" class="mb-4"> | |||
| 84 | + | <div class="form-control mb-4"> | |||
| 85 | + | <label for="token" class="label"> | |||
| 86 | + | <span class="label-text">Admin Token</span> | |||
| 87 | + | </label> | |||
| 88 | + | <input | |||
| 89 | + | type="text" | |||
| 90 | + | id="token" | |||
| 91 | + | name="token" | |||
| 92 | + | placeholder="Enter your admin token" | |||
| 93 | + | class="input input-bordered w-full" | |||
| 94 | + | required | |||
| 95 | + | /> | |||
| 96 | + | </div> | |||
| 97 | + | <button | |||
| 98 | + | type="submit" | |||
| 99 | + | class="btn btn-primary w-full" | |||
| 100 | + | > | |||
| 101 | + | Continue with Token | |||
| 102 | + | </button> | |||
| 103 | + | </form> | |||
| 104 | + | <div class="divider">OR</div> | |||
| 105 | + | </div> | |||
| 106 | + | ||||
| 107 | + | <!-- GitHub OAuth --> | |||
| 108 | + | <div class="w-full"> | |||
| 109 | + | <a | |||
| 110 | + | href="login/oauth/github" | |||
| 111 | + | class="btn btn-secondary w-full" | |||
| 112 | + | > | |||
| 113 | + | <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> | |||
| 114 | + | <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" /> | |||
| 115 | + | </svg> | |||
| 116 | + | Sign in with GitHub | |||
| 117 | + | </a> | |||
| 118 | + | </div> | |||
| 119 | + | </div> | |||
| 120 | + | </div> | |||
| 121 | + | <!-- End Login Section --> | |||
| 122 | + | </div> | |||
| 123 | + | </div> | |||
| 124 | + | </body> | |||
| 125 | + | </html> | |||
| 126 | + | }⏎̸ | |||
A internal/templating/models/templ/pages/login/index_templ.go +102 -0 generated file: collapsed
A internal/templating/render/templ.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net/http" | |||
| 7 | + | ||||
| 8 | + | "github.com/a-h/templ" | |||
| 9 | + | "github.com/bnema/gordon/internal/server" | |||
| 10 | + | "github.com/labstack/echo/v4" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Renderer for templ templates | |||
| 14 | + | type TemplRenderer struct { | |||
| 15 | + | BuildVersion string | |||
| 16 | + | LatestVersion string | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // RenderTempl renders a templ component | |||
| 20 | + | func (r *TemplRenderer) RenderTempl(ctx echo.Context, component templ.Component) error { | |||
| 21 | + | // Set the content type and status code | |||
| 22 | + | ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) | |||
| 23 | + | ctx.Response().WriteHeader(http.StatusOK) | |||
| 24 | + | ||||
| 25 | + | // Create a render context | |||
| 26 | + | renderCtx := context.WithValue(ctx.Request().Context(), "BuildVersion", r.BuildVersion) | |||
| 27 | + | ||||
| 28 | + | // Render the component | |||
| 29 | + | err := component.Render(renderCtx, ctx.Response().Writer) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return fmt.Errorf("failed to render templ component: %w", err) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | return nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // NewTemplRenderer creates a new templ renderer | |||
| 38 | + | func NewTemplRenderer(a *server.App) *TemplRenderer { | |||
| 39 | + | return &TemplRenderer{ | |||
| 40 | + | BuildVersion: a.Config.Build.BuildVersion, | |||
| 41 | + | } | |||
| 42 | + | }⏎̸ | |||
A internal/templating/templ_embed.go +13 -0
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package templating | |||
| 2 | + | ||||
| 3 | + | // This file contains templ components embeddings | |||
| 4 | + | // As we migrate from traditional Go templates to templ, | |||
| 5 | + | // we'll add the appropriate code here to handle templ templates. | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | // Import templ generated templates, which will make them available to the application | |||
| 9 | + | _ "github.com/bnema/gordon/internal/templating/models/templ/components" | |||
| 10 | + | _ "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |||
| 11 | + | _ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |||
| 12 | + | _ "github.com/bnema/gordon/internal/templating/models/templ/pages/login" | |||
| 13 | + | )⏎̸ | |||
M internal/webui/public/assets/css/custom.css +112 -3
| @@ -6,9 +6,118 @@ | |||||
| 6 | 6 | ||||
| 7 | /* Embeded path : assets/css/cutsom.css */ | 7 | /* Embeded path : assets/css/cutsom.css */ | ||
| 8 | 8 | ||||
| 9 | - | @tailwind base; | 9 | + | @import "tailwindcss"; |
| 10 | - | @tailwind components; | 10 | + | @source "../../../templating/models/html/**/*.{gohtml,go}"; |
| 11 | - | @tailwind utilities; | 11 | + | @plugin "daisyui"; |
| 12 | + | ||||
| 13 | + | /* Custom DaisyUI theme */ | |||
| 14 | + | @plugin "daisyui/theme" { | |||
| 15 | + | name: "gordon-dark"; | |||
| 16 | + | default: true; | |||
| 17 | + | prefersdark: true; | |||
| 18 | + | color-scheme: "dark"; | |||
| 19 | + | --color-base-100: oklch(28% 0.091 267.935); | |||
| 20 | + | --color-base-200: oklch(13% 0.028 261.692); | |||
| 21 | + | --color-base-300: oklch(20% 0.042 265.755); | |||
| 22 | + | --color-base-content: oklch(93% 0.034 272.788); | |||
| 23 | + | --color-primary: oklch(87% 0 0); | |||
| 24 | + | --color-primary-content: oklch(14% 0 0); | |||
| 25 | + | --color-secondary: oklch(83% 0.145 321.434); | |||
| 26 | + | --color-secondary-content: oklch(29% 0.136 325.661); | |||
| 27 | + | --color-accent: oklch(80% 0.105 251.813); | |||
| 28 | + | --color-accent-content: oklch(28% 0.091 267.935); | |||
| 29 | + | --color-neutral: oklch(37% 0.034 259.733); | |||
| 30 | + | --color-neutral-content: oklch(98% 0.002 247.839); | |||
| 31 | + | --color-info: oklch(68% 0.169 237.323); | |||
| 32 | + | --color-info-content: oklch(97% 0.013 236.62); | |||
| 33 | + | --color-success: oklch(70% 0.14 182.503); | |||
| 34 | + | --color-success-content: oklch(98% 0.014 180.72); | |||
| 35 | + | --color-warning: oklch(76% 0.188 70.08); | |||
| 36 | + | --color-warning-content: oklch(98% 0.022 95.277); | |||
| 37 | + | --color-error: oklch(64% 0.246 16.439); | |||
| 38 | + | --color-error-content: oklch(96% 0.015 12.422); | |||
| 39 | + | --radius-selector: 1rem; | |||
| 40 | + | --radius-field: 0rem; | |||
| 41 | + | --radius-box: 0.25rem; | |||
| 42 | + | --size-selector: 0.25rem; | |||
| 43 | + | --size-field: 0.25rem; | |||
| 44 | + | --border: 1px; | |||
| 45 | + | --depth: 0; | |||
| 46 | + | --noise: 0; | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | ||||
| 50 | + | /* Custom component classes */ | |||
| 51 | + | @layer components { | |||
| 52 | + | /* Button styles */ | |||
| 53 | + | .btn-primary { | |||
| 54 | + | @apply flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2; | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | .btn-secondary { | |||
| 58 | + | @apply flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2; | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | .btn-danger { | |||
| 62 | + | @apply flex w-full justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2; | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | /* Alert styles */ | |||
| 66 | + | .alert-error { | |||
| 67 | + | @apply rounded-md bg-red-50 p-4 dark:bg-red-900/20; | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | .alert-error-icon { | |||
| 71 | + | @apply h-5 w-5 text-red-400; | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | .alert-error-text { | |||
| 75 | + | @apply text-sm font-medium text-red-800 dark:text-red-300; | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | .alert-success { | |||
| 79 | + | @apply rounded-md bg-green-50 p-4 dark:bg-green-900/20; | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | .alert-success-icon { | |||
| 83 | + | @apply h-5 w-5 text-green-400; | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | .alert-success-text { | |||
| 87 | + | @apply text-sm font-medium text-green-800 dark:text-green-300; | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | /* Card styles */ | |||
| 91 | + | .card { | |||
| 92 | + | @apply bg-white p-4 rounded-md shadow mb-4; | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | .card-header { | |||
| 96 | + | @apply text-lg font-semibold text-indigo-600 mb-2; | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | /* Background styles */ | |||
| 100 | + | .bg-app { | |||
| 101 | + | @apply bg-indigo-50; | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | .bg-sidebar { | |||
| 105 | + | @apply bg-indigo-900; | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | .bg-card { | |||
| 109 | + | @apply bg-white; | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | /* Text styles */ | |||
| 113 | + | .text-title { | |||
| 114 | + | @apply text-indigo-600 font-semibold; | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | .text-subtitle { | |||
| 118 | + | @apply text-indigo-500; | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 12 | 121 | ||||
| 13 | /* Import IBM Plex Fonts */ | 122 | /* Import IBM Plex Fonts */ | ||
| 14 | @font-face { | 123 | @font-face { | ||
M internal/webui/public/assets/css/tailwind.css +8334 -1315 large diff (9649 lines changed): collapsed
M internal/webui/public/assets/js/custom.js +33 -1
| @@ -187,18 +187,36 @@ function refreshContainerList() { | |||||
| 187 | 187 | ||||
| 188 | // Show loading indicator | 188 | // Show loading indicator | ||
| 189 | const containerManager = $('#container-manager'); | 189 | const containerManager = $('#container-manager'); | ||
| 190 | + | if (containerManager.length === 0) { | |||
| 191 | + | console.error("Container manager element not found in DOM"); | |||
| 192 | + | return; | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | console.debug("Container manager element found:", containerManager[0]); | |||
| 196 | + | console.debug("Container manager classes:", containerManager.attr('class')); | |||
| 197 | + | ||||
| 190 | const originalContent = containerManager.html(); | 198 | const originalContent = containerManager.html(); | ||
| 191 | containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block"></span> Loading containers...</div>'); | 199 | containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block"></span> Loading containers...</div>'); | ||
| 192 | 200 | ||||
| 201 | + | // Ensure the container is visible | |||
| 202 | + | containerManager.removeClass('hidden'); | |||
| 203 | + | ||||
| 193 | $.ajax({ | 204 | $.ajax({ | ||
| 194 | url: '/htmx/container-manager', | 205 | url: '/htmx/container-manager', | ||
| 195 | type: 'GET', | 206 | type: 'GET', | ||
| 196 | success: function(data) { | 207 | success: function(data) { | ||
| 208 | + | console.debug("Container data received, length:", data.length); | |||
| 197 | $('#container-manager').html(data); | 209 | $('#container-manager').html(data); | ||
| 210 | + | ||||
| 211 | + | // Force visibility | |||
| 212 | + | $('#container-manager').removeClass('hidden'); | |||
| 213 | + | ||||
| 198 | console.debug("Container list refreshed successfully"); | 214 | console.debug("Container list refreshed successfully"); | ||
| 199 | }, | 215 | }, | ||
| 200 | error: function(xhr, status, error) { | 216 | error: function(xhr, status, error) { | ||
| 201 | console.error("Error refreshing container list:", error); | 217 | console.error("Error refreshing container list:", error); | ||
| 218 | + | console.error("Status:", status); | |||
| 219 | + | console.error("Response:", xhr.responseText); | |||
| 202 | // Restore original content on error | 220 | // Restore original content on error | ||
| 203 | containerManager.html(originalContent); | 221 | containerManager.html(originalContent); | ||
| 204 | } | 222 | } | ||
| @@ -211,6 +229,14 @@ function refreshImageList() { | |||||
| 211 | 229 | ||||
| 212 | // Show loading indicator | 230 | // Show loading indicator | ||
| 213 | const imageManager = $('#image-manager'); | 231 | const imageManager = $('#image-manager'); | ||
| 232 | + | if (imageManager.length === 0) { | |||
| 233 | + | console.error("Image manager element not found in DOM"); | |||
| 234 | + | return; | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | console.debug("Image manager element found:", imageManager[0]); | |||
| 238 | + | console.debug("Image manager classes:", imageManager.attr('class')); | |||
| 239 | + | ||||
| 214 | const originalContent = imageManager.html(); | 240 | const originalContent = imageManager.html(); | ||
| 215 | imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block"></span> Loading images...</div>'); | 241 | imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block"></span> Loading images...</div>'); | ||
| 216 | 242 | ||||
| @@ -218,11 +244,14 @@ function refreshImageList() { | |||||
| 218 | url: '/htmx/image-manager', | 244 | url: '/htmx/image-manager', | ||
| 219 | type: 'GET', | 245 | type: 'GET', | ||
| 220 | success: function(data) { | 246 | success: function(data) { | ||
| 247 | + | console.debug("Image data received, length:", data.length); | |||
| 221 | $('#image-manager').html(data); | 248 | $('#image-manager').html(data); | ||
| 222 | console.debug("Image list refreshed successfully"); | 249 | console.debug("Image list refreshed successfully"); | ||
| 223 | }, | 250 | }, | ||
| 224 | error: function(xhr, status, error) { | 251 | error: function(xhr, status, error) { | ||
| 225 | console.error("Error refreshing image list:", error); | 252 | console.error("Error refreshing image list:", error); | ||
| 253 | + | console.error("Status:", status); | |||
| 254 | + | console.error("Response:", xhr.responseText); | |||
| 226 | // Restore original content on error | 255 | // Restore original content on error | ||
| 227 | imageManager.html(originalContent); | 256 | imageManager.html(originalContent); | ||
| 228 | } | 257 | } | ||
| @@ -236,6 +265,9 @@ function setupHtmxEventListeners() { | |||||
| 236 | const targetId = event.detail.target.id; | 265 | const targetId = event.detail.target.id; | ||
| 237 | console.debug(`HTMX afterOnLoad event for target: ${targetId}`); | 266 | console.debug(`HTMX afterOnLoad event for target: ${targetId}`); | ||
| 238 | 267 | ||||
| 268 | + | // Remove the tab visibility fix which was interfering with DaisyUI | |||
| 269 | + | // DaisyUI handles tab visibility automatically with the input[type="radio"] approach | |||
| 270 | + | ||||
| 239 | // Check if the target is an action response | 271 | // Check if the target is an action response | ||
| 240 | if (targetId && targetId.startsWith('actionResponse-')) { | 272 | if (targetId && targetId.startsWith('actionResponse-')) { | ||
| 241 | console.debug(`Action completed for ${targetId}`); | 273 | console.debug(`Action completed for ${targetId}`); | ||
| @@ -257,7 +289,7 @@ function setupHtmxEventListeners() { | |||||
| 257 | console.debug('Image manager updated'); | 289 | console.debug('Image manager updated'); | ||
| 258 | 290 | ||||
| 259 | // Show notification for successful upload | 291 | // Show notification for successful upload | ||
| 260 | - | if (event.detail.xhr.responseText.includes('Success')) { | 292 | + | if (event.detail.xhr && event.detail.xhr.responseText && event.detail.xhr.responseText.includes('Success')) { |
| 261 | showNotification('Image uploaded successfully', 'success'); | 293 | showNotification('Image uploaded successfully', 'success'); | ||
| 262 | } | 294 | } | ||
| 263 | 295 | ||||
A internal/webui/public/libs/chart.js +20 -0 too large to show in the browser
too large to show in the browser view raw
A internal/webui/public/libs/daisyui.js +8 -0 too large to show in the browser
too large to show in the browser view raw
M package.json +3 -1
| @@ -1,5 +1,7 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | + | "type": "module", | |||
| 2 | "devDependencies": { | 3 | "devDependencies": { | ||
| 4 | + | "daisyui": "^5.0.6", | |||
| 3 | "prettier": "^3.5.3", | 5 | "prettier": "^3.5.3", | ||
| 4 | "prettier-plugin-tailwindcss": "^0.5.14", | 6 | "prettier-plugin-tailwindcss": "^0.5.14", | ||
| 5 | "tailwindcss": "^4.0.14" | 7 | "tailwindcss": "^4.0.14" | ||
| @@ -9,7 +11,7 @@ | |||||
| 9 | "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css" | 11 | "build:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css" | ||
| 10 | }, | 12 | }, | ||
| 11 | "dependencies": { | 13 | "dependencies": { | ||
| 12 | - | "caniuse-lite": "^1.0.30001705", | 14 | + | "@tailwindcss/cli": "^4.0.14", |
| 13 | "dotenv": "^16.4.7" | 15 | "dotenv": "^16.4.7" | ||
| 14 | } | 16 | } | ||
| 15 | } | 17 | } | ||
M pkg/docker/client.go +19 -3
| @@ -28,14 +28,15 @@ func InitializeClient(config *Config) error { | |||||
| 28 | return fmt.Errorf("sock field in Config is empty") | 28 | return fmt.Errorf("sock field in Config is empty") | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | + | logger.Debug("Checking socket file existence", "socket_path", config.Sock) | |||
| 31 | if _, err := os.Stat(config.Sock); os.IsNotExist(err) { | 32 | if _, err := os.Stat(config.Sock); os.IsNotExist(err) { | ||
| 32 | - | logger.Error("Failed to initialize Docker client", "error", err) | 33 | + | logger.Error("Failed to initialize Docker client", "error", err, "socket_path", config.Sock) |
| 33 | return err | 34 | return err | ||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | // For Podman compatibility, we need to ensure we're using the right connection approach | 37 | // For Podman compatibility, we need to ensure we're using the right connection approach | ||
| 37 | host := "unix://" + config.Sock | 38 | host := "unix://" + config.Sock | ||
| 38 | - | logger.Debug("Setting up Docker client", "host", host) | 39 | + | logger.Debug("Setting up Docker client", "host", host, "podman_enabled", config.PodmanEnable) |
| 39 | 40 | ||||
| 40 | // Create the Docker client with appropriate options | 41 | // Create the Docker client with appropriate options | ||
| 41 | cli, err := client.NewClientWithOpts( | 42 | cli, err := client.NewClientWithOpts( | ||
| @@ -47,7 +48,18 @@ func InitializeClient(config *Config) error { | |||||
| 47 | return fmt.Errorf("error initializing Docker client: %s", err) | 48 | return fmt.Errorf("error initializing Docker client: %s", err) | ||
| 48 | } | 49 | } | ||
| 49 | 50 | ||||
| 50 | - | logger.Debug("Docker client initialized", "socket", config.Sock, "podman_enabled", config.PodmanEnable) | 51 | + | // Test the connection to make sure it works |
| 52 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 53 | + | defer cancel() | |||
| 54 | + | ||||
| 55 | + | logger.Debug("Testing Docker client connection", "socket", config.Sock) | |||
| 56 | + | _, err = cli.Ping(ctx) | |||
| 57 | + | if err != nil { | |||
| 58 | + | logger.Error("Docker client ping failed", "error", err, "socket", config.Sock) | |||
| 59 | + | return fmt.Errorf("docker daemon not responding: %s", err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | logger.Debug("Docker client initialized successfully", "socket", config.Sock, "podman_enabled", config.PodmanEnable) | |||
| 51 | dockerCli = cli | 63 | dockerCli = cli | ||
| 52 | currentConfig = config | 64 | currentConfig = config | ||
| 53 | return nil | 65 | return nil | ||
| @@ -56,17 +68,20 @@ func InitializeClient(config *Config) error { | |||||
| 56 | func CheckIfInitialized() error { | 68 | func CheckIfInitialized() error { | ||
| 57 | if currentConfig == nil || currentConfig.Sock == "" { | 69 | if currentConfig == nil || currentConfig.Sock == "" { | ||
| 58 | logger.Error("Docker client check failed", "error", "client not initialized or configuration missing") | 70 | logger.Error("Docker client check failed", "error", "client not initialized or configuration missing") | ||
| 71 | + | logger.Debug("Docker client initialization failed: configuration is missing or empty") | |||
| 59 | return fmt.Errorf("docker client is not initialized or configuration is missing") | 72 | return fmt.Errorf("docker client is not initialized or configuration is missing") | ||
| 60 | } | 73 | } | ||
| 61 | 74 | ||||
| 62 | socketPath := currentConfig.Sock | 75 | socketPath := currentConfig.Sock | ||
| 63 | if _, err := os.Stat(socketPath); os.IsNotExist(err) { | 76 | if _, err := os.Stat(socketPath); os.IsNotExist(err) { | ||
| 64 | logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath) | 77 | logger.Error("Docker client check failed", "error", "socket file not found", "path", socketPath) | ||
| 78 | + | logger.Debug("Docker client check failed: socket file not found", "path", socketPath) | |||
| 65 | return fmt.Errorf("sock file does not exist: %s", socketPath) | 79 | return fmt.Errorf("sock file does not exist: %s", socketPath) | ||
| 66 | } | 80 | } | ||
| 67 | 81 | ||||
| 68 | if dockerCli == nil { | 82 | if dockerCli == nil { | ||
| 69 | logger.Error("Docker client check failed", "error", "client is nil") | 83 | logger.Error("Docker client check failed", "error", "client is nil") | ||
| 84 | + | logger.Debug("Docker client check failed: client is nil") | |||
| 70 | return fmt.Errorf("docker client is not initialized") | 85 | return fmt.Errorf("docker client is not initialized") | ||
| 71 | } | 86 | } | ||
| 72 | 87 | ||||
| @@ -76,6 +91,7 @@ func CheckIfInitialized() error { | |||||
| 76 | _, err := dockerCli.ContainerList(ctx, container.ListOptions{}) | 91 | _, err := dockerCli.ContainerList(ctx, container.ListOptions{}) | ||
| 77 | if err != nil { | 92 | if err != nil { | ||
| 78 | logger.Error("Docker client check failed", "error", err) | 93 | logger.Error("Docker client check failed", "error", err) | ||
| 94 | + | logger.Debug("Docker client check failed: cannot connect to Docker daemon", "error", err) | |||
| 79 | return fmt.Errorf("cannot connect to Docker daemon: %s", err) | 95 | return fmt.Errorf("cannot connect to Docker daemon: %s", err) | ||
| 80 | } | 96 | } | ||
| 81 | 97 | ||||
M pkg/docker/containers.go +7 -5
| @@ -100,10 +100,7 @@ func ListRunningContainers() ([]types.Container, error) { | |||||
| 100 | if err != nil { | 100 | if err != nil { | ||
| 101 | return nil, err | 101 | return nil, err | ||
| 102 | } | 102 | } | ||
| 103 | - | // Check if the list is empty | 103 | + | // Just return the containers, even if it's an empty slice |
| 104 | - | if len(containers) == 0 { | |||
| 105 | - | return nil, fmt.Errorf("no containers found") | |||
| 106 | - | } | |||
| 107 | return containers, nil | 104 | return containers, nil | ||
| 108 | } | 105 | } | ||
| 109 | 106 | ||||
| @@ -420,6 +417,11 @@ func CheckIfNetworkExists(networkName string) (bool, error) { | |||||
| 420 | 417 | ||||
| 421 | // GetNetworkInfo returns information about a network | 418 | // GetNetworkInfo returns information about a network | ||
| 422 | func GetNetworkInfo(networkName string) (*network.Inspect, error) { | 419 | func GetNetworkInfo(networkName string) (*network.Inspect, error) { | ||
| 420 | + | // Check if Docker client is initialized | |||
| 421 | + | if err := CheckIfInitialized(); err != nil { | |||
| 422 | + | return nil, fmt.Errorf("cannot get network info: %w", err) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 423 | networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{}) | 425 | networkInfo, err := dockerCli.NetworkInspect(context.Background(), networkName, network.InspectOptions{}) | ||
| 424 | if err != nil { | 426 | if err != nil { | ||
| 425 | return nil, err | 427 | return nil, err | ||
| @@ -947,7 +949,7 @@ func ListenForContainerEvents(networkFilter string, startCallback func(string, s | |||||
| 947 | logger.Debug("Container stopped", | 949 | logger.Debug("Container stopped", | ||
| 948 | "container_id", event.id, | 950 | "container_id", event.id, | ||
| 949 | "container_name", event.name) | 951 | "container_name", event.name) | ||
| 950 | - | 952 | + | ||
| 951 | // Call the stop callback if provided | 953 | // Call the stop callback if provided | ||
| 952 | if stopCallback != nil { | 954 | if stopCallback != nil { | ||
| 953 | logger.Debug("Calling container stop callback", | 955 | logger.Debug("Calling container stop callback", | ||
A pkg/scripts/container_diagnostic/diagnostic.sh +171 -0
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | #!/bin/bash | |||
| 2 | + | ||||
| 3 | + | # Gordon Container Diagnostic Script | |||
| 4 | + | # Usage: podman exec -it gordon /bin/sh /diagnostic.sh | |||
| 5 | + | ||||
| 6 | + | echo "----- Gordon System Information -----" | |||
| 7 | + | echo "Date: $(date)" | |||
| 8 | + | echo "Hostname: $(hostname)" | |||
| 9 | + | echo "Container ID: $(hostname)" | |||
| 10 | + | ||||
| 11 | + | echo "----- Network Interfaces -----" | |||
| 12 | + | ip addr show | |||
| 13 | + | ||||
| 14 | + | echo "----- Network Routes -----" | |||
| 15 | + | ip route show | |||
| 16 | + | ||||
| 17 | + | echo "----- Listening Ports -----" | |||
| 18 | + | netstat -tuln | |||
| 19 | + | ||||
| 20 | + | echo "----- Process Listening On Ports -----" | |||
| 21 | + | netstat -tulnp | |||
| 22 | + | ||||
| 23 | + | echo "----- HTTP Server Test -----" | |||
| 24 | + | curl -v http://localhost:8080/admin/ping 2>&1 | grep -v '^{.*}' | |||
| 25 | + | ||||
| 26 | + | echo "----- HTTPS/HTTP Binding Test -----" | |||
| 27 | + | # Try to create listeners on ports 80, 443, and 8080 to test if they're available | |||
| 28 | + | # More reliable port check using timeout | |||
| 29 | + | check_port() { | |||
| 30 | + | local port=$1 | |||
| 31 | + | local service=$2 | |||
| 32 | + | # Try to bind to the port briefly | |||
| 33 | + | timeout 1 bash -c "nc -l -p $port >/dev/null 2>&1" >/dev/null 2>&1 | |||
| 34 | + | if [ $? -eq 124 ] || [ $? -eq 1 ]; then | |||
| 35 | + | # Timeout or error means port is likely in use | |||
| 36 | + | echo "✅ Port $port is in use - $service server is likely running" | |||
| 37 | + | echo "Process using port $port:" | |||
| 38 | + | # Multiple ways to check what's using the port | |||
| 39 | + | lsof -i :$port 2>/dev/null || \ | |||
| 40 | + | ss -tulpn | grep ":$port" 2>/dev/null || \ | |||
| 41 | + | netstat -tulpn | grep ":$port" 2>/dev/null || \ | |||
| 42 | + | ls -l /proc/*/fd/* 2>/dev/null | grep -i "0.0.0.0:$port" | head -5 | |||
| 43 | + | else | |||
| 44 | + | echo "❌ WARNING: Port $port is NOT in use - $service server is not running!" | |||
| 45 | + | # Get the Gordon PID | |||
| 46 | + | GORDON_PID=$(pgrep -f "/gordon serve" || pgrep -f "gordon$" || echo "") | |||
| 47 | + | if [ -n "$GORDON_PID" ]; then | |||
| 48 | + | echo "Gordon process found (PID: $GORDON_PID)" | |||
| 49 | + | ||||
| 50 | + | # Check if anything is preventing binding to the port | |||
| 51 | + | echo "Checking process capabilities:" | |||
| 52 | + | grep Cap /proc/$GORDON_PID/status || echo "Could not check process capabilities" | |||
| 53 | + | ||||
| 54 | + | # Check if the process tried to bind but failed (search logs) | |||
| 55 | + | echo "Checking for binding errors in Gordon logs for port $port:" | |||
| 56 | + | grep -i "error\|fail\|warn" /proc/1/fd/1 | grep -i "server\|listen\|bind\|port $port" | tail -10 || echo "No specific binding errors found in logs" | |||
| 57 | + | ||||
| 58 | + | # Try to get socket information | |||
| 59 | + | echo "Socket status for port $port:" | |||
| 60 | + | ss -tulpen | grep ":$port" || echo "No socket listening on port $port" | |||
| 61 | + | ||||
| 62 | + | # Check port availability externally | |||
| 63 | + | echo "Testing if port can be bound explicitly:" | |||
| 64 | + | timeout 2 nc -l -p $port -v 2>&1 || echo "Could not bind to port $port" | |||
| 65 | + | else | |||
| 66 | + | echo "Gordon process not found" | |||
| 67 | + | fi | |||
| 68 | + | fi | |||
| 69 | + | echo "" | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | # Check main ports | |||
| 73 | + | check_port 80 "HTTP" | |||
| 74 | + | check_port 443 "HTTPS" | |||
| 75 | + | check_port 8080 "Main HTTP" | |||
| 76 | + | ||||
| 77 | + | # Additional detailed diagnostics for HTTPS/TLS | |||
| 78 | + | echo "----- Detailed TLS Server Diagnostics -----" | |||
| 79 | + | # Check for TLS configuration issues | |||
| 80 | + | echo "TLS configuration in logs:" | |||
| 81 | + | grep -i "TLS\|cert\|getCertificate\|fallback" /proc/1/fd/1 | tail -15 || echo "No TLS configuration logs found" | |||
| 82 | + | ||||
| 83 | + | # Check for listener creation logs | |||
| 84 | + | echo "Listener creation logs:" | |||
| 85 | + | grep -i "listener\|starting.*server\|bound to" /proc/1/fd/1 | tail -15 || echo "No listener creation logs found" | |||
| 86 | + | ||||
| 87 | + | # Check for server startup sequence issues | |||
| 88 | + | echo "Server startup sequence:" | |||
| 89 | + | grep -i "starting\|init\|ready\|signal" /proc/1/fd/1 | grep -v "DEBUG" | tail -20 || echo "No server startup sequence logs found" | |||
| 90 | + | ||||
| 91 | + | # Check port binding status in container | |||
| 92 | + | echo "Port mapping configuration:" | |||
| 93 | + | cat /proc/1/environ | tr '\0' '\n' | grep -i "PORT\|GORDON_PROXY" || echo "No port mapping environment variables found" | |||
| 94 | + | ||||
| 95 | + | echo "----- Privileges and Capabilities Check -----" | |||
| 96 | + | # Check process privileges | |||
| 97 | + | echo "Process privileges:" | |||
| 98 | + | id || echo "Could not get process ID" | |||
| 99 | + | ||||
| 100 | + | # Check capabilities | |||
| 101 | + | echo "Process capabilities:" | |||
| 102 | + | if command -v capsh >/dev/null 2>&1; then | |||
| 103 | + | capsh --print || echo "Could not run capsh" | |||
| 104 | + | else | |||
| 105 | + | grep Cap /proc/self/status || echo "Could not check capabilities via proc" | |||
| 106 | + | fi | |||
| 107 | + | ||||
| 108 | + | # Check container security context | |||
| 109 | + | echo "Container security context:" | |||
| 110 | + | if [ -f "/.dockerenv" ] || [ -f "/run/.containerenv" ]; then | |||
| 111 | + | echo "Running in a container" | |||
| 112 | + | ls -la /proc/1/ns/ || echo "Could not check namespaces" | |||
| 113 | + | else | |||
| 114 | + | echo "Not running in a container" | |||
| 115 | + | fi | |||
| 116 | + | ||||
| 117 | + | echo "----- Running Gordon Processes -----" | |||
| 118 | + | ps aux | grep -i gordon | grep -v grep | |||
| 119 | + | ||||
| 120 | + | echo "----- DNS Resolution Test -----" | |||
| 121 | + | # Try to resolve our own domain | |||
| 122 | + | DOMAIN=$(grep GORDON_HTTP_DOMAIN /proc/1/environ | cut -d= -f2 | tr -d '\0') | |||
| 123 | + | SUBDOMAIN=$(grep GORDON_HTTP_SUBDOMAIN /proc/1/environ | cut -d= -f2 | tr -d '\0') | |||
| 124 | + | if [ -n "$DOMAIN" ] && [ -n "$SUBDOMAIN" ]; then | |||
| 125 | + | FQDN="${SUBDOMAIN}.${DOMAIN}" | |||
| 126 | + | echo "Testing DNS resolution for $FQDN" | |||
| 127 | + | dig $FQDN +short | |||
| 128 | + | host $FQDN | |||
| 129 | + | else | |||
| 130 | + | echo "Domain information not found in environment" | |||
| 131 | + | fi | |||
| 132 | + | ||||
| 133 | + | echo "----- TLS Certificate Status -----" | |||
| 134 | + | CERT_DIR=/certs | |||
| 135 | + | if [ -d "$CERT_DIR" ]; then | |||
| 136 | + | echo "Certificate directory contents:" | |||
| 137 | + | ls -la $CERT_DIR | |||
| 138 | + | ||||
| 139 | + | # Check for certificates for our domain | |||
| 140 | + | if [ -n "$FQDN" ]; then | |||
| 141 | + | echo "Looking for certificates for $FQDN:" | |||
| 142 | + | find $CERT_DIR -name "*$FQDN*" -o -name "cert-$FQDN" | |||
| 143 | + | fi | |||
| 144 | + | else | |||
| 145 | + | echo "Certificate directory $CERT_DIR not found" | |||
| 146 | + | fi | |||
| 147 | + | ||||
| 148 | + | echo "----- Gordon HTTPS Server Logs -----" | |||
| 149 | + | echo "Last 20 log entries related to HTTPS server initialization:" | |||
| 150 | + | grep -i "server\|tls\|https\|Certificate\|net\." /proc/1/fd/1 | tail -20 | |||
| 151 | + | ||||
| 152 | + | echo "----- Goroutines Status -----" | |||
| 153 | + | # Check if GOMAXPROCS is set too high | |||
| 154 | + | echo "GOMAXPROCS setting:" | |||
| 155 | + | grep -i "GOMAXPROCS" /proc/1/environ | tr '\0' '\n' || echo "GOMAXPROCS not explicitly set" | |||
| 156 | + | ||||
| 157 | + | # Check the number of threads used by gordon | |||
| 158 | + | echo "Number of threads used by Gordon process:" | |||
| 159 | + | pid=$(pgrep -f "^/gordon") | |||
| 160 | + | if [ -n "$pid" ]; then | |||
| 161 | + | ls -l /proc/$pid/task | wc -l | |||
| 162 | + | else | |||
| 163 | + | echo "Gordon process not found" | |||
| 164 | + | fi | |||
| 165 | + | ||||
| 166 | + | echo "----- TCP Connection Status -----" | |||
| 167 | + | # Check existing TCP connections and states | |||
| 168 | + | echo "TCP connection states:" | |||
| 169 | + | netstat -nat | awk '{print $6}' | sort | uniq -c | |||
| 170 | + | ||||
| 171 | + | echo "----- End of Diagnostic -----" ⏎̸ | |||
M tailwind.config.js +39 -0
| @@ -14,7 +14,46 @@ module.exports = { | |||||
| 14 | lbeige: "#FFDCB3", | 14 | lbeige: "#FFDCB3", | ||
| 15 | mbeige: "#D7B49F", | 15 | mbeige: "#D7B49F", | ||
| 16 | }, | 16 | }, | ||
| 17 | + | // Custom color scheme | |||
| 18 | + | indigo: { | |||
| 19 | + | 50: "#eef2ff", | |||
| 20 | + | 100: "#e0e7ff", | |||
| 21 | + | 200: "#c7d2fe", | |||
| 22 | + | 300: "#a5b4fc", | |||
| 23 | + | 400: "#818cf8", | |||
| 24 | + | 500: "#6366f1", | |||
| 25 | + | 600: "#4f46e5", | |||
| 26 | + | 700: "#4338ca", | |||
| 27 | + | 800: "#3730a3", | |||
| 28 | + | 900: "#312e81", | |||
| 29 | + | 950: "#1e1b4b", | |||
| 30 | + | }, | |||
| 31 | + | red: { | |||
| 32 | + | 50: "#fef2f2", | |||
| 33 | + | 100: "#fee2e2", | |||
| 34 | + | 200: "#fecaca", | |||
| 35 | + | 300: "#fca5a5", | |||
| 36 | + | 400: "#f87171", | |||
| 37 | + | 500: "#ef4444", | |||
| 38 | + | 600: "#dc2626", | |||
| 39 | + | 700: "#b91c1c", | |||
| 40 | + | 800: "#991b1b", | |||
| 41 | + | 900: { | |||
| 42 | + | DEFAULT: "#7f1d1d", | |||
| 43 | + | 20: "rgba(127, 29, 29, 0.2)", | |||
| 44 | + | }, | |||
| 45 | + | 950: "#450a0a", | |||
| 46 | + | }, | |||
| 17 | }, | 47 | }, | ||
| 18 | }, | 48 | }, | ||
| 19 | }, | 49 | }, | ||
| 50 | + | plugins: [require("daisyui")], | |||
| 51 | + | daisyui: { | |||
| 52 | + | themes: ["light", "dark", "gordon", "gordon-dark"], | |||
| 53 | + | darkTheme: "gordon-dark", | |||
| 54 | + | base: true, | |||
| 55 | + | styled: true, | |||
| 56 | + | utils: true, | |||
| 57 | + | logs: true, | |||
| 58 | + | }, | |||
| 20 | }; | 59 | }; | ||
A tools.go +7 -0
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | // +build tools | |||
| 2 | + | ||||
| 3 | + | package tools | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | _ "github.com/a-h/templ/cmd/templ" | |||
| 7 | + | )⏎̸ | |||