detached f9408b4

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
f9408b4c45faaf2d1ef092c867cc0462e9a10506
author
bamen · · 2025-03-13 06:35 UTC
parent
b757931
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 string16 token string
17 expiration time.Time17 expiration time.Time
18- githubUser *queries.GithubUserInfo18+ 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 cache35 // 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 cache65 // 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 string26 var port string
27 var targetDomain string27 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 deployCmd70 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 early74 // 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 := 10169 maxRetries := 10
155 retryInterval := time.Second170 retryInterval := time.Second
156 171
157 var shortContainerID string172 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 instance43 // 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 address49 // 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 ID55+ // Get container info to verify the IP
56- if !force && containerID != "" {56+ containerInfo, err := docker.GetContainerInfo(containerID)
57- // Get container info to verify the IP57+ 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 config62+ if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
63- networkName := a.Config.ContainerEngine.Network63+ 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.IPAddress73+ 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 route79 // 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 return91 return
90 }92 }
91 93
92- // If remove flag is set, remove the route94+ // 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 return108 return
104 }109 }
105 110
106- // If domain is set, add a new route111+ // If domain and containerID are provided, add a route
107- if domain != "" {112+ if domain != "" && containerID != "" {
108- // Validate required fields113+ 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 return128 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 flags186+ 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 proxyCmd196 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 = port41 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 handler1 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 function38 // 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 work61 // 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 signals71 // 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 := <-sigs77+ 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 started80+ 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 nil122 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 on76+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS77+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates78+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates79+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates80+ 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 Encrypt82+ Email string `yaml:"email"` // Email for Let's Encrypt
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache83+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds84+ 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 values89 // Default values
@@ -98,6 +99,7 @@ var (
98 cacheSize = 1000 // entries99 cacheSize = 1000 // entries
99 gracePeriod = 30 // seconds100 gracePeriod = 30 // seconds
100 defaultLogLevel = "info" // Default log level101 defaultLogLevel = "info" // Default log level
102+ disableRateLimit = false
101 )103 )
102 104
103 // applyDefaultsToConfig applies default values to any fields that have zero values105 // 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 = true157 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 autoRenew166 // 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.Domain926+ return domain
897 }927 }
898 928
899 // GetVersion929 // GetVersion
M internal/common/payload.go +6 -5
@@ -55,11 +55,12 @@ type StartResponse struct {
55 55
56 // DeployPayload represents the payload for deployment requests56 // 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.ReadCloser62+ SkipProxySetup bool `json:"skip_proxy_setup"`
63+ Data io.ReadCloser
63 }64 }
64 65
65 // PushPayload represents the payload for push requests66 // PushPayload represents the payload for push requests
M internal/db/db_struct.go +9 -0
@@ -1,5 +1,6 @@
1 package db1 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 queries1 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, err36 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 bool41 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, err49 return false, err
25 }50 }
51+
26 return exists, nil52 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 err58+ 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 err66+ 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 queries1 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 tables10 // 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 err14 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 table36 // 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 err39 return err
42 }40 }
@@ -54,10 +52,11 @@ func InspectInMemoryDB(a *server.App) error {
54 }52 }
55 53
56 // Print the content of accounts table54 // 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 err57 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 queries1 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 err41 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 err62+ 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 = provider68+// 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 err49+ 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 necessary53+ // Create a Sessions struct to return
26- a.DBTables.Sessions.ID = sessions.ID54+ session := &db.Sessions{
27- a.DBTables.Sessions.AccessToken = accessToken55+ ID: sessionID,
28- a.DBTables.Sessions.Expires = expireTime56+ AccountID: accountID,
29-57+ AccessToken: accessToken,
30- return nil58+ 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, nil86 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, nil92+ 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 err110 return err
53 }111 }
54-112+
113+ logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime)
55 return nil114 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 info119 // 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 session126 // 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 session135+ // 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 nil144 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 err157 return err
90 }158 }
91-159+
160+ logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID)
92 return nil161 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 string166 var expirationTime string
97- // with userID and sessionID we can get the expiration time167+
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, err175 return currentTime, err
102 }176 }
@@ -107,36 +181,58 @@ func GetSessionExpiration(a *server.App, accountID string, sessionID string, cur
107 return currentTime, err181 return currentTime, err
108 }182 }
109 183
184+ logger.Debug("Session expiration time retrieved", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTimeParsed)
185+
110 return expirationTimeParsed, nil186 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 err200 return err
118 }201 }
119-202+
203+ logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime)
120 return nil204 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 bool209 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, err216 return false, err
129 }217 }
130 218
219+ logger.Debug("Session existence checked", "sessionID", sessionID, "exists", sessionExists)
131 return sessionExists, nil220 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 err233 return err
139 }234 }
140-235+
236+ logger.Debug("Session invalidated", "sessionID", sessionID)
141 return nil237 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 []string22+ 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 err47+ 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 state52+ logger.Debug("Creating database account for user", "user_id", user.ID)
37- a.DBTables.User.ID = user.ID53+ 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 err55+ 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 state60+ logger.Debug("Creating GitHub provider for account", "account_id", account.ID)
47- a.DBTables.Account.ID = account.ID61+ err = CreateDBGitHubProvider(database, account.ID, userInfo)
48- a.DBTables.Account.UserID = account.UserID62+ 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 err71+ 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, err91 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 string96 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, nil100 return false, nil
@@ -86,50 +109,87 @@ func CheckDBUserExists(a *server.App) (bool, error) {
86 return false, nil109 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 user112+// 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.Login114 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 email119 // 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, nil126+ return nil, false, nil
100 }127 }
101- return false, err128+ return nil, false, err
102 }129 }
103 130
104- if a.DBTables.User.ID != "" {131+ if user.ID != "" {
105- return true, nil132+ return user, true, nil
106 }133 }
107 134
108- return false, nil135+ 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.User146+ 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, err154+ 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, nil163+ 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 int191 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, err194 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 IP192+ // 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 inputs195+ 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 container218+ // 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 operation222+ // 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 route82+ // 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 successful100 // 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 checks276 // 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 Encrypt498 // 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 payload107 // 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 domain257 // 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 protocol263 // 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://" + domain265 return "https://" + domain
M internal/httpserve/handlers/login.go +79 -32
@@ -22,18 +22,22 @@ import (
22 22
23 var urlToken string23 var urlToken string
24 24
25-// ClLI :
26 // compareGordonToken compares the token from the URL query parameter with the one from the config.yml25 // 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 err31 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 nil41 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 OAuth178 // 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.Path181 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 token190 // update the struct with the new access token
184 a.DBTables.Sessions.AccessToken = accessToken191 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 err205 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 err213 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 err220 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 err228 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 update258 // 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 token270 // 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 user289 // 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 user305 // 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, nil312+ 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 database321 // 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, nil329+ 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 database333 // 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, nil340+ 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 user344 // 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"] = true346 sess.Values["authenticated"] = true
301 sess.Values["accountID"] = accountID347 sess.Values["accountID"] = accountID
302- sess.Values["expires"] = expires348+ sess.Values["expires"] = session.Expires
303- sess.Values["sessionID"] = sessionID349+ 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 token390 // 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 info394 // 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 database23 // 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 err19+ 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 expiring47 // 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 nil56 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, err64 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), nil68 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, err75 return false, err
64 }76 }
65 77
78+ logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists)
79+
66 return accountExists, nil80 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.GithubUserInfo20+// GithubUserInfo is an alias to db.GithubUserInfo
21+type GithubUserInfo = db.GithubUserInfo
20 22
21 // middleware/require_token.go23 // 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 first37 // 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 authorized39 // 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 authorized60 // 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.Domain71 rootDomain := p.app.GetConfig().Http.Domain
71 72
72- // Use a more permissive hostpolicy that logs unknown domains rather than rejecting them73+ // 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 domain75 // 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 nil85 return nil
86 }86 }
87 87
88- // Log the attempt but still allow it - this helps debug88+ // Log the attempt and reject it
89- // certificate acquisition issues during development89+ 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 issues94+ // Return an error for domains not in our routes
96- // Change this to return an error in production for stricter security95+ 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 domain98 // Generate a fallback self-signed certificate for the admin domain
@@ -153,49 +151,226 @@ func (p *Proxy) checkCertificateInCache(domain string) bool {
153 return false151 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 validity170 // 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 false175+ 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 false183+ return false, 0
170 }184 }
171 185
172 // Check if the certificate is still valid186 // 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 false193+ 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 expire205 // 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 false216 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 true278 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 certificate374 // requestAdminCertificate preemptively requests a Let's Encrypt certificate
200 // for the Gordon admin interface375 // 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 cache408+ // 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 return432 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 cache562+ // 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 return586 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.Manager37 certManager *autocert.Manager
37 fallbackCert *tls.Certificate // Fallback self-signed certificate38 fallbackCert *tls.Certificate // Fallback self-signed certificate
38 serverStarted bool39 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 operations41 // Flag to track specific domain certificate operations
51 processingSpecificDomain bool42 processingSpecificDomain bool
@@ -61,6 +52,14 @@ type Proxy struct {
61 acmePerDomainRefreshMutex sync.Mutex52 acmePerDomainRefreshMutex sync.Mutex
62 acmeDirCache autocert.DirCache53 acmeDirCache autocert.DirCache
63 shutdown chan struct{} // Channel for signaling shutdown to background goroutines54 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 proxy65 // 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 map107 // 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 proxy113 // 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 time123+ 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 reference128 // 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 database9 // 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.Result11+ 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 database14 // 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.Rows16+ 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 database19 // 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.Tx21+ 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 logic24 // 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.Result26+ 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 proxy1 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 limiters16 // 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 context19 // 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 headers33+ // 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 HTTPS47+ 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 server50+ 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 data52+ },
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 minutes55+ 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 limiting97+ 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 config100 // 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 HTTP114+ // 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 server117+ 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 data119+ 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 minutes122+ )
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 fails127+ 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 expiration133+ },
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 limiting178+ 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 config181 // 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 request200+ // Generate a UUID for this request
219 requestID := uuid.New().String()201 requestID := uuid.New().String()
220 202
221- // Store the UUID in the context203+ // Store it in the context
222 c.Set(RequestIDKey, requestID)204 c.Set(RequestIDKey, requestID)
223 205
224- // Add the UUID as a response header206+ // 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 middleware209+ // 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 mechanism52 // 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 domain172 // 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 retry274 // 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 available293 // Get the account ID - default to the first account if available
304 var accountID string294 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 exists305 // Check if the domain exists
316 var domainID string306 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 it310 // 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 it340 // 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 string367 var existingRouteID string
372 var existingContainerID string368 var existingContainerID string
373 var existingContainerPort string369 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 it373 // 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 transaction431 // 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 routes439+ // 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 ID505 // Get the domain ID
484 var domainID string506 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 retry515 // 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 retry521 // 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 map585 // 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 database788 // Next, update the database
772 // Get the domain ID with retry789 // Get the domain ID with retry
773 var domainID string790 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 active810 // 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 name881 // 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 result885 return result
@@ -930,9 +939,39 @@ func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRoute
930 return result939 return result
931 }940 }
932 941
933-// HandleContainerRecreation handles a container that has been recreated with the same name but a new ID942+// 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 := false1147+ 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 ID1154+ // 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 changed1157+ // 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 check1162+ // 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 reliable1166+ p.mu.RLock()
1143- containerIP = verifiedIP1167+ 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 changed1173+ // 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 issues1175+ 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 changed1177+ 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 first1183+ // 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 = containerIP1186+ "container_id", containerID,
1163- r.Active = true1187+ "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- return1192+ 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- return1200+
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 directly1204+ 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 update1223+ 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 IP1245+ // 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 active1253+ 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 route1269+ // 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 checks1291+ // 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 events1296 // 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 route1388 // 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 ID1437 // First, get the domain ID
1384 var domainID string1438 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 continue1442 continue
@@ -1390,7 +1444,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1390 1444
1391 // Update the route to mark it as inactive1445 // 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 continue1450 continue
@@ -1426,9 +1480,6 @@ func (p *Proxy) DiscoverMissingRoutes() {
1426 return1480 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 network1483 // 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 domain1537 // 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 continue1541 continue
@@ -1551,93 +1602,111 @@ func (p *Proxy) LogActiveContainersAndRoutes() {
1551 // Get the admin domain1602 // 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 information1615+ 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 continue1620 continue
1581 }1621 }
1582 1622
1583- // If this container is not in the map yet, add it1623+ 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 string1626+ if _, exists := containerInfo[route.ContainerID]; !exists {
1587- IP string1627+ containerInfo[route.ContainerID] = struct {
1588- Image string1628+ ContainerIP string
1589- Domains []string1629+ ContainerID string
1590- Protocols []string1630+ Protocols []string
1591- Ports []string1631+ 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 domains1639+ 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 images1653 // 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.Image1663+ containerImages[containerID] = containerInfo.Config.Image
1619- containerMap[containerID] = containerData
1620 }1664 }
1621 }1665 }
1622 }1666 }
1623 1667
1624 // Display each container and its routes1668 // Display each container and its routes
1625- for containerID, container := range containerMap {1669+ for containerID, domains := range containerGroups {
1626 // Log container info1670 // Log container info
1627 // Safety check to prevent slice bounds panic1671 // Safety check to prevent slice bounds panic
1628 containerIDShort := containerID1672 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 container1693 // 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 proxy1892 // 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 first1903 // Call the original director first
1855 originalDirector(req)1904 originalDirector(req)
1856 1905
1857- // Handle the Host header differently based on domain type1906+ // 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 information1909 // 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 host1918 // 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 proxying1922 // 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 nil1963 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 server1 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.Config24 Config common.Config
23 DB *sql.DB25 DB *sql.DB
24 DBTables DBTables26 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.Time29 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 server1 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 nil48 return nil
17 }49 }
18 50
M internal/server/sqlite_init.go +359 -29
@@ -1,10 +1,12 @@
1 package server1 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, nil73 return true, nil
70 }74 }
71 75
72-// InitializeDB initializes the database76+// 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 = dbPath84+ 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 working105+ 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 = db130+ a.DB = memDB
105 131
106 if dbExists {132 if dbExists {
107 // Populate the struct tables from the database133 // 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, err136 return nil, err
111 }137 }
112- } else {
113- log.Debug("Initialized new database")
114 }138 }
115 139
116- return db, nil140+ // 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 file151+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 transaction453 // 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 tables459 // 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 transaction538 // 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 nil545 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 tables632 // PopulateStructWithTables updates db struct with tables
@@ -345,7 +675,7 @@ func PopulateStructWithTables(a *App) error {
345 675
346 // Populate the ProxyRoute table676 // 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 err679 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 domain34 // Protocol (http/https), Container name, Container subdomain, and Container domain
35 protocol := parsedURL.Scheme35 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 to66 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.com9+*, ::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 }

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close