chore: Update dependencies and refactor token cache handling
- Upgrade `prettier` to version 3.5.3 and `tailwindcss` to version 3.4.17 in package.json - Refactor token cache to use the new `db.GithubUserInfo` type instead of `queries.GithubUserInfo` - Enhance deployment command to include a `skipProxySetup` flag for optional proxy setup - Improve error handling and logging in various components, including server shutdown and session management - Add support for skipping proxy setup during container creation and deployment
- commit
- ddc5793bae6a14387af67d28898a66d577eaf6b8
- author
- bamen · · 2025-03-13 06:35 UTC
- parent
- 90b5233
- signature
- unsigned
35 files · +2512 -1647
M internal/cli/auth/token_cache.go +4 -4
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "sync" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 7 | + | "github.com/bnema/gordon/internal/db" | |
| 8 | 8 | "github.com/charmbracelet/log" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| @@ -15,7 +15,7 @@ type TokenCache struct { | |||
| 15 | 15 | type cacheEntry struct { | |
| 16 | 16 | token string | |
| 17 | 17 | expiration time.Time | |
| 18 | - | githubUser *queries.GithubUserInfo | |
| 18 | + | githubUser *db.GithubUserInfo | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | var ( | |
| @@ -33,7 +33,7 @@ func GetTokenCache() *TokenCache { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // GetWithUser retrieves a token and GitHub user info from the cache | |
| 36 | - | func (tc *TokenCache) GetWithUser(key string) (string, *queries.GithubUserInfo, bool) { | |
| 36 | + | func (tc *TokenCache) GetWithUser(key string) (string, *db.GithubUserInfo, bool) { | |
| 37 | 37 | if value, ok := tc.cache.Load(key); ok { | |
| 38 | 38 | entry := value.(cacheEntry) | |
| 39 | 39 | if time.Now().Before(entry.expiration) { | |
| @@ -63,7 +63,7 @@ func (tc *TokenCache) Get(key string) (bool, bool) { | |||
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | 65 | // SetWithUser stores a token and GitHub user info in the cache | |
| 66 | - | func (tc *TokenCache) SetWithUser(key string, token string, user *queries.GithubUserInfo, duration time.Duration) { | |
| 66 | + | func (tc *TokenCache) SetWithUser(key string, token string, user *db.GithubUserInfo, duration time.Duration) { | |
| 67 | 67 | tc.cache.Store(key, cacheEntry{ | |
| 68 | 68 | token: token, | |
| 69 | 69 | githubUser: user, | |
M internal/cli/cmd/deploy.go +20 -3
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | func NewDeployCommand(a *cli.App) *cobra.Command { | |
| 26 | 26 | var port string | |
| 27 | 27 | var targetDomain string | |
| 28 | + | var skipProxySetup bool | |
| 28 | 29 | ||
| 29 | 30 | deployCmd := &cobra.Command{ | |
| 30 | 31 | Use: "deploy [image:tag]", | |
| @@ -56,7 +57,7 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 56 | 57 | ||
| 57 | 58 | logger.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB)) | |
| 58 | 59 | ||
| 59 | - | if err := deployImage(a, reader, imageName, port, targetDomain); err != nil { | |
| 60 | + | if err := deployImage(a, reader, imageName, port, targetDomain, skipProxySetup); err != nil { | |
| 60 | 61 | os.Exit(1) | |
| 61 | 62 | } | |
| 62 | 63 | }, | |
| @@ -64,11 +65,12 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 64 | 65 | ||
| 65 | 66 | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to") | |
| 66 | 67 | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for the proxy") | |
| 68 | + | deployCmd.Flags().BoolVar(&skipProxySetup, "skip-proxy", false, "Skip proxy setup (create container only)") | |
| 67 | 69 | ||
| 68 | 70 | return deployCmd | |
| 69 | 71 | } | |
| 70 | 72 | ||
| 71 | - | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string) error { | |
| 73 | + | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string, skipProxySetup bool) error { | |
| 72 | 74 | // Check authentication early | |
| 73 | 75 | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 74 | 76 | logger.Error("Authentication check failed", "error", err) | |
| @@ -128,6 +130,12 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||
| 128 | 130 | "Content-Type": {"application/octet-stream"}, | |
| 129 | 131 | } | |
| 130 | 132 | ||
| 133 | + | // Add skip proxy setup header if enabled | |
| 134 | + | if skipProxySetup { | |
| 135 | + | headers.Add("X-Skip-Proxy-Setup", "true") | |
| 136 | + | logger.Info("Skipping proxy setup as requested") | |
| 137 | + | } | |
| 138 | + | ||
| 131 | 139 | chunkedClient := handler.NewChunkedClient(a) | |
| 132 | 140 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | |
| 133 | 141 | defer cancel() | |
| @@ -150,13 +158,22 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||
| 150 | 158 | } | |
| 151 | 159 | ||
| 152 | 160 | func waitForDeployment(domain string, containerID string) error { | |
| 161 | + | // If domain is empty, it means proxy setup was skipped | |
| 162 | + | if domain == "" { | |
| 163 | + | logger.Info("Deployment successful (proxy setup skipped)", | |
| 164 | + | "container_id", containerID) | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | ||
| 153 | 168 | client := &http.Client{Timeout: 10 * time.Second} | |
| 154 | 169 | maxRetries := 10 | |
| 155 | 170 | retryInterval := time.Second | |
| 156 | 171 | ||
| 157 | 172 | var shortContainerID string | |
| 158 | - | if containerID != "" { | |
| 173 | + | if containerID != "" && len(containerID) >= 12 { | |
| 159 | 174 | shortContainerID = containerID[:12] | |
| 175 | + | } else { | |
| 176 | + | shortContainerID = containerID | |
| 160 | 177 | } | |
| 161 | 178 | ||
| 162 | 179 | logger.Info("Waiting for deployment to be reachable", | |
M internal/cli/cmd/proxy.go +59 -59
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/gordon/internal/cli/handler" | |
| 7 | 8 | "github.com/bnema/gordon/internal/proxy" | |
| 8 | 9 | "github.com/bnema/gordon/internal/server" | |
| 9 | 10 | "github.com/bnema/gordon/pkg/docker" | |
| @@ -36,6 +37,9 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 36 | 37 | log.Fatal("Failed to initialize database:", err) | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 40 | + | // Register the server instance for global signal handling | |
| 41 | + | handler.SetGlobalServer(a) | |
| 42 | + | ||
| 39 | 43 | // Create a new proxy instance | |
| 40 | 44 | p, err := proxy.NewProxy(a) | |
| 41 | 45 | if err != nil { | |
| @@ -44,91 +48,83 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 44 | 48 | ||
| 45 | 49 | // If update flag is set, update a container's IP address | |
| 46 | 50 | if update { | |
| 47 | - | if domain == "" { | |
| 48 | - | log.Fatal("Domain is required for updating a route IP") | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | if containerIP == "" { | |
| 52 | - | log.Fatal("Container IP is required for updating a route IP") | |
| 51 | + | if containerID == "" || containerIP == "" { | |
| 52 | + | log.Fatal("Container ID and IP address are required for update") | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | - | // If force flag is not set, try to get the IP from the container ID | |
| 56 | - | if !force && containerID != "" { | |
| 57 | - | // Get container info to verify the IP | |
| 58 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 59 | - | if err != nil { | |
| 60 | - | log.Warn("Failed to get container info, using provided IP", "error", err) | |
| 61 | - | } else { | |
| 62 | - | // Look for the network specified in the config | |
| 63 | - | networkName := a.Config.ContainerEngine.Network | |
| 64 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |
| 65 | - | if networkSettings.IPAddress != containerIP { | |
| 66 | - | log.Warn("Provided IP doesn't match container's actual IP", | |
| 67 | - | "provided", containerIP, | |
| 68 | - | "actual", networkSettings.IPAddress) | |
| 69 | - | if !force { | |
| 70 | - | log.Info("Using actual container IP instead of provided IP", | |
| 71 | - | "container_id", containerID, | |
| 72 | - | "new_ip", networkSettings.IPAddress) | |
| 73 | - | containerIP = networkSettings.IPAddress | |
| 74 | - | } else { | |
| 75 | - | log.Warn("Force flag is set, using provided IP despite mismatch") | |
| 76 | - | } | |
| 55 | + | // Get container info to verify the IP | |
| 56 | + | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 57 | + | if err != nil { | |
| 58 | + | log.Warn("Failed to get container info, using provided IP", "error", err) | |
| 59 | + | } else { | |
| 60 | + | // Look for the network specified in the config | |
| 61 | + | networkName := a.Config.ContainerEngine.Network | |
| 62 | + | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |
| 63 | + | if networkSettings.IPAddress != containerIP { | |
| 64 | + | log.Warn("Provided IP doesn't match container's actual IP", | |
| 65 | + | "provided", containerIP, | |
| 66 | + | "actual", networkSettings.IPAddress) | |
| 67 | + | if !force { | |
| 68 | + | log.Info("Using actual container IP instead of provided IP", | |
| 69 | + | "container_id", containerID, | |
| 70 | + | "new_ip", networkSettings.IPAddress) | |
| 71 | + | containerIP = networkSettings.IPAddress | |
| 72 | + | } else { | |
| 73 | + | log.Warn("Force flag is set, using provided IP despite mismatch") | |
| 77 | 74 | } | |
| 78 | 75 | } | |
| 79 | 76 | } | |
| 80 | 77 | } | |
| 81 | 78 | ||
| 82 | 79 | // Update the route | |
| 83 | - | err := p.ForceUpdateRouteIP(domain, containerIP) | |
| 80 | + | err = p.ForceUpdateRouteIP(domain, containerIP) | |
| 84 | 81 | if err != nil { | |
| 85 | 82 | log.Fatal("Failed to update route IP:", err) | |
| 86 | 83 | } | |
| 87 | 84 | ||
| 88 | - | log.Info("Route IP updated successfully", "domain", domain, "ip", containerIP) | |
| 85 | + | log.Info("Route IP updated successfully") | |
| 86 | + | ||
| 87 | + | // Close the database connection | |
| 88 | + | if err := a.Shutdown(); err != nil { | |
| 89 | + | log.Error("Error closing database", "error", err) | |
| 90 | + | } | |
| 89 | 91 | return | |
| 90 | 92 | } | |
| 91 | 93 | ||
| 92 | - | // If remove flag is set, remove the route | |
| 94 | + | // If remove flag is set, remove a route | |
| 93 | 95 | if remove { | |
| 94 | 96 | if domain == "" { | |
| 95 | - | log.Fatal("Domain is required for removing a route") | |
| 97 | + | log.Fatal("Domain is required for removal") | |
| 96 | 98 | } | |
| 97 | - | ||
| 98 | 99 | if err := p.RemoveRoute(domain); err != nil { | |
| 99 | 100 | log.Fatal("Failed to remove route:", err) | |
| 100 | 101 | } | |
| 102 | + | log.Info("Route removed successfully") | |
| 101 | 103 | ||
| 102 | - | log.Info("Route removed successfully", "domain", domain) | |
| 104 | + | // Close the database connection | |
| 105 | + | if err := a.Shutdown(); err != nil { | |
| 106 | + | log.Error("Error closing database", "error", err) | |
| 107 | + | } | |
| 103 | 108 | return | |
| 104 | 109 | } | |
| 105 | 110 | ||
| 106 | - | // If domain is set, add a new route | |
| 107 | - | if domain != "" { | |
| 108 | - | // Validate required fields | |
| 109 | - | if containerID == "" || containerIP == "" || containerPort == "" { | |
| 110 | - | log.Fatal("Container ID, IP, and port are required for adding a route") | |
| 111 | + | // If domain and containerID are provided, add a route | |
| 112 | + | if domain != "" && containerID != "" { | |
| 113 | + | if containerPort == "" { | |
| 114 | + | log.Fatal("Container port is required") | |
| 111 | 115 | } | |
| 112 | - | ||
| 113 | - | // Set defaults for optional fields | |
| 114 | 116 | if protocol == "" { | |
| 115 | 117 | protocol = "http" | |
| 116 | 118 | } | |
| 117 | - | ||
| 118 | - | if path == "" { | |
| 119 | - | path = "/" | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // Add the route | |
| 123 | 119 | if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil { | |
| 124 | 120 | log.Fatal("Failed to add route:", err) | |
| 125 | 121 | } | |
| 122 | + | log.Info("Route added successfully") | |
| 126 | 123 | ||
| 127 | - | log.Info("Route added successfully", | |
| 128 | - | "domain", domain, | |
| 129 | - | "containerIP", containerIP, | |
| 130 | - | "containerPort", containerPort, | |
| 131 | - | ) | |
| 124 | + | // Close the database connection | |
| 125 | + | if err := a.Shutdown(); err != nil { | |
| 126 | + | log.Error("Error closing database", "error", err) | |
| 127 | + | } | |
| 132 | 128 | return | |
| 133 | 129 | } | |
| 134 | 130 | ||
| @@ -179,19 +175,23 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 179 | 175 | ||
| 180 | 176 | fmt.Println(strings.Repeat("-", 75)) | |
| 181 | 177 | fmt.Printf("Total routes: %d\n", count) | |
| 178 | + | ||
| 179 | + | // Close the database connection | |
| 180 | + | if err := a.Shutdown(); err != nil { | |
| 181 | + | log.Error("Error closing database", "error", err) | |
| 182 | + | } | |
| 182 | 183 | }, | |
| 183 | 184 | } | |
| 184 | 185 | ||
| 185 | - | // Add flags | |
| 186 | - | proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name for the route") | |
| 186 | + | proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name") | |
| 187 | 187 | proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID") | |
| 188 | 188 | proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address") | |
| 189 | 189 | proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port") | |
| 190 | - | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | |
| 191 | - | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | |
| 192 | - | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain") | |
| 193 | - | proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update the IP address for an existing route") | |
| 194 | - | proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force update with provided IP even if it doesn't match container's actual IP") | |
| 190 | + | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "", "Protocol (http or https)") | |
| 191 | + | proxyCmd.Flags().StringVarP(&path, "path", "a", "", "Path prefix") | |
| 192 | + | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove a route") | |
| 193 | + | proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update a container's IP address") | |
| 194 | + | proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force removal of a route") | |
| 195 | 195 | ||
| 196 | 196 | return proxyCmd | |
| 197 | 197 | } | |
M internal/cli/cmd/serve.go +6 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/gordon/internal/common" | |
| 10 | 10 | "github.com/bnema/gordon/internal/server" | |
| 11 | 11 | "github.com/bnema/gordon/pkg/docker" | |
| 12 | + | "github.com/bnema/gordon/pkg/logger" | |
| 12 | 13 | "github.com/spf13/cobra" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -40,6 +41,11 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||
| 40 | 41 | a.Config.Http.Port = port | |
| 41 | 42 | err := handler.StartServer(a, port) | |
| 42 | 43 | if err != nil { | |
| 44 | + | logger.Error("Server error", "error", err) | |
| 45 | + | // Ensure database is closed on error | |
| 46 | + | if err := a.Shutdown(); err != nil { | |
| 47 | + | logger.Error("Error during shutdown after server error", "error", err) | |
| 48 | + | } | |
| 43 | 49 | panic(err) | |
| 44 | 50 | } | |
| 45 | 51 | }, | |
M internal/cli/handler/start_server.go +63 -20
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package handler | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "os" | |
| 6 | 7 | "os/signal" | |
| 8 | + | "sync" | |
| 7 | 9 | "syscall" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| @@ -13,9 +15,32 @@ import ( | |||
| 13 | 15 | "github.com/labstack/echo/v4" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 18 | + | // Global server instance for shutdown handling | |
| 19 | + | var ( | |
| 20 | + | globalServer *server.App | |
| 21 | + | globalServerLock sync.Mutex | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // SetGlobalServer sets the global server instance for shutdown handling | |
| 25 | + | func SetGlobalServer(s *server.App) { | |
| 26 | + | globalServerLock.Lock() | |
| 27 | + | defer globalServerLock.Unlock() | |
| 28 | + | globalServer = s | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // GetGlobalServer gets the global server instance | |
| 32 | + | func GetGlobalServer() *server.App { | |
| 33 | + | globalServerLock.Lock() | |
| 34 | + | defer globalServerLock.Unlock() | |
| 35 | + | return globalServer | |
| 36 | + | } | |
| 37 | + | ||
| 16 | 38 | // execute cmd/srv/main.go main function | |
| 17 | 39 | ||
| 18 | 40 | func StartServer(a *server.App, port string) error { | |
| 41 | + | // Set the global server instance for shutdown handling | |
| 42 | + | SetGlobalServer(a) | |
| 43 | + | ||
| 19 | 44 | _, err := server.InitializeDB(a) | |
| 20 | 45 | if err != nil { | |
| 21 | 46 | logger.Fatal("Failed to initialize database:", err) | |
| @@ -36,33 +61,27 @@ func StartServer(a *server.App, port string) error { | |||
| 36 | 61 | // Continue even if proxy fails, as the main server should still work | |
| 37 | 62 | } | |
| 38 | 63 | ||
| 64 | + | e := echo.New() | |
| 65 | + | e.Server.ReadTimeout = 10 * time.Minute | |
| 66 | + | e.Server.WriteTimeout = 10 * time.Minute | |
| 67 | + | e.HideBanner = true | |
| 68 | + | e.HidePort = true | |
| 69 | + | e = httpserve.RegisterRoutes(e, a) | |
| 70 | + | ||
| 39 | 71 | // Setup a channel to capture termination signals | |
| 40 | 72 | sigs := make(chan os.Signal, 1) | |
| 41 | 73 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | |
| 42 | 74 | ||
| 75 | + | // Start server in a goroutine | |
| 43 | 76 | go func() { | |
| 44 | - | sig := <-sigs | |
| 45 | - | logger.Info("Received signal", "signal", sig) | |
| 46 | - | ||
| 47 | - | // Stop the proxy if it was started | |
| 48 | - | if p != nil { | |
| 49 | - | if err := p.Stop(); err != nil { | |
| 50 | - | logger.Error("Failed to stop reverse proxy", "error", err) | |
| 77 | + | logger.Info("Starting server", "port", port) | |
| 78 | + | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | |
| 79 | + | if err.Error() != "http: Server closed" { | |
| 80 | + | logger.Error("Server error", "error", err) | |
| 51 | 81 | } | |
| 52 | 82 | } | |
| 53 | - | ||
| 54 | - | os.Exit(0) | |
| 55 | 83 | }() | |
| 56 | 84 | ||
| 57 | - | e := echo.New() | |
| 58 | - | e.Server.ReadTimeout = 10 * time.Minute | |
| 59 | - | e.Server.WriteTimeout = 10 * time.Minute | |
| 60 | - | e.HideBanner = true | |
| 61 | - | e.HidePort = true | |
| 62 | - | e = httpserve.RegisterRoutes(e, a) | |
| 63 | - | ||
| 64 | - | logger.Info("Starting server", "port", port) | |
| 65 | - | ||
| 66 | 85 | // Test admin connections after the server has started (in a separate goroutine) | |
| 67 | 86 | if p != nil { | |
| 68 | 87 | go func() { | |
| @@ -72,9 +91,33 @@ func StartServer(a *server.App, port string) error { | |||
| 72 | 91 | }() | |
| 73 | 92 | } | |
| 74 | 93 | ||
| 75 | - | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | |
| 76 | - | logger.Fatal("Server error", "error", err) | |
| 94 | + | // Wait for interrupt signal | |
| 95 | + | <-sigs | |
| 96 | + | logger.Info("Received shutdown signal") | |
| 97 | + | ||
| 98 | + | // Create a deadline for graceful shutdown | |
| 99 | + | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 100 | + | defer cancel() | |
| 101 | + | ||
| 102 | + | // Gracefully shutdown the server | |
| 103 | + | logger.Info("Shutting down server...") | |
| 104 | + | if err := e.Shutdown(ctx); err != nil { | |
| 105 | + | logger.Error("Server shutdown error", "error", err) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Perform application shutdown (including database closure) | |
| 109 | + | if err := a.Shutdown(); err != nil { | |
| 110 | + | logger.Error("Application shutdown error", "error", err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Stop the proxy if it was started | |
| 114 | + | if p != nil { | |
| 115 | + | logger.Info("Stopping reverse proxy...") | |
| 116 | + | if err := p.Stop(); err != nil { | |
| 117 | + | logger.Error("Failed to stop reverse proxy", "error", err) | |
| 118 | + | } | |
| 77 | 119 | } | |
| 78 | 120 | ||
| 121 | + | logger.Info("Shutdown complete") | |
| 79 | 122 | return nil | |
| 80 | 123 | } | |
M internal/common/init_config.go +60 -30
| @@ -73,16 +73,17 @@ type ContainerEngineConfig struct { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | type ReverseProxyConfig struct { | |
| 76 | - | Port string `yaml:"port"` // Port for the reverse proxy to listen on | |
| 77 | - | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | |
| 78 | - | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | |
| 79 | - | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | |
| 80 | - | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |
| 81 | - | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | |
| 82 | - | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 83 | - | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |
| 84 | - | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |
| 85 | - | EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true) | |
| 76 | + | Port string `yaml:"port"` // Port for the reverse proxy to listen on | |
| 77 | + | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | |
| 78 | + | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | |
| 79 | + | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | |
| 80 | + | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |
| 81 | + | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | |
| 82 | + | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 83 | + | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |
| 84 | + | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |
| 85 | + | EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true) | |
| 86 | + | DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false) | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | // Default values | |
| @@ -98,6 +99,7 @@ var ( | |||
| 98 | 99 | cacheSize = 1000 // entries | |
| 99 | 100 | gracePeriod = 30 // seconds | |
| 100 | 101 | defaultLogLevel = "info" // Default log level | |
| 102 | + | disableRateLimit = false | |
| 101 | 103 | ) | |
| 102 | 104 | ||
| 103 | 105 | // applyDefaultsToConfig applies default values to any fields that have zero values | |
| @@ -154,6 +156,12 @@ func applyDefaultsToConfig(config *Config) bool { | |||
| 154 | 156 | logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true) | |
| 155 | 157 | defaultsApplied = true | |
| 156 | 158 | } | |
| 159 | + | // Set DisableRateLimit to false by default if not specified | |
| 160 | + | if !config.ReverseProxy.DisableRateLimit { | |
| 161 | + | config.ReverseProxy.DisableRateLimit = false | |
| 162 | + | logger.Debug("Applied default value for ReverseProxy.DisableRateLimit", "value", false) | |
| 163 | + | defaultsApplied = true | |
| 164 | + | } | |
| 157 | 165 | ||
| 158 | 166 | // Handle autoRenew | |
| 159 | 167 | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| @@ -524,6 +532,15 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||
| 524 | 532 | } | |
| 525 | 533 | logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs) | |
| 526 | 534 | } | |
| 535 | + | ||
| 536 | + | // Handle GORDON_PROXY_DISABLE_RATE_LIMIT environment variable | |
| 537 | + | if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" { | |
| 538 | + | disableRateLimit, err := strconv.ParseBool(val) | |
| 539 | + | if err == nil { | |
| 540 | + | config.ReverseProxy.DisableRateLimit = disableRateLimit | |
| 541 | + | } | |
| 542 | + | logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT", "value", config.ReverseProxy.DisableRateLimit) | |
| 543 | + | } | |
| 527 | 544 | } | |
| 528 | 545 | ||
| 529 | 546 | func (config *Config) LoadConfig() (*Config, error) { | |
| @@ -570,15 +587,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 570 | 587 | Network: "gordon", | |
| 571 | 588 | }, | |
| 572 | 589 | ReverseProxy: ReverseProxyConfig{ | |
| 573 | - | Port: reverseProxyPort, | |
| 574 | - | HttpPort: httpPort, | |
| 575 | - | CertDir: certDir, | |
| 576 | - | AutoRenew: autoRenew, | |
| 577 | - | RenewBefore: renewBefore, | |
| 578 | - | LetsEncryptMode: letsEncryptMode, | |
| 579 | - | CacheSize: cacheSize, | |
| 580 | - | GracePeriod: gracePeriod, | |
| 581 | - | EnableLogs: true, | |
| 590 | + | Port: reverseProxyPort, | |
| 591 | + | HttpPort: httpPort, | |
| 592 | + | CertDir: certDir, | |
| 593 | + | AutoRenew: autoRenew, | |
| 594 | + | RenewBefore: renewBefore, | |
| 595 | + | LetsEncryptMode: letsEncryptMode, | |
| 596 | + | CacheSize: cacheSize, | |
| 597 | + | GracePeriod: gracePeriod, | |
| 598 | + | EnableLogs: true, | |
| 599 | + | DisableRateLimit: false, | |
| 582 | 600 | }, | |
| 583 | 601 | } | |
| 584 | 602 | ||
| @@ -708,15 +726,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 708 | 726 | Network: "gordon", | |
| 709 | 727 | }, | |
| 710 | 728 | ReverseProxy: ReverseProxyConfig{ | |
| 711 | - | Port: reverseProxyPort, | |
| 712 | - | HttpPort: httpPort, | |
| 713 | - | CertDir: certDir, | |
| 714 | - | AutoRenew: autoRenew, | |
| 715 | - | RenewBefore: renewBefore, | |
| 716 | - | LetsEncryptMode: letsEncryptMode, | |
| 717 | - | CacheSize: cacheSize, | |
| 718 | - | GracePeriod: gracePeriod, | |
| 719 | - | EnableLogs: true, | |
| 729 | + | Port: reverseProxyPort, | |
| 730 | + | HttpPort: httpPort, | |
| 731 | + | CertDir: certDir, | |
| 732 | + | AutoRenew: autoRenew, | |
| 733 | + | RenewBefore: renewBefore, | |
| 734 | + | LetsEncryptMode: letsEncryptMode, | |
| 735 | + | CacheSize: cacheSize, | |
| 736 | + | GracePeriod: gracePeriod, | |
| 737 | + | EnableLogs: true, | |
| 738 | + | DisableRateLimit: false, | |
| 720 | 739 | }, | |
| 721 | 740 | } | |
| 722 | 741 | ||
| @@ -890,10 +909,21 @@ func (c *HttpConfig) Protocol() string { | |||
| 890 | 909 | } | |
| 891 | 910 | ||
| 892 | 911 | func (c *HttpConfig) FullDomain() string { | |
| 912 | + | domain := c.Domain | |
| 913 | + | ||
| 914 | + | // Clean the domain to ensure it doesn't contain path components | |
| 915 | + | domain = strings.TrimSuffix(domain, "/") | |
| 916 | + | if idx := strings.Index(domain, "/"); idx >= 0 { | |
| 917 | + | // If there's a path component, remove it | |
| 918 | + | domain = domain[:idx] | |
| 919 | + | } | |
| 920 | + | ||
| 893 | 921 | if c.SubDomain != "" { | |
| 894 | - | return fmt.Sprintf("%s.%s", c.SubDomain, c.Domain) | |
| 922 | + | // Clean the subdomain too | |
| 923 | + | subdomain := strings.TrimSuffix(c.SubDomain, "/") | |
| 924 | + | return fmt.Sprintf("%s.%s", subdomain, domain) | |
| 895 | 925 | } | |
| 896 | - | return c.Domain | |
| 926 | + | return domain | |
| 897 | 927 | } | |
| 898 | 928 | ||
| 899 | 929 | // GetVersion | |
M internal/common/payload.go +6 -5
| @@ -55,11 +55,12 @@ type StartResponse struct { | |||
| 55 | 55 | ||
| 56 | 56 | // DeployPayload represents the payload for deployment requests | |
| 57 | 57 | type DeployPayload struct { | |
| 58 | - | Port string `json:"port"` | |
| 59 | - | TargetDomain string `json:"targetdomain"` | |
| 60 | - | ImageName string `json:"imagename"` | |
| 61 | - | ImageID string `json:"imageid"` | |
| 62 | - | Data io.ReadCloser | |
| 58 | + | Port string `json:"port"` | |
| 59 | + | TargetDomain string `json:"targetdomain"` | |
| 60 | + | ImageName string `json:"imagename"` | |
| 61 | + | ImageID string `json:"imageid"` | |
| 62 | + | SkipProxySetup bool `json:"skip_proxy_setup"` | |
| 63 | + | Data io.ReadCloser | |
| 63 | 64 | } | |
| 64 | 65 | ||
| 65 | 66 | // PushPayload represents the payload for push requests | |
M internal/db/db_struct.go +9 -0
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | package db | |
| 2 | 2 | ||
| 3 | + | // User struct model | |
| 3 | 4 | type User struct { | |
| 4 | 5 | ID string `sql:"id, primary_key"` | |
| 5 | 6 | Name string `sql:"name"` | |
| @@ -77,3 +78,11 @@ type ProxyRoute struct { | |||
| 77 | 78 | CreatedAt string `sql:"created_at"` | |
| 78 | 79 | UpdatedAt string `sql:"updated_at"` | |
| 79 | 80 | } | |
| 81 | + | ||
| 82 | + | // GithubUserInfo holds the essential information for a Github user | |
| 83 | + | type GithubUserInfo struct { | |
| 84 | + | Login string `json:"login"` | |
| 85 | + | AvatarURL string `json:"avatar_url"` | |
| 86 | + | ProfileURL string `json:"html_url"` | |
| 87 | + | Emails []string | |
| 88 | + | } | |
A internal/db/helpers.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package db | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "database/sql" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/pkg/logger" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ExecWithRetry performs a database execution with retry logic for handling locked database | |
| 12 | + | func ExecWithRetry(db *sql.DB, query string, args ...interface{}) (sql.Result, error) { | |
| 13 | + | var result sql.Result | |
| 14 | + | var err error | |
| 15 | + | maxRetries := 10 | |
| 16 | + | retryDelay := 200 * time.Millisecond | |
| 17 | + | ||
| 18 | + | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 19 | + | result, err = db.Exec(query, args...) | |
| 20 | + | if err == nil { | |
| 21 | + | return result, nil | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Check if this is a database locked error | |
| 25 | + | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 26 | + | logger.Debug("Database locked, retrying operation", | |
| 27 | + | "attempt", attempt, | |
| 28 | + | "max_retries", maxRetries, | |
| 29 | + | "query", query) | |
| 30 | + | time.Sleep(retryDelay) | |
| 31 | + | // Increase delay for next retry (exponential backoff) | |
| 32 | + | retryDelay *= 2 | |
| 33 | + | continue | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // If it's not a locking error, return immediately | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // QueryWithRetry performs a database query with retry logic for handling locked database | |
| 44 | + | func QueryWithRetry(db *sql.DB, query string, args ...interface{}) (*sql.Rows, error) { | |
| 45 | + | var rows *sql.Rows | |
| 46 | + | var err error | |
| 47 | + | maxRetries := 10 | |
| 48 | + | retryDelay := 200 * time.Millisecond | |
| 49 | + | ||
| 50 | + | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 51 | + | rows, err = db.Query(query, args...) | |
| 52 | + | if err == nil { | |
| 53 | + | return rows, nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Check if this is a database locked error | |
| 57 | + | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 58 | + | logger.Debug("Database locked, retrying operation", | |
| 59 | + | "attempt", attempt, | |
| 60 | + | "max_retries", maxRetries, | |
| 61 | + | "query", query) | |
| 62 | + | time.Sleep(retryDelay) | |
| 63 | + | // Increase delay for next retry (exponential backoff) | |
| 64 | + | retryDelay *= 2 | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // If it's not a locking error, return immediately | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // BeginWithRetry starts a transaction with retry logic for handling locked database | |
| 76 | + | func BeginWithRetry(db *sql.DB) (*sql.Tx, error) { | |
| 77 | + | var tx *sql.Tx | |
| 78 | + | var err error | |
| 79 | + | maxRetries := 10 | |
| 80 | + | retryDelay := 200 * time.Millisecond | |
| 81 | + | ||
| 82 | + | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 83 | + | tx, err = db.Begin() | |
| 84 | + | if err == nil { | |
| 85 | + | return tx, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Check if this is a database locked error | |
| 89 | + | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 90 | + | logger.Debug("Database locked, retrying transaction start", | |
| 91 | + | "attempt", attempt, | |
| 92 | + | "max_retries", maxRetries) | |
| 93 | + | time.Sleep(retryDelay) | |
| 94 | + | // Increase delay for next retry (exponential backoff) | |
| 95 | + | retryDelay *= 2 | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // If it's not a locking error, return immediately | |
| 100 | + | return nil, err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // TxExecWithRetry executes a query within a transaction with retry logic | |
| 107 | + | func TxExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) { | |
| 108 | + | var result sql.Result | |
| 109 | + | var err error | |
| 110 | + | maxRetries := 10 | |
| 111 | + | retryDelay := 200 * time.Millisecond | |
| 112 | + | ||
| 113 | + | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 114 | + | result, err = tx.Exec(query, args...) | |
| 115 | + | if err == nil { | |
| 116 | + | return result, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Check if this is a database locked error | |
| 120 | + | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 121 | + | logger.Debug("Database locked, retrying transaction operation", | |
| 122 | + | "attempt", attempt, | |
| 123 | + | "max_retries", maxRetries, | |
| 124 | + | "query", query) | |
| 125 | + | time.Sleep(retryDelay) | |
| 126 | + | // Increase delay for next retry (exponential backoff) | |
| 127 | + | retryDelay *= 2 | |
| 128 | + | continue | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // If it's not a locking error, return immediately | |
| 132 | + | return nil, err | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 136 | + | } | |
M internal/db/queries/account.go +48 -24
| @@ -1,47 +1,71 @@ | |||
| 1 | 1 | package queries | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "database/sql" | |
| 4 | 5 | "github.com/bnema/gordon/internal/db" | |
| 5 | - | "github.com/bnema/gordon/internal/server" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | func createDBAccount(a *server.App) (*db.Account, error) { | |
| 8 | + | // AccountQueries contains all SQL queries for account operations | |
| 9 | + | type AccountQueries struct { | |
| 10 | + | InsertAccount string | |
| 11 | + | CheckAccountExists string | |
| 12 | + | GetAccountByID string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // NewAccountQueries returns a new instance of AccountQueries | |
| 16 | + | func NewAccountQueries() *AccountQueries { | |
| 17 | + | return &AccountQueries{ | |
| 18 | + | InsertAccount: "INSERT INTO account (id, user_id) VALUES (?, ?)", | |
| 19 | + | CheckAccountExists: "SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)", | |
| 20 | + | GetAccountByID: "SELECT id, user_id FROM account WHERE id = ?", | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // CreateDBAccount creates a new account in the database | |
| 25 | + | func CreateDBAccount(database *sql.DB, userID string) (*db.Account, error) { | |
| 9 | 26 | account := &db.Account{ | |
| 10 | - | ID: generateUUID(), | |
| 27 | + | ID: generateUUID(), | |
| 28 | + | UserID: userID, | |
| 11 | 29 | } | |
| 12 | 30 | ||
| 13 | - | _, err := a.DB.Exec( | |
| 14 | - | "INSERT INTO account (id, user_id) VALUES (?, ?)", | |
| 15 | - | account.ID, a.DBTables.User.ID, | |
| 31 | + | _, err := database.Exec( | |
| 32 | + | NewAccountQueries().InsertAccount, | |
| 33 | + | account.ID, userID, | |
| 16 | 34 | ) | |
| 35 | + | ||
| 17 | 36 | return account, err | |
| 18 | 37 | } | |
| 19 | 38 | ||
| 20 | - | func CheckDBAccountExists(a *server.App, accountID string) (bool, error) { | |
| 39 | + | // CheckDBAccountExists checks if an account exists in the database | |
| 40 | + | func CheckDBAccountExists(database *sql.DB, accountID string) (bool, error) { | |
| 21 | 41 | var exists bool | |
| 22 | - | err := a.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)", accountID).Scan(&exists) | |
| 42 | + | ||
| 43 | + | err := database.QueryRow( | |
| 44 | + | NewAccountQueries().CheckAccountExists, | |
| 45 | + | accountID, | |
| 46 | + | ).Scan(&exists) | |
| 47 | + | ||
| 23 | 48 | if err != nil { | |
| 24 | 49 | return false, err | |
| 25 | 50 | } | |
| 51 | + | ||
| 26 | 52 | return exists, nil | |
| 27 | 53 | } | |
| 28 | 54 | ||
| 29 | - | func PopulateAccountFromDB(a *server.App) error { | |
| 30 | - | rows, err := a.DB.Query("SELECT id, user_id FROM account") | |
| 31 | - | if err != nil { | |
| 32 | - | return err | |
| 55 | + | // GetDBAccountByID gets an account from the database by ID | |
| 56 | + | func GetDBAccountByID(database *sql.DB, accountID string) (*db.Account, error) { | |
| 57 | + | account := &db.Account{ | |
| 58 | + | ID: accountID, | |
| 33 | 59 | } | |
| 34 | - | defer rows.Close() | |
| 35 | - | ||
| 36 | - | for rows.Next() { | |
| 37 | - | account := db.Account{} | |
| 38 | - | err := rows.Scan(&account.ID, &account.UserID) | |
| 39 | - | if err != nil { | |
| 40 | - | return err | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | a.DBTables.Account = account | |
| 60 | + | ||
| 61 | + | err := database.QueryRow( | |
| 62 | + | NewAccountQueries().GetAccountByID, | |
| 63 | + | accountID, | |
| 64 | + | ).Scan(&account.ID, &account.UserID) | |
| 65 | + | ||
| 66 | + | if err != nil { | |
| 67 | + | return nil, err | |
| 44 | 68 | } | |
| 45 | - | ||
| 46 | - | return rows.Err() | |
| 69 | + | ||
| 70 | + | return account, nil | |
| 47 | 71 | } | |
M internal/db/queries/inspectdb.go +6 -7
| @@ -1,16 +1,14 @@ | |||
| 1 | 1 | package queries | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "database/sql" | |
| 4 | 5 | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | 6 | ) | |
| 8 | 7 | ||
| 9 | 8 | // InspectInMemoryDB inspects the in-memory database. (for debug purpose) | |
| 10 | - | func InspectInMemoryDB(a *server.App) error { | |
| 11 | - | memDb := a.DB | |
| 9 | + | func InspectInMemoryDB(database *sql.DB) error { | |
| 12 | 10 | // Query the sqlite_master table to get a list of all tables | |
| 13 | - | rows, err := memDb.Query("SELECT name FROM sqlite_master WHERE type='table'") | |
| 11 | + | rows, err := database.Query("SELECT name FROM sqlite_master WHERE type='table'") | |
| 14 | 12 | fmt.Println("rows", rows) | |
| 15 | 13 | if err != nil { | |
| 16 | 14 | return err | |
| @@ -36,7 +34,7 @@ func InspectInMemoryDB(a *server.App) error { | |||
| 36 | 34 | fmt.Println(table) | |
| 37 | 35 | } | |
| 38 | 36 | // Print the content of the users table | |
| 39 | - | userRows, err := memDb.Query("SELECT * FROM user") | |
| 37 | + | userRows, err := database.Query("SELECT * FROM user") | |
| 40 | 38 | if err != nil { | |
| 41 | 39 | return err | |
| 42 | 40 | } | |
| @@ -54,10 +52,11 @@ func InspectInMemoryDB(a *server.App) error { | |||
| 54 | 52 | } | |
| 55 | 53 | ||
| 56 | 54 | // Print the content of accounts table | |
| 57 | - | accountRows, err := memDb.Query("SELECT * FROM account") | |
| 55 | + | accountRows, err := database.Query("SELECT * FROM account") | |
| 58 | 56 | if err != nil { | |
| 59 | 57 | return err | |
| 60 | 58 | } | |
| 59 | + | defer accountRows.Close() | |
| 61 | 60 | ||
| 62 | 61 | fmt.Println("\nContent of the 'account' table:") | |
| 63 | 62 | for accountRows.Next() { | |
M internal/db/queries/provider.go +91 -24
| @@ -1,41 +1,108 @@ | |||
| 1 | 1 | package queries | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "database/sql" | |
| 4 | 5 | "github.com/bnema/gordon/internal/db" | |
| 5 | - | "github.com/bnema/gordon/internal/server" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | func createDBGitHubProvider(a *server.App, userInfo *GithubUserInfo) error { | |
| 9 | - | provider := &db.Provider{ | |
| 10 | - | ID: generateUUID(), | |
| 11 | - | Email: userInfo.Emails[0], | |
| 12 | - | Login: userInfo.Login, | |
| 13 | - | AvatarURL: userInfo.AvatarURL, | |
| 14 | - | ProfileURL: userInfo.ProfileURL, | |
| 15 | - | Name: "GitHub", | |
| 8 | + | // ProviderQueries contains all SQL queries for provider operations | |
| 9 | + | type ProviderQueries struct { | |
| 10 | + | InsertProvider string | |
| 11 | + | PopulateProviderFromDB string | |
| 12 | + | CheckProviderExists string | |
| 13 | + | GetProviderByAccountLogin string | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewProviderQueries returns a new instance of ProviderQueries | |
| 17 | + | func NewProviderQueries() *ProviderQueries { | |
| 18 | + | return &ProviderQueries{ | |
| 19 | + | InsertProvider: "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 20 | + | PopulateProviderFromDB: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE account_id = ?", | |
| 21 | + | CheckProviderExists: "SELECT EXISTS(SELECT 1 FROM provider WHERE login = ? AND email = ?)", | |
| 22 | + | GetProviderByAccountLogin: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE login = ? AND email = ?", | |
| 16 | 23 | } | |
| 24 | + | } | |
| 17 | 25 | ||
| 18 | - | query := "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)" | |
| 19 | - | _, err := a.DB.Exec(query, provider.ID, a.DBTables.Account.ID, provider.Name, provider.Login, provider.AvatarURL, provider.ProfileURL, provider.Email) | |
| 26 | + | // CreateDBGitHubProvider creates a new GitHub provider in the database. | |
| 27 | + | func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.GithubUserInfo) error { | |
| 28 | + | providerID := generateUUID() | |
| 29 | + | ||
| 30 | + | _, err := database.Exec( | |
| 31 | + | NewProviderQueries().InsertProvider, | |
| 32 | + | providerID, | |
| 33 | + | accountID, | |
| 34 | + | "github", | |
| 35 | + | userInfo.Login, | |
| 36 | + | userInfo.AvatarURL, | |
| 37 | + | userInfo.ProfileURL, | |
| 38 | + | userInfo.Emails[0], | |
| 39 | + | ) | |
| 40 | + | ||
| 20 | 41 | return err | |
| 21 | 42 | } | |
| 22 | 43 | ||
| 23 | - | func PopulateProviderFromDB(a *server.App) error { | |
| 24 | - | rows, err := a.DB.Query("SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider") | |
| 44 | + | // PopulateDBProviderFromDB populates provider information from the database. | |
| 45 | + | func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider, error) { | |
| 46 | + | provider := &db.Provider{} | |
| 47 | + | ||
| 48 | + | err := database.QueryRow( | |
| 49 | + | NewProviderQueries().PopulateProviderFromDB, | |
| 50 | + | accountID, | |
| 51 | + | ).Scan( | |
| 52 | + | &provider.ID, | |
| 53 | + | &provider.AccountID, | |
| 54 | + | &provider.Name, | |
| 55 | + | &provider.Login, | |
| 56 | + | &provider.AvatarURL, | |
| 57 | + | &provider.ProfileURL, | |
| 58 | + | &provider.Email, | |
| 59 | + | ) | |
| 60 | + | ||
| 25 | 61 | if err != nil { | |
| 26 | - | return err | |
| 62 | + | return nil, err | |
| 27 | 63 | } | |
| 28 | - | defer rows.Close() | |
| 29 | - | ||
| 30 | - | for rows.Next() { | |
| 31 | - | provider := db.Provider{} | |
| 32 | - | err := rows.Scan(&provider.ID, &provider.AccountID, &provider.Name, &provider.Login, &provider.AvatarURL, &provider.ProfileURL, &provider.Email) | |
| 33 | - | if err != nil { | |
| 34 | - | return err | |
| 35 | - | } | |
| 64 | + | ||
| 65 | + | return provider, nil | |
| 66 | + | } | |
| 36 | 67 | ||
| 37 | - | a.DBTables.Provider = provider | |
| 68 | + | // CheckDBProviderExists checks if a provider exists in the database. | |
| 69 | + | func CheckDBProviderExists(database *sql.DB, login string, email string) (bool, error) { | |
| 70 | + | var exists bool | |
| 71 | + | ||
| 72 | + | err := database.QueryRow( | |
| 73 | + | NewProviderQueries().CheckProviderExists, | |
| 74 | + | login, | |
| 75 | + | email, | |
| 76 | + | ).Scan(&exists) | |
| 77 | + | ||
| 78 | + | if err != nil { | |
| 79 | + | return false, err | |
| 38 | 80 | } | |
| 81 | + | ||
| 82 | + | return exists, nil | |
| 83 | + | } | |
| 39 | 84 | ||
| 40 | - | return rows.Err() | |
| 85 | + | // GetProviderByLoginAndEmail returns a provider from the database by login and email. | |
| 86 | + | func GetProviderByLoginAndEmail(database *sql.DB, login string, email string) (*db.Provider, error) { | |
| 87 | + | provider := &db.Provider{} | |
| 88 | + | ||
| 89 | + | err := database.QueryRow( | |
| 90 | + | NewProviderQueries().GetProviderByAccountLogin, | |
| 91 | + | login, | |
| 92 | + | email, | |
| 93 | + | ).Scan( | |
| 94 | + | &provider.ID, | |
| 95 | + | &provider.AccountID, | |
| 96 | + | &provider.Name, | |
| 97 | + | &provider.Login, | |
| 98 | + | &provider.AvatarURL, | |
| 99 | + | &provider.ProfileURL, | |
| 100 | + | &provider.Email, | |
| 101 | + | ) | |
| 102 | + | ||
| 103 | + | if err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | return provider, nil | |
| 41 | 108 | } | |
A internal/db/queries/proxy.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package queries | |
| 2 | + | ||
| 3 | + | // ProxyQueries contains all SQL queries used by the proxy package | |
| 4 | + | type ProxyQueries struct { | |
| 5 | + | // Route queries | |
| 6 | + | GetActiveRoutes string | |
| 7 | + | GetRouteByDomain string | |
| 8 | + | InsertDomain string | |
| 9 | + | GetDomainByName string | |
| 10 | + | GetFirstAccount string | |
| 11 | + | UpdateRoute string | |
| 12 | + | InsertRoute string | |
| 13 | + | DeleteRouteByDomainID string | |
| 14 | + | DeleteDomainByID string | |
| 15 | + | UpdateRouteIP string | |
| 16 | + | GetAllRoutes string | |
| 17 | + | MarkRouteInactive string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewProxyQueries returns a new instance of ProxyQueries | |
| 21 | + | func NewProxyQueries() *ProxyQueries { | |
| 22 | + | return &ProxyQueries{ | |
| 23 | + | // Route queries | |
| 24 | + | GetActiveRoutes: ` | |
| 25 | + | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |
| 26 | + | FROM proxy_route pr | |
| 27 | + | JOIN domain d ON pr.domain_id = d.id | |
| 28 | + | WHERE pr.active = 1 | |
| 29 | + | `, | |
| 30 | + | GetRouteByDomain: ` | |
| 31 | + | SELECT id, container_id, container_port | |
| 32 | + | FROM proxy_route | |
| 33 | + | WHERE domain_id = ? | |
| 34 | + | `, | |
| 35 | + | InsertDomain: ` | |
| 36 | + | INSERT INTO domain (id, name, account_id, created_at, updated_at) | |
| 37 | + | VALUES (?, ?, ?, ?, ?) | |
| 38 | + | `, | |
| 39 | + | GetDomainByName: ` | |
| 40 | + | SELECT id FROM domain WHERE name = ? | |
| 41 | + | `, | |
| 42 | + | GetFirstAccount: ` | |
| 43 | + | SELECT id FROM account LIMIT 1 | |
| 44 | + | `, | |
| 45 | + | UpdateRoute: ` | |
| 46 | + | UPDATE proxy_route SET | |
| 47 | + | container_id = ?, | |
| 48 | + | container_ip = ?, | |
| 49 | + | container_port = ?, | |
| 50 | + | protocol = ?, | |
| 51 | + | path = ?, | |
| 52 | + | active = ?, | |
| 53 | + | updated_at = ? | |
| 54 | + | WHERE id = ? | |
| 55 | + | `, | |
| 56 | + | InsertRoute: ` | |
| 57 | + | INSERT INTO proxy_route ( | |
| 58 | + | id, domain_id, container_id, container_ip, container_port, | |
| 59 | + | protocol, path, active, created_at, updated_at | |
| 60 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 61 | + | `, | |
| 62 | + | DeleteRouteByDomainID: ` | |
| 63 | + | DELETE FROM proxy_route WHERE domain_id = ? | |
| 64 | + | `, | |
| 65 | + | DeleteDomainByID: ` | |
| 66 | + | DELETE FROM domain WHERE id = ? | |
| 67 | + | `, | |
| 68 | + | UpdateRouteIP: ` | |
| 69 | + | UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ? | |
| 70 | + | WHERE domain_id = ? AND container_id = ? | |
| 71 | + | `, | |
| 72 | + | GetAllRoutes: ` | |
| 73 | + | SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path | |
| 74 | + | FROM proxy_route pr | |
| 75 | + | JOIN domain d ON pr.domain_id = d.id | |
| 76 | + | WHERE pr.active = 1 | |
| 77 | + | `, | |
| 78 | + | MarkRouteInactive: ` | |
| 79 | + | UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ? | |
| 80 | + | `, | |
| 81 | + | } | |
| 82 | + | } | |
M internal/db/queries/sessions.go +148 -52
| @@ -6,97 +6,171 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/gordon/internal/db" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | + | "github.com/bnema/gordon/pkg/logger" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // createSession creates a session for the user. | |
| 13 | - | func createDBSession(a *server.App, browserInfo string, accessToken string, accountID string) error { | |
| 14 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 15 | - | sessions := &db.Sessions{ | |
| 16 | - | ID: generateUUID(), | |
| 12 | + | // SessionQueries contains all SQL queries for session operations | |
| 13 | + | type SessionQueries struct { | |
| 14 | + | InsertSession string | |
| 15 | + | GetSessionByToken string | |
| 16 | + | UpdateSessionToken string | |
| 17 | + | DeleteSession string | |
| 18 | + | GetSessionExpiry string | |
| 19 | + | UpdateSessionExpiry string | |
| 20 | + | CheckSessionExists string | |
| 21 | + | InvalidateSession string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewSessionQueries returns a new instance of SessionQueries | |
| 25 | + | func NewSessionQueries() *SessionQueries { | |
| 26 | + | return &SessionQueries{ | |
| 27 | + | InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)", | |
| 28 | + | GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?", | |
| 29 | + | UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?", | |
| 30 | + | DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?", | |
| 31 | + | GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?", | |
| 32 | + | UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?", | |
| 33 | + | CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)", | |
| 34 | + | InvalidateSession: "UPDATE sessions SET is_online = ? WHERE id = ?", | |
| 17 | 35 | } | |
| 36 | + | } | |
| 18 | 37 | ||
| 19 | - | query := "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)" | |
| 20 | - | _, err := a.DB.Exec(query, sessions.ID, accountID, accessToken, browserInfo, expireTime, true) | |
| 38 | + | // CreateDBSession creates a session for the user. | |
| 39 | + | func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, accountID string) (*db.Sessions, error) { | |
| 40 | + | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 41 | + | sessionID := generateUUID() | |
| 42 | + | ||
| 43 | + | _, err := database.Exec( | |
| 44 | + | NewSessionQueries().InsertSession, | |
| 45 | + | sessionID, accountID, accessToken, browserInfo, expireTime, true, | |
| 46 | + | ) | |
| 47 | + | ||
| 21 | 48 | if err != nil { | |
| 22 | - | return err | |
| 49 | + | logger.Error("Failed to create session in database", "error", err, "session_id", sessionID, "account_id", accountID) | |
| 50 | + | return nil, err | |
| 23 | 51 | } | |
| 24 | - | ||
| 25 | - | // Update global state here if really necessary | |
| 26 | - | a.DBTables.Sessions.ID = sessions.ID | |
| 27 | - | a.DBTables.Sessions.AccessToken = accessToken | |
| 28 | - | a.DBTables.Sessions.Expires = expireTime | |
| 29 | - | ||
| 30 | - | return nil | |
| 52 | + | ||
| 53 | + | // Create a Sessions struct to return | |
| 54 | + | session := &db.Sessions{ | |
| 55 | + | ID: sessionID, | |
| 56 | + | AccountID: accountID, | |
| 57 | + | AccessToken: accessToken, | |
| 58 | + | BrowserInfo: browserInfo, | |
| 59 | + | Expires: expireTime, | |
| 60 | + | IsOnline: true, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime) | |
| 64 | + | return session, nil | |
| 31 | 65 | } | |
| 32 | 66 | ||
| 33 | 67 | // GetDBUserSession gets the user session from the database based on the access token and browser info. | |
| 34 | - | func GetDBUserSession(a *server.App, accessToken string, browserInfo string) (*db.Sessions, error) { | |
| 35 | - | query := "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?" | |
| 36 | - | err := a.DB.QueryRow(query, accessToken, browserInfo).Scan(&a.DBTables.Sessions.ID, &a.DBTables.Sessions.AccountID, &a.DBTables.Sessions.AccessToken, &a.DBTables.Sessions.BrowserInfo, &a.DBTables.Sessions.Expires, &a.DBTables.Sessions.IsOnline) | |
| 68 | + | func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string) (*db.Sessions, error) { | |
| 69 | + | session := &db.Sessions{} | |
| 70 | + | ||
| 71 | + | err := database.QueryRow( | |
| 72 | + | NewSessionQueries().GetSessionByToken, | |
| 73 | + | accessToken, | |
| 74 | + | browserInfo, | |
| 75 | + | ).Scan( | |
| 76 | + | &session.ID, | |
| 77 | + | &session.AccountID, | |
| 78 | + | &session.AccessToken, | |
| 79 | + | &session.BrowserInfo, | |
| 80 | + | &session.Expires, | |
| 81 | + | &session.IsOnline, | |
| 82 | + | ) | |
| 83 | + | ||
| 37 | 84 | if err != nil { | |
| 38 | 85 | if err == sql.ErrNoRows { | |
| 39 | 86 | return nil, nil | |
| 40 | 87 | } | |
| 88 | + | logger.Warn("Failed to get session", "error", err) | |
| 41 | 89 | return nil, fmt.Errorf("error getting session: %w", err) | |
| 42 | 90 | } | |
| 43 | 91 | ||
| 44 | - | return &a.DBTables.Sessions, nil | |
| 92 | + | logger.Debug("Session retrieved from database", "session", session) | |
| 93 | + | ||
| 94 | + | return session, nil | |
| 45 | 95 | } | |
| 46 | 96 | ||
| 47 | - | func updateDBSession(a *server.App, accessToken string) error { | |
| 97 | + | // UpdateDBSession updates an existing session with a new access token | |
| 98 | + | func UpdateDBSession(database *sql.DB, sessionID string, accessToken string) error { | |
| 48 | 99 | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 49 | - | query := "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?" | |
| 50 | - | _, err := a.DB.Exec(query, accessToken, expireTime, a.DBTables.Sessions.ID) | |
| 100 | + | ||
| 101 | + | _, err := database.Exec( | |
| 102 | + | NewSessionQueries().UpdateSessionToken, | |
| 103 | + | accessToken, | |
| 104 | + | expireTime, | |
| 105 | + | sessionID, | |
| 106 | + | ) | |
| 107 | + | ||
| 51 | 108 | if err != nil { | |
| 109 | + | logger.Error("Failed to update session in database", "error", err, "session_id", sessionID) | |
| 52 | 110 | return err | |
| 53 | 111 | } | |
| 54 | - | ||
| 112 | + | ||
| 113 | + | logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime) | |
| 55 | 114 | return nil | |
| 56 | 115 | } | |
| 57 | 116 | ||
| 58 | - | func CreateOrUpdateDBSession(a *server.App, accessToken string, browserInfo string) error { | |
| 117 | + | // CreateOrUpdateDBSession creates a new session or updates an existing one | |
| 118 | + | func CreateOrUpdateDBSession(database *sql.DB, accessToken string, browserInfo string, accountID string) error { | |
| 59 | 119 | // Check if the session exists using the access token and browser info | |
| 60 | - | session, err := GetDBUserSession(a, accessToken, browserInfo) | |
| 120 | + | session, err := GetDBUserSession(database, accessToken, browserInfo) | |
| 61 | 121 | if err != nil && err != sql.ErrNoRows { | |
| 122 | + | logger.Warn("Failed to check for session", "error", err) | |
| 62 | 123 | return fmt.Errorf("error checking for session: %w", err) | |
| 63 | 124 | } | |
| 64 | 125 | ||
| 65 | 126 | // If the session exists, update the session | |
| 66 | 127 | if session != nil { | |
| 67 | - | err := updateDBSession(a, accessToken) | |
| 128 | + | err := UpdateDBSession(database, session.ID, accessToken) | |
| 68 | 129 | if err != nil { | |
| 130 | + | logger.Warn("Failed to update session", "error", err) | |
| 69 | 131 | return fmt.Errorf("error updating session: %w", err) | |
| 70 | 132 | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // If the session does not exist, create the session | |
| 74 | - | if session == nil { | |
| 75 | - | err := createDBSession(a, browserInfo, accessToken, a.DBTables.Account.ID) | |
| 133 | + | logger.Debug("Session updated in database", "accountID", accountID, "sessionID", session.ID) | |
| 134 | + | } else { | |
| 135 | + | // If the session does not exist, create the session | |
| 136 | + | _, err := CreateDBSession(database, browserInfo, accessToken, accountID) | |
| 76 | 137 | if err != nil { | |
| 138 | + | logger.Warn("Failed to create session", "error", err) | |
| 77 | 139 | return fmt.Errorf("error creating session: %w", err) | |
| 78 | 140 | } | |
| 141 | + | logger.Debug("New session created in database", "accountID", accountID) | |
| 79 | 142 | } | |
| 80 | 143 | ||
| 81 | 144 | return nil | |
| 82 | 145 | } | |
| 83 | 146 | ||
| 84 | - | // DeleteSession deletes the session from the database. | |
| 85 | - | func DeleteDBSession(a *server.App, accountID string, sessionID string) error { | |
| 86 | - | query := "DELETE FROM sessions WHERE account_id = ? AND id = ?" | |
| 87 | - | _, err := a.DB.Exec(query, accountID, sessionID) | |
| 147 | + | // DeleteDBSession deletes the session from the database. | |
| 148 | + | func DeleteDBSession(database *sql.DB, accountID string, sessionID string) error { | |
| 149 | + | _, err := database.Exec( | |
| 150 | + | NewSessionQueries().DeleteSession, | |
| 151 | + | accountID, | |
| 152 | + | sessionID, | |
| 153 | + | ) | |
| 154 | + | ||
| 88 | 155 | if err != nil { | |
| 156 | + | logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID) | |
| 89 | 157 | return err | |
| 90 | 158 | } | |
| 91 | - | ||
| 159 | + | ||
| 160 | + | logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID) | |
| 92 | 161 | return nil | |
| 93 | 162 | } | |
| 94 | 163 | ||
| 95 | - | func GetSessionExpiration(a *server.App, accountID string, sessionID string, currentTime time.Time) (time.Time, error) { | |
| 164 | + | // GetSessionExpiration gets the expiration time for a session | |
| 165 | + | func GetSessionExpiration(database *sql.DB, accountID string, sessionID string, currentTime time.Time) (time.Time, error) { | |
| 96 | 166 | var expirationTime string | |
| 97 | - | // with userID and sessionID we can get the expiration time | |
| 98 | - | query := "SELECT expires FROM sessions WHERE account_id = ? AND id = ?" | |
| 99 | - | err := a.DB.QueryRow(query, accountID, sessionID).Scan(&expirationTime) | |
| 167 | + | ||
| 168 | + | err := database.QueryRow( | |
| 169 | + | NewSessionQueries().GetSessionExpiry, | |
| 170 | + | accountID, | |
| 171 | + | sessionID, | |
| 172 | + | ).Scan(&expirationTime) | |
| 173 | + | ||
| 100 | 174 | if err != nil { | |
| 101 | 175 | return currentTime, err | |
| 102 | 176 | } | |
| @@ -107,36 +181,58 @@ func GetSessionExpiration(a *server.App, accountID string, sessionID string, cur | |||
| 107 | 181 | return currentTime, err | |
| 108 | 182 | } | |
| 109 | 183 | ||
| 184 | + | logger.Debug("Session expiration time retrieved", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTimeParsed) | |
| 185 | + | ||
| 110 | 186 | return expirationTimeParsed, nil | |
| 111 | 187 | } | |
| 112 | 188 | ||
| 113 | - | func ExtendSessionExpiration(a *server.App, accountID string, sessionID string, newExpirationTime time.Time) error { | |
| 114 | - | query := "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?" | |
| 115 | - | _, err := a.DB.Exec(query, newExpirationTime.Format(time.RFC3339), accountID, sessionID) | |
| 189 | + | // ExtendSessionExpiration extends the expiration time for a session | |
| 190 | + | func ExtendSessionExpiration(database *sql.DB, accountID string, sessionID string, newExpirationTime time.Time) error { | |
| 191 | + | _, err := database.Exec( | |
| 192 | + | NewSessionQueries().UpdateSessionExpiry, | |
| 193 | + | newExpirationTime.Format(time.RFC3339), | |
| 194 | + | accountID, | |
| 195 | + | sessionID, | |
| 196 | + | ) | |
| 197 | + | ||
| 116 | 198 | if err != nil { | |
| 199 | + | logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID) | |
| 117 | 200 | return err | |
| 118 | 201 | } | |
| 119 | - | ||
| 202 | + | ||
| 203 | + | logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime) | |
| 120 | 204 | return nil | |
| 121 | 205 | } | |
| 122 | 206 | ||
| 123 | - | func CheckDBSessionExists(a *server.App, sessionID string) (bool, error) { | |
| 207 | + | // CheckDBSessionExists checks if a session exists | |
| 208 | + | func CheckDBSessionExists(database *sql.DB, sessionID string) (bool, error) { | |
| 124 | 209 | var sessionExists bool | |
| 125 | - | query := "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)" | |
| 126 | - | err := a.DB.QueryRow(query, sessionID).Scan(&sessionExists) | |
| 210 | + | err := database.QueryRow( | |
| 211 | + | NewSessionQueries().CheckSessionExists, | |
| 212 | + | sessionID, | |
| 213 | + | ).Scan(&sessionExists) | |
| 214 | + | ||
| 127 | 215 | if err != nil { | |
| 128 | 216 | return false, err | |
| 129 | 217 | } | |
| 130 | 218 | ||
| 219 | + | logger.Debug("Session existence checked", "sessionID", sessionID, "exists", sessionExists) | |
| 131 | 220 | return sessionExists, nil | |
| 132 | 221 | } | |
| 133 | 222 | ||
| 134 | - | func InvalidateDBSession(a *server.App, sessionID string) error { | |
| 135 | - | query := "UPDATE sessions SET is_online = ? WHERE id = ?" | |
| 136 | - | _, err := a.DB.Exec(query, false, sessionID) | |
| 223 | + | // InvalidateDBSession marks a session as offline | |
| 224 | + | func InvalidateDBSession(database *sql.DB, sessionID string) error { | |
| 225 | + | _, err := database.Exec( | |
| 226 | + | NewSessionQueries().InvalidateSession, | |
| 227 | + | false, | |
| 228 | + | sessionID, | |
| 229 | + | ) | |
| 230 | + | ||
| 137 | 231 | if err != nil { | |
| 232 | + | logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID) | |
| 138 | 233 | return err | |
| 139 | 234 | } | |
| 140 | - | ||
| 235 | + | ||
| 236 | + | logger.Debug("Session invalidated", "sessionID", sessionID) | |
| 141 | 237 | return nil | |
| 142 | 238 | } | |
M internal/db/queries/user.go +112 -52
| @@ -3,9 +3,10 @@ package queries | |||
| 3 | 3 | import ( | |
| 4 | 4 | "database/sql" | |
| 5 | 5 | "fmt" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/gordon/internal/db" | |
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | + | "github.com/bnema/gordon/pkg/logger" | |
| 9 | 10 | "github.com/google/uuid" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -13,65 +14,87 @@ func generateUUID() string { | |||
| 13 | 14 | return uuid.New().String() | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | - | // UserInfo holds the essential information for a Github user. | |
| 17 | - | type GithubUserInfo struct { | |
| 18 | - | Login string `json:"login"` | |
| 19 | - | AvatarURL string `json:"avatar_url"` | |
| 20 | - | ProfileURL string `json:"html_url"` | |
| 21 | - | Emails []string | |
| 17 | + | // UserQueries contains all SQL queries for user operations | |
| 18 | + | type UserQueries struct { | |
| 19 | + | InsertUser string | |
| 20 | + | CheckUserExists string | |
| 21 | + | GetUserByProvider string | |
| 22 | + | GetAccountCount string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewUserQueries returns a new instance of UserQueries | |
| 26 | + | func NewUserQueries() *UserQueries { | |
| 27 | + | return &UserQueries{ | |
| 28 | + | InsertUser: "INSERT INTO user (id, name, email) VALUES (?, ?, ?)", | |
| 29 | + | CheckUserExists: "SELECT id FROM user", | |
| 30 | + | GetUserByProvider: "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?", | |
| 31 | + | GetAccountCount: "SELECT COUNT(*) FROM account", | |
| 32 | + | } | |
| 22 | 33 | } | |
| 23 | 34 | ||
| 24 | 35 | // CreateUser creates a new user along with the associated account, provider, and session. | |
| 25 | - | func CreateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) error { | |
| 36 | + | func CreateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 26 | 37 | // Check if a user already exists. If so, return an error. | |
| 27 | - | if exists, err := CheckDBUserExists(a); err != nil || exists { | |
| 28 | - | return fmt.Errorf("error checking user or user already exists: %v", err) | |
| 38 | + | logger.Debug("Starting CreateUser database function") | |
| 39 | + | if exists, err := CheckDBUserExists(database); err != nil || exists { | |
| 40 | + | logger.Warn("User already exists or error checking", "error", err, "exists", exists) | |
| 41 | + | return nil, "", nil, fmt.Errorf("error checking user or user already exists: %v", err) | |
| 29 | 42 | } | |
| 30 | 43 | ||
| 31 | - | user, err := createDBUser(a, userInfo) | |
| 44 | + | logger.Debug("Creating database user", "login", userInfo.Login, "email", userInfo.Emails[0]) | |
| 45 | + | user, err := CreateDBUser(database, userInfo) | |
| 32 | 46 | if err != nil { | |
| 33 | - | return err | |
| 47 | + | logger.Error("Failed to create database user", "error", err) | |
| 48 | + | return nil, "", nil, err | |
| 34 | 49 | } | |
| 50 | + | logger.Info("Database user created successfully", "user_id", user.ID) | |
| 35 | 51 | ||
| 36 | - | // update the global state | |
| 37 | - | a.DBTables.User.ID = user.ID | |
| 38 | - | a.DBTables.User.Name = user.Name | |
| 39 | - | a.DBTables.User.Email = user.Email | |
| 40 | - | ||
| 41 | - | account, err := createDBAccount(a) | |
| 52 | + | logger.Debug("Creating database account for user", "user_id", user.ID) | |
| 53 | + | account, err := CreateDBAccount(database, user.ID) | |
| 42 | 54 | if err != nil { | |
| 43 | - | return err | |
| 55 | + | logger.Error("Failed to create database account", "error", err) | |
| 56 | + | return nil, "", nil, err | |
| 44 | 57 | } | |
| 58 | + | logger.Info("Database account created successfully", "account_id", account.ID) | |
| 45 | 59 | ||
| 46 | - | // update the global state | |
| 47 | - | a.DBTables.Account.ID = account.ID | |
| 48 | - | a.DBTables.Account.UserID = account.UserID | |
| 60 | + | logger.Debug("Creating GitHub provider for account", "account_id", account.ID) | |
| 61 | + | err = CreateDBGitHubProvider(database, account.ID, userInfo) | |
| 62 | + | if err != nil { | |
| 63 | + | logger.Error("Failed to create GitHub provider", "error", err) | |
| 64 | + | return nil, "", nil, err | |
| 65 | + | } | |
| 66 | + | logger.Info("GitHub provider created successfully") | |
| 49 | 67 | ||
| 50 | - | err = createDBGitHubProvider(a, userInfo) | |
| 68 | + | logger.Debug("Creating session for account", "account_id", account.ID) | |
| 69 | + | session, err := CreateDBSession(database, browserInfo, accessToken, account.ID) | |
| 51 | 70 | if err != nil { | |
| 52 | - | return err | |
| 71 | + | logger.Error("Failed to create session", "error", err) | |
| 72 | + | return nil, "", nil, err | |
| 53 | 73 | } | |
| 74 | + | logger.Info("Session created successfully") | |
| 54 | 75 | ||
| 55 | - | return createDBSession(a, browserInfo, accessToken, account.ID) | |
| 76 | + | return user, account.ID, session, nil | |
| 56 | 77 | } | |
| 57 | 78 | ||
| 58 | - | func createDBUser(a *server.App, userInfo *GithubUserInfo) (*db.User, error) { | |
| 79 | + | // CreateDBUser creates a new user in the database | |
| 80 | + | func CreateDBUser(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, error) { | |
| 59 | 81 | user := &db.User{ | |
| 60 | 82 | ID: generateUUID(), | |
| 61 | 83 | Name: userInfo.Login, | |
| 62 | 84 | Email: userInfo.Emails[0], | |
| 63 | 85 | } | |
| 64 | 86 | ||
| 65 | - | _, err := a.DB.Exec( | |
| 66 | - | "INSERT INTO user (id, name, email) VALUES (?, ?, ?)", | |
| 87 | + | _, err := database.Exec( | |
| 88 | + | NewUserQueries().InsertUser, | |
| 67 | 89 | user.ID, user.Name, user.Email, | |
| 68 | 90 | ) | |
| 69 | 91 | return user, err | |
| 70 | 92 | } | |
| 71 | 93 | ||
| 72 | - | func CheckDBUserExists(a *server.App) (bool, error) { | |
| 94 | + | // CheckDBUserExists checks if any user exists in the database | |
| 95 | + | func CheckDBUserExists(database *sql.DB) (bool, error) { | |
| 73 | 96 | var userID string | |
| 74 | - | err := a.DB.QueryRow("SELECT id FROM user").Scan(&userID) | |
| 97 | + | err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&userID) | |
| 75 | 98 | if err != nil { | |
| 76 | 99 | if err == sql.ErrNoRows { | |
| 77 | 100 | return false, nil | |
| @@ -86,50 +109,87 @@ func CheckDBUserExists(a *server.App) (bool, error) { | |||
| 86 | 109 | return false, nil | |
| 87 | 110 | } | |
| 88 | 111 | ||
| 89 | - | // If there is already a user, we compare the login and email to see if it is the same user | |
| 90 | - | func CheckDBUserIsGood(a *server.App, userInfo *GithubUserInfo) (bool, error) { | |
| 112 | + | // CheckDBUserIsGood checks if the user matches the provided GitHub info | |
| 113 | + | func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) { | |
| 91 | 114 | login := userInfo.Login | |
| 92 | 115 | email := userInfo.Emails[0] | |
| 93 | 116 | ||
| 117 | + | user := &db.User{} | |
| 118 | + | ||
| 94 | 119 | // Check if the user exists based on github login and email | |
| 95 | - | query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?" | |
| 96 | - | err := a.DB.QueryRow(query, login, email).Scan(&a.DBTables.User.ID, &a.DBTables.User.Name, &a.DBTables.User.Email) | |
| 120 | + | err := database.QueryRow(NewUserQueries().GetUserByProvider, login, email).Scan( | |
| 121 | + | &user.ID, &user.Name, &user.Email, | |
| 122 | + | ) | |
| 123 | + | ||
| 97 | 124 | if err != nil { | |
| 98 | 125 | if err == sql.ErrNoRows { | |
| 99 | - | return false, nil | |
| 126 | + | return nil, false, nil | |
| 100 | 127 | } | |
| 101 | - | return false, err | |
| 128 | + | return nil, false, err | |
| 102 | 129 | } | |
| 103 | 130 | ||
| 104 | - | if a.DBTables.User.ID != "" { | |
| 105 | - | return true, nil | |
| 131 | + | if user.ID != "" { | |
| 132 | + | return user, true, nil | |
| 106 | 133 | } | |
| 107 | 134 | ||
| 108 | - | return false, nil | |
| 135 | + | return nil, false, nil | |
| 109 | 136 | } | |
| 110 | 137 | ||
| 111 | - | func UpdateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) (*db.User, error) { | |
| 112 | - | ||
| 113 | - | err := CreateOrUpdateDBSession(a, accessToken, browserInfo) | |
| 138 | + | // UpdateUser updates the user session with new access token | |
| 139 | + | func UpdateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 140 | + | // First, get the user info to get their account ID | |
| 141 | + | user, isGood, err := CheckDBUserIsGood(database, userInfo) | |
| 114 | 142 | if err != nil { | |
| 115 | - | return nil, fmt.Errorf("could not create or update session: %w", err) | |
| 143 | + | return nil, "", nil, fmt.Errorf("error checking user: %w", err) | |
| 116 | 144 | } | |
| 117 | - | ||
| 118 | - | user := a.DBTables.User | |
| 119 | - | ||
| 120 | - | query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?" | |
| 121 | - | err = a.DB.QueryRow(query, userInfo.Login, userInfo.Emails[0]).Scan(&user.ID, &user.Name, &user.Email) | |
| 145 | + | ||
| 146 | + | if !isGood { | |
| 147 | + | return nil, "", nil, fmt.Errorf("user not found in database") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // Get the account ID for this user | |
| 151 | + | var accountID string | |
| 152 | + | err = database.QueryRow("SELECT id FROM account WHERE user_id = ?", user.ID).Scan(&accountID) | |
| 122 | 153 | if err != nil { | |
| 123 | - | return nil, err | |
| 154 | + | return nil, "", nil, fmt.Errorf("error getting account ID: %w", err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // Check if the session exists using the access token and browser info | |
| 158 | + | existingSession, err := GetDBUserSession(database, accessToken, browserInfo) | |
| 159 | + | if err != nil && err != sql.ErrNoRows { | |
| 160 | + | return nil, "", nil, fmt.Errorf("error checking for session: %w", err) | |
| 124 | 161 | } | |
| 125 | 162 | ||
| 126 | - | return &user, nil | |
| 163 | + | var session *db.Sessions | |
| 164 | + | ||
| 165 | + | // If the session exists, update the session | |
| 166 | + | if existingSession != nil { | |
| 167 | + | expiresTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 168 | + | err := UpdateDBSession(database, existingSession.ID, accessToken) | |
| 169 | + | if err != nil { | |
| 170 | + | return nil, "", nil, fmt.Errorf("error updating session: %w", err) | |
| 171 | + | } | |
| 172 | + | // Update the session struct with new values | |
| 173 | + | existingSession.AccessToken = accessToken | |
| 174 | + | existingSession.Expires = expiresTime | |
| 175 | + | session = existingSession | |
| 176 | + | logger.Debug("Session updated in database", "accountID", accountID, "sessionID", session.ID) | |
| 177 | + | } else { | |
| 178 | + | // If the session does not exist, create the session | |
| 179 | + | session, err = CreateDBSession(database, browserInfo, accessToken, accountID) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, "", nil, fmt.Errorf("could not create session: %w", err) | |
| 182 | + | } | |
| 183 | + | logger.Debug("New session created in database", "accountID", accountID, "sessionID", session.ID) | |
| 184 | + | } | |
| 127 | 185 | ||
| 186 | + | return user, accountID, session, nil | |
| 128 | 187 | } | |
| 129 | 188 | ||
| 130 | - | func GetAccountCount(a *server.App) (int, error) { | |
| 189 | + | // GetAccountCount returns the number of accounts in the database | |
| 190 | + | func GetAccountCount(database *sql.DB) (int, error) { | |
| 131 | 191 | var count int | |
| 132 | - | err := a.DB.QueryRow("SELECT COUNT(*) FROM account").Scan(&count) | |
| 192 | + | err := database.QueryRow(NewUserQueries().GetAccountCount).Scan(&count) | |
| 133 | 193 | if err != nil { | |
| 134 | 194 | return 0, err | |
| 135 | 195 | } | |
M internal/httpserve/handlers/container_crud.go +35 -14
| @@ -189,21 +189,42 @@ func CreateContainerPOST(c echo.Context, a *server.App) error { | |||
| 189 | 189 | containerName = strings.TrimPrefix(containerName, "/") | |
| 190 | 190 | } | |
| 191 | 191 | ||
| 192 | - | // Get container IP | |
| 193 | - | containerIP := GetContainerIP(a, containerID, containerName) | |
| 194 | - | ||
| 195 | - | // Construct target domain from form inputs | |
| 196 | - | targetDomain := fmt.Sprintf("%s://%s.%s", | |
| 197 | - | sanitizedInputs["container_protocol"], | |
| 198 | - | sanitizedInputs["container_subdomain"], | |
| 199 | - | sanitizedInputs["container_domain"]) | |
| 192 | + | // Check if we should skip proxy setup | |
| 193 | + | skipProxySetup := false | |
| 194 | + | if skipProxyValue, exists := sanitizedInputs["skip_proxy_setup"]; exists { | |
| 195 | + | skipProxySetup = skipProxyValue == "true" || skipProxyValue == "1" || skipProxyValue == "yes" | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Only set up proxy if not skipped | |
| 199 | + | if !skipProxySetup { | |
| 200 | + | // Get container IP | |
| 201 | + | containerIP := GetContainerIP(a, containerID, containerName) | |
| 202 | + | ||
| 203 | + | // Construct target domain from form inputs | |
| 204 | + | var targetDomain string | |
| 205 | + | if sanitizedInputs["container_subdomain"] == "" { | |
| 206 | + | // If subdomain is empty, use just the domain | |
| 207 | + | targetDomain = fmt.Sprintf("%s://%s", | |
| 208 | + | sanitizedInputs["container_protocol"], | |
| 209 | + | sanitizedInputs["container_domain"]) | |
| 210 | + | } else { | |
| 211 | + | // If subdomain is provided, use subdomain.domain format | |
| 212 | + | targetDomain = fmt.Sprintf("%s://%s.%s", | |
| 213 | + | sanitizedInputs["container_protocol"], | |
| 214 | + | sanitizedInputs["container_subdomain"], | |
| 215 | + | sanitizedInputs["container_domain"]) | |
| 216 | + | } | |
| 200 | 217 | ||
| 201 | - | // Add proxy route for the new container | |
| 202 | - | err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["proxy_port"], targetDomain) | |
| 203 | - | if err != nil { | |
| 204 | - | log.Error("Failed to add proxy route", "error", err) | |
| 205 | - | // Continue anyway, don't fail the whole operation | |
| 218 | + | // Add proxy route for the new container | |
| 219 | + | err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["container_port"], targetDomain) | |
| 220 | + | if err != nil { | |
| 221 | + | log.Error("Failed to add proxy route", "error", err) | |
| 222 | + | // Continue anyway, as the container is already created and started | |
| 223 | + | } | |
| 224 | + | } else { | |
| 225 | + | log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName) | |
| 206 | 226 | } | |
| 207 | 227 | ||
| 208 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 228 | + | // Redirect to the container list page | |
| 229 | + | return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/containers", a.Config.Admin.Path)) | |
| 209 | 230 | } | |
M internal/httpserve/handlers/endpoint_deploy.go +35 -7
| @@ -79,12 +79,22 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||
| 79 | 79 | }) | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | - | // After successfully deploying a container, add a proxy route | |
| 83 | - | containerIP := GetContainerIP(a, containerID, containerName) | |
| 84 | - | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | |
| 85 | - | if err != nil { | |
| 86 | - | log.Error("Failed to add proxy route", "error", err) | |
| 87 | - | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |
| 82 | + | // Check if we should skip proxy setup | |
| 83 | + | skipProxySetup := false | |
| 84 | + | if payload.SkipProxySetup { | |
| 85 | + | skipProxySetup = true | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // After successfully deploying a container, add a proxy route if not skipped | |
| 89 | + | if !skipProxySetup { | |
| 90 | + | containerIP := GetContainerIP(a, containerID, containerName) | |
| 91 | + | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | |
| 92 | + | if err != nil { | |
| 93 | + | log.Error("Failed to add proxy route", "error", err) | |
| 94 | + | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |
| 95 | + | } | |
| 96 | + | } else { | |
| 97 | + | log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName) | |
| 88 | 98 | } | |
| 89 | 99 | ||
| 90 | 100 | // Arrived here means deployment was successful | |
| @@ -265,7 +275,18 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||
| 265 | 275 | ||
| 266 | 276 | // Extract container name from domain and perform safety checks | |
| 267 | 277 | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 268 | - | containerName := strings.Split(cleanDomain, ".")[0] | |
| 278 | + | ||
| 279 | + | // Check if the domain has a subdomain | |
| 280 | + | domainParts := strings.Split(cleanDomain, ".") | |
| 281 | + | var containerName string | |
| 282 | + | ||
| 283 | + | if len(domainParts) >= 2 { | |
| 284 | + | // If there's a subdomain (e.g., sub.domain.tld), use it as the container name | |
| 285 | + | containerName = domainParts[0] | |
| 286 | + | } else { | |
| 287 | + | // If there's no subdomain (e.g., domain.tld), use the domain as the container name | |
| 288 | + | containerName = strings.ReplaceAll(cleanDomain, ".", "-") | |
| 289 | + | } | |
| 269 | 290 | ||
| 270 | 291 | if containerName == "" { | |
| 271 | 292 | return sendDeployErrorResponse(c, "Invalid target domain", | |
| @@ -471,6 +492,9 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 471 | 492 | cleanDomain = strings.Split(cleanDomain, "://")[1] | |
| 472 | 493 | } | |
| 473 | 494 | ||
| 495 | + | // Remove any leading dot that might have been added | |
| 496 | + | cleanDomain = strings.TrimPrefix(cleanDomain, ".") | |
| 497 | + | ||
| 474 | 498 | // Log that we're adding the domain for Let's Encrypt | |
| 475 | 499 | log.Debug("Adding domain for Let's Encrypt verification", | |
| 476 | 500 | "domain", cleanDomain) | |
| @@ -595,6 +619,10 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||
| 595 | 619 | return fmt.Errorf("failed to add proxy route: %w", err) | |
| 596 | 620 | } | |
| 597 | 621 | ||
| 622 | + | // Register this container to prevent immediate recreation logic | |
| 623 | + | // and avoid database locking conflicts | |
| 624 | + | p.RegisterNewlyCreatedContainer(containerID) | |
| 625 | + | ||
| 598 | 626 | log.Debug("Added proxy route", | |
| 599 | 627 | "domain", targetDomain, | |
| 600 | 628 | "containerIP", containerIP, | |
M internal/httpserve/handlers/endpoint_helpers.go +7 -3
| @@ -107,9 +107,10 @@ func GetInfos(c echo.Context, a *server.App) error { | |||
| 107 | 107 | // validateAndPrepareDeployPayload validates and prepares the deploy payload | |
| 108 | 108 | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | |
| 109 | 109 | payload := &common.DeployPayload{ | |
| 110 | - | Port: c.Request().Header.Get("X-Ports"), | |
| 111 | - | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 112 | - | TargetDomain: c.Request().Header.Get("X-Target-Domain"), | |
| 110 | + | Port: c.Request().Header.Get("X-Ports"), | |
| 111 | + | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 112 | + | TargetDomain: c.Request().Header.Get("X-Target-Domain"), | |
| 113 | + | SkipProxySetup: c.Request().Header.Get("X-Skip-Proxy-Setup") == "true", | |
| 113 | 114 | } | |
| 114 | 115 | ||
| 115 | 116 | if payload.ImageName == "" { | |
| @@ -256,6 +257,9 @@ func normalizeTargetDomain(domain string) string { | |||
| 256 | 257 | // First, remove any leading dot from the domain | |
| 257 | 258 | domain = strings.TrimPrefix(domain, ".") | |
| 258 | 259 | ||
| 260 | + | // If domain contains a dot prefix in the middle (like "example.com/.domain.tld"), fix it | |
| 261 | + | domain = strings.ReplaceAll(domain, "/.", "/") | |
| 262 | + | ||
| 259 | 263 | // Then check if we need to add the protocol | |
| 260 | 264 | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | |
| 261 | 265 | return "https://" + domain | |
M internal/httpserve/handlers/login.go +79 -32
| @@ -22,18 +22,22 @@ import ( | |||
| 22 | 22 | ||
| 23 | 23 | var urlToken string | |
| 24 | 24 | ||
| 25 | - | // ClLI : | |
| 26 | 25 | // compareGordonToken compares the token from the URL query parameter with the one from the config.yml | |
| 27 | 26 | func CompareGordonToken(c echo.Context, a *server.App) error { | |
| 27 | + | logger.Debug("Starting token comparison") | |
| 28 | 28 | configToken, err := a.Config.GetToken() | |
| 29 | 29 | if err != nil { | |
| 30 | + | logger.Error("Error retrieving token from config", "error", err) | |
| 30 | 31 | return err | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 34 | + | logger.Debug("Comparing tokens", "url_token_exists", urlToken != "", "config_token_exists", configToken != "") | |
| 33 | 35 | if urlToken != configToken { | |
| 36 | + | logger.Warn("Token mismatch or empty", "url_token_empty", urlToken == "") | |
| 34 | 37 | return fmt.Errorf("token is empty or not valid") | |
| 35 | 38 | } | |
| 36 | 39 | ||
| 40 | + | logger.Debug("Token validation successful") | |
| 37 | 41 | return nil | |
| 38 | 42 | } | |
| 39 | 43 | ||
| @@ -113,7 +117,7 @@ func StartOAuthGithub(c echo.Context, a *server.App) error { | |||
| 113 | 117 | } | |
| 114 | 118 | ||
| 115 | 119 | logger.Info("Checking if session exists in DB") | |
| 116 | - | sessionExists, err := queries.CheckDBSessionExists(a, sessionID) | |
| 120 | + | sessionExists, err := queries.CheckDBSessionExists(a.DB, sessionID) | |
| 117 | 121 | if err != nil { | |
| 118 | 122 | logger.Info("Error checking if session exists", "error", err) | |
| 119 | 123 | return fmt.Errorf("could not check if session exists: %w", err) | |
| @@ -126,7 +130,7 @@ func StartOAuthGithub(c echo.Context, a *server.App) error { | |||
| 126 | 130 | ||
| 127 | 131 | logger.Info("Getting the expiration time of the session") | |
| 128 | 132 | currentTime := time.Now() | |
| 129 | - | sessionExpiration, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime) | |
| 133 | + | sessionExpiration, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime) | |
| 130 | 134 | if err != nil { | |
| 131 | 135 | logger.Info("Error getting session expiration", "error", err) | |
| 132 | 136 | return fmt.Errorf("could not get session expiration: %w", err) | |
| @@ -137,9 +141,9 @@ func StartOAuthGithub(c echo.Context, a *server.App) error { | |||
| 137 | 141 | return initiateGithubOAuthFlow(c, a) | |
| 138 | 142 | } | |
| 139 | 143 | ||
| 140 | - | logger.Info("Extending session expiration time by 30 minutes") | |
| 141 | - | newExpirationTime := currentTime.Add(30 * time.Minute) | |
| 142 | - | err = queries.ExtendSessionExpiration(a, accountID, sessionID, newExpirationTime) | |
| 144 | + | logger.Info("Extending session expiration time by 24 hours") | |
| 145 | + | newExpirationTime := currentTime.Add(time.Hour * 24) | |
| 146 | + | err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, newExpirationTime) | |
| 143 | 147 | if err != nil { | |
| 144 | 148 | logger.Info("Error extending session expiration", "error", err) | |
| 145 | 149 | return fmt.Errorf("could not extend session expiration: %w", err) | |
| @@ -173,12 +177,15 @@ type Sessions struct { | |||
| 173 | 177 | ||
| 174 | 178 | // OAuthCallback handles the callback response from Github OAuth | |
| 175 | 179 | func OAuthCallback(c echo.Context, a *server.App) error { | |
| 180 | + | logger.Info("Starting OAuth callback handler") | |
| 176 | 181 | redirectPath := a.Config.Admin.Path | |
| 177 | 182 | ||
| 178 | 183 | accessToken, encodedState, err := parseQueryParams(c) | |
| 179 | 184 | if err != nil { | |
| 185 | + | logger.Error("Failed to parse query params from OAuth callback", "error", err) | |
| 180 | 186 | return c.String(http.StatusBadRequest, err.Error()) | |
| 181 | 187 | } | |
| 188 | + | logger.Debug("Parsed access token and state from query params", "token_length", len(accessToken), "encoded_state", encodedState) | |
| 182 | 189 | ||
| 183 | 190 | // update the struct with the new access token | |
| 184 | 191 | a.DBTables.Sessions.AccessToken = accessToken | |
| @@ -187,30 +194,42 @@ func OAuthCallback(c echo.Context, a *server.App) error { | |||
| 187 | 194 | redirectDomain := a.GenerateOauthCallbackURL() | |
| 188 | 195 | encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | |
| 189 | 196 | if encodedState != encodedRedirectDomain { | |
| 197 | + | logger.Error("Invalid state parameter", "expected", encodedRedirectDomain, "received", encodedState) | |
| 190 | 198 | return c.String(http.StatusBadRequest, "invalid state parameter") | |
| 191 | 199 | } | |
| 200 | + | logger.Debug("State parameter validation successful") | |
| 192 | 201 | ||
| 193 | 202 | sess, err := getSession(c) | |
| 194 | 203 | if err != nil { | |
| 204 | + | logger.Error("Failed to get session", "error", err) | |
| 195 | 205 | return err | |
| 196 | 206 | } | |
| 207 | + | logger.Debug("Session retrieved successfully") | |
| 197 | 208 | ||
| 198 | 209 | browserInfo := c.Request().UserAgent() | |
| 199 | 210 | userInfo, err := githubGetUserDetails(c) | |
| 200 | 211 | if err != nil { | |
| 212 | + | logger.Error("Failed to get GitHub user details", "error", err) | |
| 201 | 213 | return err | |
| 202 | 214 | } | |
| 215 | + | logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails)) | |
| 203 | 216 | ||
| 204 | - | _, err = handleUser(c, a, accessToken, browserInfo, userInfo) | |
| 217 | + | user, accountID, session, err := handleUser(c, a, accessToken, browserInfo, userInfo) | |
| 205 | 218 | if err != nil { | |
| 219 | + | logger.Error("Failed to handle user", "error", err) | |
| 206 | 220 | return err | |
| 207 | 221 | } | |
| 222 | + | logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID) | |
| 208 | 223 | ||
| 209 | - | err = setSessionValues(c, sess, a.DBTables.Account.ID, a.DBTables.Sessions.Expires, a.DBTables.Sessions.ID) | |
| 224 | + | logger.Debug("Before setting session values", "account_id", accountID, "session", session) | |
| 225 | + | err = setSessionValues(c, sess, accountID, session) | |
| 210 | 226 | if err != nil { | |
| 227 | + | logger.Error("Failed to set session values", "error", err) | |
| 211 | 228 | return err | |
| 212 | 229 | } | |
| 230 | + | logger.Info("Session values set successfully") | |
| 213 | 231 | ||
| 232 | + | logger.Info("OAuth callback successful, redirecting to", "path", redirectPath) | |
| 214 | 233 | return c.Redirect(http.StatusFound, redirectPath) | |
| 215 | 234 | } | |
| 216 | 235 | ||
| @@ -237,70 +256,98 @@ func getSession(c echo.Context) (*sessions.Session, error) { | |||
| 237 | 256 | } | |
| 238 | 257 | ||
| 239 | 258 | // handleUser handles the user creation or update | |
| 240 | - | func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | |
| 241 | - | userExists, err := queries.CheckDBUserExists(a) | |
| 259 | + | func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 260 | + | logger.Debug("Starting handleUser function", "github_login", userInfo.Login, "github_email", userInfo.Emails[0]) | |
| 261 | + | userExists, err := queries.CheckDBUserExists(a.DB) | |
| 242 | 262 | if err != nil { | |
| 243 | - | return nil, fmt.Errorf("could not check if user exists: %w", err) | |
| 263 | + | logger.Error("Error checking if user exists", "error", err) | |
| 264 | + | return nil, "", nil, fmt.Errorf("could not check if user exists: %w", err) | |
| 244 | 265 | } | |
| 245 | 266 | ||
| 267 | + | logger.Debug("User exists check result", "user_exists", userExists) | |
| 268 | + | ||
| 246 | 269 | if !userExists { | |
| 247 | 270 | // if it is a new user creation we compare the gordon token | |
| 271 | + | logger.Debug("No user exists, attempting to create new user") | |
| 248 | 272 | err := CompareGordonToken(c, a) | |
| 249 | 273 | if err != nil { | |
| 250 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is empty or not valid") | |
| 274 | + | logger.Error("Token comparison failed", "error", err) | |
| 275 | + | return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is empty or not valid") | |
| 251 | 276 | } | |
| 277 | + | logger.Debug("Token comparison successful, proceeding with user creation") | |
| 252 | 278 | ||
| 253 | - | return createUser(a, accessToken, browserInfo, userInfo) | |
| 279 | + | user, accountID, session, err := createUser(a, accessToken, browserInfo, userInfo) | |
| 280 | + | if err != nil { | |
| 281 | + | logger.Error("Failed to create user", "error", err) | |
| 282 | + | return nil, "", nil, err | |
| 283 | + | } | |
| 284 | + | logger.Info("Successfully created new user", "user_id", user.ID, "user_name", user.Name, "account_id", accountID) | |
| 285 | + | return user, accountID, session, nil | |
| 254 | 286 | } | |
| 255 | 287 | ||
| 256 | 288 | if userExists { | |
| 257 | 289 | // if it is an existing user we compare the login and email to see if it is the same user | |
| 258 | - | isGoodUser, err := queries.CheckDBUserIsGood(a, userInfo) | |
| 290 | + | logger.Debug("User exists, checking if GitHub info matches existing user") | |
| 291 | + | _, isGoodUser, err := queries.CheckDBUserIsGood(a.DB, userInfo) | |
| 259 | 292 | if err != nil { | |
| 260 | - | return nil, fmt.Errorf("could not check if user is good: %w", err) | |
| 293 | + | logger.Error("Error checking if user is good", "error", err) | |
| 294 | + | return nil, "", nil, fmt.Errorf("could not check if user is good: %w", err) | |
| 261 | 295 | } | |
| 262 | 296 | ||
| 297 | + | logger.Debug("User check result", "is_good_user", isGoodUser) | |
| 298 | + | ||
| 263 | 299 | if !isGoodUser { | |
| 264 | - | return nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user") | |
| 300 | + | logger.Warn("Unauthorized user", "github_login", userInfo.Login) | |
| 301 | + | return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user") | |
| 265 | 302 | } | |
| 266 | 303 | ||
| 267 | 304 | if isGoodUser { | |
| 268 | 305 | // Update the user | |
| 269 | - | updatedUser, err := updateUser(a, accessToken, browserInfo, userInfo) | |
| 306 | + | logger.Debug("User is authorized, updating session") | |
| 307 | + | updatedUser, accountID, session, err := updateUser(a, accessToken, browserInfo, userInfo) | |
| 270 | 308 | if err != nil { | |
| 271 | - | return nil, fmt.Errorf("could not update user: %w", err) | |
| 309 | + | logger.Error("Could not update user", "error", err) | |
| 310 | + | return nil, "", nil, fmt.Errorf("could not update user: %w", err) | |
| 272 | 311 | } | |
| 273 | - | return updatedUser, nil | |
| 312 | + | logger.Info("Successfully updated user session", "user_id", updatedUser.ID, "account_id", accountID) | |
| 313 | + | return updatedUser, accountID, session, nil | |
| 274 | 314 | } | |
| 275 | 315 | } | |
| 276 | 316 | ||
| 277 | - | return nil, fmt.Errorf("could not handle user") | |
| 317 | + | logger.Error("Could not handle user - reached end of function without returning") | |
| 318 | + | return nil, "", nil, fmt.Errorf("could not handle user") | |
| 278 | 319 | } | |
| 279 | 320 | ||
| 280 | 321 | // createUser creates a new user in the database | |
| 281 | - | func createUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | |
| 282 | - | err := queries.CreateUser(a, accessToken, browserInfo, userInfo) | |
| 322 | + | func createUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 323 | + | logger.Debug("Starting createUser function", "github_login", userInfo.Login) | |
| 324 | + | user, accountID, session, err := queries.CreateUser(a.DB, accessToken, browserInfo, userInfo) | |
| 283 | 325 | if err != nil { | |
| 284 | - | return nil, fmt.Errorf("could not create user: %w", err) | |
| 326 | + | logger.Error("Failed to create user in database", "error", err) | |
| 327 | + | return nil, "", nil, fmt.Errorf("could not create user: %w", err) | |
| 285 | 328 | } | |
| 286 | - | return &a.DBTables.User, nil | |
| 329 | + | logger.Info("User created successfully", "user_id", user.ID, "name", user.Name, "account_id", accountID) | |
| 330 | + | return user, accountID, session, nil | |
| 287 | 331 | } | |
| 288 | 332 | ||
| 289 | 333 | // updateUser updates an existing user in the database | |
| 290 | - | func updateUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | |
| 291 | - | user, err := queries.UpdateUser(a, accessToken, browserInfo, userInfo) | |
| 334 | + | func updateUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 335 | + | user, accountID, session, err := queries.UpdateUser(a.DB, accessToken, browserInfo, userInfo) | |
| 292 | 336 | if err != nil { | |
| 293 | - | return nil, fmt.Errorf("could not update user: %w", err) | |
| 337 | + | logger.Error("Could not update user", "error", err) | |
| 338 | + | return nil, "", nil, fmt.Errorf("could not update user: %w", err) | |
| 294 | 339 | } | |
| 295 | - | return user, nil | |
| 340 | + | logger.Info("User updated successfully", "user_id", user.ID, "account_id", accountID) | |
| 341 | + | return user, accountID, session, nil | |
| 296 | 342 | } | |
| 297 | 343 | ||
| 298 | 344 | // setSessionValues sets the session values for the user | |
| 299 | - | func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, expires string, sessionID string) error { | |
| 345 | + | func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, session *db.Sessions) error { | |
| 300 | 346 | sess.Values["authenticated"] = true | |
| 301 | 347 | sess.Values["accountID"] = accountID | |
| 302 | - | sess.Values["expires"] = expires | |
| 303 | - | sess.Values["sessionID"] = sessionID | |
| 348 | + | sess.Values["expires"] = session.Expires | |
| 349 | + | sess.Values["sessionID"] = session.ID | |
| 350 | + | sess.Values["isOnline"] = session.IsOnline | |
| 304 | 351 | ||
| 305 | 352 | if err := sess.Save(c.Request(), c.Response()); err != nil { | |
| 306 | 353 | return fmt.Errorf("could not save session: %w", err) | |
| @@ -341,7 +388,7 @@ func fetchGithubAPI(url string, authHeader string, result interface{}) error { | |||
| 341 | 388 | } | |
| 342 | 389 | ||
| 343 | 390 | // githubGetUserDetails gets the user details from the Github API using the access token | |
| 344 | - | func githubGetUserDetails(c echo.Context) (userInfo *queries.GithubUserInfo, err error) { | |
| 391 | + | func githubGetUserDetails(c echo.Context) (userInfo *db.GithubUserInfo, err error) { | |
| 345 | 392 | accessToken := c.QueryParam("access_token") | |
| 346 | 393 | ||
| 347 | 394 | // Fetch user info | |
M internal/httpserve/handlers/logout.go +1 -1
| @@ -23,7 +23,7 @@ func Logout(c echo.Context, a *server.App) error { | |||
| 23 | 23 | // Invalidate the session in the database | |
| 24 | 24 | sessionID, ok := sess.Values["sessionID"].(string) | |
| 25 | 25 | if ok { | |
| 26 | - | err := queries.InvalidateDBSession(a, sessionID) | |
| 26 | + | err := queries.InvalidateDBSession(a.DB, sessionID) | |
| 27 | 27 | if err != nil { | |
| 28 | 28 | return fmt.Errorf("failed to invalidate session: %w", err) | |
| 29 | 29 | } | |
M internal/httpserve/handlers/session_db.go +18 -4
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/gordon/internal/db/queries" | |
| 8 | 8 | "github.com/bnema/gordon/internal/server" | |
| 9 | + | "github.com/bnema/gordon/pkg/logger" | |
| 9 | 10 | "github.com/labstack/echo-contrib/session" | |
| 10 | 11 | "github.com/labstack/echo/v4" | |
| 11 | 12 | ) | |
| @@ -15,54 +16,67 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error { | |||
| 15 | 16 | ||
| 16 | 17 | sess, err := session.Get("session", c) | |
| 17 | 18 | if err != nil { | |
| 18 | - | return err | |
| 19 | + | logger.Warn("Failed to get session", "error", err) | |
| 20 | + | return fmt.Errorf("failed to get session: %w", err) | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | accountID, ok := sess.Values["accountID"].(string) | |
| 22 | 24 | if !ok { | |
| 25 | + | logger.Warn("Account ID not found or invalid type") | |
| 23 | 26 | return fmt.Errorf("accountID not found or invalid type") | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | sessionID, ok := sess.Values["sessionID"].(string) | |
| 27 | 30 | if !ok { | |
| 31 | + | logger.Warn("Session ID not found or invalid type") | |
| 28 | 32 | return fmt.Errorf("sessionID not found or invalid type") | |
| 29 | 33 | } | |
| 30 | 34 | ||
| 31 | 35 | accountExists, err := IsAccountIDInDB(a, accountID) | |
| 32 | 36 | if err != nil || !accountExists { | |
| 37 | + | logger.Warn("Account does not exist") | |
| 33 | 38 | return fmt.Errorf("account does not exist") | |
| 34 | 39 | } | |
| 35 | 40 | ||
| 36 | 41 | sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID) | |
| 37 | 42 | if err != nil || sessionExpired { | |
| 43 | + | logger.Warn("Session is expired") | |
| 38 | 44 | return fmt.Errorf("session is expired") | |
| 39 | 45 | } | |
| 40 | 46 | ||
| 41 | 47 | // Extend session expiration conditionally, e.g., if it's close to expiring | |
| 42 | - | err = queries.ExtendSessionExpiration(a, accountID, sessionID, time.Now().Add(sessionExpiryTime)) | |
| 48 | + | err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, time.Now().Add(sessionExpiryTime)) | |
| 43 | 49 | if err != nil { | |
| 50 | + | logger.Warn("Failed to extend session", "error", err) | |
| 44 | 51 | return fmt.Errorf("failed to extend session: %v", err) | |
| 45 | 52 | } | |
| 46 | 53 | ||
| 54 | + | logger.Debug("Session extended successfully", "accountID", accountID, "sessionID", sessionID) | |
| 55 | + | ||
| 47 | 56 | return nil | |
| 48 | 57 | } | |
| 49 | 58 | ||
| 50 | 59 | func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) { | |
| 51 | 60 | currentTime := time.Now() | |
| 52 | - | expirationTime, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime) | |
| 61 | + | expirationTime, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime) | |
| 53 | 62 | if err != nil { | |
| 63 | + | logger.Warn("Failed to get session expiration time", "error", err) | |
| 54 | 64 | return false, err | |
| 55 | 65 | } | |
| 56 | 66 | ||
| 67 | + | logger.Debug("Session expiration time for account", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTime) | |
| 57 | 68 | return currentTime.After(expirationTime), nil | |
| 58 | 69 | } | |
| 59 | 70 | ||
| 60 | 71 | func IsAccountIDInDB(a *server.App, accountID string) (bool, error) { | |
| 61 | - | accountExists, err := queries.CheckDBAccountExists(a, accountID) | |
| 72 | + | accountExists, err := queries.CheckDBAccountExists(a.DB, accountID) | |
| 62 | 73 | if err != nil { | |
| 74 | + | logger.Warn("Failed to check account existence", "error", err) | |
| 63 | 75 | return false, err | |
| 64 | 76 | } | |
| 65 | 77 | ||
| 78 | + | logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists) | |
| 79 | + | ||
| 66 | 80 | return accountExists, nil | |
| 67 | 81 | } | |
| 68 | 82 | ||
M internal/httpserve/middleware/requiretoken.go +5 -3
| @@ -10,13 +10,15 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/cli/auth" | |
| 13 | + | "github.com/bnema/gordon/internal/db" | |
| 13 | 14 | "github.com/bnema/gordon/internal/db/queries" | |
| 14 | 15 | "github.com/bnema/gordon/internal/server" | |
| 15 | 16 | "github.com/bnema/gordon/pkg/logger" | |
| 16 | 17 | "github.com/labstack/echo/v4" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | - | type GithubUserInfo = queries.GithubUserInfo | |
| 20 | + | // GithubUserInfo is an alias to db.GithubUserInfo | |
| 21 | + | type GithubUserInfo = db.GithubUserInfo | |
| 20 | 22 | ||
| 21 | 23 | // middleware/require_token.go | |
| 22 | 24 | func RequireToken(a *server.App) echo.MiddlewareFunc { | |
| @@ -35,7 +37,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc { | |||
| 35 | 37 | // Check cache first | |
| 36 | 38 | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | |
| 37 | 39 | // Verify if user exists in database and is authorized | |
| 38 | - | isAuthorized, err := queries.CheckDBUserIsGood(a, cachedUser) | |
| 40 | + | _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, cachedUser) | |
| 39 | 41 | if err != nil || !isAuthorized { | |
| 40 | 42 | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | |
| 41 | 43 | "success": false, | |
| @@ -56,7 +58,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc { | |||
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // Verify if user exists in database and is authorized | |
| 59 | - | isAuthorized, err := queries.CheckDBUserIsGood(a, githubUser) | |
| 61 | + | _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, githubUser) | |
| 60 | 62 | if err != nil { | |
| 61 | 63 | return c.JSON(http.StatusInternalServerError, map[string]interface{}{ | |
| 62 | 64 | "success": false, | |
D internal/proxy/blacklist.go +0 -464
| @@ -1,464 +0,0 @@ | |||
| 1 | - | package proxy | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net" | |
| 6 | - | "os" | |
| 7 | - | "path/filepath" | |
| 8 | - | "strings" | |
| 9 | - | "sync" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/pkg/logger" | |
| 13 | - | "gopkg.in/yaml.v3" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // BlacklistConfig represents the structure of the blacklist YAML file (.yaml or .yml) | |
| 17 | - | type BlacklistConfig struct { | |
| 18 | - | IPs []string `yaml:"ips"` | |
| 19 | - | Ranges []string `yaml:"ranges"` | |
| 20 | - | Networks []*net.IPNet `yaml:"-"` // Parsed IP networks | |
| 21 | - | LastMod time.Time `yaml:"-"` // Last modification time | |
| 22 | - | mu sync.RWMutex `yaml:"-"` // Mutex for thread safety | |
| 23 | - | path string `yaml:"-"` // Path to blacklist file | |
| 24 | - | ||
| 25 | - | // In-memory cache of blocked IPs for quick lookups | |
| 26 | - | blockedCache map[string]bool `yaml:"-"` | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // MarshalableConfig is a copy of BlacklistConfig without the mutex and other non-serializable fields | |
| 30 | - | // Used to prevent copying mutex when marshaling to YAML | |
| 31 | - | type MarshalableConfig struct { | |
| 32 | - | IPs []string `yaml:"ips"` | |
| 33 | - | Ranges []string `yaml:"ranges"` | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // NewBlacklist creates a new BlacklistConfig from a file | |
| 37 | - | func NewBlacklist(path string) (*BlacklistConfig, error) { | |
| 38 | - | // Create the blacklist config | |
| 39 | - | blacklist := &BlacklistConfig{ | |
| 40 | - | path: path, | |
| 41 | - | blockedCache: make(map[string]bool), | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Create the blacklist file if it doesn't exist | |
| 45 | - | if _, err := os.Stat(path); os.IsNotExist(err) { | |
| 46 | - | // Create parent directory if it doesn't exist | |
| 47 | - | dir := filepath.Dir(path) | |
| 48 | - | if _, err := os.Stat(dir); os.IsNotExist(err) { | |
| 49 | - | if err := os.MkdirAll(dir, 0755); err != nil { | |
| 50 | - | return nil, fmt.Errorf("failed to create directory for blacklist: %w", err) | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Create the blacklist file with default empty structure | |
| 55 | - | data, err := yaml.Marshal(MarshalableConfig{ | |
| 56 | - | IPs: []string{}, | |
| 57 | - | Ranges: []string{}, | |
| 58 | - | }) | |
| 59 | - | if err != nil { | |
| 60 | - | return nil, fmt.Errorf("failed to marshal default blacklist: %w", err) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | if err := os.WriteFile(path, data, 0644); err != nil { | |
| 64 | - | return nil, fmt.Errorf("failed to create blacklist file: %w", err) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | logger.Info("Created new blacklist file", "path", path) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // Load the blacklist | |
| 71 | - | if err := blacklist.Load(); err != nil { | |
| 72 | - | return nil, fmt.Errorf("failed to load blacklist: %w", err) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | logger.Info("Blacklist loaded", | |
| 76 | - | "ips", len(blacklist.IPs), | |
| 77 | - | "ranges", len(blacklist.Ranges)) | |
| 78 | - | ||
| 79 | - | return blacklist, nil | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Load reloads the blacklist from the file if it has changed | |
| 83 | - | func (b *BlacklistConfig) Load() error { | |
| 84 | - | b.mu.Lock() | |
| 85 | - | defer b.mu.Unlock() | |
| 86 | - | ||
| 87 | - | // Get file info for last modified time | |
| 88 | - | fileInfo, err := os.Stat(b.path) | |
| 89 | - | if err != nil { | |
| 90 | - | return err | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // Check if file hasn't been modified in the last 10 seconds | |
| 94 | - | // This ensures we check more frequently, but not on every single request | |
| 95 | - | forceReload := time.Since(b.LastMod) > 10*time.Second | |
| 96 | - | if fileInfo.ModTime() == b.LastMod && !forceReload { | |
| 97 | - | return nil // File hasn't changed and we're within the refresh window | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // Read the blacklist file | |
| 101 | - | data, err := os.ReadFile(b.path) | |
| 102 | - | if err != nil { | |
| 103 | - | return err | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // Create a temporary config to unmarshal into | |
| 107 | - | var tempConfig MarshalableConfig | |
| 108 | - | if err := yaml.Unmarshal(data, &tempConfig); err != nil { | |
| 109 | - | return err | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // Parse IP ranges into networks | |
| 113 | - | networks := make([]*net.IPNet, 0, len(tempConfig.Ranges)) | |
| 114 | - | for _, cidr := range tempConfig.Ranges { | |
| 115 | - | // Add /32 to plain IPs if not already a CIDR | |
| 116 | - | if !strings.Contains(cidr, "/") { | |
| 117 | - | cidr = cidr + "/32" | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | _, network, err := net.ParseCIDR(cidr) | |
| 121 | - | if err != nil { | |
| 122 | - | logger.Warn("Invalid CIDR in blacklist", "cidr", cidr, "error", err) | |
| 123 | - | continue | |
| 124 | - | } | |
| 125 | - | networks = append(networks, network) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // Update the blacklist | |
| 129 | - | b.IPs = tempConfig.IPs | |
| 130 | - | b.Ranges = tempConfig.Ranges | |
| 131 | - | b.Networks = networks | |
| 132 | - | b.LastMod = fileInfo.ModTime() | |
| 133 | - | ||
| 134 | - | // Rebuild the blocked cache | |
| 135 | - | b.rebuildBlockedCache() | |
| 136 | - | ||
| 137 | - | // If the file has actually changed, log about it | |
| 138 | - | if fileInfo.ModTime() != b.LastMod { | |
| 139 | - | logger.Info("Loaded blacklist", | |
| 140 | - | "ips", len(b.IPs), | |
| 141 | - | "ranges", len(b.Ranges), | |
| 142 | - | "last_modified", b.LastMod.Format(time.RFC3339)) | |
| 143 | - | } else if forceReload { | |
| 144 | - | logger.Debug("Reloaded blacklist (forced refresh)", | |
| 145 | - | "ips", len(b.IPs), | |
| 146 | - | "ranges", len(b.Ranges)) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | return nil | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // rebuildBlockedCache rebuilds the in-memory cache of blocked IPs for quick lookups | |
| 153 | - | func (b *BlacklistConfig) rebuildBlockedCache() { | |
| 154 | - | // Create a new map to hold all blocked IPs | |
| 155 | - | newCache := make(map[string]bool, len(b.IPs)) | |
| 156 | - | ||
| 157 | - | // Add direct IPs | |
| 158 | - | for _, ip := range b.IPs { | |
| 159 | - | newCache[ip] = true | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // Replace the cache atomically | |
| 163 | - | b.blockedCache = newCache | |
| 164 | - | ||
| 165 | - | logger.Debug("Rebuilt blocked IP cache", "direct_ips", len(b.blockedCache)) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // IsBlocked checks if an IP is in the blacklist | |
| 169 | - | func (b *BlacklistConfig) IsBlocked(ip string) bool { | |
| 170 | - | // Try to reload the blacklist first | |
| 171 | - | if err := b.Load(); err != nil { | |
| 172 | - | logger.Error("Failed to reload blacklist", "error", err) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | b.mu.RLock() | |
| 176 | - | defer b.mu.RUnlock() | |
| 177 | - | ||
| 178 | - | // Fast path: Check direct IP match in cache first (fastest) | |
| 179 | - | if b.blockedCache[ip] { | |
| 180 | - | return true | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | // Parse the IP | |
| 184 | - | parsedIP := net.ParseIP(ip) | |
| 185 | - | if parsedIP == nil { | |
| 186 | - | logger.Warn("Failed to parse IP", "ip", ip) | |
| 187 | - | return false | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // Check if IP is in any blocked ranges | |
| 191 | - | for _, network := range b.Networks { | |
| 192 | - | if network.Contains(parsedIP) { | |
| 193 | - | // Add to cache for faster lookups next time | |
| 194 | - | // This is safe because we have a read lock and the map is only replaced, not modified | |
| 195 | - | b.blockedCache[ip] = true | |
| 196 | - | return true | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | return false | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | // AddIP adds an IP to the blacklist immediately | |
| 204 | - | func (b *BlacklistConfig) AddIP(ip string) error { | |
| 205 | - | b.mu.Lock() | |
| 206 | - | defer b.mu.Unlock() | |
| 207 | - | ||
| 208 | - | // Read the current file | |
| 209 | - | data, err := os.ReadFile(b.path) | |
| 210 | - | if err != nil { | |
| 211 | - | return err | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | // Parse the current data | |
| 215 | - | var config MarshalableConfig | |
| 216 | - | if err := yaml.Unmarshal(data, &config); err != nil { | |
| 217 | - | return err | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | // Check if the IP is already in the list | |
| 221 | - | for _, blockedIP := range config.IPs { | |
| 222 | - | if blockedIP == ip { | |
| 223 | - | return nil // Already in the list | |
| 224 | - | } | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // Add the IP to the list | |
| 228 | - | config.IPs = append(config.IPs, ip) | |
| 229 | - | ||
| 230 | - | // Write the updated config back to the file | |
| 231 | - | newData, err := yaml.Marshal(config) | |
| 232 | - | if err != nil { | |
| 233 | - | return err | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | if err := os.WriteFile(b.path, newData, 0644); err != nil { | |
| 237 | - | return err | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // Add to in-memory cache immediately | |
| 241 | - | b.blockedCache[ip] = true | |
| 242 | - | logger.Info("Added IP to blacklist", "ip", ip) | |
| 243 | - | ||
| 244 | - | // Force a reload | |
| 245 | - | return b.Load() | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | // AddRange adds an IP range to the blacklist immediately | |
| 249 | - | func (b *BlacklistConfig) AddRange(cidr string) error { | |
| 250 | - | b.mu.Lock() | |
| 251 | - | defer b.mu.Unlock() | |
| 252 | - | ||
| 253 | - | // Validate the CIDR | |
| 254 | - | if !strings.Contains(cidr, "/") { | |
| 255 | - | cidr = cidr + "/32" // Add /32 for single IPs | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | _, _, err := net.ParseCIDR(cidr) | |
| 259 | - | if err != nil { | |
| 260 | - | return fmt.Errorf("invalid CIDR format: %w", err) | |
| 261 | - | } | |
| 262 | - | ||
| 263 | - | // Read the current file | |
| 264 | - | data, err := os.ReadFile(b.path) | |
| 265 | - | if err != nil { | |
| 266 | - | return err | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | // Parse the current data | |
| 270 | - | var config MarshalableConfig | |
| 271 | - | if err := yaml.Unmarshal(data, &config); err != nil { | |
| 272 | - | return err | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | // Check if the range is already in the list | |
| 276 | - | for _, blockedRange := range config.Ranges { | |
| 277 | - | if blockedRange == cidr { | |
| 278 | - | return nil // Already in the list | |
| 279 | - | } | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | // Add the range to the list | |
| 283 | - | config.Ranges = append(config.Ranges, cidr) | |
| 284 | - | ||
| 285 | - | // Write the updated config back to the file | |
| 286 | - | newData, err := yaml.Marshal(config) | |
| 287 | - | if err != nil { | |
| 288 | - | return err | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | if err := os.WriteFile(b.path, newData, 0644); err != nil { | |
| 292 | - | return err | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | logger.Info("Added CIDR range to blacklist", "cidr", cidr) | |
| 296 | - | ||
| 297 | - | // Force a reload to update Networks and the cache | |
| 298 | - | return b.Load() | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | // RemoveIP removes an IP from the blacklist | |
| 302 | - | func (b *BlacklistConfig) RemoveIP(ip string) error { | |
| 303 | - | b.mu.Lock() | |
| 304 | - | defer b.mu.Unlock() | |
| 305 | - | ||
| 306 | - | // Read the current file | |
| 307 | - | data, err := os.ReadFile(b.path) | |
| 308 | - | if err != nil { | |
| 309 | - | return err | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // Parse the current data | |
| 313 | - | var config MarshalableConfig | |
| 314 | - | if err := yaml.Unmarshal(data, &config); err != nil { | |
| 315 | - | return err | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | // Check if the IP is in the list | |
| 319 | - | found := false | |
| 320 | - | newIPs := make([]string, 0, len(config.IPs)) | |
| 321 | - | for _, blockedIP := range config.IPs { | |
| 322 | - | if blockedIP == ip { | |
| 323 | - | found = true | |
| 324 | - | continue // Skip this IP | |
| 325 | - | } | |
| 326 | - | newIPs = append(newIPs, blockedIP) | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | if !found { | |
| 330 | - | return fmt.Errorf("IP not found in blacklist: %s", ip) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | // Update the list | |
| 334 | - | config.IPs = newIPs | |
| 335 | - | ||
| 336 | - | // Write the updated config back to the file | |
| 337 | - | newData, err := yaml.Marshal(config) | |
| 338 | - | if err != nil { | |
| 339 | - | return err | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | if err := os.WriteFile(b.path, newData, 0644); err != nil { | |
| 343 | - | return err | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | // Remove from cache | |
| 347 | - | delete(b.blockedCache, ip) | |
| 348 | - | logger.Info("Removed IP from blacklist", "ip", ip) | |
| 349 | - | ||
| 350 | - | // Force a reload | |
| 351 | - | return b.Load() | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | // RemoveRange removes an IP range from the blacklist | |
| 355 | - | func (b *BlacklistConfig) RemoveRange(cidr string) error { | |
| 356 | - | b.mu.Lock() | |
| 357 | - | defer b.mu.Unlock() | |
| 358 | - | ||
| 359 | - | // Normalize CIDR | |
| 360 | - | if !strings.Contains(cidr, "/") { | |
| 361 | - | cidr = cidr + "/32" // Add /32 for single IPs | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | // Read the current file | |
| 365 | - | data, err := os.ReadFile(b.path) | |
| 366 | - | if err != nil { | |
| 367 | - | return err | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | // Parse the current data | |
| 371 | - | var config MarshalableConfig | |
| 372 | - | if err := yaml.Unmarshal(data, &config); err != nil { | |
| 373 | - | return err | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | // Check if the range is in the list | |
| 377 | - | found := false | |
| 378 | - | newRanges := make([]string, 0, len(config.Ranges)) | |
| 379 | - | for _, blockedRange := range config.Ranges { | |
| 380 | - | if blockedRange == cidr { | |
| 381 | - | found = true | |
| 382 | - | continue // Skip this range | |
| 383 | - | } | |
| 384 | - | newRanges = append(newRanges, blockedRange) | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | if !found { | |
| 388 | - | return fmt.Errorf("range not found in blacklist: %s", cidr) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | // Update the list | |
| 392 | - | config.Ranges = newRanges | |
| 393 | - | ||
| 394 | - | // Write the updated config back to the file | |
| 395 | - | newData, err := yaml.Marshal(config) | |
| 396 | - | if err != nil { | |
| 397 | - | return err | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | if err := os.WriteFile(b.path, newData, 0644); err != nil { | |
| 401 | - | return err | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | logger.Info("Removed CIDR range from blacklist", "cidr", cidr) | |
| 405 | - | ||
| 406 | - | // Force a reload to rebuild Networks and the cache | |
| 407 | - | return b.Load() | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | // logBlockedIP is a helper method to log blocked IPs with rate limiting | |
| 411 | - | func (p *Proxy) logBlockedIP(clientIP, path, userAgent string) { | |
| 412 | - | p.blockedIPCountMu.Lock() | |
| 413 | - | defer p.blockedIPCountMu.Unlock() | |
| 414 | - | ||
| 415 | - | now := time.Now() | |
| 416 | - | ||
| 417 | - | // If this is the first block or it's been more than 5 minutes since the last summary, | |
| 418 | - | // or if this is a new IP we haven't seen recently | |
| 419 | - | if p.lastBlockedLog.IsZero() || | |
| 420 | - | now.Sub(p.lastBlockedLog) > 5*time.Minute || | |
| 421 | - | p.blockedIPCounter[clientIP] == 0 { | |
| 422 | - | ||
| 423 | - | // Log this block and update counters | |
| 424 | - | logger.Info("Blocked request from blacklisted IP", | |
| 425 | - | "ip", clientIP, | |
| 426 | - | "path", path, | |
| 427 | - | "user_agent", userAgent) | |
| 428 | - | ||
| 429 | - | // If we're resetting due to time, clear the counters | |
| 430 | - | if p.lastBlockedLog.IsZero() || now.Sub(p.lastBlockedLog) > 5*time.Minute { | |
| 431 | - | p.blockedIPCounter = make(map[string]int) | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | p.blockedIPCounter[clientIP] = 1 | |
| 435 | - | p.lastBlockedLog = now | |
| 436 | - | return | |
| 437 | - | } | |
| 438 | - | ||
| 439 | - | // Increment counter for this IP | |
| 440 | - | p.blockedIPCounter[clientIP]++ | |
| 441 | - | ||
| 442 | - | // Only log summaries every 5 minutes by default | |
| 443 | - | if now.Sub(p.lastBlockedLog) >= 5*time.Minute { | |
| 444 | - | // Log summary of blocked requests | |
| 445 | - | totalBlocked := 0 | |
| 446 | - | for _, count := range p.blockedIPCounter { | |
| 447 | - | totalBlocked += count | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | logger.Info("Blocked IP summary", | |
| 451 | - | "unique_ips", len(p.blockedIPCounter), | |
| 452 | - | "total_requests", totalBlocked, | |
| 453 | - | "since", p.lastBlockedLog.Format(time.RFC3339)) | |
| 454 | - | ||
| 455 | - | // Reset counters | |
| 456 | - | p.blockedIPCounter = make(map[string]int) | |
| 457 | - | p.lastBlockedLog = now | |
| 458 | - | } | |
| 459 | - | ||
| 460 | - | // Debug logging only if needed | |
| 461 | - | if false { | |
| 462 | - | logger.Debug("Request marked as blacklisted for logging skip", "ip", clientIP) | |
| 463 | - | } | |
| 464 | - | } | |
M internal/proxy/certificates.go +240 -27
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "net" | |
| 15 | 15 | "net/http" | |
| 16 | 16 | "os" | |
| 17 | + | "path/filepath" | |
| 17 | 18 | "regexp" | |
| 18 | 19 | "strings" | |
| 19 | 20 | "time" | |
| @@ -69,8 +70,7 @@ func (p *Proxy) setupCertManager() { | |||
| 69 | 70 | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 70 | 71 | rootDomain := p.app.GetConfig().Http.Domain | |
| 71 | 72 | ||
| 72 | - | // Use a more permissive hostpolicy that logs unknown domains rather than rejecting them | |
| 73 | - | // This helps debug certificate acquisition issues | |
| 73 | + | // Use a stricter hostpolicy that only allows domains in our routes | |
| 74 | 74 | certManager.HostPolicy = func(_ context.Context, host string) error { | |
| 75 | 75 | // Always allow the admin domain and root domain | |
| 76 | 76 | if host == adminDomain || host == rootDomain { | |
| @@ -85,16 +85,14 @@ func (p *Proxy) setupCertManager() { | |||
| 85 | 85 | return nil | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | // Log the attempt but still allow it - this helps debug | |
| 89 | - | // certificate acquisition issues during development | |
| 90 | - | logger.Warn("Unknown host in certificate request", | |
| 88 | + | // Log the attempt and reject it | |
| 89 | + | logger.Warn("Rejecting certificate request for unknown host", | |
| 91 | 90 | "host", host, | |
| 92 | 91 | "adminDomain", adminDomain, | |
| 93 | - | "allowed", "yes") | |
| 92 | + | "allowed", "no") | |
| 94 | 93 | ||
| 95 | - | // Allow unknown domains temporarily to help diagnose issues | |
| 96 | - | // Change this to return an error in production for stricter security | |
| 97 | - | return nil | |
| 94 | + | // Return an error for domains not in our routes | |
| 95 | + | return fmt.Errorf("host %q not configured in routes", host) | |
| 98 | 96 | } | |
| 99 | 97 | ||
| 100 | 98 | // Generate a fallback self-signed certificate for the admin domain | |
| @@ -153,49 +151,226 @@ func (p *Proxy) checkCertificateInCache(domain string) bool { | |||
| 153 | 151 | return false | |
| 154 | 152 | } | |
| 155 | 153 | ||
| 154 | + | // Validate the certificate data | |
| 155 | + | validCert, expiresInDays := isCertificateValid(certData, domain) | |
| 156 | + | if !validCert { | |
| 157 | + | return false | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | logger.Info("Valid certificate found in cache", | |
| 161 | + | "domain", domain, | |
| 162 | + | "expires_in", expiresInDays, | |
| 163 | + | "days") | |
| 164 | + | return true | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // isCertificateValid checks if a certificate PEM data is valid and not expired | |
| 168 | + | // Returns whether it's valid and how many days until expiration | |
| 169 | + | func isCertificateValid(certData []byte, domain string) (bool, float64) { | |
| 156 | 170 | // Parse the certificate to check its validity | |
| 157 | 171 | block, _ := pem.Decode(certData) | |
| 158 | 172 | if block == nil || block.Type != "CERTIFICATE" { | |
| 159 | - | logger.Warn("Invalid certificate data in cache", | |
| 173 | + | logger.Warn("Invalid certificate data", | |
| 160 | 174 | "domain", domain) | |
| 161 | - | return false | |
| 175 | + | return false, 0 | |
| 162 | 176 | } | |
| 163 | 177 | ||
| 164 | 178 | cert, err := x509.ParseCertificate(block.Bytes) | |
| 165 | 179 | if err != nil { | |
| 166 | - | logger.Warn("Failed to parse certificate from cache", | |
| 180 | + | logger.Warn("Failed to parse certificate", | |
| 167 | 181 | "domain", domain, | |
| 168 | 182 | "error", err) | |
| 169 | - | return false | |
| 183 | + | return false, 0 | |
| 170 | 184 | } | |
| 171 | 185 | ||
| 172 | 186 | // Check if the certificate is still valid | |
| 173 | 187 | now := time.Now() | |
| 174 | 188 | if now.After(cert.NotAfter) || now.Before(cert.NotBefore) { | |
| 175 | - | logger.Info("Certificate in cache has expired or is not yet valid", | |
| 189 | + | logger.Info("Certificate has expired or is not yet valid", | |
| 176 | 190 | "domain", domain, | |
| 177 | 191 | "not_before", cert.NotBefore, | |
| 178 | 192 | "not_after", cert.NotAfter) | |
| 179 | - | return false | |
| 193 | + | return false, 0 | |
| 180 | 194 | } | |
| 181 | 195 | ||
| 196 | + | // Calculate days until expiration | |
| 197 | + | expiresInDays := cert.NotAfter.Sub(now).Hours() / 24 | |
| 198 | + | ||
| 182 | 199 | // Check if the certificate is about to expire (within 30 days) | |
| 183 | - | if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) { | |
| 184 | - | logger.Info("Certificate in cache is valid but will expire soon", | |
| 200 | + | if expiresInDays < 30 { | |
| 201 | + | logger.Info("Certificate is valid but will expire soon", | |
| 185 | 202 | "domain", domain, | |
| 186 | - | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | |
| 203 | + | "expires_in", expiresInDays, | |
| 187 | 204 | "days") | |
| 188 | 205 | // Return false to trigger renewal if it's about to expire | |
| 206 | + | return false, expiresInDays | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | return true, expiresInDays | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // checkCertificateInFilesystem checks if valid certificate files exist directly in the certs directory | |
| 213 | + | // This is useful when the database record might be missing but valid certificate files exist | |
| 214 | + | func (p *Proxy) checkCertificateInFilesystem(domain string) bool { | |
| 215 | + | if p.certManager == nil { | |
| 189 | 216 | return false | |
| 190 | 217 | } | |
| 191 | 218 | ||
| 192 | - | logger.Info("Valid certificate found in cache", | |
| 219 | + | // Get the certificate directory path | |
| 220 | + | certDir := p.config.CertDir | |
| 221 | + | if certDir == "" { | |
| 222 | + | certDir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Check for the certificate file and its corresponding private key | |
| 226 | + | certFile := filepath.Join(certDir, domain) | |
| 227 | + | keyFile := filepath.Join(certDir, domain+"+rsa") | |
| 228 | + | ||
| 229 | + | // Check if both files exist | |
| 230 | + | if _, err := os.Stat(certFile); os.IsNotExist(err) { | |
| 231 | + | logger.Debug("Certificate file not found in filesystem", | |
| 232 | + | "domain", domain, | |
| 233 | + | "file", certFile) | |
| 234 | + | return false | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | if _, err := os.Stat(keyFile); os.IsNotExist(err) { | |
| 238 | + | logger.Debug("Certificate key file not found in filesystem", | |
| 239 | + | "domain", domain, | |
| 240 | + | "file", keyFile) | |
| 241 | + | return false | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // If we found both files, validate the certificate | |
| 245 | + | certData, err := os.ReadFile(certFile) | |
| 246 | + | if err != nil { | |
| 247 | + | logger.Warn("Failed to read certificate file", | |
| 248 | + | "domain", domain, | |
| 249 | + | "file", certFile, | |
| 250 | + | "error", err) | |
| 251 | + | return false | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // Validate the certificate data | |
| 255 | + | validCert, expiresInDays := isCertificateValid(certData, domain) | |
| 256 | + | if !validCert { | |
| 257 | + | return false | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | logger.Info("Valid certificate found in filesystem", | |
| 193 | 261 | "domain", domain, | |
| 194 | - | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | |
| 195 | - | "days") | |
| 262 | + | "expires_in", expiresInDays, | |
| 263 | + | "days", | |
| 264 | + | "not_in_cache", true) | |
| 265 | + | ||
| 266 | + | // Log some debug information about the certificate files | |
| 267 | + | certFileInfo, _ := os.Stat(certFile) | |
| 268 | + | keyFileInfo, _ := os.Stat(keyFile) | |
| 269 | + | logger.Debug("Certificate file details", | |
| 270 | + | "domain", domain, | |
| 271 | + | "cert_file", certFile, | |
| 272 | + | "cert_size", certFileInfo.Size(), | |
| 273 | + | "cert_modified", certFileInfo.ModTime(), | |
| 274 | + | "key_file", keyFile, | |
| 275 | + | "key_size", keyFileInfo.Size(), | |
| 276 | + | "key_modified", keyFileInfo.ModTime()) | |
| 277 | + | ||
| 196 | 278 | return true | |
| 197 | 279 | } | |
| 198 | 280 | ||
| 281 | + | // testCertificateWithTLSConnection attempts to establish a TLS connection to verify the certificate | |
| 282 | + | // This is an optional verification step for certificates we find on the filesystem | |
| 283 | + | func testCertificateWithTLSConnection(domain string, port string) bool { | |
| 284 | + | if port == "" { | |
| 285 | + | port = "443" // Default HTTPS port | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // Create a connection with a short timeout | |
| 289 | + | dialer := &net.Dialer{ | |
| 290 | + | Timeout: 5 * time.Second, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // Connect to the server | |
| 294 | + | logger.Debug("Testing certificate by establishing TLS connection", | |
| 295 | + | "domain", domain, | |
| 296 | + | "address", domain+":"+port) | |
| 297 | + | ||
| 298 | + | conn, err := tls.DialWithDialer(dialer, "tcp", domain+":"+port, &tls.Config{ | |
| 299 | + | // Skip verification since we're testing if the connection works | |
| 300 | + | // not validating the cert chain (which might use custom CA) | |
| 301 | + | InsecureSkipVerify: true, | |
| 302 | + | ServerName: domain, | |
| 303 | + | }) | |
| 304 | + | ||
| 305 | + | if err != nil { | |
| 306 | + | logger.Debug("TLS connection test failed", | |
| 307 | + | "domain", domain, | |
| 308 | + | "error", err) | |
| 309 | + | return false | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // Close the connection when done | |
| 313 | + | defer conn.Close() | |
| 314 | + | ||
| 315 | + | // Verify the connection state | |
| 316 | + | state := conn.ConnectionState() | |
| 317 | + | ||
| 318 | + | // Check if the certificate is valid | |
| 319 | + | for _, cert := range state.PeerCertificates { | |
| 320 | + | // Verify the certificate is valid for the domain | |
| 321 | + | if err := cert.VerifyHostname(domain); err != nil { | |
| 322 | + | logger.Debug("Certificate hostname verification failed", | |
| 323 | + | "domain", domain, | |
| 324 | + | "cert_domains", cert.DNSNames, | |
| 325 | + | "error", err) | |
| 326 | + | continue | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // Check certificate expiration | |
| 330 | + | now := time.Now() | |
| 331 | + | if now.After(cert.NotAfter) || now.Before(cert.NotBefore) { | |
| 332 | + | logger.Debug("Certificate from TLS connection is not valid", | |
| 333 | + | "domain", domain, | |
| 334 | + | "not_before", cert.NotBefore, | |
| 335 | + | "not_after", cert.NotAfter) | |
| 336 | + | continue | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // We found a valid certificate | |
| 340 | + | expiresInDays := cert.NotAfter.Sub(now).Hours() / 24 | |
| 341 | + | logger.Info("Successfully verified certificate via TLS connection", | |
| 342 | + | "domain", domain, | |
| 343 | + | "expires_in", expiresInDays, | |
| 344 | + | "days", | |
| 345 | + | "issuer", cert.Issuer.CommonName) | |
| 346 | + | return true | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | logger.Debug("No valid certificate found from TLS connection", | |
| 350 | + | "domain", domain) | |
| 351 | + | return false | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // checkCertificateExists is a helper function that checks if a valid certificate exists | |
| 355 | + | // either in the cache or the filesystem. Returns true if a valid certificate is found. | |
| 356 | + | func (p *Proxy) checkCertificateExists(domain string) bool { | |
| 357 | + | // First check in the cache | |
| 358 | + | if p.checkCertificateInCache(domain) { | |
| 359 | + | logger.Debug("Certificate exists in cache", "domain", domain) | |
| 360 | + | return true | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // If not in cache, check the filesystem | |
| 364 | + | if p.checkCertificateInFilesystem(domain) { | |
| 365 | + | logger.Debug("Certificate exists in filesystem but not in cache", "domain", domain) | |
| 366 | + | return true | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // No valid certificate found | |
| 370 | + | logger.Debug("No valid certificate found for domain", "domain", domain) | |
| 371 | + | return false | |
| 372 | + | } | |
| 373 | + | ||
| 199 | 374 | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | |
| 200 | 375 | // for the Gordon admin interface | |
| 201 | 376 | func (p *Proxy) requestAdminCertificate() { | |
| @@ -230,11 +405,30 @@ func (p *Proxy) requestAdminCertificate() { | |||
| 230 | 405 | "domain", adminDomain, | |
| 231 | 406 | "ips", ipStrings) | |
| 232 | 407 | ||
| 233 | - | // Check if we already have a valid certificate in the cache | |
| 234 | - | if p.checkCertificateInCache(adminDomain) { | |
| 235 | - | logger.Info("Using existing certificate from cache", | |
| 408 | + | // Check if we already have a valid certificate (cache or filesystem) | |
| 409 | + | if p.checkCertificateExists(adminDomain) { | |
| 410 | + | logger.Info("Using existing certificate", | |
| 236 | 411 | "domain", adminDomain, | |
| 237 | 412 | "action", "skipping Let's Encrypt request to avoid rate limits") | |
| 413 | + | ||
| 414 | + | // If a valid certificate was found in the filesystem but not in the cache, | |
| 415 | + | // try to verify it with a TLS connection (non-blocking) | |
| 416 | + | go func() { | |
| 417 | + | // Only attempt TLS verification if certificate was found in filesystem | |
| 418 | + | // and not in cache (to avoid unnecessary check for cached certificates) | |
| 419 | + | if !p.checkCertificateInCache(adminDomain) && p.checkCertificateInFilesystem(adminDomain) { | |
| 420 | + | if testCertificateWithTLSConnection(adminDomain, p.config.Port) { | |
| 421 | + | logger.Debug("Certificate verification succeeded via TLS connection", | |
| 422 | + | "domain", adminDomain, | |
| 423 | + | "note", "Certificate is trusted and working properly") | |
| 424 | + | } else { | |
| 425 | + | logger.Warn("Certificate verification via TLS connection failed", | |
| 426 | + | "domain", adminDomain, | |
| 427 | + | "note", "Certificate exists but might not be trusted or properly configured") | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | }() | |
| 431 | + | ||
| 238 | 432 | return | |
| 239 | 433 | } | |
| 240 | 434 | ||
| @@ -365,11 +559,30 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||
| 365 | 559 | "domain", domain, | |
| 366 | 560 | "ips", ipStrings) | |
| 367 | 561 | ||
| 368 | - | // Check if we already have a valid certificate in the cache | |
| 369 | - | if p.checkCertificateInCache(domain) { | |
| 370 | - | logger.Info("Using existing certificate from cache", | |
| 562 | + | // Check if we already have a valid certificate (cache or filesystem) | |
| 563 | + | if p.checkCertificateExists(domain) { | |
| 564 | + | logger.Info("Using existing certificate", | |
| 371 | 565 | "domain", domain, | |
| 372 | 566 | "action", "skipping Let's Encrypt request to avoid rate limits") | |
| 567 | + | ||
| 568 | + | // If a valid certificate was found in the filesystem but not in the cache, | |
| 569 | + | // try to verify it with a TLS connection (non-blocking) | |
| 570 | + | go func() { | |
| 571 | + | // Only attempt TLS verification if certificate was found in filesystem | |
| 572 | + | // and not in cache (to avoid unnecessary check for cached certificates) | |
| 573 | + | if !p.checkCertificateInCache(domain) && p.checkCertificateInFilesystem(domain) { | |
| 574 | + | if testCertificateWithTLSConnection(domain, p.config.Port) { | |
| 575 | + | logger.Debug("Certificate verification succeeded via TLS connection", | |
| 576 | + | "domain", domain, | |
| 577 | + | "note", "Certificate is trusted and working properly") | |
| 578 | + | } else { | |
| 579 | + | logger.Warn("Certificate verification via TLS connection failed", | |
| 580 | + | "domain", domain, | |
| 581 | + | "note", "Certificate exists but might not be trusted or properly configured") | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | }() | |
| 585 | + | ||
| 373 | 586 | return | |
| 374 | 587 | } | |
| 375 | 588 | ||
M internal/proxy/core.go +23 -29
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "net/http" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/common" | |
| 13 | + | "github.com/bnema/gordon/internal/db/queries" | |
| 13 | 14 | "github.com/bnema/gordon/internal/interfaces" | |
| 14 | 15 | "github.com/bnema/gordon/pkg/docker" | |
| 15 | 16 | "github.com/bnema/gordon/pkg/logger" | |
| @@ -36,16 +37,6 @@ type Proxy struct { | |||
| 36 | 37 | certManager *autocert.Manager | |
| 37 | 38 | fallbackCert *tls.Certificate // Fallback self-signed certificate | |
| 38 | 39 | serverStarted bool | |
| 39 | - | blacklist *BlacklistConfig | |
| 40 | - | ||
| 41 | - | // Fields for throttling blacklist logs | |
| 42 | - | lastBlockedLog time.Time | |
| 43 | - | blockedIPCounter map[string]int | |
| 44 | - | blockedIPCountMu sync.Mutex | |
| 45 | - | ||
| 46 | - | // Firewall-like memory of recently blocked IPs for quick rejection | |
| 47 | - | recentlyBlocked map[string]time.Time | |
| 48 | - | recentlyBlockedMu sync.RWMutex | |
| 49 | 40 | ||
| 50 | 41 | // Flag to track specific domain certificate operations | |
| 51 | 42 | processingSpecificDomain bool | |
| @@ -61,6 +52,14 @@ type Proxy struct { | |||
| 61 | 52 | acmePerDomainRefreshMutex sync.Mutex | |
| 62 | 53 | acmeDirCache autocert.DirCache | |
| 63 | 54 | shutdown chan struct{} // Channel for signaling shutdown to background goroutines | |
| 55 | + | ||
| 56 | + | // Track recently created containers to prevent recreation logic | |
| 57 | + | recentContainers map[string]time.Time | |
| 58 | + | recentContainersMu sync.RWMutex | |
| 59 | + | containerCooldownPeriod time.Duration | |
| 60 | + | ||
| 61 | + | // SQL queries | |
| 62 | + | queries *queries.ProxyQueries | |
| 64 | 63 | } | |
| 65 | 64 | ||
| 66 | 65 | // NewProxy creates a new instance of the reverse proxy | |
| @@ -105,30 +104,25 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 105 | 104 | } | |
| 106 | 105 | } | |
| 107 | 106 | ||
| 108 | - | // Initialize the blacklist | |
| 109 | - | blacklistPath := app.GetConfig().General.StorageDir + "/blacklist.json" | |
| 110 | - | blacklist, err := NewBlacklist(blacklistPath) | |
| 111 | - | if err != nil { | |
| 112 | - | logger.Warn("Failed to initialize blacklist, continuing without it", "error", err) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | 107 | // Initialize routes map | |
| 116 | 108 | routes := make(map[string]*ProxyRouteInfo) | |
| 117 | 109 | ||
| 110 | + | // Initialize SQL queries | |
| 111 | + | proxyQueries := queries.NewProxyQueries() | |
| 112 | + | ||
| 118 | 113 | // Create the proxy | |
| 119 | 114 | p := &Proxy{ | |
| 120 | - | config: config, | |
| 121 | - | app: app, | |
| 122 | - | httpsServer: httpsServer, | |
| 123 | - | httpServer: httpServer, | |
| 124 | - | routes: routes, | |
| 125 | - | serverStarted: false, | |
| 126 | - | blacklist: blacklist, | |
| 127 | - | blockedIPCounter: make(map[string]int), | |
| 128 | - | lastBlockedLog: time.Time{}, // Zero time | |
| 129 | - | recentlyBlocked: make(map[string]time.Time), | |
| 130 | - | gordonContainerID: ourContainerID, | |
| 131 | - | shutdown: make(chan struct{}), | |
| 115 | + | config: config, | |
| 116 | + | app: app, | |
| 117 | + | httpsServer: httpsServer, | |
| 118 | + | httpServer: httpServer, | |
| 119 | + | routes: routes, | |
| 120 | + | serverStarted: false, | |
| 121 | + | gordonContainerID: ourContainerID, | |
| 122 | + | shutdown: make(chan struct{}), | |
| 123 | + | recentContainers: make(map[string]time.Time), | |
| 124 | + | containerCooldownPeriod: 10 * time.Second, // Default 10 second cooldown period | |
| 125 | + | queries: proxyQueries, | |
| 132 | 126 | } | |
| 133 | 127 | ||
| 134 | 128 | // Now add the middleware with the proxy reference | |
M internal/proxy/db_helpers.go +5 -111
| @@ -2,132 +2,26 @@ package proxy | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | "time" | |
| 7 | 5 | ||
| 8 | - | "github.com/charmbracelet/log" | |
| 6 | + | "github.com/bnema/gordon/internal/db" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| 11 | 9 | // dbExecWithRetry performs a database execution with retry logic for handling locked database | |
| 12 | 10 | func (p *Proxy) dbExecWithRetry(query string, args ...interface{}) (sql.Result, error) { | |
| 13 | - | var result sql.Result | |
| 14 | - | var err error | |
| 15 | - | maxRetries := 5 | |
| 16 | - | retryDelay := 100 * time.Millisecond | |
| 17 | - | ||
| 18 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 19 | - | result, err = p.app.GetDB().Exec(query, args...) | |
| 20 | - | if err == nil { | |
| 21 | - | return result, nil | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // Check if this is a database locked error | |
| 25 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 26 | - | log.Debug("Database locked, retrying operation", | |
| 27 | - | "attempt", attempt, | |
| 28 | - | "max_retries", maxRetries) | |
| 29 | - | time.Sleep(retryDelay) | |
| 30 | - | // Increase delay for next retry (exponential backoff) | |
| 31 | - | retryDelay *= 2 | |
| 32 | - | continue | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // If it's not a locking error, return immediately | |
| 36 | - | return nil, err | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 11 | + | return db.ExecWithRetry(p.app.GetDB(), query, args...) | |
| 40 | 12 | } | |
| 41 | 13 | ||
| 42 | 14 | // dbQueryWithRetry performs a database query with retry logic for handling locked database | |
| 43 | 15 | func (p *Proxy) dbQueryWithRetry(query string, args ...interface{}) (*sql.Rows, error) { | |
| 44 | - | var rows *sql.Rows | |
| 45 | - | var err error | |
| 46 | - | maxRetries := 5 | |
| 47 | - | retryDelay := 100 * time.Millisecond | |
| 48 | - | ||
| 49 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 50 | - | rows, err = p.app.GetDB().Query(query, args...) | |
| 51 | - | if err == nil { | |
| 52 | - | return rows, nil | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // Check if this is a database locked error | |
| 56 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 57 | - | log.Debug("Database locked, retrying operation", | |
| 58 | - | "attempt", attempt, | |
| 59 | - | "max_retries", maxRetries) | |
| 60 | - | time.Sleep(retryDelay) | |
| 61 | - | // Increase delay for next retry (exponential backoff) | |
| 62 | - | retryDelay *= 2 | |
| 63 | - | continue | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // If it's not a locking error, return immediately | |
| 67 | - | return nil, err | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 16 | + | return db.QueryWithRetry(p.app.GetDB(), query, args...) | |
| 71 | 17 | } | |
| 72 | 18 | ||
| 73 | 19 | // dbBeginWithRetry starts a transaction with retry logic for handling locked database | |
| 74 | 20 | func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) { | |
| 75 | - | var tx *sql.Tx | |
| 76 | - | var err error | |
| 77 | - | maxRetries := 5 | |
| 78 | - | retryDelay := 100 * time.Millisecond | |
| 79 | - | ||
| 80 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 81 | - | tx, err = p.app.GetDB().Begin() | |
| 82 | - | if err == nil { | |
| 83 | - | return tx, nil | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // Check if this is a database locked error | |
| 87 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 88 | - | log.Debug("Database locked, retrying transaction start", | |
| 89 | - | "attempt", attempt, | |
| 90 | - | "max_retries", maxRetries) | |
| 91 | - | time.Sleep(retryDelay) | |
| 92 | - | // Increase delay for next retry (exponential backoff) | |
| 93 | - | retryDelay *= 2 | |
| 94 | - | continue | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // If it's not a locking error, return immediately | |
| 98 | - | return nil, err | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 21 | + | return db.BeginWithRetry(p.app.GetDB()) | |
| 102 | 22 | } | |
| 103 | 23 | ||
| 104 | 24 | // txExecWithRetry executes a query within a transaction with retry logic | |
| 105 | 25 | func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) { | |
| 106 | - | var result sql.Result | |
| 107 | - | var err error | |
| 108 | - | maxRetries := 5 | |
| 109 | - | retryDelay := 100 * time.Millisecond | |
| 110 | - | ||
| 111 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 112 | - | result, err = tx.Exec(query, args...) | |
| 113 | - | if err == nil { | |
| 114 | - | return result, nil | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Check if this is a database locked error | |
| 118 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 119 | - | log.Debug("Database locked, retrying transaction operation", | |
| 120 | - | "attempt", attempt, | |
| 121 | - | "max_retries", maxRetries) | |
| 122 | - | time.Sleep(retryDelay) | |
| 123 | - | // Increase delay for next retry (exponential backoff) | |
| 124 | - | retryDelay *= 2 | |
| 125 | - | continue | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // If it's not a locking error, return immediately | |
| 129 | - | return nil, err | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 26 | + | return db.TxExecWithRetry(tx, query, args...) | |
| 133 | 27 | } | |
M internal/proxy/middleware.go +130 -192
| @@ -1,9 +1,7 @@ | |||
| 1 | 1 | package proxy | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "net" | |
| 5 | 4 | "net/http" | |
| 6 | - | "strings" | |
| 7 | 5 | "time" | |
| 8 | 6 | ||
| 9 | 7 | "github.com/bnema/gordon/pkg/logger" | |
| @@ -18,12 +16,6 @@ import ( | |||
| 18 | 16 | // rateLimiterCleanup holds the cleanup functions for rate limiters | |
| 19 | 17 | var rateLimiterCleanup []func() | |
| 20 | 18 | ||
| 21 | - | // Create blacklistedIPs set to track IPs for middleware skipping | |
| 22 | - | type requestContext struct { | |
| 23 | - | blacklisted bool | |
| 24 | - | requestID string | |
| 25 | - | } | |
| 26 | - | ||
| 27 | 19 | // RequestIDKey is the key used to store the request ID in the context | |
| 28 | 20 | const RequestIDKey = "request_id" | |
| 29 | 21 | ||
| @@ -38,83 +30,71 @@ func (p *Proxy) setupMiddleware() { | |||
| 38 | 30 | p.httpsServer.Use(p.createRequestIDMiddleware()) | |
| 39 | 31 | p.httpServer.Use(p.createRequestIDMiddleware()) | |
| 40 | 32 | ||
| 41 | - | // Add debug middleware to log all headers | |
| 42 | - | p.httpsServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 43 | - | return func(c echo.Context) error { | |
| 44 | - | req := c.Request() | |
| 45 | - | logger.Debug("Request headers", | |
| 46 | - | "method", req.Method, | |
| 47 | - | "path", req.URL.Path, | |
| 48 | - | "remote_addr", req.RemoteAddr, | |
| 49 | - | "x-forwarded-for", req.Header.Get("X-Forwarded-For"), | |
| 50 | - | "x-real-ip", req.Header.Get("X-Real-IP")) | |
| 51 | - | return next(c) | |
| 52 | - | } | |
| 53 | - | }) | |
| 54 | - | ||
| 55 | - | // Create blacklist middleware for HTTPS | |
| 56 | - | p.httpsServer.Use(p.createBlacklistMiddleware()) | |
| 57 | - | ||
| 58 | - | // Create a new Starskey rate limiter store for HTTPS server | |
| 59 | - | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | |
| 60 | - | "./data/ratelimiter/https", // Path to store rate limit data | |
| 61 | - | 10, // Rate: 10 requests per second (lowered from 10) | |
| 62 | - | 30, // Burst: 30 requests (lowered from 30) | |
| 63 | - | 3*time.Minute, // Expires in 3 minutes | |
| 64 | - | ) | |
| 65 | - | if err != nil { | |
| 66 | - | logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err) | |
| 67 | - | ||
| 68 | - | // Fallback to memory store if Starskey store creation fails | |
| 69 | - | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 70 | - | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |
| 71 | - | middleware.RateLimiterMemoryStoreConfig{ | |
| 72 | - | Rate: rate.Limit(10), // 10 requests per second (lowered from 10) | |
| 73 | - | Burst: 30, // Burst of 30 requests (lowered from 30) | |
| 74 | - | ExpiresIn: 3 * time.Minute, // Store expiration | |
| 33 | + | // Check if rate limiting is disabled via configuration | |
| 34 | + | if !p.config.DisableRateLimit { | |
| 35 | + | // Create a new Starskey rate limiter store for HTTPS server | |
| 36 | + | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | |
| 37 | + | "./data/ratelimiter/https", // Path to store rate limit data | |
| 38 | + | 10, // Rate: 10 requests per second (lowered from 10) | |
| 39 | + | 30, // Burst: 30 requests (lowered from 30) | |
| 40 | + | 3*time.Minute, // Expires in 3 minutes | |
| 41 | + | ) | |
| 42 | + | if err != nil { | |
| 43 | + | logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err) | |
| 44 | + | ||
| 45 | + | // Fallback to memory store if Starskey store creation fails | |
| 46 | + | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 47 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |
| 48 | + | middleware.RateLimiterMemoryStoreConfig{ | |
| 49 | + | Rate: rate.Limit(10), // 10 requests per second (lowered from 10) | |
| 50 | + | Burst: 30, // Burst of 30 requests (lowered from 30) | |
| 51 | + | ExpiresIn: 3 * time.Minute, // Store expiration | |
| 52 | + | }, | |
| 53 | + | ), | |
| 54 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 55 | + | logger.Warn("Rate limit exceeded", | |
| 56 | + | "ip", identifier, | |
| 57 | + | "path", context.Request().URL.Path, | |
| 58 | + | "method", context.Request().Method) | |
| 59 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 75 | 60 | }, | |
| 76 | - | ), | |
| 77 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 78 | - | logger.Warn("Rate limit exceeded", | |
| 79 | - | "ip", identifier, | |
| 80 | - | "path", context.Request().URL.Path, | |
| 81 | - | "method", context.Request().Method) | |
| 82 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 83 | - | }, | |
| 84 | - | })) | |
| 61 | + | })) | |
| 62 | + | } else { | |
| 63 | + | // Use the Starskey store for HTTPS rate limiting | |
| 64 | + | logger.Debug("Using Starskey rate limiter for HTTPS server") | |
| 65 | + | ||
| 66 | + | // Add custom Starskey rate limiter middleware | |
| 67 | + | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 68 | + | Store: httpsRateLimiter, | |
| 69 | + | IdentifierExtractor: func(ctx echo.Context) (string, error) { | |
| 70 | + | id := ctx.RealIP() | |
| 71 | + | logger.Debug("Rate limiting HTTPS request", | |
| 72 | + | "ip", id, | |
| 73 | + | "remote_addr", ctx.Request().RemoteAddr) | |
| 74 | + | ||
| 75 | + | // Add a response header to show the detected IP (for debugging) | |
| 76 | + | ctx.Response().Header().Set("X-Rate-Limit-IP", id) | |
| 77 | + | ||
| 78 | + | return id, nil | |
| 79 | + | }, | |
| 80 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 81 | + | logger.Warn("HTTPS rate limit exceeded", | |
| 82 | + | "ip", identifier, | |
| 83 | + | "path", context.Request().URL.Path, | |
| 84 | + | "method", context.Request().Method) | |
| 85 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 86 | + | }, | |
| 87 | + | })) | |
| 88 | + | ||
| 89 | + | // Add cleanup function to global slice | |
| 90 | + | rateLimiterCleanup = append(rateLimiterCleanup, func() { | |
| 91 | + | if err := httpsRateLimiter.Close(); err != nil { | |
| 92 | + | logger.Error("Failed to close HTTPS rate limiter store", "error", err) | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 85 | 96 | } else { | |
| 86 | - | // Use the Starskey store for HTTPS rate limiting | |
| 87 | - | logger.Debug("Using Starskey rate limiter for HTTPS server") | |
| 88 | - | ||
| 89 | - | // Add custom Starskey rate limiter middleware | |
| 90 | - | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 91 | - | Store: httpsRateLimiter, | |
| 92 | - | IdentifierExtractor: func(ctx echo.Context) (string, error) { | |
| 93 | - | id := ctx.RealIP() | |
| 94 | - | logger.Debug("Rate limiting HTTPS request", | |
| 95 | - | "ip", id, | |
| 96 | - | "remote_addr", ctx.Request().RemoteAddr) | |
| 97 | - | ||
| 98 | - | // Add a response header to show the detected IP (for debugging) | |
| 99 | - | ctx.Response().Header().Set("X-Rate-Limit-IP", id) | |
| 100 | - | ||
| 101 | - | return id, nil | |
| 102 | - | }, | |
| 103 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 104 | - | logger.Warn("HTTPS rate limit exceeded", | |
| 105 | - | "ip", identifier, | |
| 106 | - | "path", context.Request().URL.Path, | |
| 107 | - | "method", context.Request().Method) | |
| 108 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 109 | - | }, | |
| 110 | - | })) | |
| 111 | - | ||
| 112 | - | // Add cleanup function to global slice | |
| 113 | - | rateLimiterCleanup = append(rateLimiterCleanup, func() { | |
| 114 | - | if err := httpsRateLimiter.Close(); err != nil { | |
| 115 | - | logger.Error("Failed to close HTTPS rate limiter store", "error", err) | |
| 116 | - | } | |
| 117 | - | }) | |
| 97 | + | logger.Info("Rate limiting is disabled for HTTPS server via configuration") | |
| 118 | 98 | } | |
| 119 | 99 | ||
| 120 | 100 | // Add custom logger middleware with request ID if enabled in config | |
| @@ -131,69 +111,71 @@ func (p *Proxy) setupMiddleware() { | |||
| 131 | 111 | logger.Debug("HTTP request logging disabled for HTTPS server") | |
| 132 | 112 | } | |
| 133 | 113 | ||
| 134 | - | // Create blacklist middleware for HTTP | |
| 135 | - | p.httpServer.Use(p.createBlacklistMiddleware()) | |
| 136 | - | ||
| 137 | - | // Create a new Starskey rate limiter store for HTTP server | |
| 138 | - | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | |
| 139 | - | "./data/ratelimiter/http", // Path to store rate limit data | |
| 140 | - | 2, // Rate: 2 requests per second (lowered from 10) | |
| 141 | - | 10, // Burst: 10 requests (lowered from 30) | |
| 142 | - | 3*time.Minute, // Expires in 3 minutes | |
| 143 | - | ) | |
| 144 | - | if err != nil { | |
| 145 | - | logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err) | |
| 146 | - | ||
| 147 | - | // Fallback to memory store if Starskey store creation fails | |
| 148 | - | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 149 | - | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |
| 150 | - | middleware.RateLimiterMemoryStoreConfig{ | |
| 151 | - | Rate: rate.Limit(2), // 2 requests per second (lowered from 10) | |
| 152 | - | Burst: 10, // Burst of 10 requests (lowered from 30) | |
| 153 | - | ExpiresIn: 3 * time.Minute, // Store expiration | |
| 114 | + | // Check if rate limiting is disabled via configuration | |
| 115 | + | if !p.config.DisableRateLimit { | |
| 116 | + | // Create a new Starskey rate limiter store for HTTP server | |
| 117 | + | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | |
| 118 | + | "./data/ratelimiter/http", // Path to store rate limit data | |
| 119 | + | 2, // Rate: 2 requests per second (lowered from 10) | |
| 120 | + | 10, // Burst: 10 requests (lowered from 30) | |
| 121 | + | 3*time.Minute, // Expires in 3 minutes | |
| 122 | + | ) | |
| 123 | + | if err != nil { | |
| 124 | + | logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err) | |
| 125 | + | ||
| 126 | + | // Fallback to memory store if Starskey store creation fails | |
| 127 | + | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 128 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |
| 129 | + | middleware.RateLimiterMemoryStoreConfig{ | |
| 130 | + | Rate: rate.Limit(2), // 2 requests per second (lowered from 10) | |
| 131 | + | Burst: 10, // Burst of 10 requests (lowered from 30) | |
| 132 | + | ExpiresIn: 3 * time.Minute, // Store expiration | |
| 133 | + | }, | |
| 134 | + | ), | |
| 135 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 136 | + | logger.Warn("Rate limit exceeded", | |
| 137 | + | "ip", identifier, | |
| 138 | + | "path", context.Request().URL.Path, | |
| 139 | + | "method", context.Request().Method) | |
| 140 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 154 | 141 | }, | |
| 155 | - | ), | |
| 156 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 157 | - | logger.Warn("Rate limit exceeded", | |
| 158 | - | "ip", identifier, | |
| 159 | - | "path", context.Request().URL.Path, | |
| 160 | - | "method", context.Request().Method) | |
| 161 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 162 | - | }, | |
| 163 | - | })) | |
| 142 | + | })) | |
| 143 | + | } else { | |
| 144 | + | // Use the Starskey store for HTTP rate limiting | |
| 145 | + | logger.Debug("Using Starskey rate limiter for HTTP server") | |
| 146 | + | ||
| 147 | + | // Add custom Starskey rate limiter middleware | |
| 148 | + | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 149 | + | Store: httpRateLimiter, | |
| 150 | + | IdentifierExtractor: func(ctx echo.Context) (string, error) { | |
| 151 | + | id := ctx.RealIP() | |
| 152 | + | logger.Debug("Rate limiting HTTP request", | |
| 153 | + | "ip", id, | |
| 154 | + | "remote_addr", ctx.Request().RemoteAddr) | |
| 155 | + | ||
| 156 | + | // Add a response header to show the detected IP (for debugging) | |
| 157 | + | ctx.Response().Header().Set("X-Rate-Limit-IP", id) | |
| 158 | + | ||
| 159 | + | return id, nil | |
| 160 | + | }, | |
| 161 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 162 | + | logger.Warn("HTTP rate limit exceeded", | |
| 163 | + | "ip", identifier, | |
| 164 | + | "path", context.Request().URL.Path, | |
| 165 | + | "method", context.Request().Method) | |
| 166 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 167 | + | }, | |
| 168 | + | })) | |
| 169 | + | ||
| 170 | + | // Add cleanup function to global slice | |
| 171 | + | rateLimiterCleanup = append(rateLimiterCleanup, func() { | |
| 172 | + | if err := httpRateLimiter.Close(); err != nil { | |
| 173 | + | logger.Error("Failed to close HTTP rate limiter store", "error", err) | |
| 174 | + | } | |
| 175 | + | }) | |
| 176 | + | } | |
| 164 | 177 | } else { | |
| 165 | - | // Use the Starskey store for HTTP rate limiting | |
| 166 | - | logger.Debug("Using Starskey rate limiter for HTTP server") | |
| 167 | - | ||
| 168 | - | // Add custom Starskey rate limiter middleware | |
| 169 | - | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 170 | - | Store: httpRateLimiter, | |
| 171 | - | IdentifierExtractor: func(ctx echo.Context) (string, error) { | |
| 172 | - | id := ctx.RealIP() | |
| 173 | - | logger.Debug("Rate limiting HTTP request", | |
| 174 | - | "ip", id, | |
| 175 | - | "remote_addr", ctx.Request().RemoteAddr) | |
| 176 | - | ||
| 177 | - | // Add a response header to show the detected IP (for debugging) | |
| 178 | - | ctx.Response().Header().Set("X-Rate-Limit-IP", id) | |
| 179 | - | ||
| 180 | - | return id, nil | |
| 181 | - | }, | |
| 182 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 183 | - | logger.Warn("HTTP rate limit exceeded", | |
| 184 | - | "ip", identifier, | |
| 185 | - | "path", context.Request().URL.Path, | |
| 186 | - | "method", context.Request().Method) | |
| 187 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 188 | - | }, | |
| 189 | - | })) | |
| 190 | - | ||
| 191 | - | // Add cleanup function to global slice | |
| 192 | - | rateLimiterCleanup = append(rateLimiterCleanup, func() { | |
| 193 | - | if err := httpRateLimiter.Close(); err != nil { | |
| 194 | - | logger.Error("Failed to close HTTP rate limiter store", "error", err) | |
| 195 | - | } | |
| 196 | - | }) | |
| 178 | + | logger.Info("Rate limiting is disabled for HTTP server via configuration") | |
| 197 | 179 | } | |
| 198 | 180 | ||
| 199 | 181 | // Add custom logger middleware with request ID for HTTP server if enabled in config | |
| @@ -215,60 +197,16 @@ func (p *Proxy) setupMiddleware() { | |||
| 215 | 197 | func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | |
| 216 | 198 | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 217 | 199 | return func(c echo.Context) error { | |
| 218 | - | // Generate a new UUID for this request | |
| 200 | + | // Generate a UUID for this request | |
| 219 | 201 | requestID := uuid.New().String() | |
| 220 | 202 | ||
| 221 | - | // Store the UUID in the context | |
| 203 | + | // Store it in the context | |
| 222 | 204 | c.Set(RequestIDKey, requestID) | |
| 223 | 205 | ||
| 224 | - | // Add the UUID as a response header | |
| 206 | + | // Add it as a response header | |
| 225 | 207 | c.Response().Header().Set(echo.HeaderXRequestID, requestID) | |
| 226 | 208 | ||
| 227 | - | // Continue with the next middleware | |
| 228 | - | return next(c) | |
| 229 | - | } | |
| 230 | - | } | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | // createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist | |
| 234 | - | func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc { | |
| 235 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 236 | - | return func(c echo.Context) error { | |
| 237 | - | // Get client IP | |
| 238 | - | clientIP := c.RealIP() | |
| 239 | - | ||
| 240 | - | // Get host and strip port if present | |
| 241 | - | host := c.Request().Host | |
| 242 | - | if strings.Contains(host, ":") { | |
| 243 | - | host = strings.Split(host, ":")[0] | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | // Check if the host is an IP address | |
| 247 | - | if net.ParseIP(host) != nil { | |
| 248 | - | // This is an IP address being used as a hostname - silently reject | |
| 249 | - | // This prevents log spam without adding IPs to the blacklist | |
| 250 | - | // Set context for other middleware | |
| 251 | - | c.Set("reqContext", &requestContext{blacklisted: true}) | |
| 252 | - | return c.String(http.StatusForbidden, "Forbidden") | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // Debug log for all requests to check if the IP is being properly recognized | |
| 256 | - | logger.Debug("Received request", "ip", clientIP, "path", c.Request().URL.Path) | |
| 257 | - | ||
| 258 | - | // Check if IP is blacklisted (if blacklist exists) | |
| 259 | - | if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) { | |
| 260 | - | // Set context for other middleware | |
| 261 | - | c.Set("reqContext", &requestContext{blacklisted: true}) | |
| 262 | - | ||
| 263 | - | // Rate-limited logging of blocked IPs | |
| 264 | - | p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent()) | |
| 265 | - | ||
| 266 | - | // Return forbidden without calling next handlers at all | |
| 267 | - | return c.String(http.StatusForbidden, "Forbidden") | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // Store the context in the request context for non-blacklisted IPs | |
| 271 | - | c.Set("reqContext", &requestContext{blacklisted: false}) | |
| 209 | + | // Continue processing | |
| 272 | 210 | return next(c) | |
| 273 | 211 | } | |
| 274 | 212 | } | |
M internal/proxy/routes.go +363 -279
| @@ -50,12 +50,7 @@ func (p *Proxy) loadRoutes() error { | |||
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | // Query the database for active proxy routes using retry mechanism | |
| 53 | - | rows, err := p.dbQueryWithRetry(` | |
| 54 | - | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |
| 55 | - | FROM proxy_route pr | |
| 56 | - | JOIN domain d ON pr.domain_id = d.id | |
| 57 | - | WHERE pr.active = 1 | |
| 58 | - | `) | |
| 53 | + | rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes) | |
| 59 | 54 | if err != nil { | |
| 60 | 55 | if err == sql.ErrNoRows { | |
| 61 | 56 | logger.Info("No active proxy routes found") | |
| @@ -174,12 +169,6 @@ func (p *Proxy) configureRoutes() { | |||
| 174 | 169 | // Normalize host (trim trailing dot, convert to lowercase) | |
| 175 | 170 | host = strings.TrimSuffix(strings.ToLower(host), ".") | |
| 176 | 171 | ||
| 177 | - | // Special handling for debug: log all request details | |
| 178 | - | logger.Debug("Request headers for proxy", | |
| 179 | - | "domain", host, | |
| 180 | - | "X-Forwarded-For", c.Request().Header.Get("X-Forwarded-For"), | |
| 181 | - | "X-Real-IP", c.Request().Header.Get("X-Real-IP")) | |
| 182 | - | ||
| 183 | 172 | // Check if this is the admin domain | |
| 184 | 173 | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 185 | 174 | if strings.EqualFold(host, adminDomain) { | |
| @@ -189,21 +178,6 @@ func (p *Proxy) configureRoutes() { | |||
| 189 | 178 | p.mu.RUnlock() | |
| 190 | 179 | ||
| 191 | 180 | if adminOk { | |
| 192 | - | logger.Debug("Proxying request to admin domain", | |
| 193 | - | "domain", host, | |
| 194 | - | "target", fmt.Sprintf("%s://%s:%s", adminRoute.Protocol, adminRoute.ContainerIP, adminRoute.ContainerPort), | |
| 195 | - | "path", c.Request().URL.Path) | |
| 196 | - | ||
| 197 | - | // Special logging for admin domain handling | |
| 198 | - | logger.Debug("Proxying request", | |
| 199 | - | "host", host, | |
| 200 | - | "target", fmt.Sprintf("%s://%s:%s", adminRoute.Protocol, adminRoute.ContainerIP, adminRoute.ContainerPort), | |
| 201 | - | "path", c.Request().URL.Path, | |
| 202 | - | "clientIP", c.RealIP(), | |
| 203 | - | "remoteAddr", c.Request().RemoteAddr, | |
| 204 | - | "originalProtocol", c.Scheme()) | |
| 205 | - | ||
| 206 | - | // Use the admin route | |
| 207 | 181 | return p.proxyRequest(c, adminRoute) | |
| 208 | 182 | } | |
| 209 | 183 | } | |
| @@ -244,16 +218,6 @@ func (p *Proxy) configureRoutes() { | |||
| 244 | 218 | "client_ip", c.RealIP()) | |
| 245 | 219 | return c.String(http.StatusServiceUnavailable, "Route is not active") | |
| 246 | 220 | } | |
| 247 | - | ||
| 248 | - | // Regular debug logging for all domains | |
| 249 | - | logger.Debug("Proxying request", | |
| 250 | - | "host", host, | |
| 251 | - | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |
| 252 | - | "path", c.Request().URL.Path, | |
| 253 | - | "clientIP", c.RealIP(), | |
| 254 | - | "remoteAddr", c.Request().RemoteAddr, | |
| 255 | - | "originalProtocol", c.Scheme()) | |
| 256 | - | ||
| 257 | 221 | return p.proxyRequest(c, route) | |
| 258 | 222 | }) | |
| 259 | 223 | ||
| @@ -284,9 +248,35 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 284 | 248 | "protocol", protocol, | |
| 285 | 249 | "path", path) | |
| 286 | 250 | ||
| 251 | + | // Check if the container is in cooldown period - if so, let the event listener handle it | |
| 252 | + | if p.IsContainerInCooldownPeriod(containerID) { | |
| 253 | + | logger.Info("Container is in cooldown period, letting event listener handle route creation", | |
| 254 | + | "container_id", containerID, | |
| 255 | + | "domain", domainName) | |
| 256 | + | ||
| 257 | + | // Add the route to memory immediately for better UX | |
| 258 | + | p.mu.Lock() | |
| 259 | + | p.routes[domainName] = &ProxyRouteInfo{ | |
| 260 | + | Domain: domainName, | |
| 261 | + | ContainerID: containerID, | |
| 262 | + | ContainerIP: containerIP, | |
| 263 | + | ContainerPort: containerPort, | |
| 264 | + | Protocol: protocol, | |
| 265 | + | Path: path, | |
| 266 | + | Active: true, | |
| 267 | + | } | |
| 268 | + | p.mu.Unlock() | |
| 269 | + | ||
| 270 | + | // Return success - the event listener will handle the database update | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | ||
| 287 | 274 | // Begin a transaction with retry | |
| 288 | 275 | tx, err := p.dbBeginWithRetry() | |
| 289 | 276 | if err != nil { | |
| 277 | + | logger.Error("Failed to begin transaction when adding route", | |
| 278 | + | "domain", domainName, | |
| 279 | + | "error", err) | |
| 290 | 280 | return fmt.Errorf("failed to begin transaction: %w", err) | |
| 291 | 281 | } | |
| 292 | 282 | defer tx.Rollback() | |
| @@ -302,7 +292,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 302 | 292 | ||
| 303 | 293 | // Get the account ID - default to the first account if available | |
| 304 | 294 | var accountID string | |
| 305 | - | err = tx.QueryRow("SELECT id FROM account LIMIT 1").Scan(&accountID) | |
| 295 | + | err = tx.QueryRow(p.queries.GetFirstAccount).Scan(&accountID) | |
| 306 | 296 | if err != nil { | |
| 307 | 297 | if err == sql.ErrNoRows { | |
| 308 | 298 | logger.Warn("No account found when adding route, domains will have NULL account_id") | |
| @@ -314,17 +304,20 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 314 | 304 | ||
| 315 | 305 | // Check if the domain exists | |
| 316 | 306 | var domainID string | |
| 317 | - | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", hostname).Scan(&domainID) | |
| 307 | + | err = tx.QueryRow(p.queries.GetDomainByName, hostname).Scan(&domainID) | |
| 318 | 308 | if err != nil { | |
| 319 | 309 | if err == sql.ErrNoRows { | |
| 320 | 310 | // Domain doesn't exist, create it | |
| 321 | 311 | domainID = proxyGenerateUUID() | |
| 322 | 312 | now := time.Now().Format(time.RFC3339) | |
| 323 | 313 | _, err = txExecWithRetry(tx, | |
| 324 | - | "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", | |
| 314 | + | p.queries.InsertDomain, | |
| 325 | 315 | domainID, hostname, accountID, now, now, | |
| 326 | 316 | ) | |
| 327 | 317 | if err != nil { | |
| 318 | + | logger.Error("Failed to insert domain", | |
| 319 | + | "domain", hostname, | |
| 320 | + | "error", err) | |
| 328 | 321 | return fmt.Errorf("failed to insert domain: %w", err) | |
| 329 | 322 | } | |
| 330 | 323 | ||
| @@ -347,7 +340,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 347 | 340 | // Domain part doesn't exist, create it | |
| 348 | 341 | domainPartID = proxyGenerateUUID() | |
| 349 | 342 | _, err = txExecWithRetry(tx, | |
| 350 | - | "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", | |
| 343 | + | p.queries.InsertDomain, | |
| 351 | 344 | domainPartID, domainPart, accountID, now, now, | |
| 352 | 345 | ) | |
| 353 | 346 | if err != nil { | |
| @@ -363,6 +356,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 363 | 356 | } | |
| 364 | 357 | } | |
| 365 | 358 | } else { | |
| 359 | + | logger.Error("Failed to query domain", | |
| 360 | + | "domain", hostname, | |
| 361 | + | "error", err) | |
| 366 | 362 | return fmt.Errorf("failed to query domain: %w", err) | |
| 367 | 363 | } | |
| 368 | 364 | } | |
| @@ -371,10 +367,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 371 | 367 | var existingRouteID string | |
| 372 | 368 | var existingContainerID string | |
| 373 | 369 | var existingContainerPort string | |
| 374 | - | err = tx.QueryRow(` | |
| 375 | - | SELECT id, container_id, container_port | |
| 376 | - | FROM proxy_route | |
| 377 | - | WHERE domain_id = ?`, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort) | |
| 370 | + | err = tx.QueryRow(p.queries.GetRouteByDomain, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort) | |
| 378 | 371 | ||
| 379 | 372 | if err == nil { | |
| 380 | 373 | // Route exists, update it | |
| @@ -408,6 +401,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 408 | 401 | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | |
| 409 | 402 | ) | |
| 410 | 403 | if err != nil { | |
| 404 | + | logger.Error("Failed to update route", | |
| 405 | + | "domain", hostname, | |
| 406 | + | "error", err) | |
| 411 | 407 | return fmt.Errorf("failed to update route: %w", err) | |
| 412 | 408 | } | |
| 413 | 409 | } else if err == sql.ErrNoRows { | |
| @@ -415,29 +411,55 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||
| 415 | 411 | routeID := proxyGenerateUUID() | |
| 416 | 412 | now := time.Now().Format(time.RFC3339) | |
| 417 | 413 | _, err = txExecWithRetry(tx, | |
| 418 | - | `INSERT INTO proxy_route ( | |
| 419 | - | id, domain_id, container_id, container_ip, container_port, | |
| 420 | - | protocol, path, active, created_at, updated_at | |
| 421 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 414 | + | p.queries.InsertRoute, | |
| 422 | 415 | routeID, domainID, containerID, containerIP, containerPort, | |
| 423 | 416 | protocol, path, true, now, now, | |
| 424 | 417 | ) | |
| 425 | 418 | if err != nil { | |
| 419 | + | logger.Error("Failed to insert route", | |
| 420 | + | "domain", hostname, | |
| 421 | + | "error", err) | |
| 426 | 422 | return fmt.Errorf("failed to insert route: %w", err) | |
| 427 | 423 | } | |
| 428 | 424 | } else { | |
| 425 | + | logger.Error("Failed to query route", | |
| 426 | + | "domain", hostname, | |
| 427 | + | "error", err) | |
| 429 | 428 | return fmt.Errorf("failed to query route: %w", err) | |
| 430 | 429 | } | |
| 431 | 430 | ||
| 432 | 431 | // Commit the transaction | |
| 433 | 432 | if err := tx.Commit(); err != nil { | |
| 433 | + | logger.Error("Failed to commit transaction", | |
| 434 | + | "domain", hostname, | |
| 435 | + | "error", err) | |
| 434 | 436 | return fmt.Errorf("failed to commit transaction: %w", err) | |
| 435 | 437 | } | |
| 436 | 438 | ||
| 437 | - | // Reload the routes | |
| 438 | - | if err := p.Reload(); err != nil { | |
| 439 | - | return fmt.Errorf("failed to reload routes: %w", err) | |
| 439 | + | // Register this container to prevent duplicate processing | |
| 440 | + | p.RegisterNewlyCreatedContainer(containerID) | |
| 441 | + | ||
| 442 | + | // Update the in-memory routes map immediately for better UX | |
| 443 | + | p.mu.Lock() | |
| 444 | + | p.routes[hostname] = &ProxyRouteInfo{ | |
| 445 | + | Domain: hostname, | |
| 446 | + | ContainerID: containerID, | |
| 447 | + | ContainerIP: containerIP, | |
| 448 | + | ContainerPort: containerPort, | |
| 449 | + | Protocol: protocol, | |
| 450 | + | Path: path, | |
| 451 | + | Active: true, | |
| 440 | 452 | } | |
| 453 | + | p.mu.Unlock() | |
| 454 | + | ||
| 455 | + | // Reload the routes in the background to avoid blocking | |
| 456 | + | go func() { | |
| 457 | + | if err := p.Reload(); err != nil { | |
| 458 | + | logger.Error("Failed to reload routes after adding route", | |
| 459 | + | "domain", hostname, | |
| 460 | + | "error", err) | |
| 461 | + | } | |
| 462 | + | }() | |
| 441 | 463 | ||
| 442 | 464 | logger.Info("Added proxy route", | |
| 443 | 465 | "domain", hostname, | |
| @@ -482,7 +504,7 @@ func (p *Proxy) RemoveRoute(domainName string) error { | |||
| 482 | 504 | ||
| 483 | 505 | // Get the domain ID | |
| 484 | 506 | var domainID string | |
| 485 | - | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |
| 507 | + | err = tx.QueryRow(p.queries.GetDomainByName, domainName).Scan(&domainID) | |
| 486 | 508 | if err != nil { | |
| 487 | 509 | if err == sql.ErrNoRows { | |
| 488 | 510 | return fmt.Errorf("domain not found: %s", domainName) | |
| @@ -491,13 +513,13 @@ func (p *Proxy) RemoveRoute(domainName string) error { | |||
| 491 | 513 | } | |
| 492 | 514 | ||
| 493 | 515 | // Delete the route with retry | |
| 494 | - | _, err = txExecWithRetry(tx, "DELETE FROM proxy_route WHERE domain_id = ?", domainID) | |
| 516 | + | _, err = txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID) | |
| 495 | 517 | if err != nil { | |
| 496 | 518 | return fmt.Errorf("failed to delete route: %w", err) | |
| 497 | 519 | } | |
| 498 | 520 | ||
| 499 | 521 | // Delete the domain with retry | |
| 500 | - | _, err = txExecWithRetry(tx, "DELETE FROM domain WHERE id = ?", domainID) | |
| 522 | + | _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID) | |
| 501 | 523 | if err != nil { | |
| 502 | 524 | return fmt.Errorf("failed to delete domain: %w", err) | |
| 503 | 525 | } | |
| @@ -561,12 +583,7 @@ func (p *Proxy) Reload() error { | |||
| 561 | 583 | newRoutes := make(map[string]*ProxyRouteInfo) | |
| 562 | 584 | ||
| 563 | 585 | // Load routes from the database into the new map | |
| 564 | - | rows, err := p.app.GetDB().Query(` | |
| 565 | - | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |
| 566 | - | FROM proxy_route pr | |
| 567 | - | JOIN domain d ON pr.domain_id = d.id | |
| 568 | - | WHERE pr.active = 1 | |
| 569 | - | `) | |
| 586 | + | rows, err := p.app.GetDB().Query(p.queries.GetActiveRoutes) | |
| 570 | 587 | if err != nil { | |
| 571 | 588 | if err == sql.ErrNoRows { | |
| 572 | 589 | logger.Info("No active proxy routes found during reload") | |
| @@ -771,7 +788,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error { | |||
| 771 | 788 | // Next, update the database | |
| 772 | 789 | // Get the domain ID with retry | |
| 773 | 790 | var domainID string | |
| 774 | - | rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain) | |
| 791 | + | rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain) | |
| 775 | 792 | if err != nil { | |
| 776 | 793 | logger.Error("Failed to query domain for IP update", "domain", domain, "error", err) | |
| 777 | 794 | return fmt.Errorf("failed to query domain: %w", err) | |
| @@ -792,10 +809,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error { | |||
| 792 | 809 | ||
| 793 | 810 | // Update the proxy_route record with retry and ensure it's marked as active | |
| 794 | 811 | now := time.Now().Format(time.RFC3339) | |
| 795 | - | result, err := p.dbExecWithRetry(` | |
| 796 | - | UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ? | |
| 797 | - | WHERE domain_id = ? AND container_id = ?`, | |
| 798 | - | newIP, now, domainID, containerID) | |
| 812 | + | result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID) | |
| 799 | 813 | ||
| 800 | 814 | if err != nil { | |
| 801 | 815 | logger.Error("Failed to update IP in database", "domain", domain, "error", err) | |
| @@ -865,12 +879,7 @@ func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRoute | |||
| 865 | 879 | result := make(map[string]*ProxyRouteInfo) | |
| 866 | 880 | ||
| 867 | 881 | // Query the database with retry to find if any route might be associated with this container name | |
| 868 | - | rows, err := p.dbQueryWithRetry(` | |
| 869 | - | SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path | |
| 870 | - | FROM proxy_route pr | |
| 871 | - | JOIN domain d ON pr.domain_id = d.id | |
| 872 | - | WHERE pr.active = 1 | |
| 873 | - | `) | |
| 882 | + | rows, err := p.app.GetDB().Query(p.queries.GetAllRoutes) | |
| 874 | 883 | if err != nil { | |
| 875 | 884 | logger.Error("Failed to query database for routes by old name", "error", err) | |
| 876 | 885 | return result | |
| @@ -930,9 +939,39 @@ func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRoute | |||
| 930 | 939 | return result | |
| 931 | 940 | } | |
| 932 | 941 | ||
| 933 | - | // HandleContainerRecreation handles a container that has been recreated with the same name but a new ID | |
| 934 | - | // It updates all relevant routes with the new container ID and IP | |
| 942 | + | // HandleContainerRecreation handles the recreation of containers and updates routes accordingly | |
| 935 | 943 | func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerIP string) { | |
| 944 | + | // Skip processing if this container is in its cooldown period | |
| 945 | + | if p.IsContainerInCooldownPeriod(containerID) { | |
| 946 | + | logger.Debug("Skipping container recreation logic - container in cooldown period", | |
| 947 | + | "container_id", containerID, | |
| 948 | + | "container_name", containerName) | |
| 949 | + | return | |
| 950 | + | } | |
| 951 | + | ||
| 952 | + | // Use a mutex to prevent concurrent processing of the same container | |
| 953 | + | p.recentContainersMu.Lock() | |
| 954 | + | ||
| 955 | + | // Check if we're already processing this container | |
| 956 | + | if _, exists := p.recentContainers[containerID]; exists { | |
| 957 | + | logger.Debug("Skipping container recreation - already being processed", | |
| 958 | + | "container_id", containerID, | |
| 959 | + | "container_name", containerName) | |
| 960 | + | p.recentContainersMu.Unlock() | |
| 961 | + | return | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | // Mark this container as being processed | |
| 965 | + | p.recentContainers[containerID] = time.Now() | |
| 966 | + | p.recentContainersMu.Unlock() | |
| 967 | + | ||
| 968 | + | // Ensure we clean up the processing state when done | |
| 969 | + | defer func() { | |
| 970 | + | p.recentContainersMu.Lock() | |
| 971 | + | delete(p.recentContainers, containerID) | |
| 972 | + | p.recentContainersMu.Unlock() | |
| 973 | + | }() | |
| 974 | + | ||
| 936 | 975 | logger.Info("Handling potential container recreation", | |
| 937 | 976 | "container_id", containerID, | |
| 938 | 977 | "container_name", containerName, | |
| @@ -1104,139 +1143,154 @@ func (p *Proxy) StartContainerEventListener() error { | |||
| 1104 | 1143 | "container_name", containerName, | |
| 1105 | 1144 | "container_ip", containerIP) | |
| 1106 | 1145 | ||
| 1107 | - | // Check if this is the Gordon container (admin domain) | |
| 1108 | - | isAdminContainer := false | |
| 1109 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1110 | - | ||
| 1111 | - | p.mu.RLock() | |
| 1112 | - | if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID { | |
| 1113 | - | isAdminContainer = true | |
| 1114 | - | logger.Debug("Admin container event detected", "container_id", containerID) | |
| 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 | |
| 1115 | 1152 | } | |
| 1116 | - | p.mu.RUnlock() | |
| 1117 | 1153 | ||
| 1118 | - | // First, check if there are any existing routes for this container ID | |
| 1119 | - | var existingRoutes = make(map[string]*ProxyRouteInfo) | |
| 1120 | - | p.mu.RLock() | |
| 1121 | - | for domain, route := range p.routes { | |
| 1122 | - | if route.ContainerID == containerID { | |
| 1123 | - | existingRoutes[domain] = route | |
| 1124 | - | } | |
| 1125 | - | } | |
| 1126 | - | p.mu.RUnlock() | |
| 1154 | + | // Register this container as recently created to prevent duplicate processing | |
| 1155 | + | p.RegisterNewlyCreatedContainer(containerID) | |
| 1127 | 1156 | ||
| 1128 | - | // If we found existing routes, check if the IP has changed | |
| 1129 | - | if len(existingRoutes) > 0 { | |
| 1130 | - | logger.Debug("Found existing routes for container", | |
| 1131 | - | "container_id", containerID, | |
| 1132 | - | "route_count", len(existingRoutes)) | |
| 1157 | + | // Use a goroutine to handle the event asynchronously to avoid blocking the event listener | |
| 1158 | + | go func() { | |
| 1159 | + | // Add a small delay to allow any direct API calls to complete first | |
| 1160 | + | time.Sleep(500 * time.Millisecond) | |
| 1133 | 1161 | ||
| 1134 | - | // Verify the container IP with an additional check | |
| 1135 | - | verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName) | |
| 1136 | - | if err == nil && verifiedIP != "" && verifiedIP != containerIP { | |
| 1137 | - | logger.Warn("Container IP mismatch from event vs. direct network check", | |
| 1138 | - | "container_id", containerID, | |
| 1139 | - | "event_ip", containerIP, | |
| 1140 | - | "network_ip", verifiedIP) | |
| 1162 | + | // Check if this is the Gordon container (admin domain) | |
| 1163 | + | isAdminContainer := false | |
| 1164 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1141 | 1165 | ||
| 1142 | - | // Use the directly checked IP as it's more reliable | |
| 1143 | - | containerIP = verifiedIP | |
| 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) | |
| 1144 | 1170 | } | |
| 1171 | + | p.mu.RUnlock() | |
| 1145 | 1172 | ||
| 1146 | - | // Check and update each route if IP has changed | |
| 1147 | - | for domain, route := range existingRoutes { | |
| 1148 | - | // Handle admin domain specially to avoid path issues | |
| 1149 | - | if domain == adminDomain && isAdminContainer { | |
| 1150 | - | // For admin domain, only update the IP if it has changed | |
| 1151 | - | if route.ContainerIP != containerIP { | |
| 1152 | - | logger.Info("Admin container restarted with new IP - updating route", | |
| 1153 | - | "container_id", containerID, | |
| 1154 | - | "container_name", containerName, | |
| 1155 | - | "domain", domain, | |
| 1156 | - | "old_ip", route.ContainerIP, | |
| 1157 | - | "new_ip", containerIP) | |
| 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() | |
| 1158 | 1182 | ||
| 1159 | - | // Update IP in memory only first | |
| 1160 | - | p.mu.Lock() | |
| 1161 | - | if r, ok := p.routes[domain]; ok { | |
| 1162 | - | r.ContainerIP = containerIP | |
| 1163 | - | r.Active = true | |
| 1164 | - | } | |
| 1165 | - | p.mu.Unlock() | |
| 1166 | - | ||
| 1167 | - | // Update DB in background | |
| 1168 | - | go func(domain, containerID, containerIP string) { | |
| 1169 | - | // Get the domain ID | |
| 1170 | - | var domainID string | |
| 1171 | - | rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain) | |
| 1172 | - | if err != nil { | |
| 1173 | - | logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err) | |
| 1174 | - | return | |
| 1175 | - | } | |
| 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)) | |
| 1176 | 1188 | ||
| 1177 | - | if !rows.Next() { | |
| 1178 | - | rows.Close() | |
| 1179 | - | logger.Error("Admin domain not found in database", "domain", domain) | |
| 1180 | - | return | |
| 1181 | - | } | |
| 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) | |
| 1182 | 1196 | ||
| 1183 | - | if err := rows.Scan(&domainID); err != nil { | |
| 1184 | - | rows.Close() | |
| 1185 | - | logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err) | |
| 1186 | - | return | |
| 1187 | - | } | |
| 1188 | - | rows.Close() | |
| 1189 | - | ||
| 1190 | - | // Update the IP in the database directly | |
| 1191 | - | now := time.Now().Format(time.RFC3339) | |
| 1192 | - | _, err = p.dbExecWithRetry(` | |
| 1193 | - | UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ? | |
| 1194 | - | WHERE domain_id = ? AND container_id = ?`, | |
| 1195 | - | containerIP, now, domainID, containerID) | |
| 1196 | - | ||
| 1197 | - | if err != nil { | |
| 1198 | - | logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err) | |
| 1199 | - | } else { | |
| 1200 | - | logger.Info("Updated admin container IP in database", | |
| 1201 | - | "domain", domain, | |
| 1202 | - | "container_id", containerID, | |
| 1203 | - | "new_ip", containerIP) | |
| 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 | |
| 1204 | 1219 | } | |
| 1205 | - | }(domain, containerID, containerIP) | |
| 1206 | - | } | |
| 1207 | - | } else if route.ContainerIP != containerIP { | |
| 1208 | - | // For non-admin domains, use the standard route update | |
| 1209 | - | logger.Info("Container restarted with new IP - updating route", | |
| 1210 | - | "container_id", containerID, | |
| 1211 | - | "container_name", containerName, | |
| 1212 | - | "domain", domain, | |
| 1213 | - | "old_ip", route.ContainerIP, | |
| 1214 | - | "new_ip", containerIP) | |
| 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() | |
| 1215 | 1244 | ||
| 1216 | - | // Update the route IP | |
| 1217 | - | err := p.ForceUpdateRouteIP(domain, containerIP) | |
| 1218 | - | if err != nil { | |
| 1219 | - | logger.Error("Failed to update IP for restarted container", | |
| 1220 | - | "domain", domain, | |
| 1221 | - | "error", err) | |
| 1222 | - | } | |
| 1223 | - | } else { | |
| 1224 | - | // Even if IP is the same, make sure the route is active | |
| 1225 | - | if !route.Active { | |
| 1226 | - | logger.Info("Reactivating route for restarted container", | |
| 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", | |
| 1227 | 1263 | "container_id", containerID, | |
| 1228 | 1264 | "container_name", containerName, | |
| 1229 | - | "domain", domain) | |
| 1265 | + | "domain", domain, | |
| 1266 | + | "old_ip", route.ContainerIP, | |
| 1267 | + | "new_ip", containerIP) | |
| 1230 | 1268 | ||
| 1231 | - | // Just reuse ForceUpdateRouteIP with the same IP to activate the route | |
| 1232 | - | p.ForceUpdateRouteIP(domain, containerIP) | |
| 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 | + | } | |
| 1233 | 1287 | } | |
| 1234 | 1288 | } | |
| 1235 | 1289 | } | |
| 1236 | - | } | |
| 1237 | 1290 | ||
| 1238 | - | // Call the standard recreation handler for additional checks | |
| 1239 | - | p.HandleContainerRecreation(containerID, containerName, containerIP) | |
| 1291 | + | // Call the standard recreation handler for additional checks | |
| 1292 | + | p.HandleContainerRecreation(containerID, containerName, containerIP) | |
| 1293 | + | }() | |
| 1240 | 1294 | } | |
| 1241 | 1295 | ||
| 1242 | 1296 | // Create a callback function to handle container stop events | |
| @@ -1332,7 +1386,7 @@ func (p *Proxy) checkRoutesForIPConsistency() { | |||
| 1332 | 1386 | "current_ip", currentIP) | |
| 1333 | 1387 | ||
| 1334 | 1388 | // Update the route | |
| 1335 | - | err := p.ForceUpdateRouteIP(domain, currentIP) | |
| 1389 | + | err = p.ForceUpdateRouteIP(domain, currentIP) | |
| 1336 | 1390 | if err != nil { | |
| 1337 | 1391 | logger.Error("Failed to update IP during consistency check", | |
| 1338 | 1392 | "domain", domain, | |
| @@ -1382,7 +1436,7 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||
| 1382 | 1436 | for _, domain := range domains { | |
| 1383 | 1437 | // First, get the domain ID | |
| 1384 | 1438 | var domainID string | |
| 1385 | - | err := tx.QueryRow("SELECT id FROM domain WHERE name = ?", domain).Scan(&domainID) | |
| 1439 | + | err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID) | |
| 1386 | 1440 | if err != nil { | |
| 1387 | 1441 | logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err) | |
| 1388 | 1442 | continue | |
| @@ -1390,7 +1444,7 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||
| 1390 | 1444 | ||
| 1391 | 1445 | // Update the route to mark it as inactive | |
| 1392 | 1446 | now := time.Now().Format(time.RFC3339) | |
| 1393 | - | _, err = tx.Exec("UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ?", now, domainID) | |
| 1447 | + | _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID) | |
| 1394 | 1448 | if err != nil { | |
| 1395 | 1449 | logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err) | |
| 1396 | 1450 | continue | |
| @@ -1426,9 +1480,6 @@ func (p *Proxy) DiscoverMissingRoutes() { | |||
| 1426 | 1480 | return | |
| 1427 | 1481 | } | |
| 1428 | 1482 | ||
| 1429 | - | // Get the admin domain from the config | |
| 1430 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1431 | - | ||
| 1432 | 1483 | // Get all containers in the network | |
| 1433 | 1484 | networkInfo, err := docker.GetNetworkInfo(networkName) | |
| 1434 | 1485 | if err != nil { | |
| @@ -1484,7 +1535,7 @@ func (p *Proxy) DiscoverMissingRoutes() { | |||
| 1484 | 1535 | domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".") | |
| 1485 | 1536 | ||
| 1486 | 1537 | // Skip if this is the admin domain | |
| 1487 | - | if domainLabel == adminDomain { | |
| 1538 | + | if domainLabel == p.app.GetConfig().Http.FullDomain() { | |
| 1488 | 1539 | logger.Debug("Skipping auto-discovery for admin domain", | |
| 1489 | 1540 | "domain", domainLabel) | |
| 1490 | 1541 | continue | |
| @@ -1551,93 +1602,111 @@ func (p *Proxy) LogActiveContainersAndRoutes() { | |||
| 1551 | 1602 | // Get the admin domain | |
| 1552 | 1603 | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 1553 | 1604 | ||
| 1554 | - | // Check if admin domain is in routes | |
| 1555 | 1605 | p.mu.RLock() | |
| 1556 | 1606 | _, adminExists := p.routes[adminDomain] | |
| 1557 | 1607 | activeRoutes := len(p.routes) | |
| 1558 | - | p.mu.RUnlock() | |
| 1559 | 1608 | ||
| 1560 | - | if !adminExists { | |
| 1561 | - | logger.Warn("Admin domain is missing from routes", "domain", adminDomain) | |
| 1562 | - | } | |
| 1563 | - | ||
| 1564 | - | logger.Info(fmt.Sprintf("Found %d active routes", activeRoutes)) | |
| 1565 | - | ||
| 1566 | - | // Create a map of container IDs to their information | |
| 1567 | - | containerMap := make(map[string]struct { | |
| 1568 | - | Name string | |
| 1569 | - | IP string | |
| 1570 | - | Image string | |
| 1571 | - | Domains []string | |
| 1572 | - | Protocols []string | |
| 1573 | - | Ports []string | |
| 1609 | + | // Group routes by container ID directly | |
| 1610 | + | containerGroups := make(map[string][]string) | |
| 1611 | + | containerInfo := make(map[string]struct { | |
| 1612 | + | ContainerIP string | |
| 1613 | + | ContainerID string | |
| 1614 | + | Protocols []string | |
| 1615 | + | Ports []string | |
| 1574 | 1616 | }) | |
| 1575 | 1617 | ||
| 1576 | - | // Populate the map with route information | |
| 1577 | - | p.mu.RLock() | |
| 1578 | 1618 | for domain, route := range p.routes { | |
| 1579 | 1619 | if !route.Active { | |
| 1580 | 1620 | continue | |
| 1581 | 1621 | } | |
| 1582 | 1622 | ||
| 1583 | - | // If this container is not in the map yet, add it | |
| 1584 | - | if _, exists := containerMap[route.ContainerID]; !exists { | |
| 1585 | - | containerMap[route.ContainerID] = struct { | |
| 1586 | - | Name string | |
| 1587 | - | IP string | |
| 1588 | - | Image string | |
| 1589 | - | Domains []string | |
| 1590 | - | Protocols []string | |
| 1591 | - | Ports []string | |
| 1623 | + | containerGroups[route.ContainerID] = append(containerGroups[route.ContainerID], domain) | |
| 1624 | + | ||
| 1625 | + | // Store container info only once per container | |
| 1626 | + | if _, exists := containerInfo[route.ContainerID]; !exists { | |
| 1627 | + | containerInfo[route.ContainerID] = struct { | |
| 1628 | + | ContainerIP string | |
| 1629 | + | ContainerID string | |
| 1630 | + | Protocols []string | |
| 1631 | + | Ports []string | |
| 1592 | 1632 | }{ | |
| 1593 | - | Name: route.ContainerID, | |
| 1594 | - | IP: route.ContainerIP, | |
| 1595 | - | Image: "unknown", | |
| 1596 | - | Domains: []string{domain}, | |
| 1597 | - | Protocols: []string{route.Protocol}, | |
| 1598 | - | Ports: []string{route.ContainerPort}, | |
| 1633 | + | ContainerIP: route.ContainerIP, | |
| 1634 | + | ContainerID: route.ContainerID, | |
| 1635 | + | Protocols: []string{route.Protocol}, | |
| 1636 | + | Ports: []string{route.ContainerPort}, | |
| 1599 | 1637 | } | |
| 1600 | 1638 | } else { | |
| 1601 | - | // Otherwise, append this domain to the container's domains | |
| 1602 | - | container := containerMap[route.ContainerID] | |
| 1603 | - | container.Domains = append(container.Domains, domain) | |
| 1604 | - | container.Protocols = append(container.Protocols, route.Protocol) | |
| 1605 | - | container.Ports = append(container.Ports, route.ContainerPort) | |
| 1606 | - | containerMap[route.ContainerID] = container | |
| 1639 | + | info := containerInfo[route.ContainerID] | |
| 1640 | + | info.Protocols = append(info.Protocols, route.Protocol) | |
| 1641 | + | info.Ports = append(info.Ports, route.ContainerPort) | |
| 1642 | + | containerInfo[route.ContainerID] = info | |
| 1607 | 1643 | } | |
| 1608 | 1644 | } | |
| 1609 | 1645 | p.mu.RUnlock() | |
| 1610 | 1646 | ||
| 1647 | + | // Log admin domain status and active routes count | |
| 1648 | + | if !adminExists { | |
| 1649 | + | logger.Warn("Admin domain is missing from routes", "domain", adminDomain) | |
| 1650 | + | } | |
| 1651 | + | logger.Info(fmt.Sprintf("Found %d active routes", activeRoutes)) | |
| 1652 | + | ||
| 1611 | 1653 | // Get container names and images | |
| 1612 | - | for containerID, containerData := range containerMap { | |
| 1654 | + | containerNames := make(map[string]string) | |
| 1655 | + | containerImages := make(map[string]string) | |
| 1656 | + | ||
| 1657 | + | for containerID := range containerGroups { | |
| 1613 | 1658 | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 1614 | 1659 | if err == nil { | |
| 1615 | 1660 | name, err := docker.GetContainerName(containerID) | |
| 1616 | 1661 | if err == nil { | |
| 1617 | - | containerData.Name = strings.TrimLeft(name, "/") | |
| 1618 | - | containerData.Image = containerInfo.Config.Image | |
| 1619 | - | containerMap[containerID] = containerData | |
| 1662 | + | containerNames[containerID] = strings.TrimLeft(name, "/") | |
| 1663 | + | containerImages[containerID] = containerInfo.Config.Image | |
| 1620 | 1664 | } | |
| 1621 | 1665 | } | |
| 1622 | 1666 | } | |
| 1623 | 1667 | ||
| 1624 | 1668 | // Display each container and its routes | |
| 1625 | - | for containerID, container := range containerMap { | |
| 1669 | + | for containerID, domains := range containerGroups { | |
| 1626 | 1670 | // Log container info | |
| 1627 | 1671 | // Safety check to prevent slice bounds panic | |
| 1628 | 1672 | containerIDShort := containerID | |
| 1629 | 1673 | if len(containerID) >= 12 { | |
| 1630 | 1674 | containerIDShort = containerID[:12] | |
| 1631 | 1675 | } | |
| 1632 | - | logger.Info(fmt.Sprintf("Container: %s (%s)", container.Name, containerIDShort), | |
| 1633 | - | "ip", container.IP, | |
| 1634 | - | "image", container.Image) | |
| 1676 | + | ||
| 1677 | + | containerName := containerID | |
| 1678 | + | if name, exists := containerNames[containerID]; exists { | |
| 1679 | + | containerName = name | |
| 1680 | + | } | |
| 1681 | + | ||
| 1682 | + | containerImage := "unknown" | |
| 1683 | + | if image, exists := containerImages[containerID]; exists { | |
| 1684 | + | containerImage = image | |
| 1685 | + | } | |
| 1686 | + | ||
| 1687 | + | info := containerInfo[containerID] | |
| 1688 | + | ||
| 1689 | + | logger.Info(fmt.Sprintf("Container: %s (%s)", containerName, containerIDShort), | |
| 1690 | + | "ip", info.ContainerIP, | |
| 1691 | + | "image", containerImage) | |
| 1635 | 1692 | ||
| 1636 | 1693 | // Log each domain for this container | |
| 1637 | - | for i, domain := range container.Domains { | |
| 1694 | + | for i, domain := range domains { | |
| 1695 | + | // Check if i is within bounds of the protocol and port arrays | |
| 1696 | + | protocol := "unknown" | |
| 1697 | + | port := "unknown" | |
| 1698 | + | ||
| 1699 | + | if i < len(info.Protocols) { | |
| 1700 | + | protocol = info.Protocols[i] | |
| 1701 | + | } | |
| 1702 | + | ||
| 1703 | + | if i < len(info.Ports) { | |
| 1704 | + | port = info.Ports[i] | |
| 1705 | + | } | |
| 1706 | + | ||
| 1638 | 1707 | logger.Info(fmt.Sprintf(" ├─ Domain: %s", domain), | |
| 1639 | - | "protocol", container.Protocols[i], | |
| 1640 | - | "port", container.Ports[i]) | |
| 1708 | + | "protocol", protocol, | |
| 1709 | + | "port", port) | |
| 1641 | 1710 | } | |
| 1642 | 1711 | } | |
| 1643 | 1712 | ||
| @@ -1820,26 +1889,6 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1820 | 1889 | } | |
| 1821 | 1890 | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | |
| 1822 | 1891 | ||
| 1823 | - | // Set the path - keeping it simple to avoid duplication issues | |
| 1824 | - | targetURL.Path = c.Request().URL.Path | |
| 1825 | - | targetURL.RawQuery = c.Request().URL.RawQuery | |
| 1826 | - | ||
| 1827 | - | // Special logging based on domain type | |
| 1828 | - | if isAdminDomain { | |
| 1829 | - | logger.Debug("Proxying request to admin domain", | |
| 1830 | - | "domain", host, | |
| 1831 | - | "target", targetURL.String(), | |
| 1832 | - | "path", c.Request().URL.Path) | |
| 1833 | - | } else { | |
| 1834 | - | logger.Debug("Proxying request to container", | |
| 1835 | - | "host", host, | |
| 1836 | - | "target", targetURL.String(), | |
| 1837 | - | "path", c.Request().URL.Path, | |
| 1838 | - | "clientIP", c.RealIP(), | |
| 1839 | - | "remoteAddr", c.Request().RemoteAddr, | |
| 1840 | - | "originalProtocol", c.Scheme()) | |
| 1841 | - | } | |
| 1842 | - | ||
| 1843 | 1892 | // Create a reverse proxy | |
| 1844 | 1893 | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |
| 1845 | 1894 | ||
| @@ -1854,14 +1903,8 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1854 | 1903 | // Call the original director first | |
| 1855 | 1904 | originalDirector(req) | |
| 1856 | 1905 | ||
| 1857 | - | // Handle the Host header differently based on domain type | |
| 1858 | - | if isAdminDomain { | |
| 1859 | - | // For admin domain, use the target host for internal routing | |
| 1860 | - | req.Host = targetURL.Host | |
| 1861 | - | } else { | |
| 1862 | - | // For other domains, keep the original host | |
| 1863 | - | req.Host = host | |
| 1864 | - | } | |
| 1906 | + | // Keep the original host in the Host header | |
| 1907 | + | req.Host = host | |
| 1865 | 1908 | ||
| 1866 | 1909 | // Set X-Forwarded headers to preserve client information | |
| 1867 | 1910 | if clientIP := c.RealIP(); clientIP != "" { | |
| @@ -1874,12 +1917,6 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1874 | 1917 | ||
| 1875 | 1918 | // Set X-Forwarded-Host to the original host | |
| 1876 | 1919 | req.Header.Set("X-Forwarded-Host", host) | |
| 1877 | - | ||
| 1878 | - | // Log request headers for debugging | |
| 1879 | - | logger.Debug("Request headers for proxy", | |
| 1880 | - | "domain", host, | |
| 1881 | - | "X-Forwarded-For", req.Header.Get("X-Forwarded-For"), | |
| 1882 | - | "X-Real-IP", req.Header.Get("X-Real-IP")) | |
| 1883 | 1920 | } | |
| 1884 | 1921 | ||
| 1885 | 1922 | // Log errors that occur during proxying | |
| @@ -1925,3 +1962,50 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||
| 1925 | 1962 | proxy.ServeHTTP(c.Response(), c.Request()) | |
| 1926 | 1963 | return nil | |
| 1927 | 1964 | } | |
| 1965 | + | ||
| 1966 | + | // RegisterNewlyCreatedContainer adds a container to the recently created list | |
| 1967 | + | // to prevent duplicate processing during the cooldown period | |
| 1968 | + | func (p *Proxy) RegisterNewlyCreatedContainer(containerID string) { | |
| 1969 | + | p.recentContainersMu.Lock() | |
| 1970 | + | defer p.recentContainersMu.Unlock() | |
| 1971 | + | ||
| 1972 | + | // Add the container to the recently created list with the current time | |
| 1973 | + | p.recentContainers[containerID] = time.Now() | |
| 1974 | + | ||
| 1975 | + | // Log the registration | |
| 1976 | + | logger.Debug("Registered container in cooldown period", | |
| 1977 | + | "container_id", containerID, | |
| 1978 | + | "cooldown_period", p.containerCooldownPeriod) | |
| 1979 | + | ||
| 1980 | + | // Schedule cleanup of old entries | |
| 1981 | + | go func() { | |
| 1982 | + | // Wait for the cooldown period | |
| 1983 | + | time.Sleep(p.containerCooldownPeriod) | |
| 1984 | + | ||
| 1985 | + | // Remove the container from the recently created list | |
| 1986 | + | p.recentContainersMu.Lock() | |
| 1987 | + | defer p.recentContainersMu.Unlock() | |
| 1988 | + | ||
| 1989 | + | // Only delete if the timestamp hasn't been updated | |
| 1990 | + | if timestamp, exists := p.recentContainers[containerID]; exists { | |
| 1991 | + | if time.Since(timestamp) >= p.containerCooldownPeriod { | |
| 1992 | + | delete(p.recentContainers, containerID) | |
| 1993 | + | logger.Debug("Removed container from cooldown period", "container_id", containerID) | |
| 1994 | + | } | |
| 1995 | + | } | |
| 1996 | + | }() | |
| 1997 | + | } | |
| 1998 | + | ||
| 1999 | + | // IsContainerInCooldownPeriod checks if a container is in its cooldown period | |
| 2000 | + | func (p *Proxy) IsContainerInCooldownPeriod(containerID string) bool { | |
| 2001 | + | p.recentContainersMu.RLock() | |
| 2002 | + | defer p.recentContainersMu.RUnlock() | |
| 2003 | + | ||
| 2004 | + | timestamp, exists := p.recentContainers[containerID] | |
| 2005 | + | if !exists { | |
| 2006 | + | return false | |
| 2007 | + | } | |
| 2008 | + | ||
| 2009 | + | // Check if the container is still in its cooldown period | |
| 2010 | + | return time.Since(timestamp) < p.containerCooldownPeriod | |
| 2011 | + | } | |
M internal/server/app_server.go +18 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package server | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "database/sql" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io/fs" | |
| @@ -8,6 +9,7 @@ import ( | |||
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/gordon/internal/common" | |
| 10 | 11 | "github.com/bnema/gordon/internal/db" | |
| 12 | + | "github.com/charmbracelet/log" | |
| 11 | 13 | _ "github.com/joho/godotenv/autoload" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| @@ -22,6 +24,8 @@ type App struct { | |||
| 22 | 24 | Config common.Config | |
| 23 | 25 | DB *sql.DB | |
| 24 | 26 | DBTables DBTables | |
| 27 | + | DBSaveCtx context.Context // Context for the auto-save routine | |
| 28 | + | DBSaveCancel context.CancelFunc // Cancel function for the auto-save routine | |
| 25 | 29 | StartTime time.Time | |
| 26 | 30 | } | |
| 27 | 31 | ||
| @@ -82,3 +86,17 @@ func (a *App) GetUptime() string { | |||
| 82 | 86 | func (a *App) GetVersionstring() string { | |
| 83 | 87 | return fmt.Sprint(a.Config.Build.BuildVersion) | |
| 84 | 88 | } | |
| 89 | + | ||
| 90 | + | // Shutdown performs a clean shutdown of the application | |
| 91 | + | func (a *App) Shutdown() error { | |
| 92 | + | log.Info("Initiating application shutdown") | |
| 93 | + | ||
| 94 | + | // Close the database connection | |
| 95 | + | if err := CloseDB(a); err != nil { | |
| 96 | + | log.Error("Error during database shutdown", "error", err) | |
| 97 | + | return err | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | log.Info("Application shutdown completed successfully") | |
| 101 | + | return nil | |
| 102 | + | } | |
M internal/server/sqlite_close.go +37 -5
| @@ -1,18 +1,50 @@ | |||
| 1 | 1 | package server | |
| 2 | 2 | ||
| 3 | - | import "fmt" | |
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | ||
| 5 | - | // CloseAndSaveDB closes the in-memory database and backup the database file if there is any modification. | |
| 6 | + | "github.com/charmbracelet/log" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // CloseDB saves the in-memory database to disk and then closes the database connection. | |
| 6 | 10 | func CloseDB(a *App) error { | |
| 11 | + | log.Debug("CloseDB called, checking if database connection exists") | |
| 12 | + | ||
| 7 | 13 | if a.DB != nil { | |
| 8 | - | // if err := saveDB(a); err != nil { | |
| 9 | - | // return fmt.Errorf("failed to save database: %w", err) | |
| 10 | - | // } | |
| 14 | + | log.Debug("Database connection exists, proceeding with cleanup") | |
| 15 | + | ||
| 16 | + | // Cancel the auto-save routine | |
| 17 | + | if a.DBSaveCancel != nil { | |
| 18 | + | log.Debug("Canceling auto-save routine") | |
| 19 | + | a.DBSaveCancel() | |
| 20 | + | } else { | |
| 21 | + | log.Debug("No auto-save routine to cancel") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | log.Info("Saving in-memory database to disk before shutdown") | |
| 11 | 25 | ||
| 26 | + | // Save the in-memory database to disk | |
| 27 | + | if err := saveMemDBToDisk(a.DB, a.DBPath); err != nil { | |
| 28 | + | log.Error("Failed to save in-memory database to disk during shutdown", "error", err) | |
| 29 | + | // Continue with closing even if saving fails | |
| 30 | + | } else { | |
| 31 | + | log.Info("Successfully saved in-memory database to disk") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Close the database connection | |
| 35 | + | log.Debug("Closing database connection") | |
| 12 | 36 | if err := a.DB.Close(); err != nil { | |
| 37 | + | log.Error("Failed to close database connection", "error", err) | |
| 13 | 38 | return fmt.Errorf("failed to close database: %w", err) | |
| 14 | 39 | } | |
| 40 | + | ||
| 41 | + | log.Info("Database connection closed successfully") | |
| 42 | + | // Set DB to nil to prevent double-close attempts | |
| 43 | + | a.DB = nil | |
| 44 | + | } else { | |
| 45 | + | log.Debug("No database connection to close") | |
| 15 | 46 | } | |
| 47 | + | ||
| 16 | 48 | return nil | |
| 17 | 49 | } | |
| 18 | 50 | ||
M internal/server/sqlite_init.go +359 -29
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | package server | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "database/sql" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/gordon/internal/db" | |
| 10 | 12 | "github.com/charmbracelet/log" | |
| @@ -13,6 +15,8 @@ import ( | |||
| 13 | 15 | ||
| 14 | 16 | const ( | |
| 15 | 17 | DBFilename = "sqlite.db" | |
| 18 | + | // Auto-save interval in minutes | |
| 19 | + | AutoSaveInterval = 5 | |
| 16 | 20 | ) | |
| 17 | 21 | ||
| 18 | 22 | func (a *App) getDiskDBFilePath() string { | |
| @@ -69,39 +73,61 @@ func (a *App) checkDBFile(dbPath string) (bool, error) { | |||
| 69 | 73 | return true, nil | |
| 70 | 74 | } | |
| 71 | 75 | ||
| 72 | - | // InitializeDB initializes the database | |
| 76 | + | // InitializeDB initializes the database in memory and loads data from disk if available | |
| 73 | 77 | func InitializeDB(a *App) (*sql.DB, error) { | |
| 74 | - | log.Debug("Initializing database") | |
| 78 | + | log.Debug("Initializing in-memory database") | |
| 75 | 79 | if err := a.ensureDBDir(); err != nil { | |
| 76 | 80 | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | |
| 77 | 81 | } | |
| 78 | 82 | ||
| 79 | - | dbPath := a.getDiskDBFilePath() | |
| 80 | - | a.DBPath = dbPath | |
| 83 | + | diskDBPath := a.getDiskDBFilePath() | |
| 84 | + | a.DBPath = diskDBPath | |
| 81 | 85 | ||
| 82 | - | dbExists, err := a.checkDBFile(dbPath) | |
| 86 | + | // Create in-memory database connection | |
| 87 | + | log.Debug("Opening in-memory SQLite database") | |
| 88 | + | memDB, err := sql.Open("sqlite", "file::memory:?cache=shared") | |
| 83 | 89 | if err != nil { | |
| 84 | - | return nil, fmt.Errorf("failed to check DB file: %w", err) | |
| 90 | + | return nil, fmt.Errorf("failed to open in-memory DB: %w", err) | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 87 | - | if !dbExists { | |
| 88 | - | log.Debug("Database needs bootstrapping", "path", dbPath) | |
| 89 | - | if err := bootstrapDB(dbPath); err != nil { | |
| 90 | - | return nil, fmt.Errorf("failed to bootstrap DB: %w", err) | |
| 91 | - | } | |
| 93 | + | // Test that the in-memory database is working | |
| 94 | + | log.Debug("Testing in-memory database connection") | |
| 95 | + | if err := memDB.Ping(); err != nil { | |
| 96 | + | return nil, fmt.Errorf("failed to ping in-memory database: %w", err) | |
| 92 | 97 | } | |
| 93 | 98 | ||
| 94 | - | db, err := sql.Open("sqlite", dbPath) | |
| 99 | + | // Check if disk database exists | |
| 100 | + | dbExists, err := a.checkDBFile(diskDBPath) | |
| 95 | 101 | if err != nil { | |
| 96 | - | return nil, fmt.Errorf("failed to open disk DB: %w", err) | |
| 102 | + | return nil, fmt.Errorf("failed to check DB file: %w", err) | |
| 97 | 103 | } | |
| 98 | 104 | ||
| 99 | - | // Test that the database is working | |
| 100 | - | if err := db.Ping(); err != nil { | |
| 101 | - | return nil, fmt.Errorf("failed to ping database: %w", err) | |
| 105 | + | if !dbExists { | |
| 106 | + | log.Debug("Database needs bootstrapping", "path", diskDBPath) | |
| 107 | + | // Create tables in memory | |
| 108 | + | log.Debug("Creating tables in memory") | |
| 109 | + | if err := bootstrapDB(memDB); err != nil { | |
| 110 | + | return nil, fmt.Errorf("failed to bootstrap in-memory DB: %w", err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Save the initialized in-memory database to disk | |
| 114 | + | log.Debug("Saving initialized in-memory database to disk") | |
| 115 | + | if err := saveMemDBToDisk(memDB, diskDBPath); err != nil { | |
| 116 | + | return nil, fmt.Errorf("failed to save initial in-memory DB to disk: %w", err) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | log.Debug("Initialized new in-memory database and saved to disk") | |
| 120 | + | } else { | |
| 121 | + | // Load existing database from disk into memory | |
| 122 | + | log.Debug("Loading existing database from disk into memory", "path", diskDBPath) | |
| 123 | + | if err := loadDiskDBToMemory(diskDBPath, memDB); err != nil { | |
| 124 | + | return nil, fmt.Errorf("failed to load disk DB into memory: %w", err) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | log.Debug("Successfully loaded disk database into memory") | |
| 102 | 128 | } | |
| 103 | 129 | ||
| 104 | - | a.DB = db | |
| 130 | + | a.DB = memDB | |
| 105 | 131 | ||
| 106 | 132 | if dbExists { | |
| 107 | 133 | // Populate the struct tables from the database | |
| @@ -109,20 +135,320 @@ func InitializeDB(a *App) (*sql.DB, error) { | |||
| 109 | 135 | if err := PopulateStructWithTables(a); err != nil { | |
| 110 | 136 | return nil, err | |
| 111 | 137 | } | |
| 112 | - | } else { | |
| 113 | - | log.Debug("Initialized new database") | |
| 114 | 138 | } | |
| 115 | 139 | ||
| 116 | - | return db, nil | |
| 140 | + | // Create a context for the auto-save routine that will be canceled when the app is shutting down | |
| 141 | + | a.DBSaveCtx, a.DBSaveCancel = context.WithCancel(context.Background()) | |
| 142 | + | ||
| 143 | + | // Start auto-save routine | |
| 144 | + | log.Debug("Starting auto-save routine with interval of", "minutes", AutoSaveInterval) | |
| 145 | + | go autoSaveDB(a.DBSaveCtx, a, AutoSaveInterval) | |
| 146 | + | ||
| 147 | + | return memDB, nil | |
| 117 | 148 | } | |
| 118 | 149 | ||
| 119 | - | func bootstrapDB(dbPath string) error { | |
| 120 | - | // Open the database file | |
| 121 | - | db, err := sql.Open("sqlite", dbPath) | |
| 150 | + | // loadDiskDBToMemory loads the database from disk into memory | |
| 151 | + | func loadDiskDBToMemory(diskDBPath string, memDB *sql.DB) error { | |
| 152 | + | log.Debug("Opening disk database for loading", "path", diskDBPath) | |
| 153 | + | // Open the disk database | |
| 154 | + | diskDB, err := sql.Open("sqlite", diskDBPath) | |
| 122 | 155 | if err != nil { | |
| 123 | - | return fmt.Errorf("error opening database: %w", err) | |
| 156 | + | return fmt.Errorf("failed to open disk DB for loading: %w", err) | |
| 124 | 157 | } | |
| 125 | - | defer db.Close() | |
| 158 | + | defer diskDB.Close() | |
| 159 | + | ||
| 160 | + | // Backup disk database to memory | |
| 161 | + | // First, get all table names | |
| 162 | + | log.Debug("Retrieving table names from disk database") | |
| 163 | + | rows, err := diskDB.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") | |
| 164 | + | if err != nil { | |
| 165 | + | return fmt.Errorf("failed to get table names: %w", err) | |
| 166 | + | } | |
| 167 | + | defer rows.Close() | |
| 168 | + | ||
| 169 | + | // Begin transaction for memory DB | |
| 170 | + | log.Debug("Beginning transaction on memory database") | |
| 171 | + | memTx, err := memDB.Begin() | |
| 172 | + | if err != nil { | |
| 173 | + | return fmt.Errorf("failed to begin transaction on memory DB: %w", err) | |
| 174 | + | } | |
| 175 | + | defer func() { | |
| 176 | + | if err != nil { | |
| 177 | + | log.Debug("Rolling back memory database transaction due to error") | |
| 178 | + | memTx.Rollback() | |
| 179 | + | } | |
| 180 | + | }() | |
| 181 | + | ||
| 182 | + | tableCount := 0 | |
| 183 | + | // For each table, create it in memory and copy data | |
| 184 | + | for rows.Next() { | |
| 185 | + | var tableName string | |
| 186 | + | if err := rows.Scan(&tableName); err != nil { | |
| 187 | + | return fmt.Errorf("failed to scan table name: %w", err) | |
| 188 | + | } | |
| 189 | + | tableCount++ | |
| 190 | + | ||
| 191 | + | log.Debug("Processing table", "name", tableName) | |
| 192 | + | ||
| 193 | + | // Get table schema | |
| 194 | + | var createStmt string | |
| 195 | + | err := diskDB.QueryRow(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&createStmt) | |
| 196 | + | if err != nil { | |
| 197 | + | return fmt.Errorf("failed to get create statement for table %s: %w", tableName, err) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Create table in memory | |
| 201 | + | log.Debug("Creating table in memory", "name", tableName) | |
| 202 | + | _, err = memTx.Exec(createStmt) | |
| 203 | + | if err != nil { | |
| 204 | + | return fmt.Errorf("failed to create table %s in memory: %w", tableName, err) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Get all data from disk table | |
| 208 | + | log.Debug("Retrieving data from disk table", "name", tableName) | |
| 209 | + | dataRows, err := diskDB.Query(fmt.Sprintf("SELECT * FROM %s", tableName)) | |
| 210 | + | if err != nil { | |
| 211 | + | return fmt.Errorf("failed to query data from table %s: %w", tableName, err) | |
| 212 | + | } | |
| 213 | + | defer dataRows.Close() | |
| 214 | + | ||
| 215 | + | // Get column names | |
| 216 | + | columns, err := dataRows.Columns() | |
| 217 | + | if err != nil { | |
| 218 | + | return fmt.Errorf("failed to get columns for table %s: %w", tableName, err) | |
| 219 | + | } | |
| 220 | + | log.Debug("Table columns", "name", tableName, "columns", columns) | |
| 221 | + | ||
| 222 | + | // Prepare insert statement for memory DB | |
| 223 | + | placeholders := make([]string, len(columns)) | |
| 224 | + | for i := range placeholders { | |
| 225 | + | placeholders[i] = "?" | |
| 226 | + | } | |
| 227 | + | insertStmt, err := memTx.Prepare(fmt.Sprintf("INSERT INTO %s VALUES (%s)", tableName, joinStrings(placeholders, ","))) | |
| 228 | + | if err != nil { | |
| 229 | + | return fmt.Errorf("failed to prepare insert statement for table %s: %w", tableName, err) | |
| 230 | + | } | |
| 231 | + | defer insertStmt.Close() | |
| 232 | + | ||
| 233 | + | // Copy data row by row | |
| 234 | + | rowCount := 0 | |
| 235 | + | for dataRows.Next() { | |
| 236 | + | // Create a slice of interface{} to hold the values | |
| 237 | + | values := make([]interface{}, len(columns)) | |
| 238 | + | valuePtrs := make([]interface{}, len(columns)) | |
| 239 | + | for i := range values { | |
| 240 | + | valuePtrs[i] = &values[i] | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Scan the row into the slice | |
| 244 | + | if err := dataRows.Scan(valuePtrs...); err != nil { | |
| 245 | + | return fmt.Errorf("failed to scan row from table %s: %w", tableName, err) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // Insert into memory DB | |
| 249 | + | _, err = insertStmt.Exec(values...) | |
| 250 | + | if err != nil { | |
| 251 | + | return fmt.Errorf("failed to insert row into memory table %s: %w", tableName, err) | |
| 252 | + | } | |
| 253 | + | rowCount++ | |
| 254 | + | } | |
| 255 | + | log.Debug("Copied rows to memory", "table", tableName, "count", rowCount) | |
| 256 | + | ||
| 257 | + | if err := dataRows.Err(); err != nil { | |
| 258 | + | return fmt.Errorf("error iterating rows for table %s: %w", tableName, err) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | if err := rows.Err(); err != nil { | |
| 263 | + | return fmt.Errorf("error iterating table names: %w", err) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Commit transaction | |
| 267 | + | log.Debug("Committing transaction to memory database") | |
| 268 | + | if err := memTx.Commit(); err != nil { | |
| 269 | + | return fmt.Errorf("failed to commit transaction to memory DB: %w", err) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | log.Debug("Successfully loaded database from disk to memory", "tables", tableCount) | |
| 273 | + | return nil | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // saveMemDBToDisk saves the in-memory database to disk | |
| 277 | + | func saveMemDBToDisk(memDB *sql.DB, diskDBPath string) error { | |
| 278 | + | log.Debug("Saving in-memory database to disk", "path", diskDBPath) | |
| 279 | + | ||
| 280 | + | // Create a backup of the existing file if it exists | |
| 281 | + | if _, err := os.Stat(diskDBPath); err == nil { | |
| 282 | + | backupPath := diskDBPath + ".bak" | |
| 283 | + | log.Debug("Creating backup of existing database file", "backup", backupPath) | |
| 284 | + | if err := os.Rename(diskDBPath, backupPath); err != nil { | |
| 285 | + | log.Warn("Failed to create backup of database file", "error", err) | |
| 286 | + | // Continue anyway, as this is just a backup | |
| 287 | + | } else { | |
| 288 | + | log.Debug("Created backup of database file", "backup", backupPath) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // Open the disk database | |
| 293 | + | log.Debug("Opening disk database for saving") | |
| 294 | + | diskDB, err := sql.Open("sqlite", diskDBPath) | |
| 295 | + | if err != nil { | |
| 296 | + | return fmt.Errorf("failed to open disk DB for saving: %w", err) | |
| 297 | + | } | |
| 298 | + | defer diskDB.Close() | |
| 299 | + | ||
| 300 | + | // Get all table names from memory DB | |
| 301 | + | log.Debug("Retrieving table names from memory database") | |
| 302 | + | rows, err := memDB.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") | |
| 303 | + | if err != nil { | |
| 304 | + | return fmt.Errorf("failed to get table names from memory: %w", err) | |
| 305 | + | } | |
| 306 | + | defer rows.Close() | |
| 307 | + | ||
| 308 | + | // Begin transaction for disk DB | |
| 309 | + | log.Debug("Beginning transaction on disk database") | |
| 310 | + | diskTx, err := diskDB.Begin() | |
| 311 | + | if err != nil { | |
| 312 | + | return fmt.Errorf("failed to begin transaction on disk DB: %w", err) | |
| 313 | + | } | |
| 314 | + | defer func() { | |
| 315 | + | if err != nil { | |
| 316 | + | log.Debug("Rolling back disk database transaction due to error") | |
| 317 | + | diskTx.Rollback() | |
| 318 | + | } | |
| 319 | + | }() | |
| 320 | + | ||
| 321 | + | tableCount := 0 | |
| 322 | + | // For each table, create it on disk and copy data | |
| 323 | + | for rows.Next() { | |
| 324 | + | var tableName string | |
| 325 | + | if err := rows.Scan(&tableName); err != nil { | |
| 326 | + | return fmt.Errorf("failed to scan table name: %w", err) | |
| 327 | + | } | |
| 328 | + | tableCount++ | |
| 329 | + | ||
| 330 | + | log.Debug("Processing table for saving", "name", tableName) | |
| 331 | + | ||
| 332 | + | // Get table schema | |
| 333 | + | var createStmt string | |
| 334 | + | err := memDB.QueryRow(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&createStmt) | |
| 335 | + | if err != nil { | |
| 336 | + | return fmt.Errorf("failed to get create statement for table %s: %w", tableName, err) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // Create table on disk | |
| 340 | + | log.Debug("Creating table on disk", "name", tableName) | |
| 341 | + | _, err = diskTx.Exec(createStmt) | |
| 342 | + | if err != nil { | |
| 343 | + | return fmt.Errorf("failed to create table %s on disk: %w", tableName, err) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // Get all data from memory table | |
| 347 | + | log.Debug("Retrieving data from memory table", "name", tableName) | |
| 348 | + | dataRows, err := memDB.Query(fmt.Sprintf("SELECT * FROM %s", tableName)) | |
| 349 | + | if err != nil { | |
| 350 | + | return fmt.Errorf("failed to query data from memory table %s: %w", tableName, err) | |
| 351 | + | } | |
| 352 | + | defer dataRows.Close() | |
| 353 | + | ||
| 354 | + | // Get column names | |
| 355 | + | columns, err := dataRows.Columns() | |
| 356 | + | if err != nil { | |
| 357 | + | return fmt.Errorf("failed to get columns for table %s: %w", tableName, err) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // Prepare insert statement for disk DB | |
| 361 | + | placeholders := make([]string, len(columns)) | |
| 362 | + | for i := range placeholders { | |
| 363 | + | placeholders[i] = "?" | |
| 364 | + | } | |
| 365 | + | insertStmt, err := diskTx.Prepare(fmt.Sprintf("INSERT INTO %s VALUES (%s)", tableName, joinStrings(placeholders, ","))) | |
| 366 | + | if err != nil { | |
| 367 | + | return fmt.Errorf("failed to prepare insert statement for table %s: %w", tableName, err) | |
| 368 | + | } | |
| 369 | + | defer insertStmt.Close() | |
| 370 | + | ||
| 371 | + | // Copy data row by row | |
| 372 | + | rowCount := 0 | |
| 373 | + | for dataRows.Next() { | |
| 374 | + | // Create a slice of interface{} to hold the values | |
| 375 | + | values := make([]interface{}, len(columns)) | |
| 376 | + | valuePtrs := make([]interface{}, len(columns)) | |
| 377 | + | for i := range values { | |
| 378 | + | valuePtrs[i] = &values[i] | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | // Scan the row into the slice | |
| 382 | + | if err := dataRows.Scan(valuePtrs...); err != nil { | |
| 383 | + | return fmt.Errorf("failed to scan row from memory table %s: %w", tableName, err) | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // Insert into disk DB | |
| 387 | + | _, err = insertStmt.Exec(values...) | |
| 388 | + | if err != nil { | |
| 389 | + | return fmt.Errorf("failed to insert row into disk table %s: %w", tableName, err) | |
| 390 | + | } | |
| 391 | + | rowCount++ | |
| 392 | + | } | |
| 393 | + | log.Debug("Copied rows to disk", "table", tableName, "count", rowCount) | |
| 394 | + | ||
| 395 | + | if err := dataRows.Err(); err != nil { | |
| 396 | + | return fmt.Errorf("error iterating rows for table %s: %w", tableName, err) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | if err := rows.Err(); err != nil { | |
| 401 | + | return fmt.Errorf("error iterating table names: %w", err) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | // Commit transaction | |
| 405 | + | log.Debug("Committing transaction to disk database") | |
| 406 | + | if err := diskTx.Commit(); err != nil { | |
| 407 | + | return fmt.Errorf("failed to commit transaction to disk DB: %w", err) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | log.Debug("Successfully saved in-memory database to disk", "tables", tableCount) | |
| 411 | + | return nil | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // autoSaveDB periodically saves the in-memory database to disk | |
| 415 | + | func autoSaveDB(ctx context.Context, a *App, intervalMinutes int) { | |
| 416 | + | ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute) | |
| 417 | + | defer ticker.Stop() | |
| 418 | + | ||
| 419 | + | log.Debug("Auto-save routine started", "interval_minutes", intervalMinutes) | |
| 420 | + | ||
| 421 | + | for { | |
| 422 | + | select { | |
| 423 | + | case <-ticker.C: | |
| 424 | + | log.Debug("Auto-saving in-memory database to disk") | |
| 425 | + | if err := saveMemDBToDisk(a.DB, a.DBPath); err != nil { | |
| 426 | + | log.Error("Failed to auto-save in-memory database to disk", "error", err) | |
| 427 | + | } else { | |
| 428 | + | log.Debug("Auto-save completed successfully") | |
| 429 | + | } | |
| 430 | + | case <-ctx.Done(): | |
| 431 | + | log.Debug("Auto-save routine stopping due to context cancellation") | |
| 432 | + | return | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // Helper function to join strings with a separator | |
| 438 | + | func joinStrings(strings []string, separator string) string { | |
| 439 | + | if len(strings) == 0 { | |
| 440 | + | return "" | |
| 441 | + | } | |
| 442 | + | result := strings[0] | |
| 443 | + | for i := 1; i < len(strings); i++ { | |
| 444 | + | result += separator + strings[i] | |
| 445 | + | } | |
| 446 | + | return result | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // bootstrapDB creates the initial database schema | |
| 450 | + | func bootstrapDB(db *sql.DB) error { | |
| 451 | + | log.Debug("Bootstrapping database with initial schema") | |
| 126 | 452 | ||
| 127 | 453 | // Begin a transaction | |
| 128 | 454 | tx, err := db.Begin() | |
| @@ -131,6 +457,7 @@ func bootstrapDB(dbPath string) error { | |||
| 131 | 457 | } | |
| 132 | 458 | ||
| 133 | 459 | // Create tables | |
| 460 | + | log.Debug("Creating database tables") | |
| 134 | 461 | _, err = tx.Exec(` | |
| 135 | 462 | CREATE TABLE IF NOT EXISTS user ( | |
| 136 | 463 | id TEXT PRIMARY KEY, | |
| @@ -204,14 +531,17 @@ func bootstrapDB(dbPath string) error { | |||
| 204 | 531 | ); | |
| 205 | 532 | `) | |
| 206 | 533 | if err != nil { | |
| 207 | - | return fmt.Errorf("failed to create tables: %w", err) | |
| 534 | + | tx.Rollback() | |
| 535 | + | return fmt.Errorf("error creating tables: %w", err) | |
| 208 | 536 | } | |
| 209 | 537 | ||
| 210 | 538 | // Commit the transaction | |
| 539 | + | log.Debug("Committing transaction with initial schema") | |
| 211 | 540 | if err := tx.Commit(); err != nil { | |
| 212 | - | return fmt.Errorf("failed to commit transaction: %w", err) | |
| 541 | + | return fmt.Errorf("error committing transaction: %w", err) | |
| 213 | 542 | } | |
| 214 | 543 | ||
| 544 | + | log.Debug("Database bootstrap completed successfully") | |
| 215 | 545 | return nil | |
| 216 | 546 | } | |
| 217 | 547 | ||
| @@ -296,7 +626,7 @@ func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error { | |||
| 296 | 626 | } | |
| 297 | 627 | ||
| 298 | 628 | func (p *ProxyRouteWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 299 | - | return rows.Scan(&p.ID, &p.DomainID, &p.ContainerID, &p.ContainerIP, &p.ContainerPort, &p.Protocol, &p.Path, &p.Active, &p.CreatedAt, &p.UpdatedAt) | |
| 629 | + | return rows.Scan(&p.ID, &p.DomainID, &p.ContainerID, &p.ContainerIP, &p.ContainerPort, &p.Protocol, &p.Path, &p.Active) | |
| 300 | 630 | } | |
| 301 | 631 | ||
| 302 | 632 | // PopulateStructWithTables updates db struct with tables | |
| @@ -345,7 +675,7 @@ func PopulateStructWithTables(a *App) error { | |||
| 345 | 675 | ||
| 346 | 676 | // Populate the ProxyRoute table | |
| 347 | 677 | proxyRouteWrapper := &ProxyRouteWrapper{ProxyRoute: &a.DBTables.ProxyRoute} | |
| 348 | - | if err := populateTableFromDB(a, "SELECT id, domain_id, container_id, container_ip, container_port, protocol, path, active, created_at, updated_at FROM proxy_route", proxyRouteWrapper); err != nil { | |
| 678 | + | if err := populateTableFromDB(a, "SELECT id, domain_id, container_id, container_ip, container_port, protocol, path, active FROM proxy_route", proxyRouteWrapper); err != nil { | |
| 349 | 679 | return err | |
| 350 | 680 | } | |
| 351 | 681 | ||
M internal/templating/cmdparams/from_cli.go +21 -4
| @@ -33,13 +33,30 @@ func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imag | |||
| 33 | 33 | ||
| 34 | 34 | // Protocol (http/https), Container name, Container subdomain, and Container domain | |
| 35 | 35 | protocol := parsedURL.Scheme | |
| 36 | - | containerSubdomain := hostParts[0] | |
| 37 | - | containerDomain := strings.Join(hostParts[1:], ".") | |
| 36 | + | ||
| 37 | + | var containerSubdomain string | |
| 38 | + | var containerDomain string | |
| 39 | + | ||
| 40 | + | if len(hostParts) == 2 { | |
| 41 | + | // No subdomain (e.g., domain.tld) | |
| 42 | + | containerSubdomain = "" | |
| 43 | + | containerDomain = parsedURL.Host | |
| 44 | + | } else { | |
| 45 | + | // Has subdomain (e.g., sub.domain.tld) | |
| 46 | + | containerSubdomain = hostParts[0] | |
| 47 | + | containerDomain = strings.Join(hostParts[1:], ".") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Use subdomain as container name, or domain with hyphens if no subdomain | |
| 51 | + | containerName := containerSubdomain | |
| 52 | + | if containerName == "" { | |
| 53 | + | containerName = strings.ReplaceAll(containerDomain, ".", "-") | |
| 54 | + | } | |
| 38 | 55 | ||
| 39 | 56 | params := docker.ContainerCommandParams{ | |
| 40 | 57 | IsSSL: protocol == "https", | |
| 41 | - | ContainerName: containerSubdomain, | |
| 42 | - | ServiceName: containerSubdomain, | |
| 58 | + | ContainerName: containerName, | |
| 59 | + | ServiceName: containerName, | |
| 43 | 60 | Domain: containerDomain, | |
| 44 | 61 | ImageName: ppl.ImageName, | |
| 45 | 62 | ImageID: imageID, | |
M internal/templating/models/html/fragments/createcontainer.gohtml +13 -1
| @@ -65,7 +65,7 @@ | |||
| 65 | 65 | <div class="text-xs text-gray-500 mb-2"> | |
| 66 | 66 | Specify the internal container port that the proxy will route traffic to | |
| 67 | 67 | </div> | |
| 68 | - | <input type="text" name="proxy_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required /> | |
| 68 | + | <input type="text" name="container_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required /> | |
| 69 | 69 | </div> | |
| 70 | 70 | ||
| 71 | 71 | <!-- Volumes --> | |
| @@ -92,6 +92,18 @@ | |||
| 92 | 92 | </select> | |
| 93 | 93 | </div> | |
| 94 | 94 | ||
| 95 | + | <!-- Skip Proxy Setup Option --> | |
| 96 | + | <div class="pb-4"> | |
| 97 | + | <label class="flex items-center space-x-2"> | |
| 98 | + | <input type="checkbox" name="skip_proxy_setup" value="true" class="form-checkbox h-5 w-5 text-blue-600" /> | |
| 99 | + | <span>Skip proxy setup (create container only, don't set up domain/certificates)</span> | |
| 100 | + | </label> | |
| 101 | + | <p class="text-sm text-gray-500 ml-7"> | |
| 102 | + | Use this option if you only want to create the container without triggering proxy setup, | |
| 103 | + | certificate generation, and other related processes. | |
| 104 | + | </p> | |
| 105 | + | </div> | |
| 106 | + | ||
| 95 | 107 | <input type="submit" value="Create" id="create-container-submit" | |
| 96 | 108 | class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" /> | |
| 97 | 109 | </form> | |
M internal/webui/public/assets/css/tailwind.css +270 -158
| @@ -6,7 +6,115 @@ | |||
| 6 | 6 | ||
| 7 | 7 | /* Embeded path : assets/css/cutsom.css */ | |
| 8 | 8 | ||
| 9 | - | /*! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com | |
| 9 | + | *, ::before, ::after { | |
| 10 | + | --tw-border-spacing-x: 0; | |
| 11 | + | --tw-border-spacing-y: 0; | |
| 12 | + | --tw-translate-x: 0; | |
| 13 | + | --tw-translate-y: 0; | |
| 14 | + | --tw-rotate: 0; | |
| 15 | + | --tw-skew-x: 0; | |
| 16 | + | --tw-skew-y: 0; | |
| 17 | + | --tw-scale-x: 1; | |
| 18 | + | --tw-scale-y: 1; | |
| 19 | + | --tw-pan-x: ; | |
| 20 | + | --tw-pan-y: ; | |
| 21 | + | --tw-pinch-zoom: ; | |
| 22 | + | --tw-scroll-snap-strictness: proximity; | |
| 23 | + | --tw-gradient-from-position: ; | |
| 24 | + | --tw-gradient-via-position: ; | |
| 25 | + | --tw-gradient-to-position: ; | |
| 26 | + | --tw-ordinal: ; | |
| 27 | + | --tw-slashed-zero: ; | |
| 28 | + | --tw-numeric-figure: ; | |
| 29 | + | --tw-numeric-spacing: ; | |
| 30 | + | --tw-numeric-fraction: ; | |
| 31 | + | --tw-ring-inset: ; | |
| 32 | + | --tw-ring-offset-width: 0px; | |
| 33 | + | --tw-ring-offset-color: #fff; | |
| 34 | + | --tw-ring-color: rgb(59 130 246 / 0.5); | |
| 35 | + | --tw-ring-offset-shadow: 0 0 #0000; | |
| 36 | + | --tw-ring-shadow: 0 0 #0000; | |
| 37 | + | --tw-shadow: 0 0 #0000; | |
| 38 | + | --tw-shadow-colored: 0 0 #0000; | |
| 39 | + | --tw-blur: ; | |
| 40 | + | --tw-brightness: ; | |
| 41 | + | --tw-contrast: ; | |
| 42 | + | --tw-grayscale: ; | |
| 43 | + | --tw-hue-rotate: ; | |
| 44 | + | --tw-invert: ; | |
| 45 | + | --tw-saturate: ; | |
| 46 | + | --tw-sepia: ; | |
| 47 | + | --tw-drop-shadow: ; | |
| 48 | + | --tw-backdrop-blur: ; | |
| 49 | + | --tw-backdrop-brightness: ; | |
| 50 | + | --tw-backdrop-contrast: ; | |
| 51 | + | --tw-backdrop-grayscale: ; | |
| 52 | + | --tw-backdrop-hue-rotate: ; | |
| 53 | + | --tw-backdrop-invert: ; | |
| 54 | + | --tw-backdrop-opacity: ; | |
| 55 | + | --tw-backdrop-saturate: ; | |
| 56 | + | --tw-backdrop-sepia: ; | |
| 57 | + | --tw-contain-size: ; | |
| 58 | + | --tw-contain-layout: ; | |
| 59 | + | --tw-contain-paint: ; | |
| 60 | + | --tw-contain-style: ; | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | ::backdrop { | |
| 64 | + | --tw-border-spacing-x: 0; | |
| 65 | + | --tw-border-spacing-y: 0; | |
| 66 | + | --tw-translate-x: 0; | |
| 67 | + | --tw-translate-y: 0; | |
| 68 | + | --tw-rotate: 0; | |
| 69 | + | --tw-skew-x: 0; | |
| 70 | + | --tw-skew-y: 0; | |
| 71 | + | --tw-scale-x: 1; | |
| 72 | + | --tw-scale-y: 1; | |
| 73 | + | --tw-pan-x: ; | |
| 74 | + | --tw-pan-y: ; | |
| 75 | + | --tw-pinch-zoom: ; | |
| 76 | + | --tw-scroll-snap-strictness: proximity; | |
| 77 | + | --tw-gradient-from-position: ; | |
| 78 | + | --tw-gradient-via-position: ; | |
| 79 | + | --tw-gradient-to-position: ; | |
| 80 | + | --tw-ordinal: ; | |
| 81 | + | --tw-slashed-zero: ; | |
| 82 | + | --tw-numeric-figure: ; | |
| 83 | + | --tw-numeric-spacing: ; | |
| 84 | + | --tw-numeric-fraction: ; | |
| 85 | + | --tw-ring-inset: ; | |
| 86 | + | --tw-ring-offset-width: 0px; | |
| 87 | + | --tw-ring-offset-color: #fff; | |
| 88 | + | --tw-ring-color: rgb(59 130 246 / 0.5); | |
| 89 | + | --tw-ring-offset-shadow: 0 0 #0000; | |
| 90 | + | --tw-ring-shadow: 0 0 #0000; | |
| 91 | + | --tw-shadow: 0 0 #0000; | |
| 92 | + | --tw-shadow-colored: 0 0 #0000; | |
| 93 | + | --tw-blur: ; | |
| 94 | + | --tw-brightness: ; | |
| 95 | + | --tw-contrast: ; | |
| 96 | + | --tw-grayscale: ; | |
| 97 | + | --tw-hue-rotate: ; | |
| 98 | + | --tw-invert: ; | |
| 99 | + | --tw-saturate: ; | |
| 100 | + | --tw-sepia: ; | |
| 101 | + | --tw-drop-shadow: ; | |
| 102 | + | --tw-backdrop-blur: ; | |
| 103 | + | --tw-backdrop-brightness: ; | |
| 104 | + | --tw-backdrop-contrast: ; | |
| 105 | + | --tw-backdrop-grayscale: ; | |
| 106 | + | --tw-backdrop-hue-rotate: ; | |
| 107 | + | --tw-backdrop-invert: ; | |
| 108 | + | --tw-backdrop-opacity: ; | |
| 109 | + | --tw-backdrop-saturate: ; | |
| 110 | + | --tw-backdrop-sepia: ; | |
| 111 | + | --tw-contain-size: ; | |
| 112 | + | --tw-contain-layout: ; | |
| 113 | + | --tw-contain-paint: ; | |
| 114 | + | --tw-contain-style: ; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com | |
| 10 | 118 | */ | |
| 11 | 119 | ||
| 12 | 120 | /* | |
| @@ -449,118 +557,10 @@ video { | |||
| 449 | 557 | ||
| 450 | 558 | /* Make elements with the HTML hidden attribute stay hidden by default */ | |
| 451 | 559 | ||
| 452 | - | [hidden] { | |
| 560 | + | [hidden]:where(:not([hidden="until-found"])) { | |
| 453 | 561 | display: none; | |
| 454 | 562 | } | |
| 455 | 563 | ||
| 456 | - | *, ::before, ::after { | |
| 457 | - | --tw-border-spacing-x: 0; | |
| 458 | - | --tw-border-spacing-y: 0; | |
| 459 | - | --tw-translate-x: 0; | |
| 460 | - | --tw-translate-y: 0; | |
| 461 | - | --tw-rotate: 0; | |
| 462 | - | --tw-skew-x: 0; | |
| 463 | - | --tw-skew-y: 0; | |
| 464 | - | --tw-scale-x: 1; | |
| 465 | - | --tw-scale-y: 1; | |
| 466 | - | --tw-pan-x: ; | |
| 467 | - | --tw-pan-y: ; | |
| 468 | - | --tw-pinch-zoom: ; | |
| 469 | - | --tw-scroll-snap-strictness: proximity; | |
| 470 | - | --tw-gradient-from-position: ; | |
| 471 | - | --tw-gradient-via-position: ; | |
| 472 | - | --tw-gradient-to-position: ; | |
| 473 | - | --tw-ordinal: ; | |
| 474 | - | --tw-slashed-zero: ; | |
| 475 | - | --tw-numeric-figure: ; | |
| 476 | - | --tw-numeric-spacing: ; | |
| 477 | - | --tw-numeric-fraction: ; | |
| 478 | - | --tw-ring-inset: ; | |
| 479 | - | --tw-ring-offset-width: 0px; | |
| 480 | - | --tw-ring-offset-color: #fff; | |
| 481 | - | --tw-ring-color: rgb(59 130 246 / 0.5); | |
| 482 | - | --tw-ring-offset-shadow: 0 0 #0000; | |
| 483 | - | --tw-ring-shadow: 0 0 #0000; | |
| 484 | - | --tw-shadow: 0 0 #0000; | |
| 485 | - | --tw-shadow-colored: 0 0 #0000; | |
| 486 | - | --tw-blur: ; | |
| 487 | - | --tw-brightness: ; | |
| 488 | - | --tw-contrast: ; | |
| 489 | - | --tw-grayscale: ; | |
| 490 | - | --tw-hue-rotate: ; | |
| 491 | - | --tw-invert: ; | |
| 492 | - | --tw-saturate: ; | |
| 493 | - | --tw-sepia: ; | |
| 494 | - | --tw-drop-shadow: ; | |
| 495 | - | --tw-backdrop-blur: ; | |
| 496 | - | --tw-backdrop-brightness: ; | |
| 497 | - | --tw-backdrop-contrast: ; | |
| 498 | - | --tw-backdrop-grayscale: ; | |
| 499 | - | --tw-backdrop-hue-rotate: ; | |
| 500 | - | --tw-backdrop-invert: ; | |
| 501 | - | --tw-backdrop-opacity: ; | |
| 502 | - | --tw-backdrop-saturate: ; | |
| 503 | - | --tw-backdrop-sepia: ; | |
| 504 | - | --tw-contain-size: ; | |
| 505 | - | --tw-contain-layout: ; | |
| 506 | - | --tw-contain-paint: ; | |
| 507 | - | --tw-contain-style: ; | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | ::backdrop { | |
| 511 | - | --tw-border-spacing-x: 0; | |
| 512 | - | --tw-border-spacing-y: 0; | |
| 513 | - | --tw-translate-x: 0; | |
| 514 | - | --tw-translate-y: 0; | |
| 515 | - | --tw-rotate: 0; | |
| 516 | - | --tw-skew-x: 0; | |
| 517 | - | --tw-skew-y: 0; | |
| 518 | - | --tw-scale-x: 1; | |
| 519 | - | --tw-scale-y: 1; | |
| 520 | - | --tw-pan-x: ; | |
| 521 | - | --tw-pan-y: ; | |
| 522 | - | --tw-pinch-zoom: ; | |
| 523 | - | --tw-scroll-snap-strictness: proximity; | |
| 524 | - | --tw-gradient-from-position: ; | |
| 525 | - | --tw-gradient-via-position: ; | |
| 526 | - | --tw-gradient-to-position: ; | |
| 527 | - | --tw-ordinal: ; | |
| 528 | - | --tw-slashed-zero: ; | |
| 529 | - | --tw-numeric-figure: ; | |
| 530 | - | --tw-numeric-spacing: ; | |
| 531 | - | --tw-numeric-fraction: ; | |
| 532 | - | --tw-ring-inset: ; | |
| 533 | - | --tw-ring-offset-width: 0px; | |
| 534 | - | --tw-ring-offset-color: #fff; | |
| 535 | - | --tw-ring-color: rgb(59 130 246 / 0.5); | |
| 536 | - | --tw-ring-offset-shadow: 0 0 #0000; | |
| 537 | - | --tw-ring-shadow: 0 0 #0000; | |
| 538 | - | --tw-shadow: 0 0 #0000; | |
| 539 | - | --tw-shadow-colored: 0 0 #0000; | |
| 540 | - | --tw-blur: ; | |
| 541 | - | --tw-brightness: ; | |
| 542 | - | --tw-contrast: ; | |
| 543 | - | --tw-grayscale: ; | |
| 544 | - | --tw-hue-rotate: ; | |
| 545 | - | --tw-invert: ; | |
| 546 | - | --tw-saturate: ; | |
| 547 | - | --tw-sepia: ; | |
| 548 | - | --tw-drop-shadow: ; | |
| 549 | - | --tw-backdrop-blur: ; | |
| 550 | - | --tw-backdrop-brightness: ; | |
| 551 | - | --tw-backdrop-contrast: ; | |
| 552 | - | --tw-backdrop-grayscale: ; | |
| 553 | - | --tw-backdrop-hue-rotate: ; | |
| 554 | - | --tw-backdrop-invert: ; | |
| 555 | - | --tw-backdrop-opacity: ; | |
| 556 | - | --tw-backdrop-saturate: ; | |
| 557 | - | --tw-backdrop-sepia: ; | |
| 558 | - | --tw-contain-size: ; | |
| 559 | - | --tw-contain-layout: ; | |
| 560 | - | --tw-contain-paint: ; | |
| 561 | - | --tw-contain-style: ; | |
| 562 | - | } | |
| 563 | - | ||
| 564 | 564 | .container { | |
| 565 | 565 | width: 100%; | |
| 566 | 566 | } | |
| @@ -603,6 +603,10 @@ video { | |||
| 603 | 603 | position: fixed; | |
| 604 | 604 | } | |
| 605 | 605 | ||
| 606 | + | .relative { | |
| 607 | + | position: relative; | |
| 608 | + | } | |
| 609 | + | ||
| 606 | 610 | .left-0 { | |
| 607 | 611 | left: 0px; | |
| 608 | 612 | } | |
| @@ -611,23 +615,38 @@ video { | |||
| 611 | 615 | top: 0px; | |
| 612 | 616 | } | |
| 613 | 617 | ||
| 614 | - | .my-2 { | |
| 615 | - | margin-top: 0.5rem; | |
| 616 | - | margin-bottom: 0.5rem; | |
| 617 | - | } | |
| 618 | - | ||
| 619 | 618 | .mb-1 { | |
| 620 | 619 | margin-bottom: 0.25rem; | |
| 621 | 620 | } | |
| 622 | 621 | ||
| 622 | + | .mb-2 { | |
| 623 | + | margin-bottom: 0.5rem; | |
| 624 | + | } | |
| 625 | + | ||
| 623 | 626 | .mb-4 { | |
| 624 | 627 | margin-bottom: 1rem; | |
| 625 | 628 | } | |
| 626 | 629 | ||
| 630 | + | .mb-6 { | |
| 631 | + | margin-bottom: 1.5rem; | |
| 632 | + | } | |
| 633 | + | ||
| 627 | 634 | .ml-2 { | |
| 628 | 635 | margin-left: 0.5rem; | |
| 629 | 636 | } | |
| 630 | 637 | ||
| 638 | + | .ml-6 { | |
| 639 | + | margin-left: 1.5rem; | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | .ml-7 { | |
| 643 | + | margin-left: 1.75rem; | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | .mt-1 { | |
| 647 | + | margin-top: 0.25rem; | |
| 648 | + | } | |
| 649 | + | ||
| 631 | 650 | .mt-2 { | |
| 632 | 651 | margin-top: 0.5rem; | |
| 633 | 652 | } | |
| @@ -636,6 +655,10 @@ video { | |||
| 636 | 655 | margin-top: 1rem; | |
| 637 | 656 | } | |
| 638 | 657 | ||
| 658 | + | .mt-8 { | |
| 659 | + | margin-top: 2rem; | |
| 660 | + | } | |
| 661 | + | ||
| 639 | 662 | .block { | |
| 640 | 663 | display: block; | |
| 641 | 664 | } | |
| @@ -664,8 +687,8 @@ video { | |||
| 664 | 687 | height: 11rem; | |
| 665 | 688 | } | |
| 666 | 689 | ||
| 667 | - | .h-64 { | |
| 668 | - | height: 16rem; | |
| 690 | + | .h-5 { | |
| 691 | + | height: 1.25rem; | |
| 669 | 692 | } | |
| 670 | 693 | ||
| 671 | 694 | .h-8 { | |
| @@ -688,14 +711,26 @@ video { | |||
| 688 | 711 | width: 66.666667%; | |
| 689 | 712 | } | |
| 690 | 713 | ||
| 714 | + | .w-5 { | |
| 715 | + | width: 1.25rem; | |
| 716 | + | } | |
| 717 | + | ||
| 691 | 718 | .w-5\/12 { | |
| 692 | 719 | width: 41.666667%; | |
| 693 | 720 | } | |
| 694 | 721 | ||
| 722 | + | .w-8 { | |
| 723 | + | width: 2rem; | |
| 724 | + | } | |
| 725 | + | ||
| 695 | 726 | .w-full { | |
| 696 | 727 | width: 100%; | |
| 697 | 728 | } | |
| 698 | 729 | ||
| 730 | + | .max-w-md { | |
| 731 | + | max-width: 28rem; | |
| 732 | + | } | |
| 733 | + | ||
| 699 | 734 | .flex-1 { | |
| 700 | 735 | flex: 1 1 0%; | |
| 701 | 736 | } | |
| @@ -712,6 +747,10 @@ video { | |||
| 712 | 747 | cursor: pointer; | |
| 713 | 748 | } | |
| 714 | 749 | ||
| 750 | + | .list-disc { | |
| 751 | + | list-style-type: disc; | |
| 752 | + | } | |
| 753 | + | ||
| 715 | 754 | .flex-row { | |
| 716 | 755 | flex-direction: row; | |
| 717 | 756 | } | |
| @@ -748,8 +787,10 @@ video { | |||
| 748 | 787 | justify-content: space-evenly; | |
| 749 | 788 | } | |
| 750 | 789 | ||
| 751 | - | .justify-items-center { | |
| 752 | - | justify-items: center; | |
| 790 | + | .space-x-2 > :not([hidden]) ~ :not([hidden]) { | |
| 791 | + | --tw-space-x-reverse: 0; | |
| 792 | + | margin-right: calc(0.5rem * var(--tw-space-x-reverse)); | |
| 793 | + | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); | |
| 753 | 794 | } | |
| 754 | 795 | ||
| 755 | 796 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { | |
| @@ -758,12 +799,6 @@ video { | |||
| 758 | 799 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); | |
| 759 | 800 | } | |
| 760 | 801 | ||
| 761 | - | .space-y-2 > :not([hidden]) ~ :not([hidden]) { | |
| 762 | - | --tw-space-y-reverse: 0; | |
| 763 | - | margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); | |
| 764 | - | margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); | |
| 765 | - | } | |
| 766 | - | ||
| 767 | 802 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { | |
| 768 | 803 | --tw-space-y-reverse: 0; | |
| 769 | 804 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); | |
| @@ -780,6 +815,10 @@ video { | |||
| 780 | 815 | border-radius: 0.25rem; | |
| 781 | 816 | } | |
| 782 | 817 | ||
| 818 | + | .rounded-full { | |
| 819 | + | border-radius: 9999px; | |
| 820 | + | } | |
| 821 | + | ||
| 783 | 822 | .rounded-md { | |
| 784 | 823 | border-radius: 0.375rem; | |
| 785 | 824 | } | |
| @@ -802,42 +841,62 @@ video { | |||
| 802 | 841 | ||
| 803 | 842 | .border-gray-300 { | |
| 804 | 843 | --tw-border-opacity: 1; | |
| 805 | - | border-color: rgb(209 213 219 / var(--tw-border-opacity)); | |
| 844 | + | border-color: rgb(209 213 219 / var(--tw-border-opacity, 1)); | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | .border-red-400 { | |
| 848 | + | --tw-border-opacity: 1; | |
| 849 | + | border-color: rgb(248 113 113 / var(--tw-border-opacity, 1)); | |
| 806 | 850 | } | |
| 807 | 851 | ||
| 808 | 852 | .bg-blue-500 { | |
| 809 | 853 | --tw-bg-opacity: 1; | |
| 810 | - | background-color: rgb(59 130 246 / var(--tw-bg-opacity)); | |
| 854 | + | background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1)); | |
| 811 | 855 | } | |
| 812 | 856 | ||
| 813 | 857 | .bg-gordonmascot-hblue { | |
| 814 | 858 | --tw-bg-opacity: 1; | |
| 815 | - | background-color: rgb(1 30 92 / var(--tw-bg-opacity)); | |
| 859 | + | background-color: rgb(1 30 92 / var(--tw-bg-opacity, 1)); | |
| 816 | 860 | } | |
| 817 | 861 | ||
| 818 | 862 | .bg-gordonmascot-lblue { | |
| 819 | 863 | --tw-bg-opacity: 1; | |
| 820 | - | background-color: rgb(0 123 192 / var(--tw-bg-opacity)); | |
| 864 | + | background-color: rgb(0 123 192 / var(--tw-bg-opacity, 1)); | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | .bg-gordonmascot-mblue { | |
| 868 | + | --tw-bg-opacity: 1; | |
| 869 | + | background-color: rgb(0 82 147 / var(--tw-bg-opacity, 1)); | |
| 821 | 870 | } | |
| 822 | 871 | ||
| 823 | 872 | .bg-gray-100 { | |
| 824 | 873 | --tw-bg-opacity: 1; | |
| 825 | - | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); | |
| 874 | + | background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); | |
| 826 | 875 | } | |
| 827 | 876 | ||
| 828 | 877 | .bg-gray-300 { | |
| 829 | 878 | --tw-bg-opacity: 1; | |
| 830 | - | background-color: rgb(209 213 219 / var(--tw-bg-opacity)); | |
| 879 | + | background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | .bg-green-500 { | |
| 883 | + | --tw-bg-opacity: 1; | |
| 884 | + | background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | .bg-red-100 { | |
| 888 | + | --tw-bg-opacity: 1; | |
| 889 | + | background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1)); | |
| 831 | 890 | } | |
| 832 | 891 | ||
| 833 | 892 | .bg-slate-100 { | |
| 834 | 893 | --tw-bg-opacity: 1; | |
| 835 | - | background-color: rgb(241 245 249 / var(--tw-bg-opacity)); | |
| 894 | + | background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1)); | |
| 836 | 895 | } | |
| 837 | 896 | ||
| 838 | 897 | .bg-slate-200 { | |
| 839 | 898 | --tw-bg-opacity: 1; | |
| 840 | - | background-color: rgb(226 232 240 / var(--tw-bg-opacity)); | |
| 899 | + | background-color: rgb(226 232 240 / var(--tw-bg-opacity, 1)); | |
| 841 | 900 | } | |
| 842 | 901 | ||
| 843 | 902 | .bg-transparent { | |
| @@ -846,12 +905,12 @@ video { | |||
| 846 | 905 | ||
| 847 | 906 | .bg-white { | |
| 848 | 907 | --tw-bg-opacity: 1; | |
| 849 | - | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | |
| 908 | + | background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); | |
| 850 | 909 | } | |
| 851 | 910 | ||
| 852 | 911 | .bg-zinc-200 { | |
| 853 | 912 | --tw-bg-opacity: 1; | |
| 854 | - | background-color: rgb(228 228 231 / var(--tw-bg-opacity)); | |
| 913 | + | background-color: rgb(228 228 231 / var(--tw-bg-opacity, 1)); | |
| 855 | 914 | } | |
| 856 | 915 | ||
| 857 | 916 | .p-0 { | |
| @@ -871,6 +930,11 @@ video { | |||
| 871 | 930 | padding-right: 0.5rem; | |
| 872 | 931 | } | |
| 873 | 932 | ||
| 933 | + | .px-3 { | |
| 934 | + | padding-left: 0.75rem; | |
| 935 | + | padding-right: 0.75rem; | |
| 936 | + | } | |
| 937 | + | ||
| 874 | 938 | .px-4 { | |
| 875 | 939 | padding-left: 1rem; | |
| 876 | 940 | padding-right: 1rem; | |
| @@ -891,6 +955,11 @@ video { | |||
| 891 | 955 | padding-bottom: 0.5rem; | |
| 892 | 956 | } | |
| 893 | 957 | ||
| 958 | + | .py-3 { | |
| 959 | + | padding-top: 0.75rem; | |
| 960 | + | padding-bottom: 0.75rem; | |
| 961 | + | } | |
| 962 | + | ||
| 894 | 963 | .pb-4 { | |
| 895 | 964 | padding-bottom: 1rem; | |
| 896 | 965 | } | |
| @@ -955,6 +1024,10 @@ video { | |||
| 955 | 1024 | font-weight: 700; | |
| 956 | 1025 | } | |
| 957 | 1026 | ||
| 1027 | + | .font-medium { | |
| 1028 | + | font-weight: 500; | |
| 1029 | + | } | |
| 1030 | + | ||
| 958 | 1031 | .font-semibold { | |
| 959 | 1032 | font-weight: 600; | |
| 960 | 1033 | } | |
| @@ -965,67 +1038,72 @@ video { | |||
| 965 | 1038 | ||
| 966 | 1039 | .text-blue-600 { | |
| 967 | 1040 | --tw-text-opacity: 1; | |
| 968 | - | color: rgb(37 99 235 / var(--tw-text-opacity)); | |
| 1041 | + | color: rgb(37 99 235 / var(--tw-text-opacity, 1)); | |
| 969 | 1042 | } | |
| 970 | 1043 | ||
| 971 | 1044 | .text-blue-800 { | |
| 972 | 1045 | --tw-text-opacity: 1; | |
| 973 | - | color: rgb(30 64 175 / var(--tw-text-opacity)); | |
| 1046 | + | color: rgb(30 64 175 / var(--tw-text-opacity, 1)); | |
| 974 | 1047 | } | |
| 975 | 1048 | ||
| 976 | 1049 | .text-gordonmascot-hblue { | |
| 977 | 1050 | --tw-text-opacity: 1; | |
| 978 | - | color: rgb(1 30 92 / var(--tw-text-opacity)); | |
| 1051 | + | color: rgb(1 30 92 / var(--tw-text-opacity, 1)); | |
| 979 | 1052 | } | |
| 980 | 1053 | ||
| 981 | 1054 | .text-gordonmascot-mblue { | |
| 982 | 1055 | --tw-text-opacity: 1; | |
| 983 | - | color: rgb(0 82 147 / var(--tw-text-opacity)); | |
| 1056 | + | color: rgb(0 82 147 / var(--tw-text-opacity, 1)); | |
| 984 | 1057 | } | |
| 985 | 1058 | ||
| 986 | 1059 | .text-gray-100 { | |
| 987 | 1060 | --tw-text-opacity: 1; | |
| 988 | - | color: rgb(243 244 246 / var(--tw-text-opacity)); | |
| 1061 | + | color: rgb(243 244 246 / var(--tw-text-opacity, 1)); | |
| 989 | 1062 | } | |
| 990 | 1063 | ||
| 991 | 1064 | .text-gray-400 { | |
| 992 | 1065 | --tw-text-opacity: 1; | |
| 993 | - | color: rgb(156 163 175 / var(--tw-text-opacity)); | |
| 1066 | + | color: rgb(156 163 175 / var(--tw-text-opacity, 1)); | |
| 994 | 1067 | } | |
| 995 | 1068 | ||
| 996 | 1069 | .text-gray-500 { | |
| 997 | 1070 | --tw-text-opacity: 1; | |
| 998 | - | color: rgb(107 114 128 / var(--tw-text-opacity)); | |
| 1071 | + | color: rgb(107 114 128 / var(--tw-text-opacity, 1)); | |
| 1072 | + | } | |
| 1073 | + | ||
| 1074 | + | .text-gray-700 { | |
| 1075 | + | --tw-text-opacity: 1; | |
| 1076 | + | color: rgb(55 65 81 / var(--tw-text-opacity, 1)); | |
| 999 | 1077 | } | |
| 1000 | 1078 | ||
| 1001 | 1079 | .text-gray-800 { | |
| 1002 | 1080 | --tw-text-opacity: 1; | |
| 1003 | - | color: rgb(31 41 55 / var(--tw-text-opacity)); | |
| 1081 | + | color: rgb(31 41 55 / var(--tw-text-opacity, 1)); | |
| 1004 | 1082 | } | |
| 1005 | 1083 | ||
| 1006 | 1084 | .text-green-300 { | |
| 1007 | 1085 | --tw-text-opacity: 1; | |
| 1008 | - | color: rgb(134 239 172 / var(--tw-text-opacity)); | |
| 1086 | + | color: rgb(134 239 172 / var(--tw-text-opacity, 1)); | |
| 1009 | 1087 | } | |
| 1010 | 1088 | ||
| 1011 | 1089 | .text-red-500 { | |
| 1012 | 1090 | --tw-text-opacity: 1; | |
| 1013 | - | color: rgb(239 68 68 / var(--tw-text-opacity)); | |
| 1091 | + | color: rgb(239 68 68 / var(--tw-text-opacity, 1)); | |
| 1014 | 1092 | } | |
| 1015 | 1093 | ||
| 1016 | 1094 | .text-red-600 { | |
| 1017 | 1095 | --tw-text-opacity: 1; | |
| 1018 | - | color: rgb(220 38 38 / var(--tw-text-opacity)); | |
| 1096 | + | color: rgb(220 38 38 / var(--tw-text-opacity, 1)); | |
| 1019 | 1097 | } | |
| 1020 | 1098 | ||
| 1021 | - | .text-slate-100 { | |
| 1099 | + | .text-red-700 { | |
| 1022 | 1100 | --tw-text-opacity: 1; | |
| 1023 | - | color: rgb(241 245 249 / var(--tw-text-opacity)); | |
| 1101 | + | color: rgb(185 28 28 / var(--tw-text-opacity, 1)); | |
| 1024 | 1102 | } | |
| 1025 | 1103 | ||
| 1026 | 1104 | .text-white { | |
| 1027 | 1105 | --tw-text-opacity: 1; | |
| 1028 | - | color: rgb(255 255 255 / var(--tw-text-opacity)); | |
| 1106 | + | color: rgb(255 255 255 / var(--tw-text-opacity, 1)); | |
| 1029 | 1107 | } | |
| 1030 | 1108 | ||
| 1031 | 1109 | .shadow { | |
| @@ -1034,6 +1112,12 @@ video { | |||
| 1034 | 1112 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); | |
| 1035 | 1113 | } | |
| 1036 | 1114 | ||
| 1115 | + | .shadow-sm { | |
| 1116 | + | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); | |
| 1117 | + | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); | |
| 1118 | + | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); | |
| 1119 | + | } | |
| 1120 | + | ||
| 1037 | 1121 | .outline-none { | |
| 1038 | 1122 | outline: 2px solid transparent; | |
| 1039 | 1123 | outline-offset: 2px; | |
| @@ -1047,6 +1131,14 @@ video { | |||
| 1047 | 1131 | transition-duration: 150ms; | |
| 1048 | 1132 | } | |
| 1049 | 1133 | ||
| 1134 | + | .duration-150 { | |
| 1135 | + | transition-duration: 150ms; | |
| 1136 | + | } | |
| 1137 | + | ||
| 1138 | + | .ease-in-out { | |
| 1139 | + | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); | |
| 1140 | + | } | |
| 1141 | + | ||
| 1050 | 1142 | /* Import IBM Plex Fonts */ | |
| 1051 | 1143 | ||
| 1052 | 1144 | @font-face { | |
| @@ -1185,30 +1277,54 @@ video { | |||
| 1185 | 1277 | ||
| 1186 | 1278 | .hover\:bg-blue-700:hover { | |
| 1187 | 1279 | --tw-bg-opacity: 1; | |
| 1188 | - | background-color: rgb(29 78 216 / var(--tw-bg-opacity)); | |
| 1280 | + | background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); | |
| 1281 | + | } | |
| 1282 | + | ||
| 1283 | + | .hover\:bg-gordonmascot-hblue:hover { | |
| 1284 | + | --tw-bg-opacity: 1; | |
| 1285 | + | background-color: rgb(1 30 92 / var(--tw-bg-opacity, 1)); | |
| 1189 | 1286 | } | |
| 1190 | 1287 | ||
| 1191 | 1288 | .hover\:bg-gordonmascot-mblue:hover { | |
| 1192 | 1289 | --tw-bg-opacity: 1; | |
| 1193 | - | background-color: rgb(0 82 147 / var(--tw-bg-opacity)); | |
| 1290 | + | background-color: rgb(0 82 147 / var(--tw-bg-opacity, 1)); | |
| 1194 | 1291 | } | |
| 1195 | 1292 | ||
| 1196 | 1293 | .hover\:bg-slate-100:hover { | |
| 1197 | 1294 | --tw-bg-opacity: 1; | |
| 1198 | - | background-color: rgb(241 245 249 / var(--tw-bg-opacity)); | |
| 1295 | + | background-color: rgb(241 245 249 / var(--tw-bg-opacity, 1)); | |
| 1199 | 1296 | } | |
| 1200 | 1297 | ||
| 1201 | 1298 | .hover\:text-gordonmascot-hblue:hover { | |
| 1202 | 1299 | --tw-text-opacity: 1; | |
| 1203 | - | color: rgb(1 30 92 / var(--tw-text-opacity)); | |
| 1300 | + | color: rgb(1 30 92 / var(--tw-text-opacity, 1)); | |
| 1204 | 1301 | } | |
| 1205 | 1302 | ||
| 1206 | 1303 | .hover\:text-red-900:hover { | |
| 1207 | 1304 | --tw-text-opacity: 1; | |
| 1208 | - | color: rgb(127 29 29 / var(--tw-text-opacity)); | |
| 1305 | + | color: rgb(127 29 29 / var(--tw-text-opacity, 1)); | |
| 1306 | + | } | |
| 1307 | + | ||
| 1308 | + | .focus\:border-gordonmascot-hblue:focus { | |
| 1309 | + | --tw-border-opacity: 1; | |
| 1310 | + | border-color: rgb(1 30 92 / var(--tw-border-opacity, 1)); | |
| 1311 | + | } | |
| 1312 | + | ||
| 1313 | + | .focus\:outline-none:focus { | |
| 1314 | + | outline: 2px solid transparent; | |
| 1315 | + | outline-offset: 2px; | |
| 1316 | + | } | |
| 1317 | + | ||
| 1318 | + | .focus\:ring-gordonmascot-hblue:focus { | |
| 1319 | + | --tw-ring-opacity: 1; | |
| 1320 | + | --tw-ring-color: rgb(1 30 92 / var(--tw-ring-opacity, 1)); | |
| 1209 | 1321 | } | |
| 1210 | 1322 | ||
| 1211 | 1323 | @media (min-width: 640px) { | |
| 1324 | + | .sm\:inline { | |
| 1325 | + | display: inline; | |
| 1326 | + | } | |
| 1327 | + | ||
| 1212 | 1328 | .sm\:h-24 { | |
| 1213 | 1329 | height: 6rem; | |
| 1214 | 1330 | } | |
| @@ -1223,10 +1339,6 @@ video { | |||
| 1223 | 1339 | height: 4rem; | |
| 1224 | 1340 | } | |
| 1225 | 1341 | ||
| 1226 | - | .md\:w-1\/2 { | |
| 1227 | - | width: 50%; | |
| 1228 | - | } | |
| 1229 | - | ||
| 1230 | 1342 | .md\:w-2\/6 { | |
| 1231 | 1343 | width: 33.333333%; | |
| 1232 | 1344 | } | |
M package.json +3 -4
| @@ -1,16 +1,15 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "devDependencies": { | |
| 3 | 3 | "@catppuccin/tailwindcss": "^0.1.6", | |
| 4 | - | "prettier": "^3.3.3", | |
| 4 | + | "prettier": "^3.5.3", | |
| 5 | 5 | "prettier-plugin-tailwindcss": "^0.5.14", | |
| 6 | - | "tailwindcss": "^3.4.14" | |
| 6 | + | "tailwindcss": "^3.4.17" | |
| 7 | 7 | }, | |
| 8 | 8 | "scripts": { | |
| 9 | 9 | "build:css": "tailwindcss build src/styles.css -o dist/styles.css", | |
| 10 | 10 | "dev:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css --watch" | |
| 11 | 11 | }, | |
| 12 | 12 | "dependencies": { | |
| 13 | - | "caniuse-lite": "^1.0.30001669", | |
| 14 | - | "dotenv": "^16.4.5" | |
| 13 | + | "dotenv": "^16.4.7" | |
| 15 | 14 | } | |
| 16 | 15 | } | |