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 | "sync" | 4 | "sync" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/gordon/internal/db/queries" | 7 | + | "github.com/bnema/gordon/internal/db" |
| 8 | "github.com/charmbracelet/log" | 8 | "github.com/charmbracelet/log" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| @@ -15,7 +15,7 @@ type TokenCache struct { | |||||
| 15 | type cacheEntry struct { | 15 | type cacheEntry struct { | ||
| 16 | token string | 16 | token string | ||
| 17 | expiration time.Time | 17 | expiration time.Time | ||
| 18 | - | githubUser *queries.GithubUserInfo | 18 | + | githubUser *db.GithubUserInfo |
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | var ( | 21 | var ( | ||
| @@ -33,7 +33,7 @@ func GetTokenCache() *TokenCache { | |||||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | // GetWithUser retrieves a token and GitHub user info from the cache | 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 | if value, ok := tc.cache.Load(key); ok { | 37 | if value, ok := tc.cache.Load(key); ok { | ||
| 38 | entry := value.(cacheEntry) | 38 | entry := value.(cacheEntry) | ||
| 39 | if time.Now().Before(entry.expiration) { | 39 | if time.Now().Before(entry.expiration) { | ||
| @@ -63,7 +63,7 @@ func (tc *TokenCache) Get(key string) (bool, bool) { | |||||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | // SetWithUser stores a token and GitHub user info in the cache | 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 | tc.cache.Store(key, cacheEntry{ | 67 | tc.cache.Store(key, cacheEntry{ | ||
| 68 | token: token, | 68 | token: token, | ||
| 69 | githubUser: user, | 69 | githubUser: user, | ||
M internal/cli/cmd/deploy.go +20 -3
| @@ -25,6 +25,7 @@ import ( | |||||
| 25 | func NewDeployCommand(a *cli.App) *cobra.Command { | 25 | func NewDeployCommand(a *cli.App) *cobra.Command { | ||
| 26 | var port string | 26 | var port string | ||
| 27 | var targetDomain string | 27 | var targetDomain string | ||
| 28 | + | var skipProxySetup bool | |||
| 28 | 29 | ||||
| 29 | deployCmd := &cobra.Command{ | 30 | deployCmd := &cobra.Command{ | ||
| 30 | Use: "deploy [image:tag]", | 31 | Use: "deploy [image:tag]", | ||
| @@ -56,7 +57,7 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||||
| 56 | 57 | ||||
| 57 | logger.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB)) | 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 | os.Exit(1) | 61 | os.Exit(1) | ||
| 61 | } | 62 | } | ||
| 62 | }, | 63 | }, | ||
| @@ -64,11 +65,12 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||||
| 64 | 65 | ||||
| 65 | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to") | 66 | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to") | ||
| 66 | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for the proxy") | 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 | return deployCmd | 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 | // Check authentication early | 74 | // Check authentication early | ||
| 73 | if err := handler.CheckAndRefreshAuth(a); err != nil { | 75 | if err := handler.CheckAndRefreshAuth(a); err != nil { | ||
| 74 | logger.Error("Authentication check failed", "error", err) | 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 | "Content-Type": {"application/octet-stream"}, | 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 | chunkedClient := handler.NewChunkedClient(a) | 139 | chunkedClient := handler.NewChunkedClient(a) | ||
| 132 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | 140 | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | ||
| 133 | defer cancel() | 141 | defer cancel() | ||
| @@ -150,13 +158,22 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str | |||||
| 150 | } | 158 | } | ||
| 151 | 159 | ||||
| 152 | func waitForDeployment(domain string, containerID string) error { | 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 | client := &http.Client{Timeout: 10 * time.Second} | 168 | client := &http.Client{Timeout: 10 * time.Second} | ||
| 154 | maxRetries := 10 | 169 | maxRetries := 10 | ||
| 155 | retryInterval := time.Second | 170 | retryInterval := time.Second | ||
| 156 | 171 | ||||
| 157 | var shortContainerID string | 172 | var shortContainerID string | ||
| 158 | - | if containerID != "" { | 173 | + | if containerID != "" && len(containerID) >= 12 { |
| 159 | shortContainerID = containerID[:12] | 174 | shortContainerID = containerID[:12] | ||
| 175 | + | } else { | |||
| 176 | + | shortContainerID = containerID | |||
| 160 | } | 177 | } | ||
| 161 | 178 | ||||
| 162 | logger.Info("Waiting for deployment to be reachable", | 179 | logger.Info("Waiting for deployment to be reachable", | ||
M internal/cli/cmd/proxy.go +59 -59
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/gordon/internal/cli/handler" | |||
| 7 | "github.com/bnema/gordon/internal/proxy" | 8 | "github.com/bnema/gordon/internal/proxy" | ||
| 8 | "github.com/bnema/gordon/internal/server" | 9 | "github.com/bnema/gordon/internal/server" | ||
| 9 | "github.com/bnema/gordon/pkg/docker" | 10 | "github.com/bnema/gordon/pkg/docker" | ||
| @@ -36,6 +37,9 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 36 | log.Fatal("Failed to initialize database:", err) | 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 | // Create a new proxy instance | 43 | // Create a new proxy instance | ||
| 40 | p, err := proxy.NewProxy(a) | 44 | p, err := proxy.NewProxy(a) | ||
| 41 | if err != nil { | 45 | if err != nil { | ||
| @@ -44,91 +48,83 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 44 | 48 | ||||
| 45 | // If update flag is set, update a container's IP address | 49 | // If update flag is set, update a container's IP address | ||
| 46 | if update { | 50 | if update { | ||
| 47 | - | if domain == "" { | 51 | + | if containerID == "" || containerIP == "" { |
| 48 | - | log.Fatal("Domain is required for updating a route IP") | 52 | + | log.Fatal("Container ID and IP address are required for update") |
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | if containerIP == "" { | |||
| 52 | - | log.Fatal("Container IP is required for updating a route IP") | |||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | - | // If force flag is not set, try to get the IP from the container ID | 55 | + | // Get container info to verify the IP |
| 56 | - | if !force && containerID != "" { | 56 | + | containerInfo, err := docker.GetContainerInfo(containerID) |
| 57 | - | // Get container info to verify the IP | 57 | + | if err != nil { |
| 58 | - | containerInfo, err := docker.GetContainerInfo(containerID) | 58 | + | log.Warn("Failed to get container info, using provided IP", "error", err) |
| 59 | - | if err != nil { | 59 | + | } else { |
| 60 | - | log.Warn("Failed to get container info, using provided IP", "error", err) | 60 | + | // Look for the network specified in the config |
| 61 | - | } else { | 61 | + | networkName := a.Config.ContainerEngine.Network |
| 62 | - | // Look for the network specified in the config | 62 | + | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { |
| 63 | - | networkName := a.Config.ContainerEngine.Network | 63 | + | if networkSettings.IPAddress != containerIP { |
| 64 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | 64 | + | log.Warn("Provided IP doesn't match container's actual IP", |
| 65 | - | if networkSettings.IPAddress != containerIP { | 65 | + | "provided", containerIP, |
| 66 | - | log.Warn("Provided IP doesn't match container's actual IP", | 66 | + | "actual", networkSettings.IPAddress) |
| 67 | - | "provided", containerIP, | 67 | + | if !force { |
| 68 | - | "actual", networkSettings.IPAddress) | 68 | + | log.Info("Using actual container IP instead of provided IP", |
| 69 | - | if !force { | 69 | + | "container_id", containerID, |
| 70 | - | log.Info("Using actual container IP instead of provided IP", | 70 | + | "new_ip", networkSettings.IPAddress) |
| 71 | - | "container_id", containerID, | 71 | + | containerIP = networkSettings.IPAddress |
| 72 | - | "new_ip", networkSettings.IPAddress) | 72 | + | } else { |
| 73 | - | containerIP = networkSettings.IPAddress | 73 | + | log.Warn("Force flag is set, using provided IP despite mismatch") |
| 74 | - | } else { | |||
| 75 | - | log.Warn("Force flag is set, using provided IP despite mismatch") | |||
| 76 | - | } | |||
| 77 | } | 74 | } | ||
| 78 | } | 75 | } | ||
| 79 | } | 76 | } | ||
| 80 | } | 77 | } | ||
| 81 | 78 | ||||
| 82 | // Update the route | 79 | // Update the route | ||
| 83 | - | err := p.ForceUpdateRouteIP(domain, containerIP) | 80 | + | err = p.ForceUpdateRouteIP(domain, containerIP) |
| 84 | if err != nil { | 81 | if err != nil { | ||
| 85 | log.Fatal("Failed to update route IP:", err) | 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 | return | 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 | if remove { | 95 | if remove { | ||
| 94 | if domain == "" { | 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 | if err := p.RemoveRoute(domain); err != nil { | 99 | if err := p.RemoveRoute(domain); err != nil { | ||
| 99 | log.Fatal("Failed to remove route:", err) | 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 | return | 108 | return | ||
| 104 | } | 109 | } | ||
| 105 | 110 | ||||
| 106 | - | // If domain is set, add a new route | 111 | + | // If domain and containerID are provided, add a route |
| 107 | - | if domain != "" { | 112 | + | if domain != "" && containerID != "" { |
| 108 | - | // Validate required fields | 113 | + | if containerPort == "" { |
| 109 | - | if containerID == "" || containerIP == "" || containerPort == "" { | 114 | + | log.Fatal("Container port is required") |
| 110 | - | log.Fatal("Container ID, IP, and port are required for adding a route") | |||
| 111 | } | 115 | } | ||
| 112 | - | ||||
| 113 | - | // Set defaults for optional fields | |||
| 114 | if protocol == "" { | 116 | if protocol == "" { | ||
| 115 | protocol = "http" | 117 | protocol = "http" | ||
| 116 | } | 118 | } | ||
| 117 | - | ||||
| 118 | - | if path == "" { | |||
| 119 | - | path = "/" | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | // Add the route | |||
| 123 | if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil { | 119 | if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil { | ||
| 124 | log.Fatal("Failed to add route:", err) | 120 | log.Fatal("Failed to add route:", err) | ||
| 125 | } | 121 | } | ||
| 122 | + | log.Info("Route added successfully") | |||
| 126 | 123 | ||||
| 127 | - | log.Info("Route added successfully", | 124 | + | // Close the database connection |
| 128 | - | "domain", domain, | 125 | + | if err := a.Shutdown(); err != nil { |
| 129 | - | "containerIP", containerIP, | 126 | + | log.Error("Error closing database", "error", err) |
| 130 | - | "containerPort", containerPort, | 127 | + | } |
| 131 | - | ) | |||
| 132 | return | 128 | return | ||
| 133 | } | 129 | } | ||
| 134 | 130 | ||||
| @@ -179,19 +175,23 @@ func NewProxyCommand(a *server.App) *cobra.Command { | |||||
| 179 | 175 | ||||
| 180 | fmt.Println(strings.Repeat("-", 75)) | 176 | fmt.Println(strings.Repeat("-", 75)) | ||
| 181 | fmt.Printf("Total routes: %d\n", count) | 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") |
| 186 | - | proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name for the route") | |||
| 187 | proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID") | 187 | proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID") | ||
| 188 | proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address") | 188 | proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address") | ||
| 189 | proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port") | 189 | proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port") | ||
| 190 | - | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | 190 | + | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "", "Protocol (http or https)") |
| 191 | - | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | 191 | + | proxyCmd.Flags().StringVarP(&path, "path", "a", "", "Path prefix") |
| 192 | - | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain") | 192 | + | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove a route") |
| 193 | - | proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update the IP address for an existing route") | 193 | + | proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update a container's IP address") |
| 194 | - | proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force update with provided IP even if it doesn't match container's actual IP") | 194 | + | proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force removal of a route") |
| 195 | 195 | ||||
| 196 | return proxyCmd | 196 | return proxyCmd | ||
| 197 | } | 197 | } | ||
M internal/cli/cmd/serve.go +6 -0
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/gordon/internal/common" | 9 | "github.com/bnema/gordon/internal/common" | ||
| 10 | "github.com/bnema/gordon/internal/server" | 10 | "github.com/bnema/gordon/internal/server" | ||
| 11 | "github.com/bnema/gordon/pkg/docker" | 11 | "github.com/bnema/gordon/pkg/docker" | ||
| 12 | + | "github.com/bnema/gordon/pkg/logger" | |||
| 12 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -40,6 +41,11 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||||
| 40 | a.Config.Http.Port = port | 41 | a.Config.Http.Port = port | ||
| 41 | err := handler.StartServer(a, port) | 42 | err := handler.StartServer(a, port) | ||
| 42 | if err != nil { | 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 | panic(err) | 49 | panic(err) | ||
| 44 | } | 50 | } | ||
| 45 | }, | 51 | }, | ||
M internal/cli/handler/start_server.go +63 -20
| @@ -1,9 +1,11 @@ | |||||
| 1 | package handler | 1 | package handler | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "os" | 6 | "os" | ||
| 6 | "os/signal" | 7 | "os/signal" | ||
| 8 | + | "sync" | |||
| 7 | "syscall" | 9 | "syscall" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| @@ -13,9 +15,32 @@ import ( | |||||
| 13 | "github.com/labstack/echo/v4" | 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 | // execute cmd/srv/main.go main function | 38 | // execute cmd/srv/main.go main function | ||
| 17 | 39 | ||||
| 18 | func StartServer(a *server.App, port string) error { | 40 | func StartServer(a *server.App, port string) error { | ||
| 41 | + | // Set the global server instance for shutdown handling | |||
| 42 | + | SetGlobalServer(a) | |||
| 43 | + | ||||
| 19 | _, err := server.InitializeDB(a) | 44 | _, err := server.InitializeDB(a) | ||
| 20 | if err != nil { | 45 | if err != nil { | ||
| 21 | logger.Fatal("Failed to initialize database:", err) | 46 | logger.Fatal("Failed to initialize database:", err) | ||
| @@ -36,33 +61,27 @@ func StartServer(a *server.App, port string) error { | |||||
| 36 | // Continue even if proxy fails, as the main server should still work | 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 | // Setup a channel to capture termination signals | 71 | // Setup a channel to capture termination signals | ||
| 40 | sigs := make(chan os.Signal, 1) | 72 | sigs := make(chan os.Signal, 1) | ||
| 41 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | 73 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | ||
| 42 | 74 | ||||
| 75 | + | // Start server in a goroutine | |||
| 43 | go func() { | 76 | go func() { | ||
| 44 | - | sig := <-sigs | 77 | + | logger.Info("Starting server", "port", port) |
| 45 | - | logger.Info("Received signal", "signal", sig) | 78 | + | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { |
| 46 | - | 79 | + | if err.Error() != "http: Server closed" { | |
| 47 | - | // Stop the proxy if it was started | 80 | + | logger.Error("Server error", "error", err) |
| 48 | - | if p != nil { | |||
| 49 | - | if err := p.Stop(); err != nil { | |||
| 50 | - | logger.Error("Failed to stop reverse proxy", "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 | // Test admin connections after the server has started (in a separate goroutine) | 85 | // Test admin connections after the server has started (in a separate goroutine) | ||
| 67 | if p != nil { | 86 | if p != nil { | ||
| 68 | go func() { | 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 { | 94 | + | // Wait for interrupt signal |
| 76 | - | logger.Fatal("Server error", "error", err) | 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 | return nil | 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 | type ReverseProxyConfig struct { | 75 | type ReverseProxyConfig struct { | ||
| 76 | - | Port string `yaml:"port"` // Port for the reverse proxy to listen on | 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 | 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 | 78 | + | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates |
| 79 | - | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | 79 | + | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates |
| 80 | - | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | 80 | + | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates |
| 81 | - | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | 81 | + | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" |
| 82 | - | Email string `yaml:"email"` // Email for Let's Encrypt | 82 | + | Email string `yaml:"email"` // Email for Let's Encrypt |
| 83 | - | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | 83 | + | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache |
| 84 | - | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | 84 | + | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds |
| 85 | - | EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true) | 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 | // Default values | 89 | // Default values | ||
| @@ -98,6 +99,7 @@ var ( | |||||
| 98 | cacheSize = 1000 // entries | 99 | cacheSize = 1000 // entries | ||
| 99 | gracePeriod = 30 // seconds | 100 | gracePeriod = 30 // seconds | ||
| 100 | defaultLogLevel = "info" // Default log level | 101 | defaultLogLevel = "info" // Default log level | ||
| 102 | + | disableRateLimit = false | |||
| 101 | ) | 103 | ) | ||
| 102 | 104 | ||||
| 103 | // applyDefaultsToConfig applies default values to any fields that have zero values | 105 | // applyDefaultsToConfig applies default values to any fields that have zero values | ||
| @@ -154,6 +156,12 @@ func applyDefaultsToConfig(config *Config) bool { | |||||
| 154 | logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true) | 156 | logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true) | ||
| 155 | defaultsApplied = true | 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 | // Handle autoRenew | 166 | // Handle autoRenew | ||
| 159 | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | 167 | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | ||
| @@ -524,6 +532,15 @@ func loadConfigFromEnv(config *Config, printLogs bool) { | |||||
| 524 | } | 532 | } | ||
| 525 | logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs) | 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 | func (config *Config) LoadConfig() (*Config, error) { | 546 | func (config *Config) LoadConfig() (*Config, error) { | ||
| @@ -570,15 +587,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 570 | Network: "gordon", | 587 | Network: "gordon", | ||
| 571 | }, | 588 | }, | ||
| 572 | ReverseProxy: ReverseProxyConfig{ | 589 | ReverseProxy: ReverseProxyConfig{ | ||
| 573 | - | Port: reverseProxyPort, | 590 | + | Port: reverseProxyPort, |
| 574 | - | HttpPort: httpPort, | 591 | + | HttpPort: httpPort, |
| 575 | - | CertDir: certDir, | 592 | + | CertDir: certDir, |
| 576 | - | AutoRenew: autoRenew, | 593 | + | AutoRenew: autoRenew, |
| 577 | - | RenewBefore: renewBefore, | 594 | + | RenewBefore: renewBefore, |
| 578 | - | LetsEncryptMode: letsEncryptMode, | 595 | + | LetsEncryptMode: letsEncryptMode, |
| 579 | - | CacheSize: cacheSize, | 596 | + | CacheSize: cacheSize, |
| 580 | - | GracePeriod: gracePeriod, | 597 | + | GracePeriod: gracePeriod, |
| 581 | - | EnableLogs: true, | 598 | + | EnableLogs: true, |
| 599 | + | DisableRateLimit: false, | |||
| 582 | }, | 600 | }, | ||
| 583 | } | 601 | } | ||
| 584 | 602 | ||||
| @@ -708,15 +726,16 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 708 | Network: "gordon", | 726 | Network: "gordon", | ||
| 709 | }, | 727 | }, | ||
| 710 | ReverseProxy: ReverseProxyConfig{ | 728 | ReverseProxy: ReverseProxyConfig{ | ||
| 711 | - | Port: reverseProxyPort, | 729 | + | Port: reverseProxyPort, |
| 712 | - | HttpPort: httpPort, | 730 | + | HttpPort: httpPort, |
| 713 | - | CertDir: certDir, | 731 | + | CertDir: certDir, |
| 714 | - | AutoRenew: autoRenew, | 732 | + | AutoRenew: autoRenew, |
| 715 | - | RenewBefore: renewBefore, | 733 | + | RenewBefore: renewBefore, |
| 716 | - | LetsEncryptMode: letsEncryptMode, | 734 | + | LetsEncryptMode: letsEncryptMode, |
| 717 | - | CacheSize: cacheSize, | 735 | + | CacheSize: cacheSize, |
| 718 | - | GracePeriod: gracePeriod, | 736 | + | GracePeriod: gracePeriod, |
| 719 | - | EnableLogs: true, | 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 | func (c *HttpConfig) FullDomain() string { | 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 | if c.SubDomain != "" { | 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 | // GetVersion | 929 | // GetVersion | ||
M internal/common/payload.go +6 -5
| @@ -55,11 +55,12 @@ type StartResponse struct { | |||||
| 55 | 55 | ||||
| 56 | // DeployPayload represents the payload for deployment requests | 56 | // DeployPayload represents the payload for deployment requests | ||
| 57 | type DeployPayload struct { | 57 | type DeployPayload struct { | ||
| 58 | - | Port string `json:"port"` | 58 | + | Port string `json:"port"` |
| 59 | - | TargetDomain string `json:"targetdomain"` | 59 | + | TargetDomain string `json:"targetdomain"` |
| 60 | - | ImageName string `json:"imagename"` | 60 | + | ImageName string `json:"imagename"` |
| 61 | - | ImageID string `json:"imageid"` | 61 | + | ImageID string `json:"imageid"` |
| 62 | - | Data io.ReadCloser | 62 | + | SkipProxySetup bool `json:"skip_proxy_setup"` |
| 63 | + | Data io.ReadCloser | |||
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| 65 | // PushPayload represents the payload for push requests | 66 | // PushPayload represents the payload for push requests | ||
M internal/db/db_struct.go +9 -0
| @@ -1,5 +1,6 @@ | |||||
| 1 | package db | 1 | package db | ||
| 2 | 2 | ||||
| 3 | + | // User struct model | |||
| 3 | type User struct { | 4 | type User struct { | ||
| 4 | ID string `sql:"id, primary_key"` | 5 | ID string `sql:"id, primary_key"` | ||
| 5 | Name string `sql:"name"` | 6 | Name string `sql:"name"` | ||
| @@ -77,3 +78,11 @@ type ProxyRoute struct { | |||||
| 77 | CreatedAt string `sql:"created_at"` | 78 | CreatedAt string `sql:"created_at"` | ||
| 78 | UpdatedAt string `sql:"updated_at"` | 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 | package queries | 1 | package queries | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "database/sql" | |||
| 4 | "github.com/bnema/gordon/internal/db" | 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 | account := &db.Account{ | 26 | account := &db.Account{ | ||
| 10 | - | ID: generateUUID(), | 27 | + | ID: generateUUID(), |
| 28 | + | UserID: userID, | |||
| 11 | } | 29 | } | ||
| 12 | 30 | ||||
| 13 | - | _, err := a.DB.Exec( | 31 | + | _, err := database.Exec( |
| 14 | - | "INSERT INTO account (id, user_id) VALUES (?, ?)", | 32 | + | NewAccountQueries().InsertAccount, |
| 15 | - | account.ID, a.DBTables.User.ID, | 33 | + | account.ID, userID, |
| 16 | ) | 34 | ) | ||
| 35 | + | ||||
| 17 | return account, err | 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 | var exists bool | 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 | if err != nil { | 48 | if err != nil { | ||
| 24 | return false, err | 49 | return false, err | ||
| 25 | } | 50 | } | ||
| 51 | + | ||||
| 26 | return exists, nil | 52 | return exists, nil | ||
| 27 | } | 53 | } | ||
| 28 | 54 | ||||
| 29 | - | func PopulateAccountFromDB(a *server.App) error { | 55 | + | // GetDBAccountByID gets an account from the database by ID |
| 30 | - | rows, err := a.DB.Query("SELECT id, user_id FROM account") | 56 | + | func GetDBAccountByID(database *sql.DB, accountID string) (*db.Account, error) { |
| 31 | - | if err != nil { | 57 | + | account := &db.Account{ |
| 32 | - | return err | 58 | + | ID: accountID, |
| 33 | } | 59 | } | ||
| 34 | - | defer rows.Close() | 60 | + | |
| 35 | - | 61 | + | err := database.QueryRow( | |
| 36 | - | for rows.Next() { | 62 | + | NewAccountQueries().GetAccountByID, |
| 37 | - | account := db.Account{} | 63 | + | accountID, |
| 38 | - | err := rows.Scan(&account.ID, &account.UserID) | 64 | + | ).Scan(&account.ID, &account.UserID) |
| 39 | - | if err != nil { | 65 | + | |
| 40 | - | return err | 66 | + | if err != nil { |
| 41 | - | } | 67 | + | return nil, err |
| 42 | - | ||||
| 43 | - | a.DBTables.Account = account | |||
| 44 | } | 68 | } | ||
| 45 | - | 69 | + | ||
| 46 | - | return rows.Err() | 70 | + | return account, nil |
| 47 | } | 71 | } | ||
M internal/db/queries/inspectdb.go +6 -7
| @@ -1,16 +1,14 @@ | |||||
| 1 | package queries | 1 | package queries | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "database/sql" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | - | ||||
| 6 | - | "github.com/bnema/gordon/internal/server" | |||
| 7 | ) | 6 | ) | ||
| 8 | 7 | ||||
| 9 | // InspectInMemoryDB inspects the in-memory database. (for debug purpose) | 8 | // InspectInMemoryDB inspects the in-memory database. (for debug purpose) | ||
| 10 | - | func InspectInMemoryDB(a *server.App) error { | 9 | + | func InspectInMemoryDB(database *sql.DB) error { |
| 11 | - | memDb := a.DB | |||
| 12 | // Query the sqlite_master table to get a list of all tables | 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 | fmt.Println("rows", rows) | 12 | fmt.Println("rows", rows) | ||
| 15 | if err != nil { | 13 | if err != nil { | ||
| 16 | return err | 14 | return err | ||
| @@ -36,7 +34,7 @@ func InspectInMemoryDB(a *server.App) error { | |||||
| 36 | fmt.Println(table) | 34 | fmt.Println(table) | ||
| 37 | } | 35 | } | ||
| 38 | // Print the content of the users table | 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 | if err != nil { | 38 | if err != nil { | ||
| 41 | return err | 39 | return err | ||
| 42 | } | 40 | } | ||
| @@ -54,10 +52,11 @@ func InspectInMemoryDB(a *server.App) error { | |||||
| 54 | } | 52 | } | ||
| 55 | 53 | ||||
| 56 | // Print the content of accounts table | 54 | // Print the content of accounts table | ||
| 57 | - | accountRows, err := memDb.Query("SELECT * FROM account") | 55 | + | accountRows, err := database.Query("SELECT * FROM account") |
| 58 | if err != nil { | 56 | if err != nil { | ||
| 59 | return err | 57 | return err | ||
| 60 | } | 58 | } | ||
| 59 | + | defer accountRows.Close() | |||
| 61 | 60 | ||||
| 62 | fmt.Println("\nContent of the 'account' table:") | 61 | fmt.Println("\nContent of the 'account' table:") | ||
| 63 | for accountRows.Next() { | 62 | for accountRows.Next() { | ||
M internal/db/queries/provider.go +91 -24
| @@ -1,41 +1,108 @@ | |||||
| 1 | package queries | 1 | package queries | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "database/sql" | |||
| 4 | "github.com/bnema/gordon/internal/db" | 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 { | 8 | + | // ProviderQueries contains all SQL queries for provider operations |
| 9 | - | provider := &db.Provider{ | 9 | + | type ProviderQueries struct { |
| 10 | - | ID: generateUUID(), | 10 | + | InsertProvider string |
| 11 | - | Email: userInfo.Emails[0], | 11 | + | PopulateProviderFromDB string |
| 12 | - | Login: userInfo.Login, | 12 | + | CheckProviderExists string |
| 13 | - | AvatarURL: userInfo.AvatarURL, | 13 | + | GetProviderByAccountLogin string |
| 14 | - | ProfileURL: userInfo.ProfileURL, | 14 | + | } |
| 15 | - | Name: "GitHub", | 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 (?, ?, ?, ?, ?, ?, ?)" | 26 | + | // CreateDBGitHubProvider creates a new GitHub provider in the database. |
| 19 | - | _, err := a.DB.Exec(query, provider.ID, a.DBTables.Account.ID, provider.Name, provider.Login, provider.AvatarURL, provider.ProfileURL, provider.Email) | 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 | return err | 41 | return err | ||
| 21 | } | 42 | } | ||
| 22 | 43 | ||||
| 23 | - | func PopulateProviderFromDB(a *server.App) error { | 44 | + | // PopulateDBProviderFromDB populates provider information from the database. |
| 24 | - | rows, err := a.DB.Query("SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider") | 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 | if err != nil { | 61 | if err != nil { | ||
| 26 | - | return err | 62 | + | return nil, err |
| 27 | } | 63 | } | ||
| 28 | - | defer rows.Close() | 64 | + | |
| 29 | - | 65 | + | return provider, nil | |
| 30 | - | for rows.Next() { | 66 | + | } |
| 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 | - | } | |||
| 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 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/gordon/internal/db" | 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. | 12 | + | // SessionQueries contains all SQL queries for session operations |
| 13 | - | func createDBSession(a *server.App, browserInfo string, accessToken string, accountID string) error { | 13 | + | type SessionQueries struct { |
| 14 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | 14 | + | InsertSession string |
| 15 | - | sessions := &db.Sessions{ | 15 | + | GetSessionByToken string |
| 16 | - | ID: generateUUID(), | 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 (?, ?, ?, ?, ?, ?)" | 38 | + | // CreateDBSession creates a session for the user. |
| 20 | - | _, err := a.DB.Exec(query, sessions.ID, accountID, accessToken, browserInfo, expireTime, true) | 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 | if err != nil { | 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 | - | 52 | + | ||
| 25 | - | // Update global state here if really necessary | 53 | + | // Create a Sessions struct to return |
| 26 | - | a.DBTables.Sessions.ID = sessions.ID | 54 | + | session := &db.Sessions{ |
| 27 | - | a.DBTables.Sessions.AccessToken = accessToken | 55 | + | ID: sessionID, |
| 28 | - | a.DBTables.Sessions.Expires = expireTime | 56 | + | AccountID: accountID, |
| 29 | - | 57 | + | AccessToken: accessToken, | |
| 30 | - | return nil | 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 | // GetDBUserSession gets the user session from the database based on the access token and browser info. | 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) { | 68 | + | func GetDBUserSession(database *sql.DB, 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 = ?" | 69 | + | session := &db.Sessions{} |
| 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) | 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 | if err != nil { | 84 | if err != nil { | ||
| 38 | if err == sql.ErrNoRows { | 85 | if err == sql.ErrNoRows { | ||
| 39 | return nil, nil | 86 | return nil, nil | ||
| 40 | } | 87 | } | ||
| 88 | + | logger.Warn("Failed to get session", "error", err) | |||
| 41 | return nil, fmt.Errorf("error getting session: %w", err) | 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 | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | 99 | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | ||
| 49 | - | query := "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?" | 100 | + | |
| 50 | - | _, err := a.DB.Exec(query, accessToken, expireTime, a.DBTables.Sessions.ID) | 101 | + | _, err := database.Exec( |
| 102 | + | NewSessionQueries().UpdateSessionToken, | |||
| 103 | + | accessToken, | |||
| 104 | + | expireTime, | |||
| 105 | + | sessionID, | |||
| 106 | + | ) | |||
| 107 | + | ||||
| 51 | if err != nil { | 108 | if err != nil { | ||
| 109 | + | logger.Error("Failed to update session in database", "error", err, "session_id", sessionID) | |||
| 52 | return err | 110 | return err | ||
| 53 | } | 111 | } | ||
| 54 | - | 112 | + | ||
| 113 | + | logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime) | |||
| 55 | return nil | 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 | // Check if the session exists using the access token and browser info | 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 | if err != nil && err != sql.ErrNoRows { | 121 | if err != nil && err != sql.ErrNoRows { | ||
| 122 | + | logger.Warn("Failed to check for session", "error", err) | |||
| 62 | return fmt.Errorf("error checking for session: %w", err) | 123 | return fmt.Errorf("error checking for session: %w", err) | ||
| 63 | } | 124 | } | ||
| 64 | 125 | ||||
| 65 | // If the session exists, update the session | 126 | // If the session exists, update the session | ||
| 66 | if session != nil { | 127 | if session != nil { | ||
| 67 | - | err := updateDBSession(a, accessToken) | 128 | + | err := UpdateDBSession(database, session.ID, accessToken) |
| 68 | if err != nil { | 129 | if err != nil { | ||
| 130 | + | logger.Warn("Failed to update session", "error", err) | |||
| 69 | return fmt.Errorf("error updating session: %w", err) | 131 | return fmt.Errorf("error updating session: %w", err) | ||
| 70 | } | 132 | } | ||
| 71 | - | } | 133 | + | logger.Debug("Session updated in database", "accountID", accountID, "sessionID", session.ID) |
| 72 | - | 134 | + | } else { | |
| 73 | - | // If the session does not exist, create the session | 135 | + | // If the session does not exist, create the session |
| 74 | - | if session == nil { | 136 | + | _, err := CreateDBSession(database, browserInfo, accessToken, accountID) |
| 75 | - | err := createDBSession(a, browserInfo, accessToken, a.DBTables.Account.ID) | |||
| 76 | if err != nil { | 137 | if err != nil { | ||
| 138 | + | logger.Warn("Failed to create session", "error", err) | |||
| 77 | return fmt.Errorf("error creating session: %w", err) | 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 | return nil | 144 | return nil | ||
| 82 | } | 145 | } | ||
| 83 | 146 | ||||
| 84 | - | // DeleteSession deletes the session from the database. | 147 | + | // DeleteDBSession deletes the session from the database. |
| 85 | - | func DeleteDBSession(a *server.App, accountID string, sessionID string) error { | 148 | + | func DeleteDBSession(database *sql.DB, accountID string, sessionID string) error { |
| 86 | - | query := "DELETE FROM sessions WHERE account_id = ? AND id = ?" | 149 | + | _, err := database.Exec( |
| 87 | - | _, err := a.DB.Exec(query, accountID, sessionID) | 150 | + | NewSessionQueries().DeleteSession, |
| 151 | + | accountID, | |||
| 152 | + | sessionID, | |||
| 153 | + | ) | |||
| 154 | + | ||||
| 88 | if err != nil { | 155 | if err != nil { | ||
| 156 | + | logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID) | |||
| 89 | return err | 157 | return err | ||
| 90 | } | 158 | } | ||
| 91 | - | 159 | + | ||
| 160 | + | logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID) | |||
| 92 | return nil | 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 | var expirationTime string | 166 | var expirationTime string | ||
| 97 | - | // with userID and sessionID we can get the expiration time | 167 | + | |
| 98 | - | query := "SELECT expires FROM sessions WHERE account_id = ? AND id = ?" | 168 | + | err := database.QueryRow( |
| 99 | - | err := a.DB.QueryRow(query, accountID, sessionID).Scan(&expirationTime) | 169 | + | NewSessionQueries().GetSessionExpiry, |
| 170 | + | accountID, | |||
| 171 | + | sessionID, | |||
| 172 | + | ).Scan(&expirationTime) | |||
| 173 | + | ||||
| 100 | if err != nil { | 174 | if err != nil { | ||
| 101 | return currentTime, err | 175 | return currentTime, err | ||
| 102 | } | 176 | } | ||
| @@ -107,36 +181,58 @@ func GetSessionExpiration(a *server.App, accountID string, sessionID string, cur | |||||
| 107 | return currentTime, err | 181 | return currentTime, err | ||
| 108 | } | 182 | } | ||
| 109 | 183 | ||||
| 184 | + | logger.Debug("Session expiration time retrieved", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTimeParsed) | |||
| 185 | + | ||||
| 110 | return expirationTimeParsed, nil | 186 | return expirationTimeParsed, nil | ||
| 111 | } | 187 | } | ||
| 112 | 188 | ||||
| 113 | - | func ExtendSessionExpiration(a *server.App, accountID string, sessionID string, newExpirationTime time.Time) error { | 189 | + | // ExtendSessionExpiration extends the expiration time for a session |
| 114 | - | query := "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?" | 190 | + | func ExtendSessionExpiration(database *sql.DB, accountID string, sessionID string, newExpirationTime time.Time) error { |
| 115 | - | _, err := a.DB.Exec(query, newExpirationTime.Format(time.RFC3339), accountID, sessionID) | 191 | + | _, err := database.Exec( |
| 192 | + | NewSessionQueries().UpdateSessionExpiry, | |||
| 193 | + | newExpirationTime.Format(time.RFC3339), | |||
| 194 | + | accountID, | |||
| 195 | + | sessionID, | |||
| 196 | + | ) | |||
| 197 | + | ||||
| 116 | if err != nil { | 198 | if err != nil { | ||
| 199 | + | logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID) | |||
| 117 | return err | 200 | return err | ||
| 118 | } | 201 | } | ||
| 119 | - | 202 | + | ||
| 203 | + | logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime) | |||
| 120 | return nil | 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 | var sessionExists bool | 209 | var sessionExists bool | ||
| 125 | - | query := "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)" | 210 | + | err := database.QueryRow( |
| 126 | - | err := a.DB.QueryRow(query, sessionID).Scan(&sessionExists) | 211 | + | NewSessionQueries().CheckSessionExists, |
| 212 | + | sessionID, | |||
| 213 | + | ).Scan(&sessionExists) | |||
| 214 | + | ||||
| 127 | if err != nil { | 215 | if err != nil { | ||
| 128 | return false, err | 216 | return false, err | ||
| 129 | } | 217 | } | ||
| 130 | 218 | ||||
| 219 | + | logger.Debug("Session existence checked", "sessionID", sessionID, "exists", sessionExists) | |||
| 131 | return sessionExists, nil | 220 | return sessionExists, nil | ||
| 132 | } | 221 | } | ||
| 133 | 222 | ||||
| 134 | - | func InvalidateDBSession(a *server.App, sessionID string) error { | 223 | + | // InvalidateDBSession marks a session as offline |
| 135 | - | query := "UPDATE sessions SET is_online = ? WHERE id = ?" | 224 | + | func InvalidateDBSession(database *sql.DB, sessionID string) error { |
| 136 | - | _, err := a.DB.Exec(query, false, sessionID) | 225 | + | _, err := database.Exec( |
| 226 | + | NewSessionQueries().InvalidateSession, | |||
| 227 | + | false, | |||
| 228 | + | sessionID, | |||
| 229 | + | ) | |||
| 230 | + | ||||
| 137 | if err != nil { | 231 | if err != nil { | ||
| 232 | + | logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID) | |||
| 138 | return err | 233 | return err | ||
| 139 | } | 234 | } | ||
| 140 | - | 235 | + | ||
| 236 | + | logger.Debug("Session invalidated", "sessionID", sessionID) | |||
| 141 | return nil | 237 | return nil | ||
| 142 | } | 238 | } | ||
M internal/db/queries/user.go +112 -52
| @@ -3,9 +3,10 @@ package queries | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "database/sql" | 4 | "database/sql" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "time" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/gordon/internal/db" | 8 | "github.com/bnema/gordon/internal/db" | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | 9 | + | "github.com/bnema/gordon/pkg/logger" |
| 9 | "github.com/google/uuid" | 10 | "github.com/google/uuid" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -13,65 +14,87 @@ func generateUUID() string { | |||||
| 13 | return uuid.New().String() | 14 | return uuid.New().String() | ||
| 14 | } | 15 | } | ||
| 15 | 16 | ||||
| 16 | - | // UserInfo holds the essential information for a Github user. | 17 | + | // UserQueries contains all SQL queries for user operations |
| 17 | - | type GithubUserInfo struct { | 18 | + | type UserQueries struct { |
| 18 | - | Login string `json:"login"` | 19 | + | InsertUser string |
| 19 | - | AvatarURL string `json:"avatar_url"` | 20 | + | CheckUserExists string |
| 20 | - | ProfileURL string `json:"html_url"` | 21 | + | GetUserByProvider string |
| 21 | - | Emails []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 | // CreateUser creates a new user along with the associated account, provider, and session. | 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 | // Check if a user already exists. If so, return an error. | 37 | // Check if a user already exists. If so, return an error. | ||
| 27 | - | if exists, err := CheckDBUserExists(a); err != nil || exists { | 38 | + | logger.Debug("Starting CreateUser database function") |
| 28 | - | return fmt.Errorf("error checking user or user already exists: %v", err) | 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 | if err != nil { | 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 | 52 | + | logger.Debug("Creating database account for user", "user_id", user.ID) |
| 37 | - | a.DBTables.User.ID = user.ID | 53 | + | account, err := CreateDBAccount(database, user.ID) |
| 38 | - | a.DBTables.User.Name = user.Name | |||
| 39 | - | a.DBTables.User.Email = user.Email | |||
| 40 | - | ||||
| 41 | - | account, err := createDBAccount(a) | |||
| 42 | if err != nil { | 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 | 60 | + | logger.Debug("Creating GitHub provider for account", "account_id", account.ID) |
| 47 | - | a.DBTables.Account.ID = account.ID | 61 | + | err = CreateDBGitHubProvider(database, account.ID, userInfo) |
| 48 | - | a.DBTables.Account.UserID = account.UserID | 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 | if err != nil { | 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 | user := &db.User{ | 81 | user := &db.User{ | ||
| 60 | ID: generateUUID(), | 82 | ID: generateUUID(), | ||
| 61 | Name: userInfo.Login, | 83 | Name: userInfo.Login, | ||
| 62 | Email: userInfo.Emails[0], | 84 | Email: userInfo.Emails[0], | ||
| 63 | } | 85 | } | ||
| 64 | 86 | ||||
| 65 | - | _, err := a.DB.Exec( | 87 | + | _, err := database.Exec( |
| 66 | - | "INSERT INTO user (id, name, email) VALUES (?, ?, ?)", | 88 | + | NewUserQueries().InsertUser, |
| 67 | user.ID, user.Name, user.Email, | 89 | user.ID, user.Name, user.Email, | ||
| 68 | ) | 90 | ) | ||
| 69 | return user, err | 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 | var userID string | 96 | var userID string | ||
| 74 | - | err := a.DB.QueryRow("SELECT id FROM user").Scan(&userID) | 97 | + | err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&userID) |
| 75 | if err != nil { | 98 | if err != nil { | ||
| 76 | if err == sql.ErrNoRows { | 99 | if err == sql.ErrNoRows { | ||
| 77 | return false, nil | 100 | return false, nil | ||
| @@ -86,50 +109,87 @@ func CheckDBUserExists(a *server.App) (bool, error) { | |||||
| 86 | return false, nil | 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 | 112 | + | // CheckDBUserIsGood checks if the user matches the provided GitHub info |
| 90 | - | func CheckDBUserIsGood(a *server.App, userInfo *GithubUserInfo) (bool, error) { | 113 | + | func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) { |
| 91 | login := userInfo.Login | 114 | login := userInfo.Login | ||
| 92 | email := userInfo.Emails[0] | 115 | email := userInfo.Emails[0] | ||
| 93 | 116 | ||||
| 117 | + | user := &db.User{} | |||
| 118 | + | ||||
| 94 | // Check if the user exists based on github login and email | 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 = ?" | 120 | + | err := database.QueryRow(NewUserQueries().GetUserByProvider, login, email).Scan( |
| 96 | - | err := a.DB.QueryRow(query, login, email).Scan(&a.DBTables.User.ID, &a.DBTables.User.Name, &a.DBTables.User.Email) | 121 | + | &user.ID, &user.Name, &user.Email, |
| 122 | + | ) | |||
| 123 | + | ||||
| 97 | if err != nil { | 124 | if err != nil { | ||
| 98 | if err == sql.ErrNoRows { | 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 != "" { | 131 | + | if user.ID != "" { |
| 105 | - | return true, nil | 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) { | 138 | + | // UpdateUser updates the user session with new access token |
| 112 | - | 139 | + | func UpdateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 113 | - | err := CreateOrUpdateDBSession(a, accessToken, browserInfo) | 140 | + | // First, get the user info to get their account ID |
| 141 | + | user, isGood, err := CheckDBUserIsGood(database, userInfo) | |||
| 114 | if err != nil { | 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 | - | 145 | + | ||
| 118 | - | user := a.DBTables.User | 146 | + | if !isGood { |
| 119 | - | 147 | + | return nil, "", nil, fmt.Errorf("user not found in database") | |
| 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 = ?" | 148 | + | } |
| 121 | - | err = a.DB.QueryRow(query, userInfo.Login, userInfo.Emails[0]).Scan(&user.ID, &user.Name, &user.Email) | 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 | if err != nil { | 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 | var count int | 191 | var count int | ||
| 132 | - | err := a.DB.QueryRow("SELECT COUNT(*) FROM account").Scan(&count) | 192 | + | err := database.QueryRow(NewUserQueries().GetAccountCount).Scan(&count) |
| 133 | if err != nil { | 193 | if err != nil { | ||
| 134 | return 0, err | 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 | containerName = strings.TrimPrefix(containerName, "/") | 189 | containerName = strings.TrimPrefix(containerName, "/") | ||
| 190 | } | 190 | } | ||
| 191 | 191 | ||||
| 192 | - | // Get container IP | 192 | + | // Check if we should skip proxy setup |
| 193 | - | containerIP := GetContainerIP(a, containerID, containerName) | 193 | + | skipProxySetup := false |
| 194 | - | 194 | + | if skipProxyValue, exists := sanitizedInputs["skip_proxy_setup"]; exists { | |
| 195 | - | // Construct target domain from form inputs | 195 | + | skipProxySetup = skipProxyValue == "true" || skipProxyValue == "1" || skipProxyValue == "yes" |
| 196 | - | targetDomain := fmt.Sprintf("%s://%s.%s", | 196 | + | } |
| 197 | - | sanitizedInputs["container_protocol"], | 197 | + | |
| 198 | - | sanitizedInputs["container_subdomain"], | 198 | + | // Only set up proxy if not skipped |
| 199 | - | sanitizedInputs["container_domain"]) | 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 | 218 | + | // Add proxy route for the new container |
| 202 | - | err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["proxy_port"], targetDomain) | 219 | + | err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["container_port"], targetDomain) |
| 203 | - | if err != nil { | 220 | + | if err != nil { |
| 204 | - | log.Error("Failed to add proxy route", "error", err) | 221 | + | log.Error("Failed to add proxy route", "error", err) |
| 205 | - | // Continue anyway, don't fail the whole operation | 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 | 82 | + | // Check if we should skip proxy setup |
| 83 | - | containerIP := GetContainerIP(a, containerID, containerName) | 83 | + | skipProxySetup := false |
| 84 | - | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | 84 | + | if payload.SkipProxySetup { |
| 85 | - | if err != nil { | 85 | + | skipProxySetup = true |
| 86 | - | log.Error("Failed to add proxy route", "error", err) | 86 | + | } |
| 87 | - | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | 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 | // Arrived here means deployment was successful | 100 | // Arrived here means deployment was successful | ||
| @@ -265,7 +275,18 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||||
| 265 | 275 | ||||
| 266 | // Extract container name from domain and perform safety checks | 276 | // Extract container name from domain and perform safety checks | ||
| 267 | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | 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 | if containerName == "" { | 291 | if containerName == "" { | ||
| 271 | return sendDeployErrorResponse(c, "Invalid target domain", | 292 | return sendDeployErrorResponse(c, "Invalid target domain", | ||
| @@ -471,6 +492,9 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 471 | cleanDomain = strings.Split(cleanDomain, "://")[1] | 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 | // Log that we're adding the domain for Let's Encrypt | 498 | // Log that we're adding the domain for Let's Encrypt | ||
| 475 | log.Debug("Adding domain for Let's Encrypt verification", | 499 | log.Debug("Adding domain for Let's Encrypt verification", | ||
| 476 | "domain", cleanDomain) | 500 | "domain", cleanDomain) | ||
| @@ -595,6 +619,10 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe | |||||
| 595 | return fmt.Errorf("failed to add proxy route: %w", err) | 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 | log.Debug("Added proxy route", | 626 | log.Debug("Added proxy route", | ||
| 599 | "domain", targetDomain, | 627 | "domain", targetDomain, | ||
| 600 | "containerIP", containerIP, | 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 | // validateAndPrepareDeployPayload validates and prepares the deploy payload | 107 | // validateAndPrepareDeployPayload validates and prepares the deploy payload | ||
| 108 | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | 108 | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | ||
| 109 | payload := &common.DeployPayload{ | 109 | payload := &common.DeployPayload{ | ||
| 110 | - | Port: c.Request().Header.Get("X-Ports"), | 110 | + | Port: c.Request().Header.Get("X-Ports"), |
| 111 | - | ImageName: c.Request().Header.Get("X-Image-Name"), | 111 | + | ImageName: c.Request().Header.Get("X-Image-Name"), |
| 112 | - | TargetDomain: c.Request().Header.Get("X-Target-Domain"), | 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 | if payload.ImageName == "" { | 116 | if payload.ImageName == "" { | ||
| @@ -256,6 +257,9 @@ func normalizeTargetDomain(domain string) string { | |||||
| 256 | // First, remove any leading dot from the domain | 257 | // First, remove any leading dot from the domain | ||
| 257 | domain = strings.TrimPrefix(domain, ".") | 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 | // Then check if we need to add the protocol | 263 | // Then check if we need to add the protocol | ||
| 260 | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | 264 | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | ||
| 261 | return "https://" + domain | 265 | return "https://" + domain | ||
M internal/httpserve/handlers/login.go +79 -32
| @@ -22,18 +22,22 @@ import ( | |||||
| 22 | 22 | ||||
| 23 | var urlToken string | 23 | var urlToken string | ||
| 24 | 24 | ||||
| 25 | - | // ClLI : | |||
| 26 | // compareGordonToken compares the token from the URL query parameter with the one from the config.yml | 25 | // compareGordonToken compares the token from the URL query parameter with the one from the config.yml | ||
| 27 | func CompareGordonToken(c echo.Context, a *server.App) error { | 26 | func CompareGordonToken(c echo.Context, a *server.App) error { | ||
| 27 | + | logger.Debug("Starting token comparison") | |||
| 28 | configToken, err := a.Config.GetToken() | 28 | configToken, err := a.Config.GetToken() | ||
| 29 | if err != nil { | 29 | if err != nil { | ||
| 30 | + | logger.Error("Error retrieving token from config", "error", err) | |||
| 30 | return err | 31 | return err | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 34 | + | logger.Debug("Comparing tokens", "url_token_exists", urlToken != "", "config_token_exists", configToken != "") | |||
| 33 | if urlToken != configToken { | 35 | if urlToken != configToken { | ||
| 36 | + | logger.Warn("Token mismatch or empty", "url_token_empty", urlToken == "") | |||
| 34 | return fmt.Errorf("token is empty or not valid") | 37 | return fmt.Errorf("token is empty or not valid") | ||
| 35 | } | 38 | } | ||
| 36 | 39 | ||||
| 40 | + | logger.Debug("Token validation successful") | |||
| 37 | return nil | 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 | logger.Info("Checking if session exists in DB") | 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 | if err != nil { | 121 | if err != nil { | ||
| 118 | logger.Info("Error checking if session exists", "error", err) | 122 | logger.Info("Error checking if session exists", "error", err) | ||
| 119 | return fmt.Errorf("could not check if session exists: %w", err) | 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 | logger.Info("Getting the expiration time of the session") | 131 | logger.Info("Getting the expiration time of the session") | ||
| 128 | currentTime := time.Now() | 132 | currentTime := time.Now() | ||
| 129 | - | sessionExpiration, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime) | 133 | + | sessionExpiration, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime) |
| 130 | if err != nil { | 134 | if err != nil { | ||
| 131 | logger.Info("Error getting session expiration", "error", err) | 135 | logger.Info("Error getting session expiration", "error", err) | ||
| 132 | return fmt.Errorf("could not get session expiration: %w", err) | 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 | return initiateGithubOAuthFlow(c, a) | 141 | return initiateGithubOAuthFlow(c, a) | ||
| 138 | } | 142 | } | ||
| 139 | 143 | ||||
| 140 | - | logger.Info("Extending session expiration time by 30 minutes") | 144 | + | logger.Info("Extending session expiration time by 24 hours") |
| 141 | - | newExpirationTime := currentTime.Add(30 * time.Minute) | 145 | + | newExpirationTime := currentTime.Add(time.Hour * 24) |
| 142 | - | err = queries.ExtendSessionExpiration(a, accountID, sessionID, newExpirationTime) | 146 | + | err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, newExpirationTime) |
| 143 | if err != nil { | 147 | if err != nil { | ||
| 144 | logger.Info("Error extending session expiration", "error", err) | 148 | logger.Info("Error extending session expiration", "error", err) | ||
| 145 | return fmt.Errorf("could not extend session expiration: %w", err) | 149 | return fmt.Errorf("could not extend session expiration: %w", err) | ||
| @@ -173,12 +177,15 @@ type Sessions struct { | |||||
| 173 | 177 | ||||
| 174 | // OAuthCallback handles the callback response from Github OAuth | 178 | // OAuthCallback handles the callback response from Github OAuth | ||
| 175 | func OAuthCallback(c echo.Context, a *server.App) error { | 179 | func OAuthCallback(c echo.Context, a *server.App) error { | ||
| 180 | + | logger.Info("Starting OAuth callback handler") | |||
| 176 | redirectPath := a.Config.Admin.Path | 181 | redirectPath := a.Config.Admin.Path | ||
| 177 | 182 | ||||
| 178 | accessToken, encodedState, err := parseQueryParams(c) | 183 | accessToken, encodedState, err := parseQueryParams(c) | ||
| 179 | if err != nil { | 184 | if err != nil { | ||
| 185 | + | logger.Error("Failed to parse query params from OAuth callback", "error", err) | |||
| 180 | return c.String(http.StatusBadRequest, err.Error()) | 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 | // update the struct with the new access token | 190 | // update the struct with the new access token | ||
| 184 | a.DBTables.Sessions.AccessToken = accessToken | 191 | a.DBTables.Sessions.AccessToken = accessToken | ||
| @@ -187,30 +194,42 @@ func OAuthCallback(c echo.Context, a *server.App) error { | |||||
| 187 | redirectDomain := a.GenerateOauthCallbackURL() | 194 | redirectDomain := a.GenerateOauthCallbackURL() | ||
| 188 | encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | 195 | encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | ||
| 189 | if encodedState != encodedRedirectDomain { | 196 | if encodedState != encodedRedirectDomain { | ||
| 197 | + | logger.Error("Invalid state parameter", "expected", encodedRedirectDomain, "received", encodedState) | |||
| 190 | return c.String(http.StatusBadRequest, "invalid state parameter") | 198 | return c.String(http.StatusBadRequest, "invalid state parameter") | ||
| 191 | } | 199 | } | ||
| 200 | + | logger.Debug("State parameter validation successful") | |||
| 192 | 201 | ||||
| 193 | sess, err := getSession(c) | 202 | sess, err := getSession(c) | ||
| 194 | if err != nil { | 203 | if err != nil { | ||
| 204 | + | logger.Error("Failed to get session", "error", err) | |||
| 195 | return err | 205 | return err | ||
| 196 | } | 206 | } | ||
| 207 | + | logger.Debug("Session retrieved successfully") | |||
| 197 | 208 | ||||
| 198 | browserInfo := c.Request().UserAgent() | 209 | browserInfo := c.Request().UserAgent() | ||
| 199 | userInfo, err := githubGetUserDetails(c) | 210 | userInfo, err := githubGetUserDetails(c) | ||
| 200 | if err != nil { | 211 | if err != nil { | ||
| 212 | + | logger.Error("Failed to get GitHub user details", "error", err) | |||
| 201 | return err | 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 | if err != nil { | 218 | if err != nil { | ||
| 219 | + | logger.Error("Failed to handle user", "error", err) | |||
| 206 | return err | 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 | if err != nil { | 226 | if err != nil { | ||
| 227 | + | logger.Error("Failed to set session values", "error", err) | |||
| 211 | return err | 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 | return c.Redirect(http.StatusFound, redirectPath) | 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 | // handleUser handles the user creation or update | 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) { | 259 | + | func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { |
| 241 | - | userExists, err := queries.CheckDBUserExists(a) | 260 | + | logger.Debug("Starting handleUser function", "github_login", userInfo.Login, "github_email", userInfo.Emails[0]) |
| 261 | + | userExists, err := queries.CheckDBUserExists(a.DB) | |||
| 242 | if err != nil { | 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 | if !userExists { | 269 | if !userExists { | ||
| 247 | // if it is a new user creation we compare the gordon token | 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 | err := CompareGordonToken(c, a) | 272 | err := CompareGordonToken(c, a) | ||
| 249 | if err != nil { | 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 | if userExists { | 288 | if userExists { | ||
| 257 | // if it is an existing user we compare the login and email to see if it is the same user | 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 | if err != nil { | 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 | if !isGoodUser { | 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 | if isGoodUser { | 304 | if isGoodUser { | ||
| 268 | // Update the user | 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 | if err != nil { | 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 | // createUser creates a new user in the database | 321 | // createUser creates a new user in the database | ||
| 281 | - | func createUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | 322 | + | func createUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { |
| 282 | - | err := queries.CreateUser(a, accessToken, browserInfo, userInfo) | 323 | + | logger.Debug("Starting createUser function", "github_login", userInfo.Login) |
| 324 | + | user, accountID, session, err := queries.CreateUser(a.DB, accessToken, browserInfo, userInfo) | |||
| 283 | if err != nil { | 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 | // updateUser updates an existing user in the database | 333 | // updateUser updates an existing user in the database | ||
| 290 | - | func updateUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) { | 334 | + | func updateUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { |
| 291 | - | user, err := queries.UpdateUser(a, accessToken, browserInfo, userInfo) | 335 | + | user, accountID, session, err := queries.UpdateUser(a.DB, accessToken, browserInfo, userInfo) |
| 292 | if err != nil { | 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 | // setSessionValues sets the session values for the user | 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 | sess.Values["authenticated"] = true | 346 | sess.Values["authenticated"] = true | ||
| 301 | sess.Values["accountID"] = accountID | 347 | sess.Values["accountID"] = accountID | ||
| 302 | - | sess.Values["expires"] = expires | 348 | + | sess.Values["expires"] = session.Expires |
| 303 | - | sess.Values["sessionID"] = sessionID | 349 | + | sess.Values["sessionID"] = session.ID |
| 350 | + | sess.Values["isOnline"] = session.IsOnline | |||
| 304 | 351 | ||||
| 305 | if err := sess.Save(c.Request(), c.Response()); err != nil { | 352 | if err := sess.Save(c.Request(), c.Response()); err != nil { | ||
| 306 | return fmt.Errorf("could not save session: %w", err) | 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 | // githubGetUserDetails gets the user details from the Github API using the access token | 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 | accessToken := c.QueryParam("access_token") | 392 | accessToken := c.QueryParam("access_token") | ||
| 346 | 393 | ||||
| 347 | // Fetch user info | 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 | // Invalidate the session in the database | 23 | // Invalidate the session in the database | ||
| 24 | sessionID, ok := sess.Values["sessionID"].(string) | 24 | sessionID, ok := sess.Values["sessionID"].(string) | ||
| 25 | if ok { | 25 | if ok { | ||
| 26 | - | err := queries.InvalidateDBSession(a, sessionID) | 26 | + | err := queries.InvalidateDBSession(a.DB, sessionID) |
| 27 | if err != nil { | 27 | if err != nil { | ||
| 28 | return fmt.Errorf("failed to invalidate session: %w", err) | 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 | "github.com/bnema/gordon/internal/db/queries" | 7 | "github.com/bnema/gordon/internal/db/queries" | ||
| 8 | "github.com/bnema/gordon/internal/server" | 8 | "github.com/bnema/gordon/internal/server" | ||
| 9 | + | "github.com/bnema/gordon/pkg/logger" | |||
| 9 | "github.com/labstack/echo-contrib/session" | 10 | "github.com/labstack/echo-contrib/session" | ||
| 10 | "github.com/labstack/echo/v4" | 11 | "github.com/labstack/echo/v4" | ||
| 11 | ) | 12 | ) | ||
| @@ -15,54 +16,67 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error { | |||||
| 15 | 16 | ||||
| 16 | sess, err := session.Get("session", c) | 17 | sess, err := session.Get("session", c) | ||
| 17 | if err != nil { | 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 | accountID, ok := sess.Values["accountID"].(string) | 23 | accountID, ok := sess.Values["accountID"].(string) | ||
| 22 | if !ok { | 24 | if !ok { | ||
| 25 | + | logger.Warn("Account ID not found or invalid type") | |||
| 23 | return fmt.Errorf("accountID not found or invalid type") | 26 | return fmt.Errorf("accountID not found or invalid type") | ||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 26 | sessionID, ok := sess.Values["sessionID"].(string) | 29 | sessionID, ok := sess.Values["sessionID"].(string) | ||
| 27 | if !ok { | 30 | if !ok { | ||
| 31 | + | logger.Warn("Session ID not found or invalid type") | |||
| 28 | return fmt.Errorf("sessionID not found or invalid type") | 32 | return fmt.Errorf("sessionID not found or invalid type") | ||
| 29 | } | 33 | } | ||
| 30 | 34 | ||||
| 31 | accountExists, err := IsAccountIDInDB(a, accountID) | 35 | accountExists, err := IsAccountIDInDB(a, accountID) | ||
| 32 | if err != nil || !accountExists { | 36 | if err != nil || !accountExists { | ||
| 37 | + | logger.Warn("Account does not exist") | |||
| 33 | return fmt.Errorf("account does not exist") | 38 | return fmt.Errorf("account does not exist") | ||
| 34 | } | 39 | } | ||
| 35 | 40 | ||||
| 36 | sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID) | 41 | sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID) | ||
| 37 | if err != nil || sessionExpired { | 42 | if err != nil || sessionExpired { | ||
| 43 | + | logger.Warn("Session is expired") | |||
| 38 | return fmt.Errorf("session is expired") | 44 | return fmt.Errorf("session is expired") | ||
| 39 | } | 45 | } | ||
| 40 | 46 | ||||
| 41 | // Extend session expiration conditionally, e.g., if it's close to expiring | 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 | if err != nil { | 49 | if err != nil { | ||
| 50 | + | logger.Warn("Failed to extend session", "error", err) | |||
| 44 | return fmt.Errorf("failed to extend session: %v", err) | 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 | return nil | 56 | return nil | ||
| 48 | } | 57 | } | ||
| 49 | 58 | ||||
| 50 | func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) { | 59 | func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) { | ||
| 51 | currentTime := time.Now() | 60 | currentTime := time.Now() | ||
| 52 | - | expirationTime, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime) | 61 | + | expirationTime, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime) |
| 53 | if err != nil { | 62 | if err != nil { | ||
| 63 | + | logger.Warn("Failed to get session expiration time", "error", err) | |||
| 54 | return false, err | 64 | return false, err | ||
| 55 | } | 65 | } | ||
| 56 | 66 | ||||
| 67 | + | logger.Debug("Session expiration time for account", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTime) | |||
| 57 | return currentTime.After(expirationTime), nil | 68 | return currentTime.After(expirationTime), nil | ||
| 58 | } | 69 | } | ||
| 59 | 70 | ||||
| 60 | func IsAccountIDInDB(a *server.App, accountID string) (bool, error) { | 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 | if err != nil { | 73 | if err != nil { | ||
| 74 | + | logger.Warn("Failed to check account existence", "error", err) | |||
| 63 | return false, err | 75 | return false, err | ||
| 64 | } | 76 | } | ||
| 65 | 77 | ||||
| 78 | + | logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists) | |||
| 79 | + | ||||
| 66 | return accountExists, nil | 80 | return accountExists, nil | ||
| 67 | } | 81 | } | ||
| 68 | 82 | ||||
M internal/httpserve/middleware/requiretoken.go +5 -3
| @@ -10,13 +10,15 @@ import ( | |||||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/cli/auth" | 12 | "github.com/bnema/gordon/internal/cli/auth" | ||
| 13 | + | "github.com/bnema/gordon/internal/db" | |||
| 13 | "github.com/bnema/gordon/internal/db/queries" | 14 | "github.com/bnema/gordon/internal/db/queries" | ||
| 14 | "github.com/bnema/gordon/internal/server" | 15 | "github.com/bnema/gordon/internal/server" | ||
| 15 | "github.com/bnema/gordon/pkg/logger" | 16 | "github.com/bnema/gordon/pkg/logger" | ||
| 16 | "github.com/labstack/echo/v4" | 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 | // middleware/require_token.go | 23 | // middleware/require_token.go | ||
| 22 | func RequireToken(a *server.App) echo.MiddlewareFunc { | 24 | func RequireToken(a *server.App) echo.MiddlewareFunc { | ||
| @@ -35,7 +37,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc { | |||||
| 35 | // Check cache first | 37 | // Check cache first | ||
| 36 | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | 38 | if _, cachedUser, found := tokenCache.GetWithUser(token); found { | ||
| 37 | // Verify if user exists in database and is authorized | 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 | if err != nil || !isAuthorized { | 41 | if err != nil || !isAuthorized { | ||
| 40 | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | 42 | return c.JSON(http.StatusUnauthorized, map[string]interface{}{ | ||
| 41 | "success": false, | 43 | "success": false, | ||
| @@ -56,7 +58,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc { | |||||
| 56 | } | 58 | } | ||
| 57 | 59 | ||||
| 58 | // Verify if user exists in database and is authorized | 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 | if err != nil { | 62 | if err != nil { | ||
| 61 | return c.JSON(http.StatusInternalServerError, map[string]interface{}{ | 63 | return c.JSON(http.StatusInternalServerError, map[string]interface{}{ | ||
| 62 | "success": false, | 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 | "net" | 14 | "net" | ||
| 15 | "net/http" | 15 | "net/http" | ||
| 16 | "os" | 16 | "os" | ||
| 17 | + | "path/filepath" | |||
| 17 | "regexp" | 18 | "regexp" | ||
| 18 | "strings" | 19 | "strings" | ||
| 19 | "time" | 20 | "time" | ||
| @@ -69,8 +70,7 @@ func (p *Proxy) setupCertManager() { | |||||
| 69 | adminDomain := p.app.GetConfig().Http.FullDomain() | 70 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 70 | rootDomain := p.app.GetConfig().Http.Domain | 71 | rootDomain := p.app.GetConfig().Http.Domain | ||
| 71 | 72 | ||||
| 72 | - | // Use a more permissive hostpolicy that logs unknown domains rather than rejecting them | 73 | + | // Use a stricter hostpolicy that only allows domains in our routes |
| 73 | - | // This helps debug certificate acquisition issues | |||
| 74 | certManager.HostPolicy = func(_ context.Context, host string) error { | 74 | certManager.HostPolicy = func(_ context.Context, host string) error { | ||
| 75 | // Always allow the admin domain and root domain | 75 | // Always allow the admin domain and root domain | ||
| 76 | if host == adminDomain || host == rootDomain { | 76 | if host == adminDomain || host == rootDomain { | ||
| @@ -85,16 +85,14 @@ func (p *Proxy) setupCertManager() { | |||||
| 85 | return nil | 85 | return nil | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | // Log the attempt but still allow it - this helps debug | 88 | + | // Log the attempt and reject it |
| 89 | - | // certificate acquisition issues during development | 89 | + | logger.Warn("Rejecting certificate request for unknown host", |
| 90 | - | logger.Warn("Unknown host in certificate request", | |||
| 91 | "host", host, | 90 | "host", host, | ||
| 92 | "adminDomain", adminDomain, | 91 | "adminDomain", adminDomain, | ||
| 93 | - | "allowed", "yes") | 92 | + | "allowed", "no") |
| 94 | 93 | ||||
| 95 | - | // Allow unknown domains temporarily to help diagnose issues | 94 | + | // Return an error for domains not in our routes |
| 96 | - | // Change this to return an error in production for stricter security | 95 | + | return fmt.Errorf("host %q not configured in routes", host) |
| 97 | - | return nil | |||
| 98 | } | 96 | } | ||
| 99 | 97 | ||||
| 100 | // Generate a fallback self-signed certificate for the admin domain | 98 | // Generate a fallback self-signed certificate for the admin domain | ||
| @@ -153,49 +151,226 @@ func (p *Proxy) checkCertificateInCache(domain string) bool { | |||||
| 153 | return false | 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 | // Parse the certificate to check its validity | 170 | // Parse the certificate to check its validity | ||
| 157 | block, _ := pem.Decode(certData) | 171 | block, _ := pem.Decode(certData) | ||
| 158 | if block == nil || block.Type != "CERTIFICATE" { | 172 | if block == nil || block.Type != "CERTIFICATE" { | ||
| 159 | - | logger.Warn("Invalid certificate data in cache", | 173 | + | logger.Warn("Invalid certificate data", |
| 160 | "domain", domain) | 174 | "domain", domain) | ||
| 161 | - | return false | 175 | + | return false, 0 |
| 162 | } | 176 | } | ||
| 163 | 177 | ||||
| 164 | cert, err := x509.ParseCertificate(block.Bytes) | 178 | cert, err := x509.ParseCertificate(block.Bytes) | ||
| 165 | if err != nil { | 179 | if err != nil { | ||
| 166 | - | logger.Warn("Failed to parse certificate from cache", | 180 | + | logger.Warn("Failed to parse certificate", |
| 167 | "domain", domain, | 181 | "domain", domain, | ||
| 168 | "error", err) | 182 | "error", err) | ||
| 169 | - | return false | 183 | + | return false, 0 |
| 170 | } | 184 | } | ||
| 171 | 185 | ||||
| 172 | // Check if the certificate is still valid | 186 | // Check if the certificate is still valid | ||
| 173 | now := time.Now() | 187 | now := time.Now() | ||
| 174 | if now.After(cert.NotAfter) || now.Before(cert.NotBefore) { | 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 | "domain", domain, | 190 | "domain", domain, | ||
| 177 | "not_before", cert.NotBefore, | 191 | "not_before", cert.NotBefore, | ||
| 178 | "not_after", cert.NotAfter) | 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 | // Check if the certificate is about to expire (within 30 days) | 199 | // Check if the certificate is about to expire (within 30 days) | ||
| 183 | - | if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) { | 200 | + | if expiresInDays < 30 { |
| 184 | - | logger.Info("Certificate in cache is valid but will expire soon", | 201 | + | logger.Info("Certificate is valid but will expire soon", |
| 185 | "domain", domain, | 202 | "domain", domain, | ||
| 186 | - | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | 203 | + | "expires_in", expiresInDays, |
| 187 | "days") | 204 | "days") | ||
| 188 | // Return false to trigger renewal if it's about to expire | 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 | return false | 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 | "domain", domain, | 261 | "domain", domain, | ||
| 194 | - | "expires_in", cert.NotAfter.Sub(now).Hours()/24, | 262 | + | "expires_in", expiresInDays, |
| 195 | - | "days") | 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 | return true | 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 | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | 374 | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | ||
| 200 | // for the Gordon admin interface | 375 | // for the Gordon admin interface | ||
| 201 | func (p *Proxy) requestAdminCertificate() { | 376 | func (p *Proxy) requestAdminCertificate() { | ||
| @@ -230,11 +405,30 @@ func (p *Proxy) requestAdminCertificate() { | |||||
| 230 | "domain", adminDomain, | 405 | "domain", adminDomain, | ||
| 231 | "ips", ipStrings) | 406 | "ips", ipStrings) | ||
| 232 | 407 | ||||
| 233 | - | // Check if we already have a valid certificate in the cache | 408 | + | // Check if we already have a valid certificate (cache or filesystem) |
| 234 | - | if p.checkCertificateInCache(adminDomain) { | 409 | + | if p.checkCertificateExists(adminDomain) { |
| 235 | - | logger.Info("Using existing certificate from cache", | 410 | + | logger.Info("Using existing certificate", |
| 236 | "domain", adminDomain, | 411 | "domain", adminDomain, | ||
| 237 | "action", "skipping Let's Encrypt request to avoid rate limits") | 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 | return | 432 | return | ||
| 239 | } | 433 | } | ||
| 240 | 434 | ||||
| @@ -365,11 +559,30 @@ func (p *Proxy) requestDomainCertificate(domain string) { | |||||
| 365 | "domain", domain, | 559 | "domain", domain, | ||
| 366 | "ips", ipStrings) | 560 | "ips", ipStrings) | ||
| 367 | 561 | ||||
| 368 | - | // Check if we already have a valid certificate in the cache | 562 | + | // Check if we already have a valid certificate (cache or filesystem) |
| 369 | - | if p.checkCertificateInCache(domain) { | 563 | + | if p.checkCertificateExists(domain) { |
| 370 | - | logger.Info("Using existing certificate from cache", | 564 | + | logger.Info("Using existing certificate", |
| 371 | "domain", domain, | 565 | "domain", domain, | ||
| 372 | "action", "skipping Let's Encrypt request to avoid rate limits") | 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 | return | 586 | return | ||
| 374 | } | 587 | } | ||
| 375 | 588 | ||||
M internal/proxy/core.go +23 -29
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "net/http" | 10 | "net/http" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/common" | 12 | "github.com/bnema/gordon/internal/common" | ||
| 13 | + | "github.com/bnema/gordon/internal/db/queries" | |||
| 13 | "github.com/bnema/gordon/internal/interfaces" | 14 | "github.com/bnema/gordon/internal/interfaces" | ||
| 14 | "github.com/bnema/gordon/pkg/docker" | 15 | "github.com/bnema/gordon/pkg/docker" | ||
| 15 | "github.com/bnema/gordon/pkg/logger" | 16 | "github.com/bnema/gordon/pkg/logger" | ||
| @@ -36,16 +37,6 @@ type Proxy struct { | |||||
| 36 | certManager *autocert.Manager | 37 | certManager *autocert.Manager | ||
| 37 | fallbackCert *tls.Certificate // Fallback self-signed certificate | 38 | fallbackCert *tls.Certificate // Fallback self-signed certificate | ||
| 38 | serverStarted bool | 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 | // Flag to track specific domain certificate operations | 41 | // Flag to track specific domain certificate operations | ||
| 51 | processingSpecificDomain bool | 42 | processingSpecificDomain bool | ||
| @@ -61,6 +52,14 @@ type Proxy struct { | |||||
| 61 | acmePerDomainRefreshMutex sync.Mutex | 52 | acmePerDomainRefreshMutex sync.Mutex | ||
| 62 | acmeDirCache autocert.DirCache | 53 | acmeDirCache autocert.DirCache | ||
| 63 | shutdown chan struct{} // Channel for signaling shutdown to background goroutines | 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 | // NewProxy creates a new instance of the reverse proxy | 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 | // Initialize routes map | 107 | // Initialize routes map | ||
| 116 | routes := make(map[string]*ProxyRouteInfo) | 108 | routes := make(map[string]*ProxyRouteInfo) | ||
| 117 | 109 | ||||
| 110 | + | // Initialize SQL queries | |||
| 111 | + | proxyQueries := queries.NewProxyQueries() | |||
| 112 | + | ||||
| 118 | // Create the proxy | 113 | // Create the proxy | ||
| 119 | p := &Proxy{ | 114 | p := &Proxy{ | ||
| 120 | - | config: config, | 115 | + | config: config, |
| 121 | - | app: app, | 116 | + | app: app, |
| 122 | - | httpsServer: httpsServer, | 117 | + | httpsServer: httpsServer, |
| 123 | - | httpServer: httpServer, | 118 | + | httpServer: httpServer, |
| 124 | - | routes: routes, | 119 | + | routes: routes, |
| 125 | - | serverStarted: false, | 120 | + | serverStarted: false, |
| 126 | - | blacklist: blacklist, | 121 | + | gordonContainerID: ourContainerID, |
| 127 | - | blockedIPCounter: make(map[string]int), | 122 | + | shutdown: make(chan struct{}), |
| 128 | - | lastBlockedLog: time.Time{}, // Zero time | 123 | + | recentContainers: make(map[string]time.Time), |
| 129 | - | recentlyBlocked: make(map[string]time.Time), | 124 | + | containerCooldownPeriod: 10 * time.Second, // Default 10 second cooldown period |
| 130 | - | gordonContainerID: ourContainerID, | 125 | + | queries: proxyQueries, |
| 131 | - | shutdown: make(chan struct{}), | |||
| 132 | } | 126 | } | ||
| 133 | 127 | ||||
| 134 | // Now add the middleware with the proxy reference | 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 | import ( | 3 | import ( | ||
| 4 | "database/sql" | 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 | // dbExecWithRetry performs a database execution with retry logic for handling locked database | 9 | // dbExecWithRetry performs a database execution with retry logic for handling locked database | ||
| 12 | func (p *Proxy) dbExecWithRetry(query string, args ...interface{}) (sql.Result, error) { | 10 | func (p *Proxy) dbExecWithRetry(query string, args ...interface{}) (sql.Result, error) { | ||
| 13 | - | var result sql.Result | 11 | + | return db.ExecWithRetry(p.app.GetDB(), query, args...) |
| 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) | |||
| 40 | } | 12 | } | ||
| 41 | 13 | ||||
| 42 | // dbQueryWithRetry performs a database query with retry logic for handling locked database | 14 | // dbQueryWithRetry performs a database query with retry logic for handling locked database | ||
| 43 | func (p *Proxy) dbQueryWithRetry(query string, args ...interface{}) (*sql.Rows, error) { | 15 | func (p *Proxy) dbQueryWithRetry(query string, args ...interface{}) (*sql.Rows, error) { | ||
| 44 | - | var rows *sql.Rows | 16 | + | return db.QueryWithRetry(p.app.GetDB(), query, args...) |
| 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) | |||
| 71 | } | 17 | } | ||
| 72 | 18 | ||||
| 73 | // dbBeginWithRetry starts a transaction with retry logic for handling locked database | 19 | // dbBeginWithRetry starts a transaction with retry logic for handling locked database | ||
| 74 | func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) { | 20 | func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) { | ||
| 75 | - | var tx *sql.Tx | 21 | + | return db.BeginWithRetry(p.app.GetDB()) |
| 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) | |||
| 102 | } | 22 | } | ||
| 103 | 23 | ||||
| 104 | // txExecWithRetry executes a query within a transaction with retry logic | 24 | // txExecWithRetry executes a query within a transaction with retry logic | ||
| 105 | func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) { | 25 | func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) { | ||
| 106 | - | var result sql.Result | 26 | + | return db.TxExecWithRetry(tx, query, args...) |
| 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) | |||
| 133 | } | 27 | } | ||
M internal/proxy/middleware.go +130 -192
| @@ -1,9 +1,7 @@ | |||||
| 1 | package proxy | 1 | package proxy | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "net" | |||
| 5 | "net/http" | 4 | "net/http" | ||
| 6 | - | "strings" | |||
| 7 | "time" | 5 | "time" | ||
| 8 | 6 | ||||
| 9 | "github.com/bnema/gordon/pkg/logger" | 7 | "github.com/bnema/gordon/pkg/logger" | ||
| @@ -18,12 +16,6 @@ import ( | |||||
| 18 | // rateLimiterCleanup holds the cleanup functions for rate limiters | 16 | // rateLimiterCleanup holds the cleanup functions for rate limiters | ||
| 19 | var rateLimiterCleanup []func() | 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 | // RequestIDKey is the key used to store the request ID in the context | 19 | // RequestIDKey is the key used to store the request ID in the context | ||
| 28 | const RequestIDKey = "request_id" | 20 | const RequestIDKey = "request_id" | ||
| 29 | 21 | ||||
| @@ -38,83 +30,71 @@ func (p *Proxy) setupMiddleware() { | |||||
| 38 | p.httpsServer.Use(p.createRequestIDMiddleware()) | 30 | p.httpsServer.Use(p.createRequestIDMiddleware()) | ||
| 39 | p.httpServer.Use(p.createRequestIDMiddleware()) | 31 | p.httpServer.Use(p.createRequestIDMiddleware()) | ||
| 40 | 32 | ||||
| 41 | - | // Add debug middleware to log all headers | 33 | + | // Check if rate limiting is disabled via configuration |
| 42 | - | p.httpsServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc { | 34 | + | if !p.config.DisableRateLimit { |
| 43 | - | return func(c echo.Context) error { | 35 | + | // Create a new Starskey rate limiter store for HTTPS server |
| 44 | - | req := c.Request() | 36 | + | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( |
| 45 | - | logger.Debug("Request headers", | 37 | + | "./data/ratelimiter/https", // Path to store rate limit data |
| 46 | - | "method", req.Method, | 38 | + | 10, // Rate: 10 requests per second (lowered from 10) |
| 47 | - | "path", req.URL.Path, | 39 | + | 30, // Burst: 30 requests (lowered from 30) |
| 48 | - | "remote_addr", req.RemoteAddr, | 40 | + | 3*time.Minute, // Expires in 3 minutes |
| 49 | - | "x-forwarded-for", req.Header.Get("X-Forwarded-For"), | 41 | + | ) |
| 50 | - | "x-real-ip", req.Header.Get("X-Real-IP")) | 42 | + | if err != nil { |
| 51 | - | return next(c) | 43 | + | logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err) |
| 52 | - | } | 44 | + | |
| 53 | - | }) | 45 | + | // Fallback to memory store if Starskey store creation fails |
| 54 | - | 46 | + | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 55 | - | // Create blacklist middleware for HTTPS | 47 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( |
| 56 | - | p.httpsServer.Use(p.createBlacklistMiddleware()) | 48 | + | middleware.RateLimiterMemoryStoreConfig{ |
| 57 | - | 49 | + | Rate: rate.Limit(10), // 10 requests per second (lowered from 10) | |
| 58 | - | // Create a new Starskey rate limiter store for HTTPS server | 50 | + | Burst: 30, // Burst of 30 requests (lowered from 30) |
| 59 | - | httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore( | 51 | + | ExpiresIn: 3 * time.Minute, // Store expiration |
| 60 | - | "./data/ratelimiter/https", // Path to store rate limit data | 52 | + | }, |
| 61 | - | 10, // Rate: 10 requests per second (lowered from 10) | 53 | + | ), |
| 62 | - | 30, // Burst: 30 requests (lowered from 30) | 54 | + | DenyHandler: func(context echo.Context, identifier string, err error) error { |
| 63 | - | 3*time.Minute, // Expires in 3 minutes | 55 | + | logger.Warn("Rate limit exceeded", |
| 64 | - | ) | 56 | + | "ip", identifier, |
| 65 | - | if err != nil { | 57 | + | "path", context.Request().URL.Path, |
| 66 | - | logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err) | 58 | + | "method", context.Request().Method) |
| 67 | - | 59 | + | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 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 | |||
| 75 | }, | 60 | }, | ||
| 76 | - | ), | 61 | + | })) |
| 77 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | 62 | + | } else { |
| 78 | - | logger.Warn("Rate limit exceeded", | 63 | + | // Use the Starskey store for HTTPS rate limiting |
| 79 | - | "ip", identifier, | 64 | + | logger.Debug("Using Starskey rate limiter for HTTPS server") |
| 80 | - | "path", context.Request().URL.Path, | 65 | + | |
| 81 | - | "method", context.Request().Method) | 66 | + | // Add custom Starskey rate limiter middleware |
| 82 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | 67 | + | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ |
| 83 | - | }, | 68 | + | Store: httpsRateLimiter, |
| 84 | - | })) | 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 | } else { | 96 | } else { | ||
| 86 | - | // Use the Starskey store for HTTPS rate limiting | 97 | + | logger.Info("Rate limiting is disabled for HTTPS server via configuration") |
| 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 | - | }) | |||
| 118 | } | 98 | } | ||
| 119 | 99 | ||||
| 120 | // Add custom logger middleware with request ID if enabled in config | 100 | // Add custom logger middleware with request ID if enabled in config | ||
| @@ -131,69 +111,71 @@ func (p *Proxy) setupMiddleware() { | |||||
| 131 | logger.Debug("HTTP request logging disabled for HTTPS server") | 111 | logger.Debug("HTTP request logging disabled for HTTPS server") | ||
| 132 | } | 112 | } | ||
| 133 | 113 | ||||
| 134 | - | // Create blacklist middleware for HTTP | 114 | + | // Check if rate limiting is disabled via configuration |
| 135 | - | p.httpServer.Use(p.createBlacklistMiddleware()) | 115 | + | if !p.config.DisableRateLimit { |
| 136 | - | 116 | + | // Create a new Starskey rate limiter store for HTTP server | |
| 137 | - | // Create a new Starskey rate limiter store for HTTP server | 117 | + | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( |
| 138 | - | httpRateLimiter, err := kv.NewStarskeyRateLimiterStore( | 118 | + | "./data/ratelimiter/http", // Path to store rate limit data |
| 139 | - | "./data/ratelimiter/http", // Path to store rate limit data | 119 | + | 2, // Rate: 2 requests per second (lowered from 10) |
| 140 | - | 2, // Rate: 2 requests per second (lowered from 10) | 120 | + | 10, // Burst: 10 requests (lowered from 30) |
| 141 | - | 10, // Burst: 10 requests (lowered from 30) | 121 | + | 3*time.Minute, // Expires in 3 minutes |
| 142 | - | 3*time.Minute, // Expires in 3 minutes | 122 | + | ) |
| 143 | - | ) | 123 | + | if err != nil { |
| 144 | - | if err != nil { | 124 | + | logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err) |
| 145 | - | logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err) | 125 | + | |
| 146 | - | 126 | + | // Fallback to memory store if Starskey store creation fails | |
| 147 | - | // Fallback to memory store if Starskey store creation fails | 127 | + | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ |
| 148 | - | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | 128 | + | Store: middleware.NewRateLimiterMemoryStoreWithConfig( |
| 149 | - | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | 129 | + | middleware.RateLimiterMemoryStoreConfig{ |
| 150 | - | middleware.RateLimiterMemoryStoreConfig{ | 130 | + | Rate: rate.Limit(2), // 2 requests per second (lowered from 10) |
| 151 | - | Rate: rate.Limit(2), // 2 requests per second (lowered from 10) | 131 | + | Burst: 10, // Burst of 10 requests (lowered from 30) |
| 152 | - | Burst: 10, // Burst of 10 requests (lowered from 30) | 132 | + | ExpiresIn: 3 * time.Minute, // Store expiration |
| 153 | - | 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 | - | ), | 142 | + | })) |
| 156 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | 143 | + | } else { |
| 157 | - | logger.Warn("Rate limit exceeded", | 144 | + | // Use the Starskey store for HTTP rate limiting |
| 158 | - | "ip", identifier, | 145 | + | logger.Debug("Using Starskey rate limiter for HTTP server") |
| 159 | - | "path", context.Request().URL.Path, | 146 | + | |
| 160 | - | "method", context.Request().Method) | 147 | + | // Add custom Starskey rate limiter middleware |
| 161 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | 148 | + | p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ |
| 162 | - | }, | 149 | + | Store: httpRateLimiter, |
| 163 | - | })) | 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 | } else { | 177 | } else { | ||
| 165 | - | // Use the Starskey store for HTTP rate limiting | 178 | + | logger.Info("Rate limiting is disabled for HTTP server via configuration") |
| 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 | - | }) | |||
| 197 | } | 179 | } | ||
| 198 | 180 | ||||
| 199 | // Add custom logger middleware with request ID for HTTP server if enabled in config | 181 | // Add custom logger middleware with request ID for HTTP server if enabled in config | ||
| @@ -215,60 +197,16 @@ func (p *Proxy) setupMiddleware() { | |||||
| 215 | func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | 197 | func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | ||
| 216 | return func(next echo.HandlerFunc) echo.HandlerFunc { | 198 | return func(next echo.HandlerFunc) echo.HandlerFunc { | ||
| 217 | return func(c echo.Context) error { | 199 | return func(c echo.Context) error { | ||
| 218 | - | // Generate a new UUID for this request | 200 | + | // Generate a UUID for this request |
| 219 | requestID := uuid.New().String() | 201 | requestID := uuid.New().String() | ||
| 220 | 202 | ||||
| 221 | - | // Store the UUID in the context | 203 | + | // Store it in the context |
| 222 | c.Set(RequestIDKey, requestID) | 204 | c.Set(RequestIDKey, requestID) | ||
| 223 | 205 | ||||
| 224 | - | // Add the UUID as a response header | 206 | + | // Add it as a response header |
| 225 | c.Response().Header().Set(echo.HeaderXRequestID, requestID) | 207 | c.Response().Header().Set(echo.HeaderXRequestID, requestID) | ||
| 226 | 208 | ||||
| 227 | - | // Continue with the next middleware | 209 | + | // Continue processing |
| 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}) | |||
| 272 | return next(c) | 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 | // Query the database for active proxy routes using retry mechanism | 52 | // Query the database for active proxy routes using retry mechanism | ||
| 53 | - | rows, err := p.dbQueryWithRetry(` | 53 | + | rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes) |
| 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 | - | `) | |||
| 59 | if err != nil { | 54 | if err != nil { | ||
| 60 | if err == sql.ErrNoRows { | 55 | if err == sql.ErrNoRows { | ||
| 61 | logger.Info("No active proxy routes found") | 56 | logger.Info("No active proxy routes found") | ||
| @@ -174,12 +169,6 @@ func (p *Proxy) configureRoutes() { | |||||
| 174 | // Normalize host (trim trailing dot, convert to lowercase) | 169 | // Normalize host (trim trailing dot, convert to lowercase) | ||
| 175 | host = strings.TrimSuffix(strings.ToLower(host), ".") | 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 | // Check if this is the admin domain | 172 | // Check if this is the admin domain | ||
| 184 | adminDomain := p.app.GetConfig().Http.FullDomain() | 173 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 185 | if strings.EqualFold(host, adminDomain) { | 174 | if strings.EqualFold(host, adminDomain) { | ||
| @@ -189,21 +178,6 @@ func (p *Proxy) configureRoutes() { | |||||
| 189 | p.mu.RUnlock() | 178 | p.mu.RUnlock() | ||
| 190 | 179 | ||||
| 191 | if adminOk { | 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 | return p.proxyRequest(c, adminRoute) | 181 | return p.proxyRequest(c, adminRoute) | ||
| 208 | } | 182 | } | ||
| 209 | } | 183 | } | ||
| @@ -244,16 +218,6 @@ func (p *Proxy) configureRoutes() { | |||||
| 244 | "client_ip", c.RealIP()) | 218 | "client_ip", c.RealIP()) | ||
| 245 | return c.String(http.StatusServiceUnavailable, "Route is not active") | 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 | return p.proxyRequest(c, route) | 221 | return p.proxyRequest(c, route) | ||
| 258 | }) | 222 | }) | ||
| 259 | 223 | ||||
| @@ -284,9 +248,35 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 284 | "protocol", protocol, | 248 | "protocol", protocol, | ||
| 285 | "path", path) | 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 | // Begin a transaction with retry | 274 | // Begin a transaction with retry | ||
| 288 | tx, err := p.dbBeginWithRetry() | 275 | tx, err := p.dbBeginWithRetry() | ||
| 289 | if err != nil { | 276 | if err != nil { | ||
| 277 | + | logger.Error("Failed to begin transaction when adding route", | |||
| 278 | + | "domain", domainName, | |||
| 279 | + | "error", err) | |||
| 290 | return fmt.Errorf("failed to begin transaction: %w", err) | 280 | return fmt.Errorf("failed to begin transaction: %w", err) | ||
| 291 | } | 281 | } | ||
| 292 | defer tx.Rollback() | 282 | defer tx.Rollback() | ||
| @@ -302,7 +292,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 302 | 292 | ||||
| 303 | // Get the account ID - default to the first account if available | 293 | // Get the account ID - default to the first account if available | ||
| 304 | var accountID string | 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 | if err != nil { | 296 | if err != nil { | ||
| 307 | if err == sql.ErrNoRows { | 297 | if err == sql.ErrNoRows { | ||
| 308 | logger.Warn("No account found when adding route, domains will have NULL account_id") | 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 | // Check if the domain exists | 305 | // Check if the domain exists | ||
| 316 | var domainID string | 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 | if err != nil { | 308 | if err != nil { | ||
| 319 | if err == sql.ErrNoRows { | 309 | if err == sql.ErrNoRows { | ||
| 320 | // Domain doesn't exist, create it | 310 | // Domain doesn't exist, create it | ||
| 321 | domainID = proxyGenerateUUID() | 311 | domainID = proxyGenerateUUID() | ||
| 322 | now := time.Now().Format(time.RFC3339) | 312 | now := time.Now().Format(time.RFC3339) | ||
| 323 | _, err = txExecWithRetry(tx, | 313 | _, err = txExecWithRetry(tx, | ||
| 324 | - | "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", | 314 | + | p.queries.InsertDomain, |
| 325 | domainID, hostname, accountID, now, now, | 315 | domainID, hostname, accountID, now, now, | ||
| 326 | ) | 316 | ) | ||
| 327 | if err != nil { | 317 | if err != nil { | ||
| 318 | + | logger.Error("Failed to insert domain", | |||
| 319 | + | "domain", hostname, | |||
| 320 | + | "error", err) | |||
| 328 | return fmt.Errorf("failed to insert domain: %w", err) | 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 | // Domain part doesn't exist, create it | 340 | // Domain part doesn't exist, create it | ||
| 348 | domainPartID = proxyGenerateUUID() | 341 | domainPartID = proxyGenerateUUID() | ||
| 349 | _, err = txExecWithRetry(tx, | 342 | _, err = txExecWithRetry(tx, | ||
| 350 | - | "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", | 343 | + | p.queries.InsertDomain, |
| 351 | domainPartID, domainPart, accountID, now, now, | 344 | domainPartID, domainPart, accountID, now, now, | ||
| 352 | ) | 345 | ) | ||
| 353 | if err != nil { | 346 | if err != nil { | ||
| @@ -363,6 +356,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 363 | } | 356 | } | ||
| 364 | } | 357 | } | ||
| 365 | } else { | 358 | } else { | ||
| 359 | + | logger.Error("Failed to query domain", | |||
| 360 | + | "domain", hostname, | |||
| 361 | + | "error", err) | |||
| 366 | return fmt.Errorf("failed to query domain: %w", err) | 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 | var existingRouteID string | 367 | var existingRouteID string | ||
| 372 | var existingContainerID string | 368 | var existingContainerID string | ||
| 373 | var existingContainerPort string | 369 | var existingContainerPort string | ||
| 374 | - | err = tx.QueryRow(` | 370 | + | err = tx.QueryRow(p.queries.GetRouteByDomain, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort) |
| 375 | - | SELECT id, container_id, container_port | |||
| 376 | - | FROM proxy_route | |||
| 377 | - | WHERE domain_id = ?`, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort) | |||
| 378 | 371 | ||||
| 379 | if err == nil { | 372 | if err == nil { | ||
| 380 | // Route exists, update it | 373 | // Route exists, update it | ||
| @@ -408,6 +401,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 408 | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | 401 | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | ||
| 409 | ) | 402 | ) | ||
| 410 | if err != nil { | 403 | if err != nil { | ||
| 404 | + | logger.Error("Failed to update route", | |||
| 405 | + | "domain", hostname, | |||
| 406 | + | "error", err) | |||
| 411 | return fmt.Errorf("failed to update route: %w", err) | 407 | return fmt.Errorf("failed to update route: %w", err) | ||
| 412 | } | 408 | } | ||
| 413 | } else if err == sql.ErrNoRows { | 409 | } else if err == sql.ErrNoRows { | ||
| @@ -415,29 +411,55 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr | |||||
| 415 | routeID := proxyGenerateUUID() | 411 | routeID := proxyGenerateUUID() | ||
| 416 | now := time.Now().Format(time.RFC3339) | 412 | now := time.Now().Format(time.RFC3339) | ||
| 417 | _, err = txExecWithRetry(tx, | 413 | _, err = txExecWithRetry(tx, | ||
| 418 | - | `INSERT INTO proxy_route ( | 414 | + | p.queries.InsertRoute, |
| 419 | - | id, domain_id, container_id, container_ip, container_port, | |||
| 420 | - | protocol, path, active, created_at, updated_at | |||
| 421 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |||
| 422 | routeID, domainID, containerID, containerIP, containerPort, | 415 | routeID, domainID, containerID, containerIP, containerPort, | ||
| 423 | protocol, path, true, now, now, | 416 | protocol, path, true, now, now, | ||
| 424 | ) | 417 | ) | ||
| 425 | if err != nil { | 418 | if err != nil { | ||
| 419 | + | logger.Error("Failed to insert route", | |||
| 420 | + | "domain", hostname, | |||
| 421 | + | "error", err) | |||
| 426 | return fmt.Errorf("failed to insert route: %w", err) | 422 | return fmt.Errorf("failed to insert route: %w", err) | ||
| 427 | } | 423 | } | ||
| 428 | } else { | 424 | } else { | ||
| 425 | + | logger.Error("Failed to query route", | |||
| 426 | + | "domain", hostname, | |||
| 427 | + | "error", err) | |||
| 429 | return fmt.Errorf("failed to query route: %w", err) | 428 | return fmt.Errorf("failed to query route: %w", err) | ||
| 430 | } | 429 | } | ||
| 431 | 430 | ||||
| 432 | // Commit the transaction | 431 | // Commit the transaction | ||
| 433 | if err := tx.Commit(); err != nil { | 432 | if err := tx.Commit(); err != nil { | ||
| 433 | + | logger.Error("Failed to commit transaction", | |||
| 434 | + | "domain", hostname, | |||
| 435 | + | "error", err) | |||
| 434 | return fmt.Errorf("failed to commit transaction: %w", err) | 436 | return fmt.Errorf("failed to commit transaction: %w", err) | ||
| 435 | } | 437 | } | ||
| 436 | 438 | ||||
| 437 | - | // Reload the routes | 439 | + | // Register this container to prevent duplicate processing |
| 438 | - | if err := p.Reload(); err != nil { | 440 | + | p.RegisterNewlyCreatedContainer(containerID) |
| 439 | - | return fmt.Errorf("failed to reload routes: %w", err) | 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 | logger.Info("Added proxy route", | 464 | logger.Info("Added proxy route", | ||
| 443 | "domain", hostname, | 465 | "domain", hostname, | ||
| @@ -482,7 +504,7 @@ func (p *Proxy) RemoveRoute(domainName string) error { | |||||
| 482 | 504 | ||||
| 483 | // Get the domain ID | 505 | // Get the domain ID | ||
| 484 | var domainID string | 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 | if err != nil { | 508 | if err != nil { | ||
| 487 | if err == sql.ErrNoRows { | 509 | if err == sql.ErrNoRows { | ||
| 488 | return fmt.Errorf("domain not found: %s", domainName) | 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 | // Delete the route with retry | 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 | if err != nil { | 517 | if err != nil { | ||
| 496 | return fmt.Errorf("failed to delete route: %w", err) | 518 | return fmt.Errorf("failed to delete route: %w", err) | ||
| 497 | } | 519 | } | ||
| 498 | 520 | ||||
| 499 | // Delete the domain with retry | 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 | if err != nil { | 523 | if err != nil { | ||
| 502 | return fmt.Errorf("failed to delete domain: %w", err) | 524 | return fmt.Errorf("failed to delete domain: %w", err) | ||
| 503 | } | 525 | } | ||
| @@ -561,12 +583,7 @@ func (p *Proxy) Reload() error { | |||||
| 561 | newRoutes := make(map[string]*ProxyRouteInfo) | 583 | newRoutes := make(map[string]*ProxyRouteInfo) | ||
| 562 | 584 | ||||
| 563 | // Load routes from the database into the new map | 585 | // Load routes from the database into the new map | ||
| 564 | - | rows, err := p.app.GetDB().Query(` | 586 | + | rows, err := p.app.GetDB().Query(p.queries.GetActiveRoutes) |
| 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 | - | `) | |||
| 570 | if err != nil { | 587 | if err != nil { | ||
| 571 | if err == sql.ErrNoRows { | 588 | if err == sql.ErrNoRows { | ||
| 572 | logger.Info("No active proxy routes found during reload") | 589 | logger.Info("No active proxy routes found during reload") | ||
| @@ -771,7 +788,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error { | |||||
| 771 | // Next, update the database | 788 | // Next, update the database | ||
| 772 | // Get the domain ID with retry | 789 | // Get the domain ID with retry | ||
| 773 | var domainID string | 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 | if err != nil { | 792 | if err != nil { | ||
| 776 | logger.Error("Failed to query domain for IP update", "domain", domain, "error", err) | 793 | logger.Error("Failed to query domain for IP update", "domain", domain, "error", err) | ||
| 777 | return fmt.Errorf("failed to query domain: %w", err) | 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 | // Update the proxy_route record with retry and ensure it's marked as active | 810 | // Update the proxy_route record with retry and ensure it's marked as active | ||
| 794 | now := time.Now().Format(time.RFC3339) | 811 | now := time.Now().Format(time.RFC3339) | ||
| 795 | - | result, err := p.dbExecWithRetry(` | 812 | + | result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID) |
| 796 | - | UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ? | |||
| 797 | - | WHERE domain_id = ? AND container_id = ?`, | |||
| 798 | - | newIP, now, domainID, containerID) | |||
| 799 | 813 | ||||
| 800 | if err != nil { | 814 | if err != nil { | ||
| 801 | logger.Error("Failed to update IP in database", "domain", domain, "error", err) | 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 | result := make(map[string]*ProxyRouteInfo) | 879 | result := make(map[string]*ProxyRouteInfo) | ||
| 866 | 880 | ||||
| 867 | // Query the database with retry to find if any route might be associated with this container name | 881 | // Query the database with retry to find if any route might be associated with this container name | ||
| 868 | - | rows, err := p.dbQueryWithRetry(` | 882 | + | rows, err := p.app.GetDB().Query(p.queries.GetAllRoutes) |
| 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 | - | `) | |||
| 874 | if err != nil { | 883 | if err != nil { | ||
| 875 | logger.Error("Failed to query database for routes by old name", "error", err) | 884 | logger.Error("Failed to query database for routes by old name", "error", err) | ||
| 876 | return result | 885 | return result | ||
| @@ -930,9 +939,39 @@ func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRoute | |||||
| 930 | return result | 939 | return result | ||
| 931 | } | 940 | } | ||
| 932 | 941 | ||||
| 933 | - | // HandleContainerRecreation handles a container that has been recreated with the same name but a new ID | 942 | + | // HandleContainerRecreation handles the recreation of containers and updates routes accordingly |
| 934 | - | // It updates all relevant routes with the new container ID and IP | |||
| 935 | func (p *Proxy) HandleContainerRecreation(containerID, containerName, containerIP string) { | 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 | logger.Info("Handling potential container recreation", | 975 | logger.Info("Handling potential container recreation", | ||
| 937 | "container_id", containerID, | 976 | "container_id", containerID, | ||
| 938 | "container_name", containerName, | 977 | "container_name", containerName, | ||
| @@ -1104,139 +1143,154 @@ func (p *Proxy) StartContainerEventListener() error { | |||||
| 1104 | "container_name", containerName, | 1143 | "container_name", containerName, | ||
| 1105 | "container_ip", containerIP) | 1144 | "container_ip", containerIP) | ||
| 1106 | 1145 | ||||
| 1107 | - | // Check if this is the Gordon container (admin domain) | 1146 | + | // Skip processing if container is in cooldown period |
| 1108 | - | isAdminContainer := false | 1147 | + | if p.IsContainerInCooldownPeriod(containerID) { |
| 1109 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | 1148 | + | logger.Debug("Skipping container event processing - container in cooldown period", |
| 1110 | - | 1149 | + | "container_id", containerID, | |
| 1111 | - | p.mu.RLock() | 1150 | + | "container_name", containerName) |
| 1112 | - | if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID { | 1151 | + | return |
| 1113 | - | isAdminContainer = true | |||
| 1114 | - | logger.Debug("Admin container event detected", "container_id", containerID) | |||
| 1115 | } | 1152 | } | ||
| 1116 | - | p.mu.RUnlock() | |||
| 1117 | 1153 | ||||
| 1118 | - | // First, check if there are any existing routes for this container ID | 1154 | + | // Register this container as recently created to prevent duplicate processing |
| 1119 | - | var existingRoutes = make(map[string]*ProxyRouteInfo) | 1155 | + | p.RegisterNewlyCreatedContainer(containerID) |
| 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() | |||
| 1127 | 1156 | ||||
| 1128 | - | // If we found existing routes, check if the IP has changed | 1157 | + | // Use a goroutine to handle the event asynchronously to avoid blocking the event listener |
| 1129 | - | if len(existingRoutes) > 0 { | 1158 | + | go func() { |
| 1130 | - | logger.Debug("Found existing routes for container", | 1159 | + | // Add a small delay to allow any direct API calls to complete first |
| 1131 | - | "container_id", containerID, | 1160 | + | time.Sleep(500 * time.Millisecond) |
| 1132 | - | "route_count", len(existingRoutes)) | |||
| 1133 | 1161 | ||||
| 1134 | - | // Verify the container IP with an additional check | 1162 | + | // Check if this is the Gordon container (admin domain) |
| 1135 | - | verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName) | 1163 | + | isAdminContainer := false |
| 1136 | - | if err == nil && verifiedIP != "" && verifiedIP != containerIP { | 1164 | + | adminDomain := p.app.GetConfig().Http.FullDomain() |
| 1137 | - | logger.Warn("Container IP mismatch from event vs. direct network check", | |||
| 1138 | - | "container_id", containerID, | |||
| 1139 | - | "event_ip", containerIP, | |||
| 1140 | - | "network_ip", verifiedIP) | |||
| 1141 | 1165 | ||||
| 1142 | - | // Use the directly checked IP as it's more reliable | 1166 | + | p.mu.RLock() |
| 1143 | - | containerIP = verifiedIP | 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 | 1173 | + | // First, check if there are any existing routes for this container ID |
| 1147 | - | for domain, route := range existingRoutes { | 1174 | + | var existingRoutes = make(map[string]*ProxyRouteInfo) |
| 1148 | - | // Handle admin domain specially to avoid path issues | 1175 | + | p.mu.RLock() |
| 1149 | - | if domain == adminDomain && isAdminContainer { | 1176 | + | for domain, route := range p.routes { |
| 1150 | - | // For admin domain, only update the IP if it has changed | 1177 | + | if route.ContainerID == containerID { |
| 1151 | - | if route.ContainerIP != containerIP { | 1178 | + | existingRoutes[domain] = route |
| 1152 | - | logger.Info("Admin container restarted with new IP - updating route", | 1179 | + | } |
| 1153 | - | "container_id", containerID, | 1180 | + | } |
| 1154 | - | "container_name", containerName, | 1181 | + | p.mu.RUnlock() |
| 1155 | - | "domain", domain, | |||
| 1156 | - | "old_ip", route.ContainerIP, | |||
| 1157 | - | "new_ip", containerIP) | |||
| 1158 | 1182 | ||||
| 1159 | - | // Update IP in memory only first | 1183 | + | // If we found existing routes, check if the IP has changed |
| 1160 | - | p.mu.Lock() | 1184 | + | if len(existingRoutes) > 0 { |
| 1161 | - | if r, ok := p.routes[domain]; ok { | 1185 | + | logger.Debug("Found existing routes for container", |
| 1162 | - | r.ContainerIP = containerIP | 1186 | + | "container_id", containerID, |
| 1163 | - | r.Active = true | 1187 | + | "route_count", len(existingRoutes)) |
| 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 | - | } | |||
| 1176 | 1188 | ||||
| 1177 | - | if !rows.Next() { | 1189 | + | // Verify the container IP with an additional check |
| 1178 | - | rows.Close() | 1190 | + | verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName) |
| 1179 | - | logger.Error("Admin domain not found in database", "domain", domain) | 1191 | + | if err == nil && verifiedIP != "" && verifiedIP != containerIP { |
| 1180 | - | return | 1192 | + | logger.Warn("Container IP mismatch from event vs. direct network check", |
| 1181 | - | } | 1193 | + | "container_id", containerID, |
| 1194 | + | "event_ip", containerIP, | |||
| 1195 | + | "network_ip", verifiedIP) | |||
| 1182 | 1196 | ||||
| 1183 | - | if err := rows.Scan(&domainID); err != nil { | 1197 | + | // Use the directly checked IP as it's more reliable |
| 1184 | - | rows.Close() | 1198 | + | containerIP = verifiedIP |
| 1185 | - | logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err) | 1199 | + | } |
| 1186 | - | return | 1200 | + | |
| 1187 | - | } | 1201 | + | // Check and update each route if IP has changed |
| 1188 | - | rows.Close() | 1202 | + | for domain, route := range existingRoutes { |
| 1189 | - | 1203 | + | // Handle admin domain specially to avoid path issues | |
| 1190 | - | // Update the IP in the database directly | 1204 | + | if domain == adminDomain && isAdminContainer { |
| 1191 | - | now := time.Now().Format(time.RFC3339) | 1205 | + | // For admin domain, only update the IP if it has changed |
| 1192 | - | _, err = p.dbExecWithRetry(` | 1206 | + | if route.ContainerIP != containerIP { |
| 1193 | - | UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ? | 1207 | + | logger.Info("Admin container restarted with new IP - updating route", |
| 1194 | - | WHERE domain_id = ? AND container_id = ?`, | 1208 | + | "container_id", containerID, |
| 1195 | - | containerIP, now, domainID, containerID) | 1209 | + | "container_name", containerName, |
| 1196 | - | 1210 | + | "domain", domain, | |
| 1197 | - | if err != nil { | 1211 | + | "old_ip", route.ContainerIP, |
| 1198 | - | logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err) | 1212 | + | "new_ip", containerIP) |
| 1199 | - | } else { | 1213 | + | |
| 1200 | - | logger.Info("Updated admin container IP in database", | 1214 | + | // Update IP in memory only first |
| 1201 | - | "domain", domain, | 1215 | + | p.mu.Lock() |
| 1202 | - | "container_id", containerID, | 1216 | + | if r, ok := p.routes[domain]; ok { |
| 1203 | - | "new_ip", containerIP) | 1217 | + | r.ContainerIP = containerIP |
| 1218 | + | r.Active = true | |||
| 1204 | } | 1219 | } | ||
| 1205 | - | }(domain, containerID, containerIP) | 1220 | + | p.mu.Unlock() |
| 1206 | - | } | 1221 | + | |
| 1207 | - | } else if route.ContainerIP != containerIP { | 1222 | + | // Update DB in background |
| 1208 | - | // For non-admin domains, use the standard route update | 1223 | + | go func(domain, containerID, containerIP string) { |
| 1209 | - | logger.Info("Container restarted with new IP - updating route", | 1224 | + | // Get the domain ID |
| 1210 | - | "container_id", containerID, | 1225 | + | var domainID string |
| 1211 | - | "container_name", containerName, | 1226 | + | rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain) |
| 1212 | - | "domain", domain, | 1227 | + | if err != nil { |
| 1213 | - | "old_ip", route.ContainerIP, | 1228 | + | logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err) |
| 1214 | - | "new_ip", containerIP) | 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 | 1245 | + | // Update the IP in the database directly |
| 1217 | - | err := p.ForceUpdateRouteIP(domain, containerIP) | 1246 | + | now := time.Now().Format(time.RFC3339) |
| 1218 | - | if err != nil { | 1247 | + | _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP, |
| 1219 | - | logger.Error("Failed to update IP for restarted container", | 1248 | + | containerIP, now, domainID, containerID) |
| 1220 | - | "domain", domain, | 1249 | + | |
| 1221 | - | "error", err) | 1250 | + | if err != nil { |
| 1222 | - | } | 1251 | + | logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err) |
| 1223 | - | } else { | 1252 | + | } else { |
| 1224 | - | // Even if IP is the same, make sure the route is active | 1253 | + | logger.Info("Updated admin container IP in database", |
| 1225 | - | if !route.Active { | 1254 | + | "domain", domain, |
| 1226 | - | logger.Info("Reactivating route for restarted container", | 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 | "container_id", containerID, | 1263 | "container_id", containerID, | ||
| 1228 | "container_name", containerName, | 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 | 1269 | + | // Update the route IP |
| 1232 | - | p.ForceUpdateRouteIP(domain, containerIP) | 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 | 1291 | + | // Call the standard recreation handler for additional checks |
| 1239 | - | p.HandleContainerRecreation(containerID, containerName, containerIP) | 1292 | + | p.HandleContainerRecreation(containerID, containerName, containerIP) |
| 1293 | + | }() | |||
| 1240 | } | 1294 | } | ||
| 1241 | 1295 | ||||
| 1242 | // Create a callback function to handle container stop events | 1296 | // Create a callback function to handle container stop events | ||
| @@ -1332,7 +1386,7 @@ func (p *Proxy) checkRoutesForIPConsistency() { | |||||
| 1332 | "current_ip", currentIP) | 1386 | "current_ip", currentIP) | ||
| 1333 | 1387 | ||||
| 1334 | // Update the route | 1388 | // Update the route | ||
| 1335 | - | err := p.ForceUpdateRouteIP(domain, currentIP) | 1389 | + | err = p.ForceUpdateRouteIP(domain, currentIP) |
| 1336 | if err != nil { | 1390 | if err != nil { | ||
| 1337 | logger.Error("Failed to update IP during consistency check", | 1391 | logger.Error("Failed to update IP during consistency check", | ||
| 1338 | "domain", domain, | 1392 | "domain", domain, | ||
| @@ -1382,7 +1436,7 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||||
| 1382 | for _, domain := range domains { | 1436 | for _, domain := range domains { | ||
| 1383 | // First, get the domain ID | 1437 | // First, get the domain ID | ||
| 1384 | var domainID string | 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 | if err != nil { | 1440 | if err != nil { | ||
| 1387 | logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err) | 1441 | logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err) | ||
| 1388 | continue | 1442 | continue | ||
| @@ -1390,7 +1444,7 @@ func (p *Proxy) markRoutesInactive(domains []string) { | |||||
| 1390 | 1444 | ||||
| 1391 | // Update the route to mark it as inactive | 1445 | // Update the route to mark it as inactive | ||
| 1392 | now := time.Now().Format(time.RFC3339) | 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 | if err != nil { | 1448 | if err != nil { | ||
| 1395 | logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err) | 1449 | logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err) | ||
| 1396 | continue | 1450 | continue | ||
| @@ -1426,9 +1480,6 @@ func (p *Proxy) DiscoverMissingRoutes() { | |||||
| 1426 | return | 1480 | return | ||
| 1427 | } | 1481 | } | ||
| 1428 | 1482 | ||||
| 1429 | - | // Get the admin domain from the config | |||
| 1430 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 1431 | - | ||||
| 1432 | // Get all containers in the network | 1483 | // Get all containers in the network | ||
| 1433 | networkInfo, err := docker.GetNetworkInfo(networkName) | 1484 | networkInfo, err := docker.GetNetworkInfo(networkName) | ||
| 1434 | if err != nil { | 1485 | if err != nil { | ||
| @@ -1484,7 +1535,7 @@ func (p *Proxy) DiscoverMissingRoutes() { | |||||
| 1484 | domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".") | 1535 | domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".") | ||
| 1485 | 1536 | ||||
| 1486 | // Skip if this is the admin domain | 1537 | // Skip if this is the admin domain | ||
| 1487 | - | if domainLabel == adminDomain { | 1538 | + | if domainLabel == p.app.GetConfig().Http.FullDomain() { |
| 1488 | logger.Debug("Skipping auto-discovery for admin domain", | 1539 | logger.Debug("Skipping auto-discovery for admin domain", | ||
| 1489 | "domain", domainLabel) | 1540 | "domain", domainLabel) | ||
| 1490 | continue | 1541 | continue | ||
| @@ -1551,93 +1602,111 @@ func (p *Proxy) LogActiveContainersAndRoutes() { | |||||
| 1551 | // Get the admin domain | 1602 | // Get the admin domain | ||
| 1552 | adminDomain := p.app.GetConfig().Http.FullDomain() | 1603 | adminDomain := p.app.GetConfig().Http.FullDomain() | ||
| 1553 | 1604 | ||||
| 1554 | - | // Check if admin domain is in routes | |||
| 1555 | p.mu.RLock() | 1605 | p.mu.RLock() | ||
| 1556 | _, adminExists := p.routes[adminDomain] | 1606 | _, adminExists := p.routes[adminDomain] | ||
| 1557 | activeRoutes := len(p.routes) | 1607 | activeRoutes := len(p.routes) | ||
| 1558 | - | p.mu.RUnlock() | |||
| 1559 | 1608 | ||||
| 1560 | - | if !adminExists { | 1609 | + | // Group routes by container ID directly |
| 1561 | - | logger.Warn("Admin domain is missing from routes", "domain", adminDomain) | 1610 | + | containerGroups := make(map[string][]string) |
| 1562 | - | } | 1611 | + | containerInfo := make(map[string]struct { |
| 1563 | - | 1612 | + | ContainerIP string | |
| 1564 | - | logger.Info(fmt.Sprintf("Found %d active routes", activeRoutes)) | 1613 | + | ContainerID string |
| 1565 | - | 1614 | + | Protocols []string | |
| 1566 | - | // Create a map of container IDs to their information | 1615 | + | Ports []string |
| 1567 | - | containerMap := make(map[string]struct { | |||
| 1568 | - | Name string | |||
| 1569 | - | IP string | |||
| 1570 | - | Image string | |||
| 1571 | - | Domains []string | |||
| 1572 | - | Protocols []string | |||
| 1573 | - | Ports []string | |||
| 1574 | }) | 1616 | }) | ||
| 1575 | 1617 | ||||
| 1576 | - | // Populate the map with route information | |||
| 1577 | - | p.mu.RLock() | |||
| 1578 | for domain, route := range p.routes { | 1618 | for domain, route := range p.routes { | ||
| 1579 | if !route.Active { | 1619 | if !route.Active { | ||
| 1580 | continue | 1620 | continue | ||
| 1581 | } | 1621 | } | ||
| 1582 | 1622 | ||||
| 1583 | - | // If this container is not in the map yet, add it | 1623 | + | containerGroups[route.ContainerID] = append(containerGroups[route.ContainerID], domain) |
| 1584 | - | if _, exists := containerMap[route.ContainerID]; !exists { | 1624 | + | |
| 1585 | - | containerMap[route.ContainerID] = struct { | 1625 | + | // Store container info only once per container |
| 1586 | - | Name string | 1626 | + | if _, exists := containerInfo[route.ContainerID]; !exists { |
| 1587 | - | IP string | 1627 | + | containerInfo[route.ContainerID] = struct { |
| 1588 | - | Image string | 1628 | + | ContainerIP string |
| 1589 | - | Domains []string | 1629 | + | ContainerID string |
| 1590 | - | Protocols []string | 1630 | + | Protocols []string |
| 1591 | - | Ports []string | 1631 | + | Ports []string |
| 1592 | }{ | 1632 | }{ | ||
| 1593 | - | Name: route.ContainerID, | 1633 | + | ContainerIP: route.ContainerIP, |
| 1594 | - | IP: route.ContainerIP, | 1634 | + | ContainerID: route.ContainerID, |
| 1595 | - | Image: "unknown", | 1635 | + | Protocols: []string{route.Protocol}, |
| 1596 | - | Domains: []string{domain}, | 1636 | + | Ports: []string{route.ContainerPort}, |
| 1597 | - | Protocols: []string{route.Protocol}, | |||
| 1598 | - | Ports: []string{route.ContainerPort}, | |||
| 1599 | } | 1637 | } | ||
| 1600 | } else { | 1638 | } else { | ||
| 1601 | - | // Otherwise, append this domain to the container's domains | 1639 | + | info := containerInfo[route.ContainerID] |
| 1602 | - | container := containerMap[route.ContainerID] | 1640 | + | info.Protocols = append(info.Protocols, route.Protocol) |
| 1603 | - | container.Domains = append(container.Domains, domain) | 1641 | + | info.Ports = append(info.Ports, route.ContainerPort) |
| 1604 | - | container.Protocols = append(container.Protocols, route.Protocol) | 1642 | + | containerInfo[route.ContainerID] = info |
| 1605 | - | container.Ports = append(container.Ports, route.ContainerPort) | |||
| 1606 | - | containerMap[route.ContainerID] = container | |||
| 1607 | } | 1643 | } | ||
| 1608 | } | 1644 | } | ||
| 1609 | p.mu.RUnlock() | 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 | // Get container names and images | 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 | containerInfo, err := docker.GetContainerInfo(containerID) | 1658 | containerInfo, err := docker.GetContainerInfo(containerID) | ||
| 1614 | if err == nil { | 1659 | if err == nil { | ||
| 1615 | name, err := docker.GetContainerName(containerID) | 1660 | name, err := docker.GetContainerName(containerID) | ||
| 1616 | if err == nil { | 1661 | if err == nil { | ||
| 1617 | - | containerData.Name = strings.TrimLeft(name, "/") | 1662 | + | containerNames[containerID] = strings.TrimLeft(name, "/") |
| 1618 | - | containerData.Image = containerInfo.Config.Image | 1663 | + | containerImages[containerID] = containerInfo.Config.Image |
| 1619 | - | containerMap[containerID] = containerData | |||
| 1620 | } | 1664 | } | ||
| 1621 | } | 1665 | } | ||
| 1622 | } | 1666 | } | ||
| 1623 | 1667 | ||||
| 1624 | // Display each container and its routes | 1668 | // Display each container and its routes | ||
| 1625 | - | for containerID, container := range containerMap { | 1669 | + | for containerID, domains := range containerGroups { |
| 1626 | // Log container info | 1670 | // Log container info | ||
| 1627 | // Safety check to prevent slice bounds panic | 1671 | // Safety check to prevent slice bounds panic | ||
| 1628 | containerIDShort := containerID | 1672 | containerIDShort := containerID | ||
| 1629 | if len(containerID) >= 12 { | 1673 | if len(containerID) >= 12 { | ||
| 1630 | containerIDShort = containerID[:12] | 1674 | containerIDShort = containerID[:12] | ||
| 1631 | } | 1675 | } | ||
| 1632 | - | logger.Info(fmt.Sprintf("Container: %s (%s)", container.Name, containerIDShort), | 1676 | + | |
| 1633 | - | "ip", container.IP, | 1677 | + | containerName := containerID |
| 1634 | - | "image", container.Image) | 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 | // Log each domain for this container | 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 | logger.Info(fmt.Sprintf(" ├─ Domain: %s", domain), | 1707 | logger.Info(fmt.Sprintf(" ├─ Domain: %s", domain), | ||
| 1639 | - | "protocol", container.Protocols[i], | 1708 | + | "protocol", protocol, |
| 1640 | - | "port", container.Ports[i]) | 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 | targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort) | 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 | // Create a reverse proxy | 1892 | // Create a reverse proxy | ||
| 1844 | proxy := httputil.NewSingleHostReverseProxy(targetURL) | 1893 | proxy := httputil.NewSingleHostReverseProxy(targetURL) | ||
| 1845 | 1894 | ||||
| @@ -1854,14 +1903,8 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1854 | // Call the original director first | 1903 | // Call the original director first | ||
| 1855 | originalDirector(req) | 1904 | originalDirector(req) | ||
| 1856 | 1905 | ||||
| 1857 | - | // Handle the Host header differently based on domain type | 1906 | + | // Keep the original host in the Host header |
| 1858 | - | if isAdminDomain { | 1907 | + | req.Host = host |
| 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 | - | } | |||
| 1865 | 1908 | ||||
| 1866 | // Set X-Forwarded headers to preserve client information | 1909 | // Set X-Forwarded headers to preserve client information | ||
| 1867 | if clientIP := c.RealIP(); clientIP != "" { | 1910 | if clientIP := c.RealIP(); clientIP != "" { | ||
| @@ -1874,12 +1917,6 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1874 | 1917 | ||||
| 1875 | // Set X-Forwarded-Host to the original host | 1918 | // Set X-Forwarded-Host to the original host | ||
| 1876 | req.Header.Set("X-Forwarded-Host", host) | 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 | // Log errors that occur during proxying | 1922 | // Log errors that occur during proxying | ||
| @@ -1925,3 +1962,50 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error { | |||||
| 1925 | proxy.ServeHTTP(c.Response(), c.Request()) | 1962 | proxy.ServeHTTP(c.Response(), c.Request()) | ||
| 1926 | return nil | 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 | package server | 1 | package server | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "database/sql" | 5 | "database/sql" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "io/fs" | 7 | "io/fs" | ||
| @@ -8,6 +9,7 @@ import ( | |||||
| 8 | 9 | ||||
| 9 | "github.com/bnema/gordon/internal/common" | 10 | "github.com/bnema/gordon/internal/common" | ||
| 10 | "github.com/bnema/gordon/internal/db" | 11 | "github.com/bnema/gordon/internal/db" | ||
| 12 | + | "github.com/charmbracelet/log" | |||
| 11 | _ "github.com/joho/godotenv/autoload" | 13 | _ "github.com/joho/godotenv/autoload" | ||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| @@ -22,6 +24,8 @@ type App struct { | |||||
| 22 | Config common.Config | 24 | Config common.Config | ||
| 23 | DB *sql.DB | 25 | DB *sql.DB | ||
| 24 | DBTables DBTables | 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 | StartTime time.Time | 29 | StartTime time.Time | ||
| 26 | } | 30 | } | ||
| 27 | 31 | ||||
| @@ -82,3 +86,17 @@ func (a *App) GetUptime() string { | |||||
| 82 | func (a *App) GetVersionstring() string { | 86 | func (a *App) GetVersionstring() string { | ||
| 83 | return fmt.Sprint(a.Config.Build.BuildVersion) | 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 | package server | 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 | func CloseDB(a *App) error { | 10 | func CloseDB(a *App) error { | ||
| 11 | + | log.Debug("CloseDB called, checking if database connection exists") | |||
| 12 | + | ||||
| 7 | if a.DB != nil { | 13 | if a.DB != nil { | ||
| 8 | - | // if err := saveDB(a); err != nil { | 14 | + | log.Debug("Database connection exists, proceeding with cleanup") |
| 9 | - | // return fmt.Errorf("failed to save database: %w", err) | 15 | + | |
| 10 | - | // } | 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 | if err := a.DB.Close(); err != nil { | 36 | if err := a.DB.Close(); err != nil { | ||
| 37 | + | log.Error("Failed to close database connection", "error", err) | |||
| 13 | return fmt.Errorf("failed to close database: %w", err) | 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 | return nil | 48 | return nil | ||
| 17 | } | 49 | } | ||
| 18 | 50 | ||||
M internal/server/sqlite_init.go +359 -29
| @@ -1,10 +1,12 @@ | |||||
| 1 | package server | 1 | package server | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "database/sql" | 5 | "database/sql" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "time" | |||
| 8 | 10 | ||||
| 9 | "github.com/bnema/gordon/internal/db" | 11 | "github.com/bnema/gordon/internal/db" | ||
| 10 | "github.com/charmbracelet/log" | 12 | "github.com/charmbracelet/log" | ||
| @@ -13,6 +15,8 @@ import ( | |||||
| 13 | 15 | ||||
| 14 | const ( | 16 | const ( | ||
| 15 | DBFilename = "sqlite.db" | 17 | DBFilename = "sqlite.db" | ||
| 18 | + | // Auto-save interval in minutes | |||
| 19 | + | AutoSaveInterval = 5 | |||
| 16 | ) | 20 | ) | ||
| 17 | 21 | ||||
| 18 | func (a *App) getDiskDBFilePath() string { | 22 | func (a *App) getDiskDBFilePath() string { | ||
| @@ -69,39 +73,61 @@ func (a *App) checkDBFile(dbPath string) (bool, error) { | |||||
| 69 | return true, nil | 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 | func InitializeDB(a *App) (*sql.DB, error) { | 77 | func InitializeDB(a *App) (*sql.DB, error) { | ||
| 74 | - | log.Debug("Initializing database") | 78 | + | log.Debug("Initializing in-memory database") |
| 75 | if err := a.ensureDBDir(); err != nil { | 79 | if err := a.ensureDBDir(); err != nil { | ||
| 76 | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | 80 | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | ||
| 77 | } | 81 | } | ||
| 78 | 82 | ||||
| 79 | - | dbPath := a.getDiskDBFilePath() | 83 | + | diskDBPath := a.getDiskDBFilePath() |
| 80 | - | a.DBPath = dbPath | 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 | if err != nil { | 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 { | 93 | + | // Test that the in-memory database is working |
| 88 | - | log.Debug("Database needs bootstrapping", "path", dbPath) | 94 | + | log.Debug("Testing in-memory database connection") |
| 89 | - | if err := bootstrapDB(dbPath); err != nil { | 95 | + | if err := memDB.Ping(); err != nil { |
| 90 | - | return nil, fmt.Errorf("failed to bootstrap DB: %w", err) | 96 | + | return nil, fmt.Errorf("failed to ping in-memory database: %w", err) |
| 91 | - | } | |||
| 92 | } | 97 | } | ||
| 93 | 98 | ||||
| 94 | - | db, err := sql.Open("sqlite", dbPath) | 99 | + | // Check if disk database exists |
| 100 | + | dbExists, err := a.checkDBFile(diskDBPath) | |||
| 95 | if err != nil { | 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 | 105 | + | if !dbExists { |
| 100 | - | if err := db.Ping(); err != nil { | 106 | + | log.Debug("Database needs bootstrapping", "path", diskDBPath) |
| 101 | - | return nil, fmt.Errorf("failed to ping database: %w", err) | 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 | if dbExists { | 132 | if dbExists { | ||
| 107 | // Populate the struct tables from the database | 133 | // Populate the struct tables from the database | ||
| @@ -109,20 +135,320 @@ func InitializeDB(a *App) (*sql.DB, error) { | |||||
| 109 | if err := PopulateStructWithTables(a); err != nil { | 135 | if err := PopulateStructWithTables(a); err != nil { | ||
| 110 | return nil, err | 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 { | 150 | + | // loadDiskDBToMemory loads the database from disk into memory |
| 120 | - | // Open the database file | 151 | + | func loadDiskDBToMemory(diskDBPath string, memDB *sql.DB) error { |
| 121 | - | db, err := sql.Open("sqlite", dbPath) | 152 | + | log.Debug("Opening disk database for loading", "path", diskDBPath) |
| 153 | + | // Open the disk database | |||
| 154 | + | diskDB, err := sql.Open("sqlite", diskDBPath) | |||
| 122 | if err != nil { | 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 | // Begin a transaction | 453 | // Begin a transaction | ||
| 128 | tx, err := db.Begin() | 454 | tx, err := db.Begin() | ||
| @@ -131,6 +457,7 @@ func bootstrapDB(dbPath string) error { | |||||
| 131 | } | 457 | } | ||
| 132 | 458 | ||||
| 133 | // Create tables | 459 | // Create tables | ||
| 460 | + | log.Debug("Creating database tables") | |||
| 134 | _, err = tx.Exec(` | 461 | _, err = tx.Exec(` | ||
| 135 | CREATE TABLE IF NOT EXISTS user ( | 462 | CREATE TABLE IF NOT EXISTS user ( | ||
| 136 | id TEXT PRIMARY KEY, | 463 | id TEXT PRIMARY KEY, | ||
| @@ -204,14 +531,17 @@ func bootstrapDB(dbPath string) error { | |||||
| 204 | ); | 531 | ); | ||
| 205 | `) | 532 | `) | ||
| 206 | if err != nil { | 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 | // Commit the transaction | 538 | // Commit the transaction | ||
| 539 | + | log.Debug("Committing transaction with initial schema") | |||
| 211 | if err := tx.Commit(); err != nil { | 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 | return nil | 545 | return nil | ||
| 216 | } | 546 | } | ||
| 217 | 547 | ||||
| @@ -296,7 +626,7 @@ func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error { | |||||
| 296 | } | 626 | } | ||
| 297 | 627 | ||||
| 298 | func (p *ProxyRouteWrapper) ScanFromRows(rows *sql.Rows) error { | 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 | // PopulateStructWithTables updates db struct with tables | 632 | // PopulateStructWithTables updates db struct with tables | ||
| @@ -345,7 +675,7 @@ func PopulateStructWithTables(a *App) error { | |||||
| 345 | 675 | ||||
| 346 | // Populate the ProxyRoute table | 676 | // Populate the ProxyRoute table | ||
| 347 | proxyRouteWrapper := &ProxyRouteWrapper{ProxyRoute: &a.DBTables.ProxyRoute} | 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 | return err | 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 | // Protocol (http/https), Container name, Container subdomain, and Container domain | 34 | // Protocol (http/https), Container name, Container subdomain, and Container domain | ||
| 35 | protocol := parsedURL.Scheme | 35 | protocol := parsedURL.Scheme | ||
| 36 | - | containerSubdomain := hostParts[0] | 36 | + | |
| 37 | - | containerDomain := strings.Join(hostParts[1:], ".") | 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 | params := docker.ContainerCommandParams{ | 56 | params := docker.ContainerCommandParams{ | ||
| 40 | IsSSL: protocol == "https", | 57 | IsSSL: protocol == "https", | ||
| 41 | - | ContainerName: containerSubdomain, | 58 | + | ContainerName: containerName, |
| 42 | - | ServiceName: containerSubdomain, | 59 | + | ServiceName: containerName, |
| 43 | Domain: containerDomain, | 60 | Domain: containerDomain, | ||
| 44 | ImageName: ppl.ImageName, | 61 | ImageName: ppl.ImageName, | ||
| 45 | ImageID: imageID, | 62 | ImageID: imageID, | ||
M internal/templating/models/html/fragments/createcontainer.gohtml +13 -1
| @@ -65,7 +65,7 @@ | |||||
| 65 | <div class="text-xs text-gray-500 mb-2"> | 65 | <div class="text-xs text-gray-500 mb-2"> | ||
| 66 | Specify the internal container port that the proxy will route traffic to | 66 | Specify the internal container port that the proxy will route traffic to | ||
| 67 | </div> | 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 | </div> | 69 | </div> | ||
| 70 | 70 | ||||
| 71 | <!-- Volumes --> | 71 | <!-- Volumes --> | ||
| @@ -92,6 +92,18 @@ | |||||
| 92 | </select> | 92 | </select> | ||
| 93 | </div> | 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 | <input type="submit" value="Create" id="create-container-submit" | 107 | <input type="submit" value="Create" id="create-container-submit" | ||
| 96 | class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" /> | 108 | class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" /> | ||
| 97 | </form> | 109 | </form> | ||
M internal/webui/public/assets/css/tailwind.css +270 -158
| @@ -6,7 +6,115 @@ | |||||
| 6 | 6 | ||||
| 7 | /* Embeded path : assets/css/cutsom.css */ | 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 | /* Make elements with the HTML hidden attribute stay hidden by default */ | 558 | /* Make elements with the HTML hidden attribute stay hidden by default */ | ||
| 451 | 559 | ||||
| 452 | - | [hidden] { | 560 | + | [hidden]:where(:not([hidden="until-found"])) { |
| 453 | display: none; | 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 | .container { | 564 | .container { | ||
| 565 | width: 100%; | 565 | width: 100%; | ||
| 566 | } | 566 | } | ||
| @@ -603,6 +603,10 @@ video { | |||||
| 603 | position: fixed; | 603 | position: fixed; | ||
| 604 | } | 604 | } | ||
| 605 | 605 | ||||
| 606 | + | .relative { | |||
| 607 | + | position: relative; | |||
| 608 | + | } | |||
| 609 | + | ||||
| 606 | .left-0 { | 610 | .left-0 { | ||
| 607 | left: 0px; | 611 | left: 0px; | ||
| 608 | } | 612 | } | ||
| @@ -611,23 +615,38 @@ video { | |||||
| 611 | top: 0px; | 615 | top: 0px; | ||
| 612 | } | 616 | } | ||
| 613 | 617 | ||||
| 614 | - | .my-2 { | |||
| 615 | - | margin-top: 0.5rem; | |||
| 616 | - | margin-bottom: 0.5rem; | |||
| 617 | - | } | |||
| 618 | - | ||||
| 619 | .mb-1 { | 618 | .mb-1 { | ||
| 620 | margin-bottom: 0.25rem; | 619 | margin-bottom: 0.25rem; | ||
| 621 | } | 620 | } | ||
| 622 | 621 | ||||
| 622 | + | .mb-2 { | |||
| 623 | + | margin-bottom: 0.5rem; | |||
| 624 | + | } | |||
| 625 | + | ||||
| 623 | .mb-4 { | 626 | .mb-4 { | ||
| 624 | margin-bottom: 1rem; | 627 | margin-bottom: 1rem; | ||
| 625 | } | 628 | } | ||
| 626 | 629 | ||||
| 630 | + | .mb-6 { | |||
| 631 | + | margin-bottom: 1.5rem; | |||
| 632 | + | } | |||
| 633 | + | ||||
| 627 | .ml-2 { | 634 | .ml-2 { | ||
| 628 | margin-left: 0.5rem; | 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 | .mt-2 { | 650 | .mt-2 { | ||
| 632 | margin-top: 0.5rem; | 651 | margin-top: 0.5rem; | ||
| 633 | } | 652 | } | ||
| @@ -636,6 +655,10 @@ video { | |||||
| 636 | margin-top: 1rem; | 655 | margin-top: 1rem; | ||
| 637 | } | 656 | } | ||
| 638 | 657 | ||||
| 658 | + | .mt-8 { | |||
| 659 | + | margin-top: 2rem; | |||
| 660 | + | } | |||
| 661 | + | ||||
| 639 | .block { | 662 | .block { | ||
| 640 | display: block; | 663 | display: block; | ||
| 641 | } | 664 | } | ||
| @@ -664,8 +687,8 @@ video { | |||||
| 664 | height: 11rem; | 687 | height: 11rem; | ||
| 665 | } | 688 | } | ||
| 666 | 689 | ||||
| 667 | - | .h-64 { | 690 | + | .h-5 { |
| 668 | - | height: 16rem; | 691 | + | height: 1.25rem; |
| 669 | } | 692 | } | ||
| 670 | 693 | ||||
| 671 | .h-8 { | 694 | .h-8 { | ||
| @@ -688,14 +711,26 @@ video { | |||||
| 688 | width: 66.666667%; | 711 | width: 66.666667%; | ||
| 689 | } | 712 | } | ||
| 690 | 713 | ||||
| 714 | + | .w-5 { | |||
| 715 | + | width: 1.25rem; | |||
| 716 | + | } | |||
| 717 | + | ||||
| 691 | .w-5\/12 { | 718 | .w-5\/12 { | ||
| 692 | width: 41.666667%; | 719 | width: 41.666667%; | ||
| 693 | } | 720 | } | ||
| 694 | 721 | ||||
| 722 | + | .w-8 { | |||
| 723 | + | width: 2rem; | |||
| 724 | + | } | |||
| 725 | + | ||||
| 695 | .w-full { | 726 | .w-full { | ||
| 696 | width: 100%; | 727 | width: 100%; | ||
| 697 | } | 728 | } | ||
| 698 | 729 | ||||
| 730 | + | .max-w-md { | |||
| 731 | + | max-width: 28rem; | |||
| 732 | + | } | |||
| 733 | + | ||||
| 699 | .flex-1 { | 734 | .flex-1 { | ||
| 700 | flex: 1 1 0%; | 735 | flex: 1 1 0%; | ||
| 701 | } | 736 | } | ||
| @@ -712,6 +747,10 @@ video { | |||||
| 712 | cursor: pointer; | 747 | cursor: pointer; | ||
| 713 | } | 748 | } | ||
| 714 | 749 | ||||
| 750 | + | .list-disc { | |||
| 751 | + | list-style-type: disc; | |||
| 752 | + | } | |||
| 753 | + | ||||
| 715 | .flex-row { | 754 | .flex-row { | ||
| 716 | flex-direction: row; | 755 | flex-direction: row; | ||
| 717 | } | 756 | } | ||
| @@ -748,8 +787,10 @@ video { | |||||
| 748 | justify-content: space-evenly; | 787 | justify-content: space-evenly; | ||
| 749 | } | 788 | } | ||
| 750 | 789 | ||||
| 751 | - | .justify-items-center { | 790 | + | .space-x-2 > :not([hidden]) ~ :not([hidden]) { |
| 752 | - | justify-items: center; | 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 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { | 796 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { | ||
| @@ -758,12 +799,6 @@ video { | |||||
| 758 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); | 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 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { | 802 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { | ||
| 768 | --tw-space-y-reverse: 0; | 803 | --tw-space-y-reverse: 0; | ||
| 769 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); | 804 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); | ||
| @@ -780,6 +815,10 @@ video { | |||||
| 780 | border-radius: 0.25rem; | 815 | border-radius: 0.25rem; | ||
| 781 | } | 816 | } | ||
| 782 | 817 | ||||
| 818 | + | .rounded-full { | |||
| 819 | + | border-radius: 9999px; | |||
| 820 | + | } | |||
| 821 | + | ||||
| 783 | .rounded-md { | 822 | .rounded-md { | ||
| 784 | border-radius: 0.375rem; | 823 | border-radius: 0.375rem; | ||
| 785 | } | 824 | } | ||
| @@ -802,42 +841,62 @@ video { | |||||
| 802 | 841 | ||||
| 803 | .border-gray-300 { | 842 | .border-gray-300 { | ||
| 804 | --tw-border-opacity: 1; | 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 | .bg-blue-500 { | 852 | .bg-blue-500 { | ||
| 809 | --tw-bg-opacity: 1; | 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 | .bg-gordonmascot-hblue { | 857 | .bg-gordonmascot-hblue { | ||
| 814 | --tw-bg-opacity: 1; | 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 | .bg-gordonmascot-lblue { | 862 | .bg-gordonmascot-lblue { | ||
| 819 | --tw-bg-opacity: 1; | 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 | .bg-gray-100 { | 872 | .bg-gray-100 { | ||
| 824 | --tw-bg-opacity: 1; | 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 | .bg-gray-300 { | 877 | .bg-gray-300 { | ||
| 829 | --tw-bg-opacity: 1; | 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 | .bg-slate-100 { | 892 | .bg-slate-100 { | ||
| 834 | --tw-bg-opacity: 1; | 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 | .bg-slate-200 { | 897 | .bg-slate-200 { | ||
| 839 | --tw-bg-opacity: 1; | 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 | .bg-transparent { | 902 | .bg-transparent { | ||
| @@ -846,12 +905,12 @@ video { | |||||
| 846 | 905 | ||||
| 847 | .bg-white { | 906 | .bg-white { | ||
| 848 | --tw-bg-opacity: 1; | 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 | .bg-zinc-200 { | 911 | .bg-zinc-200 { | ||
| 853 | --tw-bg-opacity: 1; | 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 | .p-0 { | 916 | .p-0 { | ||
| @@ -871,6 +930,11 @@ video { | |||||
| 871 | padding-right: 0.5rem; | 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 | .px-4 { | 938 | .px-4 { | ||
| 875 | padding-left: 1rem; | 939 | padding-left: 1rem; | ||
| 876 | padding-right: 1rem; | 940 | padding-right: 1rem; | ||
| @@ -891,6 +955,11 @@ video { | |||||
| 891 | padding-bottom: 0.5rem; | 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 | .pb-4 { | 963 | .pb-4 { | ||
| 895 | padding-bottom: 1rem; | 964 | padding-bottom: 1rem; | ||
| 896 | } | 965 | } | ||
| @@ -955,6 +1024,10 @@ video { | |||||
| 955 | font-weight: 700; | 1024 | font-weight: 700; | ||
| 956 | } | 1025 | } | ||
| 957 | 1026 | ||||
| 1027 | + | .font-medium { | |||
| 1028 | + | font-weight: 500; | |||
| 1029 | + | } | |||
| 1030 | + | ||||
| 958 | .font-semibold { | 1031 | .font-semibold { | ||
| 959 | font-weight: 600; | 1032 | font-weight: 600; | ||
| 960 | } | 1033 | } | ||
| @@ -965,67 +1038,72 @@ video { | |||||
| 965 | 1038 | ||||
| 966 | .text-blue-600 { | 1039 | .text-blue-600 { | ||
| 967 | --tw-text-opacity: 1; | 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 | .text-blue-800 { | 1044 | .text-blue-800 { | ||
| 972 | --tw-text-opacity: 1; | 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 | .text-gordonmascot-hblue { | 1049 | .text-gordonmascot-hblue { | ||
| 977 | --tw-text-opacity: 1; | 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 | .text-gordonmascot-mblue { | 1054 | .text-gordonmascot-mblue { | ||
| 982 | --tw-text-opacity: 1; | 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 | .text-gray-100 { | 1059 | .text-gray-100 { | ||
| 987 | --tw-text-opacity: 1; | 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 | .text-gray-400 { | 1064 | .text-gray-400 { | ||
| 992 | --tw-text-opacity: 1; | 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 | .text-gray-500 { | 1069 | .text-gray-500 { | ||
| 997 | --tw-text-opacity: 1; | 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 | .text-gray-800 { | 1079 | .text-gray-800 { | ||
| 1002 | --tw-text-opacity: 1; | 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 | .text-green-300 { | 1084 | .text-green-300 { | ||
| 1007 | --tw-text-opacity: 1; | 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 | .text-red-500 { | 1089 | .text-red-500 { | ||
| 1012 | --tw-text-opacity: 1; | 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 | .text-red-600 { | 1094 | .text-red-600 { | ||
| 1017 | --tw-text-opacity: 1; | 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 | --tw-text-opacity: 1; | 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 | .text-white { | 1104 | .text-white { | ||
| 1027 | --tw-text-opacity: 1; | 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 | .shadow { | 1109 | .shadow { | ||
| @@ -1034,6 +1112,12 @@ video { | |||||
| 1034 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); | 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 | .outline-none { | 1121 | .outline-none { | ||
| 1038 | outline: 2px solid transparent; | 1122 | outline: 2px solid transparent; | ||
| 1039 | outline-offset: 2px; | 1123 | outline-offset: 2px; | ||
| @@ -1047,6 +1131,14 @@ video { | |||||
| 1047 | transition-duration: 150ms; | 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 | /* Import IBM Plex Fonts */ | 1142 | /* Import IBM Plex Fonts */ | ||
| 1051 | 1143 | ||||
| 1052 | @font-face { | 1144 | @font-face { | ||
| @@ -1185,30 +1277,54 @@ video { | |||||
| 1185 | 1277 | ||||
| 1186 | .hover\:bg-blue-700:hover { | 1278 | .hover\:bg-blue-700:hover { | ||
| 1187 | --tw-bg-opacity: 1; | 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 | .hover\:bg-gordonmascot-mblue:hover { | 1288 | .hover\:bg-gordonmascot-mblue:hover { | ||
| 1192 | --tw-bg-opacity: 1; | 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 | .hover\:bg-slate-100:hover { | 1293 | .hover\:bg-slate-100:hover { | ||
| 1197 | --tw-bg-opacity: 1; | 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 | .hover\:text-gordonmascot-hblue:hover { | 1298 | .hover\:text-gordonmascot-hblue:hover { | ||
| 1202 | --tw-text-opacity: 1; | 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 | .hover\:text-red-900:hover { | 1303 | .hover\:text-red-900:hover { | ||
| 1207 | --tw-text-opacity: 1; | 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 | @media (min-width: 640px) { | 1323 | @media (min-width: 640px) { | ||
| 1324 | + | .sm\:inline { | |||
| 1325 | + | display: inline; | |||
| 1326 | + | } | |||
| 1327 | + | ||||
| 1212 | .sm\:h-24 { | 1328 | .sm\:h-24 { | ||
| 1213 | height: 6rem; | 1329 | height: 6rem; | ||
| 1214 | } | 1330 | } | ||
| @@ -1223,10 +1339,6 @@ video { | |||||
| 1223 | height: 4rem; | 1339 | height: 4rem; | ||
| 1224 | } | 1340 | } | ||
| 1225 | 1341 | ||||
| 1226 | - | .md\:w-1\/2 { | |||
| 1227 | - | width: 50%; | |||
| 1228 | - | } | |||
| 1229 | - | ||||
| 1230 | .md\:w-2\/6 { | 1342 | .md\:w-2\/6 { | ||
| 1231 | width: 33.333333%; | 1343 | width: 33.333333%; | ||
| 1232 | } | 1344 | } | ||
M package.json +3 -4
| @@ -1,16 +1,15 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | "devDependencies": { | 2 | "devDependencies": { | ||
| 3 | "@catppuccin/tailwindcss": "^0.1.6", | 3 | "@catppuccin/tailwindcss": "^0.1.6", | ||
| 4 | - | "prettier": "^3.3.3", | 4 | + | "prettier": "^3.5.3", |
| 5 | "prettier-plugin-tailwindcss": "^0.5.14", | 5 | "prettier-plugin-tailwindcss": "^0.5.14", | ||
| 6 | - | "tailwindcss": "^3.4.14" | 6 | + | "tailwindcss": "^3.4.17" |
| 7 | }, | 7 | }, | ||
| 8 | "scripts": { | 8 | "scripts": { | ||
| 9 | "build:css": "tailwindcss build src/styles.css -o dist/styles.css", | 9 | "build:css": "tailwindcss build src/styles.css -o dist/styles.css", | ||
| 10 | "dev:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css --watch" | 10 | "dev:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css --watch" | ||
| 11 | }, | 11 | }, | ||
| 12 | "dependencies": { | 12 | "dependencies": { | ||
| 13 | - | "caniuse-lite": "^1.0.30001669", | 13 | + | "dotenv": "^16.4.7" |
| 14 | - | "dotenv": "^16.4.5" | |||
| 15 | } | 14 | } | ||
| 16 | } | 15 | } | ||