detached ddc5793

chore: Update dependencies and refactor token cache handling

- Upgrade `prettier` to version 3.5.3 and `tailwindcss` to version 3.4.17 in package.json
- Refactor token cache to use the new `db.GithubUserInfo` type instead of `queries.GithubUserInfo`
- Enhance deployment command to include a `skipProxySetup` flag for optional proxy setup
- Improve error handling and logging in various components, including server shutdown and session management
- Add support for skipping proxy setup during container creation and deployment
commit
ddc5793bae6a14387af67d28898a66d577eaf6b8
author
bamen · · 2025-03-13 06:35 UTC
parent
90b5233
signature
unsigned
35 files · +2512 -1647
M internal/cli/auth/token_cache.go +4 -4
@@ -4,7 +4,7 @@ import (
44 "sync"
55 "time"
66
7- "github.com/bnema/gordon/internal/db/queries"
7+ "github.com/bnema/gordon/internal/db"
88 "github.com/charmbracelet/log"
99 )
1010
@@ -15,7 +15,7 @@ type TokenCache struct {
1515 type cacheEntry struct {
1616 token string
1717 expiration time.Time
18- githubUser *queries.GithubUserInfo
18+ githubUser *db.GithubUserInfo
1919 }
2020
2121 var (
@@ -33,7 +33,7 @@ func GetTokenCache() *TokenCache {
3333 }
3434
3535 // 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) {
3737 if value, ok := tc.cache.Load(key); ok {
3838 entry := value.(cacheEntry)
3939 if time.Now().Before(entry.expiration) {
@@ -63,7 +63,7 @@ func (tc *TokenCache) Get(key string) (bool, bool) {
6363 }
6464
6565 // 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) {
6767 tc.cache.Store(key, cacheEntry{
6868 token: token,
6969 githubUser: user,
M internal/cli/cmd/deploy.go +20 -3
@@ -25,6 +25,7 @@ import (
2525 func NewDeployCommand(a *cli.App) *cobra.Command {
2626 var port string
2727 var targetDomain string
28+ var skipProxySetup bool
2829
2930 deployCmd := &cobra.Command{
3031 Use: "deploy [image:tag]",
@@ -56,7 +57,7 @@ func NewDeployCommand(a *cli.App) *cobra.Command {
5657
5758 logger.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
5859
59- if err := deployImage(a, reader, imageName, port, targetDomain); err != nil {
60+ if err := deployImage(a, reader, imageName, port, targetDomain, skipProxySetup); err != nil {
6061 os.Exit(1)
6162 }
6263 },
@@ -64,11 +65,12 @@ func NewDeployCommand(a *cli.App) *cobra.Command {
6465
6566 deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to")
6667 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)")
6769
6870 return deployCmd
6971 }
7072
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 {
7274 // Check authentication early
7375 if err := handler.CheckAndRefreshAuth(a); err != nil {
7476 logger.Error("Authentication check failed", "error", err)
@@ -128,6 +130,12 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str
128130 "Content-Type": {"application/octet-stream"},
129131 }
130132
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+
131139 chunkedClient := handler.NewChunkedClient(a)
132140 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
133141 defer cancel()
@@ -150,13 +158,22 @@ func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain str
150158 }
151159
152160 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+
153168 client := &http.Client{Timeout: 10 * time.Second}
154169 maxRetries := 10
155170 retryInterval := time.Second
156171
157172 var shortContainerID string
158- if containerID != "" {
173+ if containerID != "" && len(containerID) >= 12 {
159174 shortContainerID = containerID[:12]
175+ } else {
176+ shortContainerID = containerID
160177 }
161178
162179 logger.Info("Waiting for deployment to be reachable",
M internal/cli/cmd/proxy.go +59 -59
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "strings"
66
7+ "github.com/bnema/gordon/internal/cli/handler"
78 "github.com/bnema/gordon/internal/proxy"
89 "github.com/bnema/gordon/internal/server"
910 "github.com/bnema/gordon/pkg/docker"
@@ -36,6 +37,9 @@ func NewProxyCommand(a *server.App) *cobra.Command {
3637 log.Fatal("Failed to initialize database:", err)
3738 }
3839
40+ // Register the server instance for global signal handling
41+ handler.SetGlobalServer(a)
42+
3943 // Create a new proxy instance
4044 p, err := proxy.NewProxy(a)
4145 if err != nil {
@@ -44,91 +48,83 @@ func NewProxyCommand(a *server.App) *cobra.Command {
4448
4549 // If update flag is set, update a container's IP address
4650 if update {
47- if domain == "" {
48- log.Fatal("Domain is required for updating a route IP")
49- }
50-
51- if containerIP == "" {
52- log.Fatal("Container IP is required for updating a route IP")
51+ if containerID == "" || containerIP == "" {
52+ log.Fatal("Container ID and IP address are required for update")
5353 }
5454
55- // If force flag is not set, try to get the IP from the container ID
56- if !force && containerID != "" {
57- // Get container info to verify the IP
58- containerInfo, err := docker.GetContainerInfo(containerID)
59- if err != nil {
60- log.Warn("Failed to get container info, using provided IP", "error", err)
61- } else {
62- // Look for the network specified in the config
63- networkName := a.Config.ContainerEngine.Network
64- if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
65- if networkSettings.IPAddress != containerIP {
66- log.Warn("Provided IP doesn't match container's actual IP",
67- "provided", containerIP,
68- "actual", networkSettings.IPAddress)
69- if !force {
70- log.Info("Using actual container IP instead of provided IP",
71- "container_id", containerID,
72- "new_ip", networkSettings.IPAddress)
73- containerIP = networkSettings.IPAddress
74- } else {
75- log.Warn("Force flag is set, using provided IP despite mismatch")
76- }
55+ // Get container info to verify the IP
56+ containerInfo, err := docker.GetContainerInfo(containerID)
57+ if err != nil {
58+ log.Warn("Failed to get container info, using provided IP", "error", err)
59+ } else {
60+ // Look for the network specified in the config
61+ networkName := a.Config.ContainerEngine.Network
62+ if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
63+ if networkSettings.IPAddress != containerIP {
64+ log.Warn("Provided IP doesn't match container's actual IP",
65+ "provided", containerIP,
66+ "actual", networkSettings.IPAddress)
67+ if !force {
68+ log.Info("Using actual container IP instead of provided IP",
69+ "container_id", containerID,
70+ "new_ip", networkSettings.IPAddress)
71+ containerIP = networkSettings.IPAddress
72+ } else {
73+ log.Warn("Force flag is set, using provided IP despite mismatch")
7774 }
7875 }
7976 }
8077 }
8178
8279 // Update the route
83- err := p.ForceUpdateRouteIP(domain, containerIP)
80+ err = p.ForceUpdateRouteIP(domain, containerIP)
8481 if err != nil {
8582 log.Fatal("Failed to update route IP:", err)
8683 }
8784
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+ }
8991 return
9092 }
9193
92- // If remove flag is set, remove the route
94+ // If remove flag is set, remove a route
9395 if remove {
9496 if domain == "" {
95- log.Fatal("Domain is required for removing a route")
97+ log.Fatal("Domain is required for removal")
9698 }
97-
9899 if err := p.RemoveRoute(domain); err != nil {
99100 log.Fatal("Failed to remove route:", err)
100101 }
102+ log.Info("Route removed successfully")
101103
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+ }
103108 return
104109 }
105110
106- // If domain is set, add a new route
107- if domain != "" {
108- // Validate required fields
109- if containerID == "" || containerIP == "" || containerPort == "" {
110- log.Fatal("Container ID, IP, and port are required for adding a route")
111+ // If domain and containerID are provided, add a route
112+ if domain != "" && containerID != "" {
113+ if containerPort == "" {
114+ log.Fatal("Container port is required")
111115 }
112-
113- // Set defaults for optional fields
114116 if protocol == "" {
115117 protocol = "http"
116118 }
117-
118- if path == "" {
119- path = "/"
120- }
121-
122- // Add the route
123119 if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil {
124120 log.Fatal("Failed to add route:", err)
125121 }
122+ log.Info("Route added successfully")
126123
127- log.Info("Route added successfully",
128- "domain", domain,
129- "containerIP", containerIP,
130- "containerPort", containerPort,
131- )
124+ // Close the database connection
125+ if err := a.Shutdown(); err != nil {
126+ log.Error("Error closing database", "error", err)
127+ }
132128 return
133129 }
134130
@@ -179,19 +175,23 @@ func NewProxyCommand(a *server.App) *cobra.Command {
179175
180176 fmt.Println(strings.Repeat("-", 75))
181177 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+ }
182183 },
183184 }
184185
185- // Add flags
186- proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name for the route")
186+ proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name")
187187 proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID")
188188 proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address")
189189 proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port")
190- proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)")
191- proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route")
192- proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain")
193- proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update the IP address for an existing route")
194- proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force update with provided IP even if it doesn't match container's actual IP")
190+ proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "", "Protocol (http or https)")
191+ proxyCmd.Flags().StringVarP(&path, "path", "a", "", "Path prefix")
192+ proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove a route")
193+ proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update a container's IP address")
194+ proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force removal of a route")
195195
196196 return proxyCmd
197197 }
M internal/cli/cmd/serve.go +6 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/gordon/internal/common"
1010 "github.com/bnema/gordon/internal/server"
1111 "github.com/bnema/gordon/pkg/docker"
12+ "github.com/bnema/gordon/pkg/logger"
1213 "github.com/spf13/cobra"
1314 )
1415
@@ -40,6 +41,11 @@ func NewServeCommand(a *server.App) *cobra.Command {
4041 a.Config.Http.Port = port
4142 err := handler.StartServer(a, port)
4243 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+ }
4349 panic(err)
4450 }
4551 },
M internal/cli/handler/start_server.go +63 -20
@@ -1,9 +1,11 @@
11 package handler
22
33 import (
4+ "context"
45 "fmt"
56 "os"
67 "os/signal"
8+ "sync"
79 "syscall"
810 "time"
911
@@ -13,9 +15,32 @@ import (
1315 "github.com/labstack/echo/v4"
1416 )
1517
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+
1638 // execute cmd/srv/main.go main function
1739
1840 func StartServer(a *server.App, port string) error {
41+ // Set the global server instance for shutdown handling
42+ SetGlobalServer(a)
43+
1944 _, err := server.InitializeDB(a)
2045 if err != nil {
2146 logger.Fatal("Failed to initialize database:", err)
@@ -36,33 +61,27 @@ func StartServer(a *server.App, port string) error {
3661 // Continue even if proxy fails, as the main server should still work
3762 }
3863
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+
3971 // Setup a channel to capture termination signals
4072 sigs := make(chan os.Signal, 1)
4173 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
4274
75+ // Start server in a goroutine
4376 go func() {
44- sig := <-sigs
45- logger.Info("Received signal", "signal", sig)
46-
47- // Stop the proxy if it was started
48- if p != nil {
49- if err := p.Stop(); err != nil {
50- logger.Error("Failed to stop reverse proxy", "error", err)
77+ logger.Info("Starting server", "port", port)
78+ if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
79+ if err.Error() != "http: Server closed" {
80+ logger.Error("Server error", "error", err)
5181 }
5282 }
53-
54- os.Exit(0)
5583 }()
5684
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-
6685 // Test admin connections after the server has started (in a separate goroutine)
6786 if p != nil {
6887 go func() {
@@ -72,9 +91,33 @@ func StartServer(a *server.App, port string) error {
7291 }()
7392 }
7493
75- if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
76- logger.Fatal("Server error", "error", err)
94+ // Wait for interrupt signal
95+ <-sigs
96+ logger.Info("Received shutdown signal")
97+
98+ // Create a deadline for graceful shutdown
99+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
100+ defer cancel()
101+
102+ // Gracefully shutdown the server
103+ logger.Info("Shutting down server...")
104+ if err := e.Shutdown(ctx); err != nil {
105+ logger.Error("Server shutdown error", "error", err)
106+ }
107+
108+ // Perform application shutdown (including database closure)
109+ if err := a.Shutdown(); err != nil {
110+ logger.Error("Application shutdown error", "error", err)
111+ }
112+
113+ // Stop the proxy if it was started
114+ if p != nil {
115+ logger.Info("Stopping reverse proxy...")
116+ if err := p.Stop(); err != nil {
117+ logger.Error("Failed to stop reverse proxy", "error", err)
118+ }
77119 }
78120
121+ logger.Info("Shutdown complete")
79122 return nil
80123 }
M internal/common/init_config.go +60 -30
@@ -73,16 +73,17 @@ type ContainerEngineConfig struct {
7373 }
7474
7575 type ReverseProxyConfig struct {
76- Port string `yaml:"port"` // Port for the reverse proxy to listen on
77- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
78- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
79- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
80- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
82- Email string `yaml:"email"` // Email for Let's Encrypt
83- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
84- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
85- EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)
76+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
77+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
78+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
79+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
80+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
81+ LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"
82+ Email string `yaml:"email"` // Email for Let's Encrypt
83+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
84+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
85+ EnableLogs bool `yaml:"enableLogs"` // Whether to enable HTTP request logging (default: true)
86+ DisableRateLimit bool `yaml:"disableRateLimit"` // Whether to disable rate limiting middleware (default: false)
8687 }
8788
8889 // Default values
@@ -98,6 +99,7 @@ var (
9899 cacheSize = 1000 // entries
99100 gracePeriod = 30 // seconds
100101 defaultLogLevel = "info" // Default log level
102+ disableRateLimit = false
101103 )
102104
103105 // applyDefaultsToConfig applies default values to any fields that have zero values
@@ -154,6 +156,12 @@ func applyDefaultsToConfig(config *Config) bool {
154156 logger.Debug("Applied default value for ReverseProxy.EnableLogs", "value", true)
155157 defaultsApplied = true
156158 }
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+ }
157165
158166 // Handle autoRenew
159167 configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml")
@@ -524,6 +532,15 @@ func loadConfigFromEnv(config *Config, printLogs bool) {
524532 }
525533 logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS", "value", config.ReverseProxy.EnableLogs)
526534 }
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+ }
527544 }
528545
529546 func (config *Config) LoadConfig() (*Config, error) {
@@ -570,15 +587,16 @@ func (config *Config) LoadConfig() (*Config, error) {
570587 Network: "gordon",
571588 },
572589 ReverseProxy: ReverseProxyConfig{
573- Port: reverseProxyPort,
574- HttpPort: httpPort,
575- CertDir: certDir,
576- AutoRenew: autoRenew,
577- RenewBefore: renewBefore,
578- LetsEncryptMode: letsEncryptMode,
579- CacheSize: cacheSize,
580- GracePeriod: gracePeriod,
581- EnableLogs: true,
590+ Port: reverseProxyPort,
591+ HttpPort: httpPort,
592+ CertDir: certDir,
593+ AutoRenew: autoRenew,
594+ RenewBefore: renewBefore,
595+ LetsEncryptMode: letsEncryptMode,
596+ CacheSize: cacheSize,
597+ GracePeriod: gracePeriod,
598+ EnableLogs: true,
599+ DisableRateLimit: false,
582600 },
583601 }
584602
@@ -708,15 +726,16 @@ func (config *Config) LoadConfig() (*Config, error) {
708726 Network: "gordon",
709727 },
710728 ReverseProxy: ReverseProxyConfig{
711- Port: reverseProxyPort,
712- HttpPort: httpPort,
713- CertDir: certDir,
714- AutoRenew: autoRenew,
715- RenewBefore: renewBefore,
716- LetsEncryptMode: letsEncryptMode,
717- CacheSize: cacheSize,
718- GracePeriod: gracePeriod,
719- EnableLogs: true,
729+ Port: reverseProxyPort,
730+ HttpPort: httpPort,
731+ CertDir: certDir,
732+ AutoRenew: autoRenew,
733+ RenewBefore: renewBefore,
734+ LetsEncryptMode: letsEncryptMode,
735+ CacheSize: cacheSize,
736+ GracePeriod: gracePeriod,
737+ EnableLogs: true,
738+ DisableRateLimit: false,
720739 },
721740 }
722741
@@ -890,10 +909,21 @@ func (c *HttpConfig) Protocol() string {
890909 }
891910
892911 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+
893921 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)
895925 }
896- return c.Domain
926+ return domain
897927 }
898928
899929 // GetVersion
M internal/common/payload.go +6 -5
@@ -55,11 +55,12 @@ type StartResponse struct {
5555
5656 // DeployPayload represents the payload for deployment requests
5757 type DeployPayload struct {
58- Port string `json:"port"`
59- TargetDomain string `json:"targetdomain"`
60- ImageName string `json:"imagename"`
61- ImageID string `json:"imageid"`
62- Data io.ReadCloser
58+ Port string `json:"port"`
59+ TargetDomain string `json:"targetdomain"`
60+ ImageName string `json:"imagename"`
61+ ImageID string `json:"imageid"`
62+ SkipProxySetup bool `json:"skip_proxy_setup"`
63+ Data io.ReadCloser
6364 }
6465
6566 // PushPayload represents the payload for push requests
M internal/db/db_struct.go +9 -0
@@ -1,5 +1,6 @@
11 package db
22
3+// User struct model
34 type User struct {
45 ID string `sql:"id, primary_key"`
56 Name string `sql:"name"`
@@ -77,3 +78,11 @@ type ProxyRoute struct {
7778 CreatedAt string `sql:"created_at"`
7879 UpdatedAt string `sql:"updated_at"`
7980 }
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 @@
11 package queries
22
33 import (
4+ "database/sql"
45 "github.com/bnema/gordon/internal/db"
5- "github.com/bnema/gordon/internal/server"
66 )
77
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) {
926 account := &db.Account{
10- ID: generateUUID(),
27+ ID: generateUUID(),
28+ UserID: userID,
1129 }
1230
13- _, err := a.DB.Exec(
14- "INSERT INTO account (id, user_id) VALUES (?, ?)",
15- account.ID, a.DBTables.User.ID,
31+ _, err := database.Exec(
32+ NewAccountQueries().InsertAccount,
33+ account.ID, userID,
1634 )
35+
1736 return account, err
1837 }
1938
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) {
2141 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+
2348 if err != nil {
2449 return false, err
2550 }
51+
2652 return exists, nil
2753 }
2854
29-func PopulateAccountFromDB(a *server.App) error {
30- rows, err := a.DB.Query("SELECT id, user_id FROM account")
31- if err != nil {
32- return err
55+// GetDBAccountByID gets an account from the database by ID
56+func GetDBAccountByID(database *sql.DB, accountID string) (*db.Account, error) {
57+ account := &db.Account{
58+ ID: accountID,
3359 }
34- defer rows.Close()
35-
36- for rows.Next() {
37- account := db.Account{}
38- err := rows.Scan(&account.ID, &account.UserID)
39- if err != nil {
40- return err
41- }
42-
43- a.DBTables.Account = account
60+
61+ err := database.QueryRow(
62+ NewAccountQueries().GetAccountByID,
63+ accountID,
64+ ).Scan(&account.ID, &account.UserID)
65+
66+ if err != nil {
67+ return nil, err
4468 }
45-
46- return rows.Err()
69+
70+ return account, nil
4771 }
M internal/db/queries/inspectdb.go +6 -7
@@ -1,16 +1,14 @@
11 package queries
22
33 import (
4+ "database/sql"
45 "fmt"
5-
6- "github.com/bnema/gordon/internal/server"
76 )
87
98 // InspectInMemoryDB inspects the in-memory database. (for debug purpose)
10-func InspectInMemoryDB(a *server.App) error {
11- memDb := a.DB
9+func InspectInMemoryDB(database *sql.DB) error {
1210 // 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'")
1412 fmt.Println("rows", rows)
1513 if err != nil {
1614 return err
@@ -36,7 +34,7 @@ func InspectInMemoryDB(a *server.App) error {
3634 fmt.Println(table)
3735 }
3836 // Print the content of the users table
39- userRows, err := memDb.Query("SELECT * FROM user")
37+ userRows, err := database.Query("SELECT * FROM user")
4038 if err != nil {
4139 return err
4240 }
@@ -54,10 +52,11 @@ func InspectInMemoryDB(a *server.App) error {
5452 }
5553
5654 // Print the content of accounts table
57- accountRows, err := memDb.Query("SELECT * FROM account")
55+ accountRows, err := database.Query("SELECT * FROM account")
5856 if err != nil {
5957 return err
6058 }
59+ defer accountRows.Close()
6160
6261 fmt.Println("\nContent of the 'account' table:")
6362 for accountRows.Next() {
M internal/db/queries/provider.go +91 -24
@@ -1,41 +1,108 @@
11 package queries
22
33 import (
4+ "database/sql"
45 "github.com/bnema/gordon/internal/db"
5- "github.com/bnema/gordon/internal/server"
66 )
77
8-func createDBGitHubProvider(a *server.App, userInfo *GithubUserInfo) error {
9- provider := &db.Provider{
10- ID: generateUUID(),
11- Email: userInfo.Emails[0],
12- Login: userInfo.Login,
13- AvatarURL: userInfo.AvatarURL,
14- ProfileURL: userInfo.ProfileURL,
15- Name: "GitHub",
8+// ProviderQueries contains all SQL queries for provider operations
9+type ProviderQueries struct {
10+ InsertProvider string
11+ PopulateProviderFromDB string
12+ CheckProviderExists string
13+ GetProviderByAccountLogin string
14+}
15+
16+// NewProviderQueries returns a new instance of ProviderQueries
17+func NewProviderQueries() *ProviderQueries {
18+ return &ProviderQueries{
19+ InsertProvider: "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)",
20+ PopulateProviderFromDB: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE account_id = ?",
21+ CheckProviderExists: "SELECT EXISTS(SELECT 1 FROM provider WHERE login = ? AND email = ?)",
22+ GetProviderByAccountLogin: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE login = ? AND email = ?",
1623 }
24+}
1725
18- query := "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)"
19- _, err := a.DB.Exec(query, provider.ID, a.DBTables.Account.ID, provider.Name, provider.Login, provider.AvatarURL, provider.ProfileURL, provider.Email)
26+// CreateDBGitHubProvider creates a new GitHub provider in the database.
27+func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.GithubUserInfo) error {
28+ providerID := generateUUID()
29+
30+ _, err := database.Exec(
31+ NewProviderQueries().InsertProvider,
32+ providerID,
33+ accountID,
34+ "github",
35+ userInfo.Login,
36+ userInfo.AvatarURL,
37+ userInfo.ProfileURL,
38+ userInfo.Emails[0],
39+ )
40+
2041 return err
2142 }
2243
23-func PopulateProviderFromDB(a *server.App) error {
24- rows, err := a.DB.Query("SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider")
44+// PopulateDBProviderFromDB populates provider information from the database.
45+func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider, error) {
46+ provider := &db.Provider{}
47+
48+ err := database.QueryRow(
49+ NewProviderQueries().PopulateProviderFromDB,
50+ accountID,
51+ ).Scan(
52+ &provider.ID,
53+ &provider.AccountID,
54+ &provider.Name,
55+ &provider.Login,
56+ &provider.AvatarURL,
57+ &provider.ProfileURL,
58+ &provider.Email,
59+ )
60+
2561 if err != nil {
26- return err
62+ return nil, err
2763 }
28- defer rows.Close()
29-
30- for rows.Next() {
31- provider := db.Provider{}
32- err := rows.Scan(&provider.ID, &provider.AccountID, &provider.Name, &provider.Login, &provider.AvatarURL, &provider.ProfileURL, &provider.Email)
33- if err != nil {
34- return err
35- }
64+
65+ return provider, nil
66+}
3667
37- a.DBTables.Provider = provider
68+// CheckDBProviderExists checks if a provider exists in the database.
69+func CheckDBProviderExists(database *sql.DB, login string, email string) (bool, error) {
70+ var exists bool
71+
72+ err := database.QueryRow(
73+ NewProviderQueries().CheckProviderExists,
74+ login,
75+ email,
76+ ).Scan(&exists)
77+
78+ if err != nil {
79+ return false, err
3880 }
81+
82+ return exists, nil
83+}
3984
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
41108 }
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 (
66 "time"
77
88 "github.com/bnema/gordon/internal/db"
9- "github.com/bnema/gordon/internal/server"
9+ "github.com/bnema/gordon/pkg/logger"
1010 )
1111
12-// createSession creates a session for the user.
13-func createDBSession(a *server.App, browserInfo string, accessToken string, accountID string) error {
14- expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
15- sessions := &db.Sessions{
16- ID: generateUUID(),
12+// SessionQueries contains all SQL queries for session operations
13+type SessionQueries struct {
14+ InsertSession string
15+ GetSessionByToken string
16+ UpdateSessionToken string
17+ DeleteSession string
18+ GetSessionExpiry string
19+ UpdateSessionExpiry string
20+ CheckSessionExists string
21+ InvalidateSession string
22+}
23+
24+// NewSessionQueries returns a new instance of SessionQueries
25+func NewSessionQueries() *SessionQueries {
26+ return &SessionQueries{
27+ InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)",
28+ GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?",
29+ UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?",
30+ DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?",
31+ GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?",
32+ UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?",
33+ CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)",
34+ InvalidateSession: "UPDATE sessions SET is_online = ? WHERE id = ?",
1735 }
36+}
1837
19- query := "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_online) VALUES (?, ?, ?, ?, ?, ?)"
20- _, err := a.DB.Exec(query, sessions.ID, accountID, accessToken, browserInfo, expireTime, true)
38+// CreateDBSession creates a session for the user.
39+func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, accountID string) (*db.Sessions, error) {
40+ expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
41+ sessionID := generateUUID()
42+
43+ _, err := database.Exec(
44+ NewSessionQueries().InsertSession,
45+ sessionID, accountID, accessToken, browserInfo, expireTime, true,
46+ )
47+
2148 if err != nil {
22- return err
49+ logger.Error("Failed to create session in database", "error", err, "session_id", sessionID, "account_id", accountID)
50+ return nil, err
2351 }
24-
25- // Update global state here if really necessary
26- a.DBTables.Sessions.ID = sessions.ID
27- a.DBTables.Sessions.AccessToken = accessToken
28- a.DBTables.Sessions.Expires = expireTime
29-
30- return nil
52+
53+ // Create a Sessions struct to return
54+ session := &db.Sessions{
55+ ID: sessionID,
56+ AccountID: accountID,
57+ AccessToken: accessToken,
58+ BrowserInfo: browserInfo,
59+ Expires: expireTime,
60+ IsOnline: true,
61+ }
62+
63+ logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)
64+ return session, nil
3165 }
3266
3367 // GetDBUserSession gets the user session from the database based on the access token and browser info.
34-func GetDBUserSession(a *server.App, accessToken string, browserInfo string) (*db.Sessions, error) {
35- query := "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_online FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?"
36- err := a.DB.QueryRow(query, accessToken, browserInfo).Scan(&a.DBTables.Sessions.ID, &a.DBTables.Sessions.AccountID, &a.DBTables.Sessions.AccessToken, &a.DBTables.Sessions.BrowserInfo, &a.DBTables.Sessions.Expires, &a.DBTables.Sessions.IsOnline)
68+func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string) (*db.Sessions, error) {
69+ session := &db.Sessions{}
70+
71+ err := database.QueryRow(
72+ NewSessionQueries().GetSessionByToken,
73+ accessToken,
74+ browserInfo,
75+ ).Scan(
76+ &session.ID,
77+ &session.AccountID,
78+ &session.AccessToken,
79+ &session.BrowserInfo,
80+ &session.Expires,
81+ &session.IsOnline,
82+ )
83+
3784 if err != nil {
3885 if err == sql.ErrNoRows {
3986 return nil, nil
4087 }
88+ logger.Warn("Failed to get session", "error", err)
4189 return nil, fmt.Errorf("error getting session: %w", err)
4290 }
4391
44- return &a.DBTables.Sessions, nil
92+ logger.Debug("Session retrieved from database", "session", session)
93+
94+ return session, nil
4595 }
4696
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 {
4899 expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
49- query := "UPDATE sessions SET access_token = ?, expires = ? WHERE id = ?"
50- _, err := a.DB.Exec(query, accessToken, expireTime, a.DBTables.Sessions.ID)
100+
101+ _, err := database.Exec(
102+ NewSessionQueries().UpdateSessionToken,
103+ accessToken,
104+ expireTime,
105+ sessionID,
106+ )
107+
51108 if err != nil {
109+ logger.Error("Failed to update session in database", "error", err, "session_id", sessionID)
52110 return err
53111 }
54-
112+
113+ logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime)
55114 return nil
56115 }
57116
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 {
59119 // 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)
61121 if err != nil && err != sql.ErrNoRows {
122+ logger.Warn("Failed to check for session", "error", err)
62123 return fmt.Errorf("error checking for session: %w", err)
63124 }
64125
65126 // If the session exists, update the session
66127 if session != nil {
67- err := updateDBSession(a, accessToken)
128+ err := UpdateDBSession(database, session.ID, accessToken)
68129 if err != nil {
130+ logger.Warn("Failed to update session", "error", err)
69131 return fmt.Errorf("error updating session: %w", err)
70132 }
71- }
72-
73- // If the session does not exist, create the session
74- if session == nil {
75- err := createDBSession(a, browserInfo, accessToken, a.DBTables.Account.ID)
133+ logger.Debug("Session updated in database", "accountID", accountID, "sessionID", session.ID)
134+ } else {
135+ // If the session does not exist, create the session
136+ _, err := CreateDBSession(database, browserInfo, accessToken, accountID)
76137 if err != nil {
138+ logger.Warn("Failed to create session", "error", err)
77139 return fmt.Errorf("error creating session: %w", err)
78140 }
141+ logger.Debug("New session created in database", "accountID", accountID)
79142 }
80143
81144 return nil
82145 }
83146
84-// DeleteSession deletes the session from the database.
85-func DeleteDBSession(a *server.App, accountID string, sessionID string) error {
86- query := "DELETE FROM sessions WHERE account_id = ? AND id = ?"
87- _, err := a.DB.Exec(query, accountID, sessionID)
147+// DeleteDBSession deletes the session from the database.
148+func DeleteDBSession(database *sql.DB, accountID string, sessionID string) error {
149+ _, err := database.Exec(
150+ NewSessionQueries().DeleteSession,
151+ accountID,
152+ sessionID,
153+ )
154+
88155 if err != nil {
156+ logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID)
89157 return err
90158 }
91-
159+
160+ logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID)
92161 return nil
93162 }
94163
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) {
96166 var expirationTime string
97- // with userID and sessionID we can get the expiration time
98- query := "SELECT expires FROM sessions WHERE account_id = ? AND id = ?"
99- err := a.DB.QueryRow(query, accountID, sessionID).Scan(&expirationTime)
167+
168+ err := database.QueryRow(
169+ NewSessionQueries().GetSessionExpiry,
170+ accountID,
171+ sessionID,
172+ ).Scan(&expirationTime)
173+
100174 if err != nil {
101175 return currentTime, err
102176 }
@@ -107,36 +181,58 @@ func GetSessionExpiration(a *server.App, accountID string, sessionID string, cur
107181 return currentTime, err
108182 }
109183
184+ logger.Debug("Session expiration time retrieved", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTimeParsed)
185+
110186 return expirationTimeParsed, nil
111187 }
112188
113-func ExtendSessionExpiration(a *server.App, accountID string, sessionID string, newExpirationTime time.Time) error {
114- query := "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?"
115- _, err := a.DB.Exec(query, newExpirationTime.Format(time.RFC3339), accountID, sessionID)
189+// ExtendSessionExpiration extends the expiration time for a session
190+func ExtendSessionExpiration(database *sql.DB, accountID string, sessionID string, newExpirationTime time.Time) error {
191+ _, err := database.Exec(
192+ NewSessionQueries().UpdateSessionExpiry,
193+ newExpirationTime.Format(time.RFC3339),
194+ accountID,
195+ sessionID,
196+ )
197+
116198 if err != nil {
199+ logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID)
117200 return err
118201 }
119-
202+
203+ logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime)
120204 return nil
121205 }
122206
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) {
124209 var sessionExists bool
125- query := "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)"
126- err := a.DB.QueryRow(query, sessionID).Scan(&sessionExists)
210+ err := database.QueryRow(
211+ NewSessionQueries().CheckSessionExists,
212+ sessionID,
213+ ).Scan(&sessionExists)
214+
127215 if err != nil {
128216 return false, err
129217 }
130218
219+ logger.Debug("Session existence checked", "sessionID", sessionID, "exists", sessionExists)
131220 return sessionExists, nil
132221 }
133222
134-func InvalidateDBSession(a *server.App, sessionID string) error {
135- query := "UPDATE sessions SET is_online = ? WHERE id = ?"
136- _, err := a.DB.Exec(query, false, sessionID)
223+// InvalidateDBSession marks a session as offline
224+func InvalidateDBSession(database *sql.DB, sessionID string) error {
225+ _, err := database.Exec(
226+ NewSessionQueries().InvalidateSession,
227+ false,
228+ sessionID,
229+ )
230+
137231 if err != nil {
232+ logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID)
138233 return err
139234 }
140-
235+
236+ logger.Debug("Session invalidated", "sessionID", sessionID)
141237 return nil
142238 }
M internal/db/queries/user.go +112 -52
@@ -3,9 +3,10 @@ package queries
33 import (
44 "database/sql"
55 "fmt"
6+ "time"
67
78 "github.com/bnema/gordon/internal/db"
8- "github.com/bnema/gordon/internal/server"
9+ "github.com/bnema/gordon/pkg/logger"
910 "github.com/google/uuid"
1011 )
1112
@@ -13,65 +14,87 @@ func generateUUID() string {
1314 return uuid.New().String()
1415 }
1516
16-// UserInfo holds the essential information for a Github user.
17-type GithubUserInfo struct {
18- Login string `json:"login"`
19- AvatarURL string `json:"avatar_url"`
20- ProfileURL string `json:"html_url"`
21- Emails []string
17+// UserQueries contains all SQL queries for user operations
18+type UserQueries struct {
19+ InsertUser string
20+ CheckUserExists string
21+ GetUserByProvider string
22+ GetAccountCount string
23+}
24+
25+// NewUserQueries returns a new instance of UserQueries
26+func NewUserQueries() *UserQueries {
27+ return &UserQueries{
28+ InsertUser: "INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
29+ CheckUserExists: "SELECT id FROM user",
30+ GetUserByProvider: "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?",
31+ GetAccountCount: "SELECT COUNT(*) FROM account",
32+ }
2233 }
2334
2435 // 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) {
2637 // Check if a user already exists. If so, return an error.
27- if exists, err := CheckDBUserExists(a); err != nil || exists {
28- return fmt.Errorf("error checking user or user already exists: %v", err)
38+ logger.Debug("Starting CreateUser database function")
39+ if exists, err := CheckDBUserExists(database); err != nil || exists {
40+ logger.Warn("User already exists or error checking", "error", err, "exists", exists)
41+ return nil, "", nil, fmt.Errorf("error checking user or user already exists: %v", err)
2942 }
3043
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)
3246 if err != nil {
33- return err
47+ logger.Error("Failed to create database user", "error", err)
48+ return nil, "", nil, err
3449 }
50+ logger.Info("Database user created successfully", "user_id", user.ID)
3551
36- // update the global state
37- a.DBTables.User.ID = user.ID
38- a.DBTables.User.Name = user.Name
39- a.DBTables.User.Email = user.Email
40-
41- account, err := createDBAccount(a)
52+ logger.Debug("Creating database account for user", "user_id", user.ID)
53+ account, err := CreateDBAccount(database, user.ID)
4254 if err != nil {
43- return err
55+ logger.Error("Failed to create database account", "error", err)
56+ return nil, "", nil, err
4457 }
58+ logger.Info("Database account created successfully", "account_id", account.ID)
4559
46- // update the global state
47- a.DBTables.Account.ID = account.ID
48- a.DBTables.Account.UserID = account.UserID
60+ logger.Debug("Creating GitHub provider for account", "account_id", account.ID)
61+ err = CreateDBGitHubProvider(database, account.ID, userInfo)
62+ if err != nil {
63+ logger.Error("Failed to create GitHub provider", "error", err)
64+ return nil, "", nil, err
65+ }
66+ logger.Info("GitHub provider created successfully")
4967
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)
5170 if err != nil {
52- return err
71+ logger.Error("Failed to create session", "error", err)
72+ return nil, "", nil, err
5373 }
74+ logger.Info("Session created successfully")
5475
55- return createDBSession(a, browserInfo, accessToken, account.ID)
76+ return user, account.ID, session, nil
5677 }
5778
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) {
5981 user := &db.User{
6082 ID: generateUUID(),
6183 Name: userInfo.Login,
6284 Email: userInfo.Emails[0],
6385 }
6486
65- _, err := a.DB.Exec(
66- "INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
87+ _, err := database.Exec(
88+ NewUserQueries().InsertUser,
6789 user.ID, user.Name, user.Email,
6890 )
6991 return user, err
7092 }
7193
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) {
7396 var userID string
74- err := a.DB.QueryRow("SELECT id FROM user").Scan(&userID)
97+ err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&userID)
7598 if err != nil {
7699 if err == sql.ErrNoRows {
77100 return false, nil
@@ -86,50 +109,87 @@ func CheckDBUserExists(a *server.App) (bool, error) {
86109 return false, nil
87110 }
88111
89-// If there is already a user, we compare the login and email to see if it is the same user
90-func CheckDBUserIsGood(a *server.App, userInfo *GithubUserInfo) (bool, error) {
112+// CheckDBUserIsGood checks if the user matches the provided GitHub info
113+func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) {
91114 login := userInfo.Login
92115 email := userInfo.Emails[0]
93116
117+ user := &db.User{}
118+
94119 // Check if the user exists based on github login and email
95- query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?"
96- err := a.DB.QueryRow(query, login, email).Scan(&a.DBTables.User.ID, &a.DBTables.User.Name, &a.DBTables.User.Email)
120+ err := database.QueryRow(NewUserQueries().GetUserByProvider, login, email).Scan(
121+ &user.ID, &user.Name, &user.Email,
122+ )
123+
97124 if err != nil {
98125 if err == sql.ErrNoRows {
99- return false, nil
126+ return nil, false, nil
100127 }
101- return false, err
128+ return nil, false, err
102129 }
103130
104- if a.DBTables.User.ID != "" {
105- return true, nil
131+ if user.ID != "" {
132+ return user, true, nil
106133 }
107134
108- return false, nil
135+ return nil, false, nil
109136 }
110137
111-func UpdateUser(a *server.App, accessToken string, browserInfo string, userInfo *GithubUserInfo) (*db.User, error) {
112-
113- err := CreateOrUpdateDBSession(a, accessToken, browserInfo)
138+// UpdateUser updates the user session with new access token
139+func UpdateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
140+ // First, get the user info to get their account ID
141+ user, isGood, err := CheckDBUserIsGood(database, userInfo)
114142 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)
116144 }
117-
118- user := a.DBTables.User
119-
120- query := "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?"
121- err = a.DB.QueryRow(query, userInfo.Login, userInfo.Emails[0]).Scan(&user.ID, &user.Name, &user.Email)
145+
146+ if !isGood {
147+ return nil, "", nil, fmt.Errorf("user not found in database")
148+ }
149+
150+ // Get the account ID for this user
151+ var accountID string
152+ err = database.QueryRow("SELECT id FROM account WHERE user_id = ?", user.ID).Scan(&accountID)
122153 if err != nil {
123- return nil, err
154+ return nil, "", nil, fmt.Errorf("error getting account ID: %w", err)
155+ }
156+
157+ // Check if the session exists using the access token and browser info
158+ existingSession, err := GetDBUserSession(database, accessToken, browserInfo)
159+ if err != nil && err != sql.ErrNoRows {
160+ return nil, "", nil, fmt.Errorf("error checking for session: %w", err)
124161 }
125162
126- return &user, nil
163+ var session *db.Sessions
164+
165+ // If the session exists, update the session
166+ if existingSession != nil {
167+ expiresTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
168+ err := UpdateDBSession(database, existingSession.ID, accessToken)
169+ if err != nil {
170+ return nil, "", nil, fmt.Errorf("error updating session: %w", err)
171+ }
172+ // Update the session struct with new values
173+ existingSession.AccessToken = accessToken
174+ existingSession.Expires = expiresTime
175+ session = existingSession
176+ logger.Debug("Session updated in database", "accountID", accountID, "sessionID", session.ID)
177+ } else {
178+ // If the session does not exist, create the session
179+ session, err = CreateDBSession(database, browserInfo, accessToken, accountID)
180+ if err != nil {
181+ return nil, "", nil, fmt.Errorf("could not create session: %w", err)
182+ }
183+ logger.Debug("New session created in database", "accountID", accountID, "sessionID", session.ID)
184+ }
127185
186+ return user, accountID, session, nil
128187 }
129188
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) {
131191 var count int
132- err := a.DB.QueryRow("SELECT COUNT(*) FROM account").Scan(&count)
192+ err := database.QueryRow(NewUserQueries().GetAccountCount).Scan(&count)
133193 if err != nil {
134194 return 0, err
135195 }
M internal/httpserve/handlers/container_crud.go +35 -14
@@ -189,21 +189,42 @@ func CreateContainerPOST(c echo.Context, a *server.App) error {
189189 containerName = strings.TrimPrefix(containerName, "/")
190190 }
191191
192- // Get container IP
193- containerIP := GetContainerIP(a, containerID, containerName)
194-
195- // Construct target domain from form inputs
196- targetDomain := fmt.Sprintf("%s://%s.%s",
197- sanitizedInputs["container_protocol"],
198- sanitizedInputs["container_subdomain"],
199- sanitizedInputs["container_domain"])
192+ // Check if we should skip proxy setup
193+ skipProxySetup := false
194+ if skipProxyValue, exists := sanitizedInputs["skip_proxy_setup"]; exists {
195+ skipProxySetup = skipProxyValue == "true" || skipProxyValue == "1" || skipProxyValue == "yes"
196+ }
197+
198+ // Only set up proxy if not skipped
199+ if !skipProxySetup {
200+ // Get container IP
201+ containerIP := GetContainerIP(a, containerID, containerName)
202+
203+ // Construct target domain from form inputs
204+ var targetDomain string
205+ if sanitizedInputs["container_subdomain"] == "" {
206+ // If subdomain is empty, use just the domain
207+ targetDomain = fmt.Sprintf("%s://%s",
208+ sanitizedInputs["container_protocol"],
209+ sanitizedInputs["container_domain"])
210+ } else {
211+ // If subdomain is provided, use subdomain.domain format
212+ targetDomain = fmt.Sprintf("%s://%s.%s",
213+ sanitizedInputs["container_protocol"],
214+ sanitizedInputs["container_subdomain"],
215+ sanitizedInputs["container_domain"])
216+ }
200217
201- // Add proxy route for the new container
202- err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["proxy_port"], targetDomain)
203- if err != nil {
204- log.Error("Failed to add proxy route", "error", err)
205- // Continue anyway, don't fail the whole operation
218+ // Add proxy route for the new container
219+ err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["container_port"], targetDomain)
220+ if err != nil {
221+ log.Error("Failed to add proxy route", "error", err)
222+ // Continue anyway, as the container is already created and started
223+ }
224+ } else {
225+ log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
206226 }
207227
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))
209230 }
M internal/httpserve/handlers/endpoint_deploy.go +35 -7
@@ -79,12 +79,22 @@ func PostDeploy(c echo.Context, a *server.App) error {
7979 })
8080 }
8181
82- // After successfully deploying a container, add a proxy route
83- containerIP := GetContainerIP(a, containerID, containerName)
84- err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
85- if err != nil {
86- log.Error("Failed to add proxy route", "error", err)
87- return sendDeployErrorResponse(c, "Failed to add proxy route", err)
82+ // Check if we should skip proxy setup
83+ skipProxySetup := false
84+ if payload.SkipProxySetup {
85+ skipProxySetup = true
86+ }
87+
88+ // After successfully deploying a container, add a proxy route if not skipped
89+ if !skipProxySetup {
90+ containerIP := GetContainerIP(a, containerID, containerName)
91+ err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
92+ if err != nil {
93+ log.Error("Failed to add proxy route", "error", err)
94+ return sendDeployErrorResponse(c, "Failed to add proxy route", err)
95+ }
96+ } else {
97+ log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
8898 }
8999
90100 // Arrived here means deployment was successful
@@ -265,7 +275,18 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
265275
266276 // Extract container name from domain and perform safety checks
267277 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+ }
269290
270291 if containerName == "" {
271292 return sendDeployErrorResponse(c, "Invalid target domain",
@@ -471,6 +492,9 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
471492 cleanDomain = strings.Split(cleanDomain, "://")[1]
472493 }
473494
495+ // Remove any leading dot that might have been added
496+ cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497+
474498 // Log that we're adding the domain for Let's Encrypt
475499 log.Debug("Adding domain for Let's Encrypt verification",
476500 "domain", cleanDomain)
@@ -595,6 +619,10 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
595619 return fmt.Errorf("failed to add proxy route: %w", err)
596620 }
597621
622+ // Register this container to prevent immediate recreation logic
623+ // and avoid database locking conflicts
624+ p.RegisterNewlyCreatedContainer(containerID)
625+
598626 log.Debug("Added proxy route",
599627 "domain", targetDomain,
600628 "containerIP", containerIP,
M internal/httpserve/handlers/endpoint_helpers.go +7 -3
@@ -107,9 +107,10 @@ func GetInfos(c echo.Context, a *server.App) error {
107107 // validateAndPrepareDeployPayload validates and prepares the deploy payload
108108 func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
109109 payload := &common.DeployPayload{
110- Port: c.Request().Header.Get("X-Ports"),
111- ImageName: c.Request().Header.Get("X-Image-Name"),
112- TargetDomain: c.Request().Header.Get("X-Target-Domain"),
110+ Port: c.Request().Header.Get("X-Ports"),
111+ ImageName: c.Request().Header.Get("X-Image-Name"),
112+ TargetDomain: c.Request().Header.Get("X-Target-Domain"),
113+ SkipProxySetup: c.Request().Header.Get("X-Skip-Proxy-Setup") == "true",
113114 }
114115
115116 if payload.ImageName == "" {
@@ -256,6 +257,9 @@ func normalizeTargetDomain(domain string) string {
256257 // First, remove any leading dot from the domain
257258 domain = strings.TrimPrefix(domain, ".")
258259
260+ // If domain contains a dot prefix in the middle (like "example.com/.domain.tld"), fix it
261+ domain = strings.ReplaceAll(domain, "/.", "/")
262+
259263 // Then check if we need to add the protocol
260264 if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
261265 return "https://" + domain
M internal/httpserve/handlers/login.go +79 -32
@@ -22,18 +22,22 @@ import (
2222
2323 var urlToken string
2424
25-// ClLI :
2625 // compareGordonToken compares the token from the URL query parameter with the one from the config.yml
2726 func CompareGordonToken(c echo.Context, a *server.App) error {
27+ logger.Debug("Starting token comparison")
2828 configToken, err := a.Config.GetToken()
2929 if err != nil {
30+ logger.Error("Error retrieving token from config", "error", err)
3031 return err
3132 }
3233
34+ logger.Debug("Comparing tokens", "url_token_exists", urlToken != "", "config_token_exists", configToken != "")
3335 if urlToken != configToken {
36+ logger.Warn("Token mismatch or empty", "url_token_empty", urlToken == "")
3437 return fmt.Errorf("token is empty or not valid")
3538 }
3639
40+ logger.Debug("Token validation successful")
3741 return nil
3842 }
3943
@@ -113,7 +117,7 @@ func StartOAuthGithub(c echo.Context, a *server.App) error {
113117 }
114118
115119 logger.Info("Checking if session exists in DB")
116- sessionExists, err := queries.CheckDBSessionExists(a, sessionID)
120+ sessionExists, err := queries.CheckDBSessionExists(a.DB, sessionID)
117121 if err != nil {
118122 logger.Info("Error checking if session exists", "error", err)
119123 return fmt.Errorf("could not check if session exists: %w", err)
@@ -126,7 +130,7 @@ func StartOAuthGithub(c echo.Context, a *server.App) error {
126130
127131 logger.Info("Getting the expiration time of the session")
128132 currentTime := time.Now()
129- sessionExpiration, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime)
133+ sessionExpiration, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime)
130134 if err != nil {
131135 logger.Info("Error getting session expiration", "error", err)
132136 return fmt.Errorf("could not get session expiration: %w", err)
@@ -137,9 +141,9 @@ func StartOAuthGithub(c echo.Context, a *server.App) error {
137141 return initiateGithubOAuthFlow(c, a)
138142 }
139143
140- logger.Info("Extending session expiration time by 30 minutes")
141- newExpirationTime := currentTime.Add(30 * time.Minute)
142- err = queries.ExtendSessionExpiration(a, accountID, sessionID, newExpirationTime)
144+ logger.Info("Extending session expiration time by 24 hours")
145+ newExpirationTime := currentTime.Add(time.Hour * 24)
146+ err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, newExpirationTime)
143147 if err != nil {
144148 logger.Info("Error extending session expiration", "error", err)
145149 return fmt.Errorf("could not extend session expiration: %w", err)
@@ -173,12 +177,15 @@ type Sessions struct {
173177
174178 // OAuthCallback handles the callback response from Github OAuth
175179 func OAuthCallback(c echo.Context, a *server.App) error {
180+ logger.Info("Starting OAuth callback handler")
176181 redirectPath := a.Config.Admin.Path
177182
178183 accessToken, encodedState, err := parseQueryParams(c)
179184 if err != nil {
185+ logger.Error("Failed to parse query params from OAuth callback", "error", err)
180186 return c.String(http.StatusBadRequest, err.Error())
181187 }
188+ logger.Debug("Parsed access token and state from query params", "token_length", len(accessToken), "encoded_state", encodedState)
182189
183190 // update the struct with the new access token
184191 a.DBTables.Sessions.AccessToken = accessToken
@@ -187,30 +194,42 @@ func OAuthCallback(c echo.Context, a *server.App) error {
187194 redirectDomain := a.GenerateOauthCallbackURL()
188195 encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
189196 if encodedState != encodedRedirectDomain {
197+ logger.Error("Invalid state parameter", "expected", encodedRedirectDomain, "received", encodedState)
190198 return c.String(http.StatusBadRequest, "invalid state parameter")
191199 }
200+ logger.Debug("State parameter validation successful")
192201
193202 sess, err := getSession(c)
194203 if err != nil {
204+ logger.Error("Failed to get session", "error", err)
195205 return err
196206 }
207+ logger.Debug("Session retrieved successfully")
197208
198209 browserInfo := c.Request().UserAgent()
199210 userInfo, err := githubGetUserDetails(c)
200211 if err != nil {
212+ logger.Error("Failed to get GitHub user details", "error", err)
201213 return err
202214 }
215+ logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails))
203216
204- _, err = handleUser(c, a, accessToken, browserInfo, userInfo)
217+ user, accountID, session, err := handleUser(c, a, accessToken, browserInfo, userInfo)
205218 if err != nil {
219+ logger.Error("Failed to handle user", "error", err)
206220 return err
207221 }
222+ logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID)
208223
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)
210226 if err != nil {
227+ logger.Error("Failed to set session values", "error", err)
211228 return err
212229 }
230+ logger.Info("Session values set successfully")
213231
232+ logger.Info("OAuth callback successful, redirecting to", "path", redirectPath)
214233 return c.Redirect(http.StatusFound, redirectPath)
215234 }
216235
@@ -237,70 +256,98 @@ func getSession(c echo.Context) (*sessions.Session, error) {
237256 }
238257
239258 // handleUser handles the user creation or update
240-func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
241- userExists, err := queries.CheckDBUserExists(a)
259+func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
260+ logger.Debug("Starting handleUser function", "github_login", userInfo.Login, "github_email", userInfo.Emails[0])
261+ userExists, err := queries.CheckDBUserExists(a.DB)
242262 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)
244265 }
245266
267+ logger.Debug("User exists check result", "user_exists", userExists)
268+
246269 if !userExists {
247270 // if it is a new user creation we compare the gordon token
271+ logger.Debug("No user exists, attempting to create new user")
248272 err := CompareGordonToken(c, a)
249273 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")
251276 }
277+ logger.Debug("Token comparison successful, proceeding with user creation")
252278
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
254286 }
255287
256288 if userExists {
257289 // 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)
259292 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)
261295 }
262296
297+ logger.Debug("User check result", "is_good_user", isGoodUser)
298+
263299 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")
265302 }
266303
267304 if isGoodUser {
268305 // 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)
270308 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)
272311 }
273- return updatedUser, nil
312+ logger.Info("Successfully updated user session", "user_id", updatedUser.ID, "account_id", accountID)
313+ return updatedUser, accountID, session, nil
274314 }
275315 }
276316
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")
278319 }
279320
280321 // createUser creates a new user in the database
281-func createUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
282- err := queries.CreateUser(a, accessToken, browserInfo, userInfo)
322+func createUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
323+ logger.Debug("Starting createUser function", "github_login", userInfo.Login)
324+ user, accountID, session, err := queries.CreateUser(a.DB, accessToken, browserInfo, userInfo)
283325 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)
285328 }
286- return &a.DBTables.User, nil
329+ logger.Info("User created successfully", "user_id", user.ID, "name", user.Name, "account_id", accountID)
330+ return user, accountID, session, nil
287331 }
288332
289333 // updateUser updates an existing user in the database
290-func updateUser(a *server.App, accessToken, browserInfo string, userInfo *queries.GithubUserInfo) (*db.User, error) {
291- user, err := queries.UpdateUser(a, accessToken, browserInfo, userInfo)
334+func updateUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
335+ user, accountID, session, err := queries.UpdateUser(a.DB, accessToken, browserInfo, userInfo)
292336 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)
294339 }
295- return user, nil
340+ logger.Info("User updated successfully", "user_id", user.ID, "account_id", accountID)
341+ return user, accountID, session, nil
296342 }
297343
298344 // 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 {
300346 sess.Values["authenticated"] = true
301347 sess.Values["accountID"] = accountID
302- sess.Values["expires"] = expires
303- sess.Values["sessionID"] = sessionID
348+ sess.Values["expires"] = session.Expires
349+ sess.Values["sessionID"] = session.ID
350+ sess.Values["isOnline"] = session.IsOnline
304351
305352 if err := sess.Save(c.Request(), c.Response()); err != nil {
306353 return fmt.Errorf("could not save session: %w", err)
@@ -341,7 +388,7 @@ func fetchGithubAPI(url string, authHeader string, result interface{}) error {
341388 }
342389
343390 // 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) {
345392 accessToken := c.QueryParam("access_token")
346393
347394 // Fetch user info
M internal/httpserve/handlers/logout.go +1 -1
@@ -23,7 +23,7 @@ func Logout(c echo.Context, a *server.App) error {
2323 // Invalidate the session in the database
2424 sessionID, ok := sess.Values["sessionID"].(string)
2525 if ok {
26- err := queries.InvalidateDBSession(a, sessionID)
26+ err := queries.InvalidateDBSession(a.DB, sessionID)
2727 if err != nil {
2828 return fmt.Errorf("failed to invalidate session: %w", err)
2929 }
M internal/httpserve/handlers/session_db.go +18 -4
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/gordon/internal/db/queries"
88 "github.com/bnema/gordon/internal/server"
9+ "github.com/bnema/gordon/pkg/logger"
910 "github.com/labstack/echo-contrib/session"
1011 "github.com/labstack/echo/v4"
1112 )
@@ -15,54 +16,67 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error {
1516
1617 sess, err := session.Get("session", c)
1718 if err != nil {
18- return err
19+ logger.Warn("Failed to get session", "error", err)
20+ return fmt.Errorf("failed to get session: %w", err)
1921 }
2022
2123 accountID, ok := sess.Values["accountID"].(string)
2224 if !ok {
25+ logger.Warn("Account ID not found or invalid type")
2326 return fmt.Errorf("accountID not found or invalid type")
2427 }
2528
2629 sessionID, ok := sess.Values["sessionID"].(string)
2730 if !ok {
31+ logger.Warn("Session ID not found or invalid type")
2832 return fmt.Errorf("sessionID not found or invalid type")
2933 }
3034
3135 accountExists, err := IsAccountIDInDB(a, accountID)
3236 if err != nil || !accountExists {
37+ logger.Warn("Account does not exist")
3338 return fmt.Errorf("account does not exist")
3439 }
3540
3641 sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID)
3742 if err != nil || sessionExpired {
43+ logger.Warn("Session is expired")
3844 return fmt.Errorf("session is expired")
3945 }
4046
4147 // 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))
4349 if err != nil {
50+ logger.Warn("Failed to extend session", "error", err)
4451 return fmt.Errorf("failed to extend session: %v", err)
4552 }
4653
54+ logger.Debug("Session extended successfully", "accountID", accountID, "sessionID", sessionID)
55+
4756 return nil
4857 }
4958
5059 func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) {
5160 currentTime := time.Now()
52- expirationTime, err := queries.GetSessionExpiration(a, accountID, sessionID, currentTime)
61+ expirationTime, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime)
5362 if err != nil {
63+ logger.Warn("Failed to get session expiration time", "error", err)
5464 return false, err
5565 }
5666
67+ logger.Debug("Session expiration time for account", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTime)
5768 return currentTime.After(expirationTime), nil
5869 }
5970
6071 func IsAccountIDInDB(a *server.App, accountID string) (bool, error) {
61- accountExists, err := queries.CheckDBAccountExists(a, accountID)
72+ accountExists, err := queries.CheckDBAccountExists(a.DB, accountID)
6273 if err != nil {
74+ logger.Warn("Failed to check account existence", "error", err)
6375 return false, err
6476 }
6577
78+ logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists)
79+
6680 return accountExists, nil
6781 }
6882
M internal/httpserve/middleware/requiretoken.go +5 -3
@@ -10,13 +10,15 @@ import (
1010 "time"
1111
1212 "github.com/bnema/gordon/internal/cli/auth"
13+ "github.com/bnema/gordon/internal/db"
1314 "github.com/bnema/gordon/internal/db/queries"
1415 "github.com/bnema/gordon/internal/server"
1516 "github.com/bnema/gordon/pkg/logger"
1617 "github.com/labstack/echo/v4"
1718 )
1819
19-type GithubUserInfo = queries.GithubUserInfo
20+// GithubUserInfo is an alias to db.GithubUserInfo
21+type GithubUserInfo = db.GithubUserInfo
2022
2123 // middleware/require_token.go
2224 func RequireToken(a *server.App) echo.MiddlewareFunc {
@@ -35,7 +37,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc {
3537 // Check cache first
3638 if _, cachedUser, found := tokenCache.GetWithUser(token); found {
3739 // Verify if user exists in database and is authorized
38- isAuthorized, err := queries.CheckDBUserIsGood(a, cachedUser)
40+ _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, cachedUser)
3941 if err != nil || !isAuthorized {
4042 return c.JSON(http.StatusUnauthorized, map[string]interface{}{
4143 "success": false,
@@ -56,7 +58,7 @@ func RequireToken(a *server.App) echo.MiddlewareFunc {
5658 }
5759
5860 // Verify if user exists in database and is authorized
59- isAuthorized, err := queries.CheckDBUserIsGood(a, githubUser)
61+ _, isAuthorized, err := queries.CheckDBUserIsGood(a.DB, githubUser)
6062 if err != nil {
6163 return c.JSON(http.StatusInternalServerError, map[string]interface{}{
6264 "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 (
1414 "net"
1515 "net/http"
1616 "os"
17+ "path/filepath"
1718 "regexp"
1819 "strings"
1920 "time"
@@ -69,8 +70,7 @@ func (p *Proxy) setupCertManager() {
6970 adminDomain := p.app.GetConfig().Http.FullDomain()
7071 rootDomain := p.app.GetConfig().Http.Domain
7172
72- // Use a more permissive hostpolicy that logs unknown domains rather than rejecting them
73- // This helps debug certificate acquisition issues
73+ // Use a stricter hostpolicy that only allows domains in our routes
7474 certManager.HostPolicy = func(_ context.Context, host string) error {
7575 // Always allow the admin domain and root domain
7676 if host == adminDomain || host == rootDomain {
@@ -85,16 +85,14 @@ func (p *Proxy) setupCertManager() {
8585 return nil
8686 }
8787
88- // Log the attempt but still allow it - this helps debug
89- // certificate acquisition issues during development
90- logger.Warn("Unknown host in certificate request",
88+ // Log the attempt and reject it
89+ logger.Warn("Rejecting certificate request for unknown host",
9190 "host", host,
9291 "adminDomain", adminDomain,
93- "allowed", "yes")
92+ "allowed", "no")
9493
95- // Allow unknown domains temporarily to help diagnose issues
96- // Change this to return an error in production for stricter security
97- return nil
94+ // Return an error for domains not in our routes
95+ return fmt.Errorf("host %q not configured in routes", host)
9896 }
9997
10098 // Generate a fallback self-signed certificate for the admin domain
@@ -153,49 +151,226 @@ func (p *Proxy) checkCertificateInCache(domain string) bool {
153151 return false
154152 }
155153
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) {
156170 // Parse the certificate to check its validity
157171 block, _ := pem.Decode(certData)
158172 if block == nil || block.Type != "CERTIFICATE" {
159- logger.Warn("Invalid certificate data in cache",
173+ logger.Warn("Invalid certificate data",
160174 "domain", domain)
161- return false
175+ return false, 0
162176 }
163177
164178 cert, err := x509.ParseCertificate(block.Bytes)
165179 if err != nil {
166- logger.Warn("Failed to parse certificate from cache",
180+ logger.Warn("Failed to parse certificate",
167181 "domain", domain,
168182 "error", err)
169- return false
183+ return false, 0
170184 }
171185
172186 // Check if the certificate is still valid
173187 now := time.Now()
174188 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",
176190 "domain", domain,
177191 "not_before", cert.NotBefore,
178192 "not_after", cert.NotAfter)
179- return false
193+ return false, 0
180194 }
181195
196+ // Calculate days until expiration
197+ expiresInDays := cert.NotAfter.Sub(now).Hours() / 24
198+
182199 // Check if the certificate is about to expire (within 30 days)
183- if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) {
184- logger.Info("Certificate in cache is valid but will expire soon",
200+ if expiresInDays < 30 {
201+ logger.Info("Certificate is valid but will expire soon",
185202 "domain", domain,
186- "expires_in", cert.NotAfter.Sub(now).Hours()/24,
203+ "expires_in", expiresInDays,
187204 "days")
188205 // 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 {
189216 return false
190217 }
191218
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",
193261 "domain", domain,
194- "expires_in", cert.NotAfter.Sub(now).Hours()/24,
195- "days")
262+ "expires_in", expiresInDays,
263+ "days",
264+ "not_in_cache", true)
265+
266+ // Log some debug information about the certificate files
267+ certFileInfo, _ := os.Stat(certFile)
268+ keyFileInfo, _ := os.Stat(keyFile)
269+ logger.Debug("Certificate file details",
270+ "domain", domain,
271+ "cert_file", certFile,
272+ "cert_size", certFileInfo.Size(),
273+ "cert_modified", certFileInfo.ModTime(),
274+ "key_file", keyFile,
275+ "key_size", keyFileInfo.Size(),
276+ "key_modified", keyFileInfo.ModTime())
277+
196278 return true
197279 }
198280
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+
199374 // requestAdminCertificate preemptively requests a Let's Encrypt certificate
200375 // for the Gordon admin interface
201376 func (p *Proxy) requestAdminCertificate() {
@@ -230,11 +405,30 @@ func (p *Proxy) requestAdminCertificate() {
230405 "domain", adminDomain,
231406 "ips", ipStrings)
232407
233- // Check if we already have a valid certificate in the cache
234- if p.checkCertificateInCache(adminDomain) {
235- logger.Info("Using existing certificate from cache",
408+ // Check if we already have a valid certificate (cache or filesystem)
409+ if p.checkCertificateExists(adminDomain) {
410+ logger.Info("Using existing certificate",
236411 "domain", adminDomain,
237412 "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+
238432 return
239433 }
240434
@@ -365,11 +559,30 @@ func (p *Proxy) requestDomainCertificate(domain string) {
365559 "domain", domain,
366560 "ips", ipStrings)
367561
368- // Check if we already have a valid certificate in the cache
369- if p.checkCertificateInCache(domain) {
370- logger.Info("Using existing certificate from cache",
562+ // Check if we already have a valid certificate (cache or filesystem)
563+ if p.checkCertificateExists(domain) {
564+ logger.Info("Using existing certificate",
371565 "domain", domain,
372566 "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+
373586 return
374587 }
375588
M internal/proxy/core.go +23 -29
@@ -10,6 +10,7 @@ import (
1010 "net/http"
1111
1212 "github.com/bnema/gordon/internal/common"
13+ "github.com/bnema/gordon/internal/db/queries"
1314 "github.com/bnema/gordon/internal/interfaces"
1415 "github.com/bnema/gordon/pkg/docker"
1516 "github.com/bnema/gordon/pkg/logger"
@@ -36,16 +37,6 @@ type Proxy struct {
3637 certManager *autocert.Manager
3738 fallbackCert *tls.Certificate // Fallback self-signed certificate
3839 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
4940
5041 // Flag to track specific domain certificate operations
5142 processingSpecificDomain bool
@@ -61,6 +52,14 @@ type Proxy struct {
6152 acmePerDomainRefreshMutex sync.Mutex
6253 acmeDirCache autocert.DirCache
6354 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
6463 }
6564
6665 // NewProxy creates a new instance of the reverse proxy
@@ -105,30 +104,25 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
105104 }
106105 }
107106
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-
115107 // Initialize routes map
116108 routes := make(map[string]*ProxyRouteInfo)
117109
110+ // Initialize SQL queries
111+ proxyQueries := queries.NewProxyQueries()
112+
118113 // Create the proxy
119114 p := &Proxy{
120- config: config,
121- app: app,
122- httpsServer: httpsServer,
123- httpServer: httpServer,
124- routes: routes,
125- serverStarted: false,
126- blacklist: blacklist,
127- blockedIPCounter: make(map[string]int),
128- lastBlockedLog: time.Time{}, // Zero time
129- recentlyBlocked: make(map[string]time.Time),
130- gordonContainerID: ourContainerID,
131- shutdown: make(chan struct{}),
115+ config: config,
116+ app: app,
117+ httpsServer: httpsServer,
118+ httpServer: httpServer,
119+ routes: routes,
120+ serverStarted: false,
121+ gordonContainerID: ourContainerID,
122+ shutdown: make(chan struct{}),
123+ recentContainers: make(map[string]time.Time),
124+ containerCooldownPeriod: 10 * time.Second, // Default 10 second cooldown period
125+ queries: proxyQueries,
132126 }
133127
134128 // Now add the middleware with the proxy reference
M internal/proxy/db_helpers.go +5 -111
@@ -2,132 +2,26 @@ package proxy
22
33 import (
44 "database/sql"
5- "fmt"
6- "time"
75
8- "github.com/charmbracelet/log"
6+ "github.com/bnema/gordon/internal/db"
97 )
108
119 // dbExecWithRetry performs a database execution with retry logic for handling locked database
1210 func (p *Proxy) dbExecWithRetry(query string, args ...interface{}) (sql.Result, error) {
13- var result sql.Result
14- var err error
15- maxRetries := 5
16- retryDelay := 100 * time.Millisecond
17-
18- for attempt := 1; attempt <= maxRetries; attempt++ {
19- result, err = p.app.GetDB().Exec(query, args...)
20- if err == nil {
21- return result, nil
22- }
23-
24- // Check if this is a database locked error
25- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
26- log.Debug("Database locked, retrying operation",
27- "attempt", attempt,
28- "max_retries", maxRetries)
29- time.Sleep(retryDelay)
30- // Increase delay for next retry (exponential backoff)
31- retryDelay *= 2
32- continue
33- }
34-
35- // If it's not a locking error, return immediately
36- return nil, err
37- }
38-
39- return nil, fmt.Errorf("max retries exceeded: %w", err)
11+ return db.ExecWithRetry(p.app.GetDB(), query, args...)
4012 }
4113
4214 // dbQueryWithRetry performs a database query with retry logic for handling locked database
4315 func (p *Proxy) dbQueryWithRetry(query string, args ...interface{}) (*sql.Rows, error) {
44- var rows *sql.Rows
45- var err error
46- maxRetries := 5
47- retryDelay := 100 * time.Millisecond
48-
49- for attempt := 1; attempt <= maxRetries; attempt++ {
50- rows, err = p.app.GetDB().Query(query, args...)
51- if err == nil {
52- return rows, nil
53- }
54-
55- // Check if this is a database locked error
56- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
57- log.Debug("Database locked, retrying operation",
58- "attempt", attempt,
59- "max_retries", maxRetries)
60- time.Sleep(retryDelay)
61- // Increase delay for next retry (exponential backoff)
62- retryDelay *= 2
63- continue
64- }
65-
66- // If it's not a locking error, return immediately
67- return nil, err
68- }
69-
70- return nil, fmt.Errorf("max retries exceeded: %w", err)
16+ return db.QueryWithRetry(p.app.GetDB(), query, args...)
7117 }
7218
7319 // dbBeginWithRetry starts a transaction with retry logic for handling locked database
7420 func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) {
75- var tx *sql.Tx
76- var err error
77- maxRetries := 5
78- retryDelay := 100 * time.Millisecond
79-
80- for attempt := 1; attempt <= maxRetries; attempt++ {
81- tx, err = p.app.GetDB().Begin()
82- if err == nil {
83- return tx, nil
84- }
85-
86- // Check if this is a database locked error
87- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
88- log.Debug("Database locked, retrying transaction start",
89- "attempt", attempt,
90- "max_retries", maxRetries)
91- time.Sleep(retryDelay)
92- // Increase delay for next retry (exponential backoff)
93- retryDelay *= 2
94- continue
95- }
96-
97- // If it's not a locking error, return immediately
98- return nil, err
99- }
100-
101- return nil, fmt.Errorf("max retries exceeded: %w", err)
21+ return db.BeginWithRetry(p.app.GetDB())
10222 }
10323
10424 // txExecWithRetry executes a query within a transaction with retry logic
10525 func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) {
106- var result sql.Result
107- var err error
108- maxRetries := 5
109- retryDelay := 100 * time.Millisecond
110-
111- for attempt := 1; attempt <= maxRetries; attempt++ {
112- result, err = tx.Exec(query, args...)
113- if err == nil {
114- return result, nil
115- }
116-
117- // Check if this is a database locked error
118- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
119- log.Debug("Database locked, retrying transaction operation",
120- "attempt", attempt,
121- "max_retries", maxRetries)
122- time.Sleep(retryDelay)
123- // Increase delay for next retry (exponential backoff)
124- retryDelay *= 2
125- continue
126- }
127-
128- // If it's not a locking error, return immediately
129- return nil, err
130- }
131-
132- return nil, fmt.Errorf("max retries exceeded: %w", err)
26+ return db.TxExecWithRetry(tx, query, args...)
13327 }
M internal/proxy/middleware.go +130 -192
@@ -1,9 +1,7 @@
11 package proxy
22
33 import (
4- "net"
54 "net/http"
6- "strings"
75 "time"
86
97 "github.com/bnema/gordon/pkg/logger"
@@ -18,12 +16,6 @@ import (
1816 // rateLimiterCleanup holds the cleanup functions for rate limiters
1917 var rateLimiterCleanup []func()
2018
21-// Create blacklistedIPs set to track IPs for middleware skipping
22-type requestContext struct {
23- blacklisted bool
24- requestID string
25-}
26-
2719 // RequestIDKey is the key used to store the request ID in the context
2820 const RequestIDKey = "request_id"
2921
@@ -38,83 +30,71 @@ func (p *Proxy) setupMiddleware() {
3830 p.httpsServer.Use(p.createRequestIDMiddleware())
3931 p.httpServer.Use(p.createRequestIDMiddleware())
4032
41- // Add debug middleware to log all headers
42- p.httpsServer.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
43- return func(c echo.Context) error {
44- req := c.Request()
45- logger.Debug("Request headers",
46- "method", req.Method,
47- "path", req.URL.Path,
48- "remote_addr", req.RemoteAddr,
49- "x-forwarded-for", req.Header.Get("X-Forwarded-For"),
50- "x-real-ip", req.Header.Get("X-Real-IP"))
51- return next(c)
52- }
53- })
54-
55- // Create blacklist middleware for HTTPS
56- p.httpsServer.Use(p.createBlacklistMiddleware())
57-
58- // Create a new Starskey rate limiter store for HTTPS server
59- httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
60- "./data/ratelimiter/https", // Path to store rate limit data
61- 10, // Rate: 10 requests per second (lowered from 10)
62- 30, // Burst: 30 requests (lowered from 30)
63- 3*time.Minute, // Expires in 3 minutes
64- )
65- if err != nil {
66- logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err)
67-
68- // Fallback to memory store if Starskey store creation fails
69- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
70- Store: middleware.NewRateLimiterMemoryStoreWithConfig(
71- middleware.RateLimiterMemoryStoreConfig{
72- Rate: rate.Limit(10), // 10 requests per second (lowered from 10)
73- Burst: 30, // Burst of 30 requests (lowered from 30)
74- ExpiresIn: 3 * time.Minute, // Store expiration
33+ // Check if rate limiting is disabled via configuration
34+ if !p.config.DisableRateLimit {
35+ // Create a new Starskey rate limiter store for HTTPS server
36+ httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
37+ "./data/ratelimiter/https", // Path to store rate limit data
38+ 10, // Rate: 10 requests per second (lowered from 10)
39+ 30, // Burst: 30 requests (lowered from 30)
40+ 3*time.Minute, // Expires in 3 minutes
41+ )
42+ if err != nil {
43+ logger.Error("Failed to create HTTPS rate limiter store, falling back to memory store", "error", err)
44+
45+ // Fallback to memory store if Starskey store creation fails
46+ p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
47+ Store: middleware.NewRateLimiterMemoryStoreWithConfig(
48+ middleware.RateLimiterMemoryStoreConfig{
49+ Rate: rate.Limit(10), // 10 requests per second (lowered from 10)
50+ Burst: 30, // Burst of 30 requests (lowered from 30)
51+ ExpiresIn: 3 * time.Minute, // Store expiration
52+ },
53+ ),
54+ DenyHandler: func(context echo.Context, identifier string, err error) error {
55+ logger.Warn("Rate limit exceeded",
56+ "ip", identifier,
57+ "path", context.Request().URL.Path,
58+ "method", context.Request().Method)
59+ return context.String(http.StatusTooManyRequests, "Too many requests")
7560 },
76- ),
77- DenyHandler: func(context echo.Context, identifier string, err error) error {
78- logger.Warn("Rate limit exceeded",
79- "ip", identifier,
80- "path", context.Request().URL.Path,
81- "method", context.Request().Method)
82- return context.String(http.StatusTooManyRequests, "Too many requests")
83- },
84- }))
61+ }))
62+ } else {
63+ // Use the Starskey store for HTTPS rate limiting
64+ logger.Debug("Using Starskey rate limiter for HTTPS server")
65+
66+ // Add custom Starskey rate limiter middleware
67+ p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
68+ Store: httpsRateLimiter,
69+ IdentifierExtractor: func(ctx echo.Context) (string, error) {
70+ id := ctx.RealIP()
71+ logger.Debug("Rate limiting HTTPS request",
72+ "ip", id,
73+ "remote_addr", ctx.Request().RemoteAddr)
74+
75+ // Add a response header to show the detected IP (for debugging)
76+ ctx.Response().Header().Set("X-Rate-Limit-IP", id)
77+
78+ return id, nil
79+ },
80+ DenyHandler: func(context echo.Context, identifier string, err error) error {
81+ logger.Warn("HTTPS rate limit exceeded",
82+ "ip", identifier,
83+ "path", context.Request().URL.Path,
84+ "method", context.Request().Method)
85+ return context.String(http.StatusTooManyRequests, "Too many requests")
86+ },
87+ }))
88+
89+ // Add cleanup function to global slice
90+ rateLimiterCleanup = append(rateLimiterCleanup, func() {
91+ if err := httpsRateLimiter.Close(); err != nil {
92+ logger.Error("Failed to close HTTPS rate limiter store", "error", err)
93+ }
94+ })
95+ }
8596 } else {
86- // Use the Starskey store for HTTPS rate limiting
87- logger.Debug("Using Starskey rate limiter for HTTPS server")
88-
89- // Add custom Starskey rate limiter middleware
90- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
91- Store: httpsRateLimiter,
92- IdentifierExtractor: func(ctx echo.Context) (string, error) {
93- id := ctx.RealIP()
94- logger.Debug("Rate limiting HTTPS request",
95- "ip", id,
96- "remote_addr", ctx.Request().RemoteAddr)
97-
98- // Add a response header to show the detected IP (for debugging)
99- ctx.Response().Header().Set("X-Rate-Limit-IP", id)
100-
101- return id, nil
102- },
103- DenyHandler: func(context echo.Context, identifier string, err error) error {
104- logger.Warn("HTTPS rate limit exceeded",
105- "ip", identifier,
106- "path", context.Request().URL.Path,
107- "method", context.Request().Method)
108- return context.String(http.StatusTooManyRequests, "Too many requests")
109- },
110- }))
111-
112- // Add cleanup function to global slice
113- rateLimiterCleanup = append(rateLimiterCleanup, func() {
114- if err := httpsRateLimiter.Close(); err != nil {
115- logger.Error("Failed to close HTTPS rate limiter store", "error", err)
116- }
117- })
97+ logger.Info("Rate limiting is disabled for HTTPS server via configuration")
11898 }
11999
120100 // Add custom logger middleware with request ID if enabled in config
@@ -131,69 +111,71 @@ func (p *Proxy) setupMiddleware() {
131111 logger.Debug("HTTP request logging disabled for HTTPS server")
132112 }
133113
134- // Create blacklist middleware for HTTP
135- p.httpServer.Use(p.createBlacklistMiddleware())
136-
137- // Create a new Starskey rate limiter store for HTTP server
138- httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
139- "./data/ratelimiter/http", // Path to store rate limit data
140- 2, // Rate: 2 requests per second (lowered from 10)
141- 10, // Burst: 10 requests (lowered from 30)
142- 3*time.Minute, // Expires in 3 minutes
143- )
144- if err != nil {
145- logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err)
146-
147- // Fallback to memory store if Starskey store creation fails
148- p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
149- Store: middleware.NewRateLimiterMemoryStoreWithConfig(
150- middleware.RateLimiterMemoryStoreConfig{
151- Rate: rate.Limit(2), // 2 requests per second (lowered from 10)
152- Burst: 10, // Burst of 10 requests (lowered from 30)
153- ExpiresIn: 3 * time.Minute, // Store expiration
114+ // Check if rate limiting is disabled via configuration
115+ if !p.config.DisableRateLimit {
116+ // Create a new Starskey rate limiter store for HTTP server
117+ httpRateLimiter, err := kv.NewStarskeyRateLimiterStore(
118+ "./data/ratelimiter/http", // Path to store rate limit data
119+ 2, // Rate: 2 requests per second (lowered from 10)
120+ 10, // Burst: 10 requests (lowered from 30)
121+ 3*time.Minute, // Expires in 3 minutes
122+ )
123+ if err != nil {
124+ logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err)
125+
126+ // Fallback to memory store if Starskey store creation fails
127+ p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
128+ Store: middleware.NewRateLimiterMemoryStoreWithConfig(
129+ middleware.RateLimiterMemoryStoreConfig{
130+ Rate: rate.Limit(2), // 2 requests per second (lowered from 10)
131+ Burst: 10, // Burst of 10 requests (lowered from 30)
132+ ExpiresIn: 3 * time.Minute, // Store expiration
133+ },
134+ ),
135+ DenyHandler: func(context echo.Context, identifier string, err error) error {
136+ logger.Warn("Rate limit exceeded",
137+ "ip", identifier,
138+ "path", context.Request().URL.Path,
139+ "method", context.Request().Method)
140+ return context.String(http.StatusTooManyRequests, "Too many requests")
154141 },
155- ),
156- DenyHandler: func(context echo.Context, identifier string, err error) error {
157- logger.Warn("Rate limit exceeded",
158- "ip", identifier,
159- "path", context.Request().URL.Path,
160- "method", context.Request().Method)
161- return context.String(http.StatusTooManyRequests, "Too many requests")
162- },
163- }))
142+ }))
143+ } else {
144+ // Use the Starskey store for HTTP rate limiting
145+ logger.Debug("Using Starskey rate limiter for HTTP server")
146+
147+ // Add custom Starskey rate limiter middleware
148+ p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
149+ Store: httpRateLimiter,
150+ IdentifierExtractor: func(ctx echo.Context) (string, error) {
151+ id := ctx.RealIP()
152+ logger.Debug("Rate limiting HTTP request",
153+ "ip", id,
154+ "remote_addr", ctx.Request().RemoteAddr)
155+
156+ // Add a response header to show the detected IP (for debugging)
157+ ctx.Response().Header().Set("X-Rate-Limit-IP", id)
158+
159+ return id, nil
160+ },
161+ DenyHandler: func(context echo.Context, identifier string, err error) error {
162+ logger.Warn("HTTP rate limit exceeded",
163+ "ip", identifier,
164+ "path", context.Request().URL.Path,
165+ "method", context.Request().Method)
166+ return context.String(http.StatusTooManyRequests, "Too many requests")
167+ },
168+ }))
169+
170+ // Add cleanup function to global slice
171+ rateLimiterCleanup = append(rateLimiterCleanup, func() {
172+ if err := httpRateLimiter.Close(); err != nil {
173+ logger.Error("Failed to close HTTP rate limiter store", "error", err)
174+ }
175+ })
176+ }
164177 } else {
165- // Use the Starskey store for HTTP rate limiting
166- logger.Debug("Using Starskey rate limiter for HTTP server")
167-
168- // Add custom Starskey rate limiter middleware
169- p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
170- Store: httpRateLimiter,
171- IdentifierExtractor: func(ctx echo.Context) (string, error) {
172- id := ctx.RealIP()
173- logger.Debug("Rate limiting HTTP request",
174- "ip", id,
175- "remote_addr", ctx.Request().RemoteAddr)
176-
177- // Add a response header to show the detected IP (for debugging)
178- ctx.Response().Header().Set("X-Rate-Limit-IP", id)
179-
180- return id, nil
181- },
182- DenyHandler: func(context echo.Context, identifier string, err error) error {
183- logger.Warn("HTTP rate limit exceeded",
184- "ip", identifier,
185- "path", context.Request().URL.Path,
186- "method", context.Request().Method)
187- return context.String(http.StatusTooManyRequests, "Too many requests")
188- },
189- }))
190-
191- // Add cleanup function to global slice
192- rateLimiterCleanup = append(rateLimiterCleanup, func() {
193- if err := httpRateLimiter.Close(); err != nil {
194- logger.Error("Failed to close HTTP rate limiter store", "error", err)
195- }
196- })
178+ logger.Info("Rate limiting is disabled for HTTP server via configuration")
197179 }
198180
199181 // Add custom logger middleware with request ID for HTTP server if enabled in config
@@ -215,60 +197,16 @@ func (p *Proxy) setupMiddleware() {
215197 func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
216198 return func(next echo.HandlerFunc) echo.HandlerFunc {
217199 return func(c echo.Context) error {
218- // Generate a new UUID for this request
200+ // Generate a UUID for this request
219201 requestID := uuid.New().String()
220202
221- // Store the UUID in the context
203+ // Store it in the context
222204 c.Set(RequestIDKey, requestID)
223205
224- // Add the UUID as a response header
206+ // Add it as a response header
225207 c.Response().Header().Set(echo.HeaderXRequestID, requestID)
226208
227- // Continue with the next middleware
228- return next(c)
229- }
230- }
231-}
232-
233-// createBlacklistMiddleware returns a middleware function that checks IPs against the blacklist
234-func (p *Proxy) createBlacklistMiddleware() echo.MiddlewareFunc {
235- return func(next echo.HandlerFunc) echo.HandlerFunc {
236- return func(c echo.Context) error {
237- // Get client IP
238- clientIP := c.RealIP()
239-
240- // Get host and strip port if present
241- host := c.Request().Host
242- if strings.Contains(host, ":") {
243- host = strings.Split(host, ":")[0]
244- }
245-
246- // Check if the host is an IP address
247- if net.ParseIP(host) != nil {
248- // This is an IP address being used as a hostname - silently reject
249- // This prevents log spam without adding IPs to the blacklist
250- // Set context for other middleware
251- c.Set("reqContext", &requestContext{blacklisted: true})
252- return c.String(http.StatusForbidden, "Forbidden")
253- }
254-
255- // Debug log for all requests to check if the IP is being properly recognized
256- logger.Debug("Received request", "ip", clientIP, "path", c.Request().URL.Path)
257-
258- // Check if IP is blacklisted (if blacklist exists)
259- if p.blacklist != nil && p.blacklist.IsBlocked(clientIP) {
260- // Set context for other middleware
261- c.Set("reqContext", &requestContext{blacklisted: true})
262-
263- // Rate-limited logging of blocked IPs
264- p.logBlockedIP(clientIP, c.Request().URL.Path, c.Request().UserAgent())
265-
266- // Return forbidden without calling next handlers at all
267- return c.String(http.StatusForbidden, "Forbidden")
268- }
269-
270- // Store the context in the request context for non-blacklisted IPs
271- c.Set("reqContext", &requestContext{blacklisted: false})
209+ // Continue processing
272210 return next(c)
273211 }
274212 }
M internal/proxy/routes.go +363 -279
@@ -50,12 +50,7 @@ func (p *Proxy) loadRoutes() error {
5050 }
5151
5252 // Query the database for active proxy routes using retry mechanism
53- rows, err := p.dbQueryWithRetry(`
54- SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active
55- FROM proxy_route pr
56- JOIN domain d ON pr.domain_id = d.id
57- WHERE pr.active = 1
58- `)
53+ rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes)
5954 if err != nil {
6055 if err == sql.ErrNoRows {
6156 logger.Info("No active proxy routes found")
@@ -174,12 +169,6 @@ func (p *Proxy) configureRoutes() {
174169 // Normalize host (trim trailing dot, convert to lowercase)
175170 host = strings.TrimSuffix(strings.ToLower(host), ".")
176171
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-
183172 // Check if this is the admin domain
184173 adminDomain := p.app.GetConfig().Http.FullDomain()
185174 if strings.EqualFold(host, adminDomain) {
@@ -189,21 +178,6 @@ func (p *Proxy) configureRoutes() {
189178 p.mu.RUnlock()
190179
191180 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
207181 return p.proxyRequest(c, adminRoute)
208182 }
209183 }
@@ -244,16 +218,6 @@ func (p *Proxy) configureRoutes() {
244218 "client_ip", c.RealIP())
245219 return c.String(http.StatusServiceUnavailable, "Route is not active")
246220 }
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-
257221 return p.proxyRequest(c, route)
258222 })
259223
@@ -284,9 +248,35 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
284248 "protocol", protocol,
285249 "path", path)
286250
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+
287274 // Begin a transaction with retry
288275 tx, err := p.dbBeginWithRetry()
289276 if err != nil {
277+ logger.Error("Failed to begin transaction when adding route",
278+ "domain", domainName,
279+ "error", err)
290280 return fmt.Errorf("failed to begin transaction: %w", err)
291281 }
292282 defer tx.Rollback()
@@ -302,7 +292,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
302292
303293 // Get the account ID - default to the first account if available
304294 var accountID string
305- err = tx.QueryRow("SELECT id FROM account LIMIT 1").Scan(&accountID)
295+ err = tx.QueryRow(p.queries.GetFirstAccount).Scan(&accountID)
306296 if err != nil {
307297 if err == sql.ErrNoRows {
308298 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
314304
315305 // Check if the domain exists
316306 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)
318308 if err != nil {
319309 if err == sql.ErrNoRows {
320310 // Domain doesn't exist, create it
321311 domainID = proxyGenerateUUID()
322312 now := time.Now().Format(time.RFC3339)
323313 _, err = txExecWithRetry(tx,
324- "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
314+ p.queries.InsertDomain,
325315 domainID, hostname, accountID, now, now,
326316 )
327317 if err != nil {
318+ logger.Error("Failed to insert domain",
319+ "domain", hostname,
320+ "error", err)
328321 return fmt.Errorf("failed to insert domain: %w", err)
329322 }
330323
@@ -347,7 +340,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
347340 // Domain part doesn't exist, create it
348341 domainPartID = proxyGenerateUUID()
349342 _, err = txExecWithRetry(tx,
350- "INSERT INTO domain (id, name, account_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
343+ p.queries.InsertDomain,
351344 domainPartID, domainPart, accountID, now, now,
352345 )
353346 if err != nil {
@@ -363,6 +356,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
363356 }
364357 }
365358 } else {
359+ logger.Error("Failed to query domain",
360+ "domain", hostname,
361+ "error", err)
366362 return fmt.Errorf("failed to query domain: %w", err)
367363 }
368364 }
@@ -371,10 +367,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
371367 var existingRouteID string
372368 var existingContainerID string
373369 var existingContainerPort string
374- err = tx.QueryRow(`
375- SELECT id, container_id, container_port
376- FROM proxy_route
377- WHERE domain_id = ?`, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort)
370+ err = tx.QueryRow(p.queries.GetRouteByDomain, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort)
378371
379372 if err == nil {
380373 // Route exists, update it
@@ -408,6 +401,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
408401 containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID,
409402 )
410403 if err != nil {
404+ logger.Error("Failed to update route",
405+ "domain", hostname,
406+ "error", err)
411407 return fmt.Errorf("failed to update route: %w", err)
412408 }
413409 } else if err == sql.ErrNoRows {
@@ -415,29 +411,55 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
415411 routeID := proxyGenerateUUID()
416412 now := time.Now().Format(time.RFC3339)
417413 _, err = txExecWithRetry(tx,
418- `INSERT INTO proxy_route (
419- id, domain_id, container_id, container_ip, container_port,
420- protocol, path, active, created_at, updated_at
421- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
414+ p.queries.InsertRoute,
422415 routeID, domainID, containerID, containerIP, containerPort,
423416 protocol, path, true, now, now,
424417 )
425418 if err != nil {
419+ logger.Error("Failed to insert route",
420+ "domain", hostname,
421+ "error", err)
426422 return fmt.Errorf("failed to insert route: %w", err)
427423 }
428424 } else {
425+ logger.Error("Failed to query route",
426+ "domain", hostname,
427+ "error", err)
429428 return fmt.Errorf("failed to query route: %w", err)
430429 }
431430
432431 // Commit the transaction
433432 if err := tx.Commit(); err != nil {
433+ logger.Error("Failed to commit transaction",
434+ "domain", hostname,
435+ "error", err)
434436 return fmt.Errorf("failed to commit transaction: %w", err)
435437 }
436438
437- // Reload the routes
438- if err := p.Reload(); err != nil {
439- return fmt.Errorf("failed to reload routes: %w", err)
439+ // Register this container to prevent duplicate processing
440+ p.RegisterNewlyCreatedContainer(containerID)
441+
442+ // Update the in-memory routes map immediately for better UX
443+ p.mu.Lock()
444+ p.routes[hostname] = &ProxyRouteInfo{
445+ Domain: hostname,
446+ ContainerID: containerID,
447+ ContainerIP: containerIP,
448+ ContainerPort: containerPort,
449+ Protocol: protocol,
450+ Path: path,
451+ Active: true,
440452 }
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+ }()
441463
442464 logger.Info("Added proxy route",
443465 "domain", hostname,
@@ -482,7 +504,7 @@ func (p *Proxy) RemoveRoute(domainName string) error {
482504
483505 // Get the domain ID
484506 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)
486508 if err != nil {
487509 if err == sql.ErrNoRows {
488510 return fmt.Errorf("domain not found: %s", domainName)
@@ -491,13 +513,13 @@ func (p *Proxy) RemoveRoute(domainName string) error {
491513 }
492514
493515 // 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)
495517 if err != nil {
496518 return fmt.Errorf("failed to delete route: %w", err)
497519 }
498520
499521 // Delete the domain with retry
500- _, err = txExecWithRetry(tx, "DELETE FROM domain WHERE id = ?", domainID)
522+ _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID)
501523 if err != nil {
502524 return fmt.Errorf("failed to delete domain: %w", err)
503525 }
@@ -561,12 +583,7 @@ func (p *Proxy) Reload() error {
561583 newRoutes := make(map[string]*ProxyRouteInfo)
562584
563585 // Load routes from the database into the new map
564- rows, err := p.app.GetDB().Query(`
565- SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active
566- FROM proxy_route pr
567- JOIN domain d ON pr.domain_id = d.id
568- WHERE pr.active = 1
569- `)
586+ rows, err := p.app.GetDB().Query(p.queries.GetActiveRoutes)
570587 if err != nil {
571588 if err == sql.ErrNoRows {
572589 logger.Info("No active proxy routes found during reload")
@@ -771,7 +788,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
771788 // Next, update the database
772789 // Get the domain ID with retry
773790 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)
775792 if err != nil {
776793 logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)
777794 return fmt.Errorf("failed to query domain: %w", err)
@@ -792,10 +809,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
792809
793810 // Update the proxy_route record with retry and ensure it's marked as active
794811 now := time.Now().Format(time.RFC3339)
795- result, err := p.dbExecWithRetry(`
796- UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ?
797- WHERE domain_id = ? AND container_id = ?`,
798- newIP, now, domainID, containerID)
812+ result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID)
799813
800814 if err != nil {
801815 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
865879 result := make(map[string]*ProxyRouteInfo)
866880
867881 // Query the database with retry to find if any route might be associated with this container name
868- rows, err := p.dbQueryWithRetry(`
869- SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path
870- FROM proxy_route pr
871- JOIN domain d ON pr.domain_id = d.id
872- WHERE pr.active = 1
873- `)
882+ rows, err := p.app.GetDB().Query(p.queries.GetAllRoutes)
874883 if err != nil {
875884 logger.Error("Failed to query database for routes by old name", "error", err)
876885 return result
@@ -930,9 +939,39 @@ func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRoute
930939 return result
931940 }
932941
933-// HandleContainerRecreation handles a container that has been recreated with the same name but a new ID
934-// It updates all relevant routes with the new container ID and IP
942+// HandleContainerRecreation handles the recreation of containers and updates routes accordingly
935943 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+
936975 logger.Info("Handling potential container recreation",
937976 "container_id", containerID,
938977 "container_name", containerName,
@@ -1104,139 +1143,154 @@ func (p *Proxy) StartContainerEventListener() error {
11041143 "container_name", containerName,
11051144 "container_ip", containerIP)
11061145
1107- // Check if this is the Gordon container (admin domain)
1108- isAdminContainer := false
1109- adminDomain := p.app.GetConfig().Http.FullDomain()
1110-
1111- p.mu.RLock()
1112- if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID {
1113- isAdminContainer = true
1114- logger.Debug("Admin container event detected", "container_id", containerID)
1146+ // Skip processing if container is in cooldown period
1147+ if p.IsContainerInCooldownPeriod(containerID) {
1148+ logger.Debug("Skipping container event processing - container in cooldown period",
1149+ "container_id", containerID,
1150+ "container_name", containerName)
1151+ return
11151152 }
1116- p.mu.RUnlock()
11171153
1118- // First, check if there are any existing routes for this container ID
1119- var existingRoutes = make(map[string]*ProxyRouteInfo)
1120- p.mu.RLock()
1121- for domain, route := range p.routes {
1122- if route.ContainerID == containerID {
1123- existingRoutes[domain] = route
1124- }
1125- }
1126- p.mu.RUnlock()
1154+ // Register this container as recently created to prevent duplicate processing
1155+ p.RegisterNewlyCreatedContainer(containerID)
11271156
1128- // If we found existing routes, check if the IP has changed
1129- if len(existingRoutes) > 0 {
1130- logger.Debug("Found existing routes for container",
1131- "container_id", containerID,
1132- "route_count", len(existingRoutes))
1157+ // Use a goroutine to handle the event asynchronously to avoid blocking the event listener
1158+ go func() {
1159+ // Add a small delay to allow any direct API calls to complete first
1160+ time.Sleep(500 * time.Millisecond)
11331161
1134- // Verify the container IP with an additional check
1135- verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName)
1136- if err == nil && verifiedIP != "" && verifiedIP != containerIP {
1137- logger.Warn("Container IP mismatch from event vs. direct network check",
1138- "container_id", containerID,
1139- "event_ip", containerIP,
1140- "network_ip", verifiedIP)
1162+ // Check if this is the Gordon container (admin domain)
1163+ isAdminContainer := false
1164+ adminDomain := p.app.GetConfig().Http.FullDomain()
11411165
1142- // Use the directly checked IP as it's more reliable
1143- containerIP = verifiedIP
1166+ p.mu.RLock()
1167+ if route, exists := p.routes[adminDomain]; exists && route.ContainerID == containerID {
1168+ isAdminContainer = true
1169+ logger.Debug("Admin container event detected", "container_id", containerID)
11441170 }
1171+ p.mu.RUnlock()
11451172
1146- // Check and update each route if IP has changed
1147- for domain, route := range existingRoutes {
1148- // Handle admin domain specially to avoid path issues
1149- if domain == adminDomain && isAdminContainer {
1150- // For admin domain, only update the IP if it has changed
1151- if route.ContainerIP != containerIP {
1152- logger.Info("Admin container restarted with new IP - updating route",
1153- "container_id", containerID,
1154- "container_name", containerName,
1155- "domain", domain,
1156- "old_ip", route.ContainerIP,
1157- "new_ip", containerIP)
1173+ // First, check if there are any existing routes for this container ID
1174+ var existingRoutes = make(map[string]*ProxyRouteInfo)
1175+ p.mu.RLock()
1176+ for domain, route := range p.routes {
1177+ if route.ContainerID == containerID {
1178+ existingRoutes[domain] = route
1179+ }
1180+ }
1181+ p.mu.RUnlock()
11581182
1159- // Update IP in memory only first
1160- p.mu.Lock()
1161- if r, ok := p.routes[domain]; ok {
1162- r.ContainerIP = containerIP
1163- r.Active = true
1164- }
1165- p.mu.Unlock()
1166-
1167- // Update DB in background
1168- go func(domain, containerID, containerIP string) {
1169- // Get the domain ID
1170- var domainID string
1171- rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain)
1172- if err != nil {
1173- logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err)
1174- return
1175- }
1183+ // If we found existing routes, check if the IP has changed
1184+ if len(existingRoutes) > 0 {
1185+ logger.Debug("Found existing routes for container",
1186+ "container_id", containerID,
1187+ "route_count", len(existingRoutes))
11761188
1177- if !rows.Next() {
1178- rows.Close()
1179- logger.Error("Admin domain not found in database", "domain", domain)
1180- return
1181- }
1189+ // Verify the container IP with an additional check
1190+ verifiedIP, err := docker.GetContainerIPFromNetwork(containerID, networkName)
1191+ if err == nil && verifiedIP != "" && verifiedIP != containerIP {
1192+ logger.Warn("Container IP mismatch from event vs. direct network check",
1193+ "container_id", containerID,
1194+ "event_ip", containerIP,
1195+ "network_ip", verifiedIP)
11821196
1183- if err := rows.Scan(&domainID); err != nil {
1184- rows.Close()
1185- logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err)
1186- return
1187- }
1188- rows.Close()
1189-
1190- // Update the IP in the database directly
1191- now := time.Now().Format(time.RFC3339)
1192- _, err = p.dbExecWithRetry(`
1193- UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ?
1194- WHERE domain_id = ? AND container_id = ?`,
1195- containerIP, now, domainID, containerID)
1196-
1197- if err != nil {
1198- logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err)
1199- } else {
1200- logger.Info("Updated admin container IP in database",
1201- "domain", domain,
1202- "container_id", containerID,
1203- "new_ip", containerIP)
1197+ // Use the directly checked IP as it's more reliable
1198+ containerIP = verifiedIP
1199+ }
1200+
1201+ // Check and update each route if IP has changed
1202+ for domain, route := range existingRoutes {
1203+ // Handle admin domain specially to avoid path issues
1204+ if domain == adminDomain && isAdminContainer {
1205+ // For admin domain, only update the IP if it has changed
1206+ if route.ContainerIP != containerIP {
1207+ logger.Info("Admin container restarted with new IP - updating route",
1208+ "container_id", containerID,
1209+ "container_name", containerName,
1210+ "domain", domain,
1211+ "old_ip", route.ContainerIP,
1212+ "new_ip", containerIP)
1213+
1214+ // Update IP in memory only first
1215+ p.mu.Lock()
1216+ if r, ok := p.routes[domain]; ok {
1217+ r.ContainerIP = containerIP
1218+ r.Active = true
12041219 }
1205- }(domain, containerID, containerIP)
1206- }
1207- } else if route.ContainerIP != containerIP {
1208- // For non-admin domains, use the standard route update
1209- logger.Info("Container restarted with new IP - updating route",
1210- "container_id", containerID,
1211- "container_name", containerName,
1212- "domain", domain,
1213- "old_ip", route.ContainerIP,
1214- "new_ip", containerIP)
1220+ p.mu.Unlock()
1221+
1222+ // Update DB in background
1223+ go func(domain, containerID, containerIP string) {
1224+ // Get the domain ID
1225+ var domainID string
1226+ rows, err := p.dbQueryWithRetry("SELECT id FROM domain WHERE name = ?", domain)
1227+ if err != nil {
1228+ logger.Error("Failed to query domain for admin IP update", "domain", domain, "error", err)
1229+ return
1230+ }
1231+
1232+ if !rows.Next() {
1233+ rows.Close()
1234+ logger.Error("Admin domain not found in database", "domain", domain)
1235+ return
1236+ }
1237+
1238+ if err := rows.Scan(&domainID); err != nil {
1239+ rows.Close()
1240+ logger.Error("Failed to scan admin domain ID", "domain", domain, "error", err)
1241+ return
1242+ }
1243+ rows.Close()
12151244
1216- // Update the route IP
1217- err := p.ForceUpdateRouteIP(domain, containerIP)
1218- if err != nil {
1219- logger.Error("Failed to update IP for restarted container",
1220- "domain", domain,
1221- "error", err)
1222- }
1223- } else {
1224- // Even if IP is the same, make sure the route is active
1225- if !route.Active {
1226- logger.Info("Reactivating route for restarted container",
1245+ // Update the IP in the database directly
1246+ now := time.Now().Format(time.RFC3339)
1247+ _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP,
1248+ containerIP, now, domainID, containerID)
1249+
1250+ if err != nil {
1251+ logger.Error("Failed to update admin container IP in database", "domain", domain, "error", err)
1252+ } else {
1253+ logger.Info("Updated admin container IP in database",
1254+ "domain", domain,
1255+ "container_id", containerID,
1256+ "new_ip", containerIP)
1257+ }
1258+ }(domain, containerID, containerIP)
1259+ }
1260+ } else if route.ContainerIP != containerIP {
1261+ // For non-admin domains, use the standard route update
1262+ logger.Info("Container restarted with new IP - updating route",
12271263 "container_id", containerID,
12281264 "container_name", containerName,
1229- "domain", domain)
1265+ "domain", domain,
1266+ "old_ip", route.ContainerIP,
1267+ "new_ip", containerIP)
12301268
1231- // Just reuse ForceUpdateRouteIP with the same IP to activate the route
1232- p.ForceUpdateRouteIP(domain, containerIP)
1269+ // Update the route IP
1270+ err = p.ForceUpdateRouteIP(domain, containerIP)
1271+ if err != nil {
1272+ logger.Error("Failed to update IP for restarted container",
1273+ "domain", domain,
1274+ "error", err)
1275+ }
1276+ } else {
1277+ // Even if IP is the same, make sure the route is active
1278+ if !route.Active {
1279+ logger.Info("Reactivating route for restarted container",
1280+ "container_id", containerID,
1281+ "container_name", containerName,
1282+ "domain", domain)
1283+
1284+ // Just reuse ForceUpdateRouteIP with the same IP to activate the route
1285+ p.ForceUpdateRouteIP(domain, containerIP)
1286+ }
12331287 }
12341288 }
12351289 }
1236- }
12371290
1238- // Call the standard recreation handler for additional checks
1239- p.HandleContainerRecreation(containerID, containerName, containerIP)
1291+ // Call the standard recreation handler for additional checks
1292+ p.HandleContainerRecreation(containerID, containerName, containerIP)
1293+ }()
12401294 }
12411295
12421296 // Create a callback function to handle container stop events
@@ -1332,7 +1386,7 @@ func (p *Proxy) checkRoutesForIPConsistency() {
13321386 "current_ip", currentIP)
13331387
13341388 // Update the route
1335- err := p.ForceUpdateRouteIP(domain, currentIP)
1389+ err = p.ForceUpdateRouteIP(domain, currentIP)
13361390 if err != nil {
13371391 logger.Error("Failed to update IP during consistency check",
13381392 "domain", domain,
@@ -1382,7 +1436,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
13821436 for _, domain := range domains {
13831437 // First, get the domain ID
13841438 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)
13861440 if err != nil {
13871441 logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)
13881442 continue
@@ -1390,7 +1444,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
13901444
13911445 // Update the route to mark it as inactive
13921446 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)
13941448 if err != nil {
13951449 logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)
13961450 continue
@@ -1426,9 +1480,6 @@ func (p *Proxy) DiscoverMissingRoutes() {
14261480 return
14271481 }
14281482
1429- // Get the admin domain from the config
1430- adminDomain := p.app.GetConfig().Http.FullDomain()
1431-
14321483 // Get all containers in the network
14331484 networkInfo, err := docker.GetNetworkInfo(networkName)
14341485 if err != nil {
@@ -1484,7 +1535,7 @@ func (p *Proxy) DiscoverMissingRoutes() {
14841535 domainLabel = strings.TrimSuffix(strings.ToLower(domainLabel), ".")
14851536
14861537 // Skip if this is the admin domain
1487- if domainLabel == adminDomain {
1538+ if domainLabel == p.app.GetConfig().Http.FullDomain() {
14881539 logger.Debug("Skipping auto-discovery for admin domain",
14891540 "domain", domainLabel)
14901541 continue
@@ -1551,93 +1602,111 @@ func (p *Proxy) LogActiveContainersAndRoutes() {
15511602 // Get the admin domain
15521603 adminDomain := p.app.GetConfig().Http.FullDomain()
15531604
1554- // Check if admin domain is in routes
15551605 p.mu.RLock()
15561606 _, adminExists := p.routes[adminDomain]
15571607 activeRoutes := len(p.routes)
1558- p.mu.RUnlock()
15591608
1560- if !adminExists {
1561- logger.Warn("Admin domain is missing from routes", "domain", adminDomain)
1562- }
1563-
1564- logger.Info(fmt.Sprintf("Found %d active routes", activeRoutes))
1565-
1566- // Create a map of container IDs to their information
1567- containerMap := make(map[string]struct {
1568- Name string
1569- IP string
1570- Image string
1571- Domains []string
1572- Protocols []string
1573- Ports []string
1609+ // Group routes by container ID directly
1610+ containerGroups := make(map[string][]string)
1611+ containerInfo := make(map[string]struct {
1612+ ContainerIP string
1613+ ContainerID string
1614+ Protocols []string
1615+ Ports []string
15741616 })
15751617
1576- // Populate the map with route information
1577- p.mu.RLock()
15781618 for domain, route := range p.routes {
15791619 if !route.Active {
15801620 continue
15811621 }
15821622
1583- // If this container is not in the map yet, add it
1584- if _, exists := containerMap[route.ContainerID]; !exists {
1585- containerMap[route.ContainerID] = struct {
1586- Name string
1587- IP string
1588- Image string
1589- Domains []string
1590- Protocols []string
1591- Ports []string
1623+ containerGroups[route.ContainerID] = append(containerGroups[route.ContainerID], domain)
1624+
1625+ // Store container info only once per container
1626+ if _, exists := containerInfo[route.ContainerID]; !exists {
1627+ containerInfo[route.ContainerID] = struct {
1628+ ContainerIP string
1629+ ContainerID string
1630+ Protocols []string
1631+ Ports []string
15921632 }{
1593- Name: route.ContainerID,
1594- IP: route.ContainerIP,
1595- Image: "unknown",
1596- Domains: []string{domain},
1597- Protocols: []string{route.Protocol},
1598- Ports: []string{route.ContainerPort},
1633+ ContainerIP: route.ContainerIP,
1634+ ContainerID: route.ContainerID,
1635+ Protocols: []string{route.Protocol},
1636+ Ports: []string{route.ContainerPort},
15991637 }
16001638 } else {
1601- // Otherwise, append this domain to the container's domains
1602- container := containerMap[route.ContainerID]
1603- container.Domains = append(container.Domains, domain)
1604- container.Protocols = append(container.Protocols, route.Protocol)
1605- container.Ports = append(container.Ports, route.ContainerPort)
1606- containerMap[route.ContainerID] = container
1639+ info := containerInfo[route.ContainerID]
1640+ info.Protocols = append(info.Protocols, route.Protocol)
1641+ info.Ports = append(info.Ports, route.ContainerPort)
1642+ containerInfo[route.ContainerID] = info
16071643 }
16081644 }
16091645 p.mu.RUnlock()
16101646
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+
16111653 // 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 {
16131658 containerInfo, err := docker.GetContainerInfo(containerID)
16141659 if err == nil {
16151660 name, err := docker.GetContainerName(containerID)
16161661 if err == nil {
1617- containerData.Name = strings.TrimLeft(name, "/")
1618- containerData.Image = containerInfo.Config.Image
1619- containerMap[containerID] = containerData
1662+ containerNames[containerID] = strings.TrimLeft(name, "/")
1663+ containerImages[containerID] = containerInfo.Config.Image
16201664 }
16211665 }
16221666 }
16231667
16241668 // Display each container and its routes
1625- for containerID, container := range containerMap {
1669+ for containerID, domains := range containerGroups {
16261670 // Log container info
16271671 // Safety check to prevent slice bounds panic
16281672 containerIDShort := containerID
16291673 if len(containerID) >= 12 {
16301674 containerIDShort = containerID[:12]
16311675 }
1632- logger.Info(fmt.Sprintf("Container: %s (%s)", container.Name, containerIDShort),
1633- "ip", container.IP,
1634- "image", container.Image)
1676+
1677+ containerName := containerID
1678+ if name, exists := containerNames[containerID]; exists {
1679+ containerName = name
1680+ }
1681+
1682+ containerImage := "unknown"
1683+ if image, exists := containerImages[containerID]; exists {
1684+ containerImage = image
1685+ }
1686+
1687+ info := containerInfo[containerID]
1688+
1689+ logger.Info(fmt.Sprintf("Container: %s (%s)", containerName, containerIDShort),
1690+ "ip", info.ContainerIP,
1691+ "image", containerImage)
16351692
16361693 // 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+
16381707 logger.Info(fmt.Sprintf(" ├─ Domain: %s", domain),
1639- "protocol", container.Protocols[i],
1640- "port", container.Ports[i])
1708+ "protocol", protocol,
1709+ "port", port)
16411710 }
16421711 }
16431712
@@ -1820,26 +1889,6 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
18201889 }
18211890 targetURL.Host = fmt.Sprintf("%s:%s", containerIP, containerPort)
18221891
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-
18431892 // Create a reverse proxy
18441893 proxy := httputil.NewSingleHostReverseProxy(targetURL)
18451894
@@ -1854,14 +1903,8 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
18541903 // Call the original director first
18551904 originalDirector(req)
18561905
1857- // Handle the Host header differently based on domain type
1858- if isAdminDomain {
1859- // For admin domain, use the target host for internal routing
1860- req.Host = targetURL.Host
1861- } else {
1862- // For other domains, keep the original host
1863- req.Host = host
1864- }
1906+ // Keep the original host in the Host header
1907+ req.Host = host
18651908
18661909 // Set X-Forwarded headers to preserve client information
18671910 if clientIP := c.RealIP(); clientIP != "" {
@@ -1874,12 +1917,6 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
18741917
18751918 // Set X-Forwarded-Host to the original host
18761919 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"))
18831920 }
18841921
18851922 // Log errors that occur during proxying
@@ -1925,3 +1962,50 @@ func (p *Proxy) proxyRequest(c echo.Context, route *ProxyRouteInfo) error {
19251962 proxy.ServeHTTP(c.Response(), c.Request())
19261963 return nil
19271964 }
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 @@
11 package server
22
33 import (
4+ "context"
45 "database/sql"
56 "fmt"
67 "io/fs"
@@ -8,6 +9,7 @@ import (
89
910 "github.com/bnema/gordon/internal/common"
1011 "github.com/bnema/gordon/internal/db"
12+ "github.com/charmbracelet/log"
1113 _ "github.com/joho/godotenv/autoload"
1214 )
1315
@@ -22,6 +24,8 @@ type App struct {
2224 Config common.Config
2325 DB *sql.DB
2426 DBTables DBTables
27+ DBSaveCtx context.Context // Context for the auto-save routine
28+ DBSaveCancel context.CancelFunc // Cancel function for the auto-save routine
2529 StartTime time.Time
2630 }
2731
@@ -82,3 +86,17 @@ func (a *App) GetUptime() string {
8286 func (a *App) GetVersionstring() string {
8387 return fmt.Sprint(a.Config.Build.BuildVersion)
8488 }
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 @@
11 package server
22
3-import "fmt"
3+import (
4+ "fmt"
45
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.
610 func CloseDB(a *App) error {
11+ log.Debug("CloseDB called, checking if database connection exists")
12+
713 if a.DB != nil {
8- // if err := saveDB(a); err != nil {
9- // return fmt.Errorf("failed to save database: %w", err)
10- // }
14+ log.Debug("Database connection exists, proceeding with cleanup")
15+
16+ // Cancel the auto-save routine
17+ if a.DBSaveCancel != nil {
18+ log.Debug("Canceling auto-save routine")
19+ a.DBSaveCancel()
20+ } else {
21+ log.Debug("No auto-save routine to cancel")
22+ }
23+
24+ log.Info("Saving in-memory database to disk before shutdown")
1125
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")
1236 if err := a.DB.Close(); err != nil {
37+ log.Error("Failed to close database connection", "error", err)
1338 return fmt.Errorf("failed to close database: %w", err)
1439 }
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")
1546 }
47+
1648 return nil
1749 }
1850
M internal/server/sqlite_init.go +359 -29
@@ -1,10 +1,12 @@
11 package server
22
33 import (
4+ "context"
45 "database/sql"
56 "fmt"
67 "os"
78 "path/filepath"
9+ "time"
810
911 "github.com/bnema/gordon/internal/db"
1012 "github.com/charmbracelet/log"
@@ -13,6 +15,8 @@ import (
1315
1416 const (
1517 DBFilename = "sqlite.db"
18+ // Auto-save interval in minutes
19+ AutoSaveInterval = 5
1620 )
1721
1822 func (a *App) getDiskDBFilePath() string {
@@ -69,39 +73,61 @@ func (a *App) checkDBFile(dbPath string) (bool, error) {
6973 return true, nil
7074 }
7175
72-// InitializeDB initializes the database
76+// InitializeDB initializes the database in memory and loads data from disk if available
7377 func InitializeDB(a *App) (*sql.DB, error) {
74- log.Debug("Initializing database")
78+ log.Debug("Initializing in-memory database")
7579 if err := a.ensureDBDir(); err != nil {
7680 return nil, fmt.Errorf("failed to ensure DB directory: %w", err)
7781 }
7882
79- dbPath := a.getDiskDBFilePath()
80- a.DBPath = dbPath
83+ diskDBPath := a.getDiskDBFilePath()
84+ a.DBPath = diskDBPath
8185
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")
8389 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)
8591 }
8692
87- if !dbExists {
88- log.Debug("Database needs bootstrapping", "path", dbPath)
89- if err := bootstrapDB(dbPath); err != nil {
90- return nil, fmt.Errorf("failed to bootstrap DB: %w", err)
91- }
93+ // Test that the in-memory database is working
94+ log.Debug("Testing in-memory database connection")
95+ if err := memDB.Ping(); err != nil {
96+ return nil, fmt.Errorf("failed to ping in-memory database: %w", err)
9297 }
9398
94- db, err := sql.Open("sqlite", dbPath)
99+ // Check if disk database exists
100+ dbExists, err := a.checkDBFile(diskDBPath)
95101 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)
97103 }
98104
99- // Test that the database is working
100- if err := db.Ping(); err != nil {
101- return nil, fmt.Errorf("failed to ping database: %w", err)
105+ if !dbExists {
106+ log.Debug("Database needs bootstrapping", "path", diskDBPath)
107+ // Create tables in memory
108+ log.Debug("Creating tables in memory")
109+ if err := bootstrapDB(memDB); err != nil {
110+ return nil, fmt.Errorf("failed to bootstrap in-memory DB: %w", err)
111+ }
112+
113+ // Save the initialized in-memory database to disk
114+ log.Debug("Saving initialized in-memory database to disk")
115+ if err := saveMemDBToDisk(memDB, diskDBPath); err != nil {
116+ return nil, fmt.Errorf("failed to save initial in-memory DB to disk: %w", err)
117+ }
118+
119+ log.Debug("Initialized new in-memory database and saved to disk")
120+ } else {
121+ // Load existing database from disk into memory
122+ log.Debug("Loading existing database from disk into memory", "path", diskDBPath)
123+ if err := loadDiskDBToMemory(diskDBPath, memDB); err != nil {
124+ return nil, fmt.Errorf("failed to load disk DB into memory: %w", err)
125+ }
126+
127+ log.Debug("Successfully loaded disk database into memory")
102128 }
103129
104- a.DB = db
130+ a.DB = memDB
105131
106132 if dbExists {
107133 // Populate the struct tables from the database
@@ -109,20 +135,320 @@ func InitializeDB(a *App) (*sql.DB, error) {
109135 if err := PopulateStructWithTables(a); err != nil {
110136 return nil, err
111137 }
112- } else {
113- log.Debug("Initialized new database")
114138 }
115139
116- return db, nil
140+ // Create a context for the auto-save routine that will be canceled when the app is shutting down
141+ a.DBSaveCtx, a.DBSaveCancel = context.WithCancel(context.Background())
142+
143+ // Start auto-save routine
144+ log.Debug("Starting auto-save routine with interval of", "minutes", AutoSaveInterval)
145+ go autoSaveDB(a.DBSaveCtx, a, AutoSaveInterval)
146+
147+ return memDB, nil
117148 }
118149
119-func bootstrapDB(dbPath string) error {
120- // Open the database file
121- db, err := sql.Open("sqlite", dbPath)
150+// loadDiskDBToMemory loads the database from disk into memory
151+func loadDiskDBToMemory(diskDBPath string, memDB *sql.DB) error {
152+ log.Debug("Opening disk database for loading", "path", diskDBPath)
153+ // Open the disk database
154+ diskDB, err := sql.Open("sqlite", diskDBPath)
122155 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)
124157 }
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")
126452
127453 // Begin a transaction
128454 tx, err := db.Begin()
@@ -131,6 +457,7 @@ func bootstrapDB(dbPath string) error {
131457 }
132458
133459 // Create tables
460+ log.Debug("Creating database tables")
134461 _, err = tx.Exec(`
135462 CREATE TABLE IF NOT EXISTS user (
136463 id TEXT PRIMARY KEY,
@@ -204,14 +531,17 @@ func bootstrapDB(dbPath string) error {
204531 );
205532 `)
206533 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)
208536 }
209537
210538 // Commit the transaction
539+ log.Debug("Committing transaction with initial schema")
211540 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)
213542 }
214543
544+ log.Debug("Database bootstrap completed successfully")
215545 return nil
216546 }
217547
@@ -296,7 +626,7 @@ func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error {
296626 }
297627
298628 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)
300630 }
301631
302632 // PopulateStructWithTables updates db struct with tables
@@ -345,7 +675,7 @@ func PopulateStructWithTables(a *App) error {
345675
346676 // Populate the ProxyRoute table
347677 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 {
349679 return err
350680 }
351681
M internal/templating/cmdparams/from_cli.go +21 -4
@@ -33,13 +33,30 @@ func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imag
3333
3434 // Protocol (http/https), Container name, Container subdomain, and Container domain
3535 protocol := parsedURL.Scheme
36- containerSubdomain := hostParts[0]
37- containerDomain := strings.Join(hostParts[1:], ".")
36+
37+ var containerSubdomain string
38+ var containerDomain string
39+
40+ if len(hostParts) == 2 {
41+ // No subdomain (e.g., domain.tld)
42+ containerSubdomain = ""
43+ containerDomain = parsedURL.Host
44+ } else {
45+ // Has subdomain (e.g., sub.domain.tld)
46+ containerSubdomain = hostParts[0]
47+ containerDomain = strings.Join(hostParts[1:], ".")
48+ }
49+
50+ // Use subdomain as container name, or domain with hyphens if no subdomain
51+ containerName := containerSubdomain
52+ if containerName == "" {
53+ containerName = strings.ReplaceAll(containerDomain, ".", "-")
54+ }
3855
3956 params := docker.ContainerCommandParams{
4057 IsSSL: protocol == "https",
41- ContainerName: containerSubdomain,
42- ServiceName: containerSubdomain,
58+ ContainerName: containerName,
59+ ServiceName: containerName,
4360 Domain: containerDomain,
4461 ImageName: ppl.ImageName,
4562 ImageID: imageID,
M internal/templating/models/html/fragments/createcontainer.gohtml +13 -1
@@ -65,7 +65,7 @@
6565 <div class="text-xs text-gray-500 mb-2">
6666 Specify the internal container port that the proxy will route traffic to
6767 </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 />
6969 </div>
7070
7171 <!-- Volumes -->
@@ -92,6 +92,18 @@
9292 </select>
9393 </div>
9494
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+
95107 <input type="submit" value="Create" id="create-container-submit"
96108 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" />
97109 </form>
M internal/webui/public/assets/css/tailwind.css +270 -158
@@ -6,7 +6,115 @@
66
77 /* Embeded path : assets/css/cutsom.css */
88
9-/*! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com
9+*, ::before, ::after {
10+ --tw-border-spacing-x: 0;
11+ --tw-border-spacing-y: 0;
12+ --tw-translate-x: 0;
13+ --tw-translate-y: 0;
14+ --tw-rotate: 0;
15+ --tw-skew-x: 0;
16+ --tw-skew-y: 0;
17+ --tw-scale-x: 1;
18+ --tw-scale-y: 1;
19+ --tw-pan-x: ;
20+ --tw-pan-y: ;
21+ --tw-pinch-zoom: ;
22+ --tw-scroll-snap-strictness: proximity;
23+ --tw-gradient-from-position: ;
24+ --tw-gradient-via-position: ;
25+ --tw-gradient-to-position: ;
26+ --tw-ordinal: ;
27+ --tw-slashed-zero: ;
28+ --tw-numeric-figure: ;
29+ --tw-numeric-spacing: ;
30+ --tw-numeric-fraction: ;
31+ --tw-ring-inset: ;
32+ --tw-ring-offset-width: 0px;
33+ --tw-ring-offset-color: #fff;
34+ --tw-ring-color: rgb(59 130 246 / 0.5);
35+ --tw-ring-offset-shadow: 0 0 #0000;
36+ --tw-ring-shadow: 0 0 #0000;
37+ --tw-shadow: 0 0 #0000;
38+ --tw-shadow-colored: 0 0 #0000;
39+ --tw-blur: ;
40+ --tw-brightness: ;
41+ --tw-contrast: ;
42+ --tw-grayscale: ;
43+ --tw-hue-rotate: ;
44+ --tw-invert: ;
45+ --tw-saturate: ;
46+ --tw-sepia: ;
47+ --tw-drop-shadow: ;
48+ --tw-backdrop-blur: ;
49+ --tw-backdrop-brightness: ;
50+ --tw-backdrop-contrast: ;
51+ --tw-backdrop-grayscale: ;
52+ --tw-backdrop-hue-rotate: ;
53+ --tw-backdrop-invert: ;
54+ --tw-backdrop-opacity: ;
55+ --tw-backdrop-saturate: ;
56+ --tw-backdrop-sepia: ;
57+ --tw-contain-size: ;
58+ --tw-contain-layout: ;
59+ --tw-contain-paint: ;
60+ --tw-contain-style: ;
61+}
62+
63+::backdrop {
64+ --tw-border-spacing-x: 0;
65+ --tw-border-spacing-y: 0;
66+ --tw-translate-x: 0;
67+ --tw-translate-y: 0;
68+ --tw-rotate: 0;
69+ --tw-skew-x: 0;
70+ --tw-skew-y: 0;
71+ --tw-scale-x: 1;
72+ --tw-scale-y: 1;
73+ --tw-pan-x: ;
74+ --tw-pan-y: ;
75+ --tw-pinch-zoom: ;
76+ --tw-scroll-snap-strictness: proximity;
77+ --tw-gradient-from-position: ;
78+ --tw-gradient-via-position: ;
79+ --tw-gradient-to-position: ;
80+ --tw-ordinal: ;
81+ --tw-slashed-zero: ;
82+ --tw-numeric-figure: ;
83+ --tw-numeric-spacing: ;
84+ --tw-numeric-fraction: ;
85+ --tw-ring-inset: ;
86+ --tw-ring-offset-width: 0px;
87+ --tw-ring-offset-color: #fff;
88+ --tw-ring-color: rgb(59 130 246 / 0.5);
89+ --tw-ring-offset-shadow: 0 0 #0000;
90+ --tw-ring-shadow: 0 0 #0000;
91+ --tw-shadow: 0 0 #0000;
92+ --tw-shadow-colored: 0 0 #0000;
93+ --tw-blur: ;
94+ --tw-brightness: ;
95+ --tw-contrast: ;
96+ --tw-grayscale: ;
97+ --tw-hue-rotate: ;
98+ --tw-invert: ;
99+ --tw-saturate: ;
100+ --tw-sepia: ;
101+ --tw-drop-shadow: ;
102+ --tw-backdrop-blur: ;
103+ --tw-backdrop-brightness: ;
104+ --tw-backdrop-contrast: ;
105+ --tw-backdrop-grayscale: ;
106+ --tw-backdrop-hue-rotate: ;
107+ --tw-backdrop-invert: ;
108+ --tw-backdrop-opacity: ;
109+ --tw-backdrop-saturate: ;
110+ --tw-backdrop-sepia: ;
111+ --tw-contain-size: ;
112+ --tw-contain-layout: ;
113+ --tw-contain-paint: ;
114+ --tw-contain-style: ;
115+}
116+
117+/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com
10118 */
11119
12120 /*
@@ -449,118 +557,10 @@ video {
449557
450558 /* Make elements with the HTML hidden attribute stay hidden by default */
451559
452-[hidden] {
560+[hidden]:where(:not([hidden="until-found"])) {
453561 display: none;
454562 }
455563
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-
564564 .container {
565565 width: 100%;
566566 }
@@ -603,6 +603,10 @@ video {
603603 position: fixed;
604604 }
605605
606+.relative {
607+ position: relative;
608+}
609+
606610 .left-0 {
607611 left: 0px;
608612 }
@@ -611,23 +615,38 @@ video {
611615 top: 0px;
612616 }
613617
614-.my-2 {
615- margin-top: 0.5rem;
616- margin-bottom: 0.5rem;
617-}
618-
619618 .mb-1 {
620619 margin-bottom: 0.25rem;
621620 }
622621
622+.mb-2 {
623+ margin-bottom: 0.5rem;
624+}
625+
623626 .mb-4 {
624627 margin-bottom: 1rem;
625628 }
626629
630+.mb-6 {
631+ margin-bottom: 1.5rem;
632+}
633+
627634 .ml-2 {
628635 margin-left: 0.5rem;
629636 }
630637
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+
631650 .mt-2 {
632651 margin-top: 0.5rem;
633652 }
@@ -636,6 +655,10 @@ video {
636655 margin-top: 1rem;
637656 }
638657
658+.mt-8 {
659+ margin-top: 2rem;
660+}
661+
639662 .block {
640663 display: block;
641664 }
@@ -664,8 +687,8 @@ video {
664687 height: 11rem;
665688 }
666689
667-.h-64 {
668- height: 16rem;
690+.h-5 {
691+ height: 1.25rem;
669692 }
670693
671694 .h-8 {
@@ -688,14 +711,26 @@ video {
688711 width: 66.666667%;
689712 }
690713
714+.w-5 {
715+ width: 1.25rem;
716+}
717+
691718 .w-5\/12 {
692719 width: 41.666667%;
693720 }
694721
722+.w-8 {
723+ width: 2rem;
724+}
725+
695726 .w-full {
696727 width: 100%;
697728 }
698729
730+.max-w-md {
731+ max-width: 28rem;
732+}
733+
699734 .flex-1 {
700735 flex: 1 1 0%;
701736 }
@@ -712,6 +747,10 @@ video {
712747 cursor: pointer;
713748 }
714749
750+.list-disc {
751+ list-style-type: disc;
752+}
753+
715754 .flex-row {
716755 flex-direction: row;
717756 }
@@ -748,8 +787,10 @@ video {
748787 justify-content: space-evenly;
749788 }
750789
751-.justify-items-center {
752- justify-items: center;
790+.space-x-2 > :not([hidden]) ~ :not([hidden]) {
791+ --tw-space-x-reverse: 0;
792+ margin-right: calc(0.5rem * var(--tw-space-x-reverse));
793+ margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
753794 }
754795
755796 .space-x-4 > :not([hidden]) ~ :not([hidden]) {
@@ -758,12 +799,6 @@ video {
758799 margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
759800 }
760801
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-
767802 .space-y-4 > :not([hidden]) ~ :not([hidden]) {
768803 --tw-space-y-reverse: 0;
769804 margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
@@ -780,6 +815,10 @@ video {
780815 border-radius: 0.25rem;
781816 }
782817
818+.rounded-full {
819+ border-radius: 9999px;
820+}
821+
783822 .rounded-md {
784823 border-radius: 0.375rem;
785824 }
@@ -802,42 +841,62 @@ video {
802841
803842 .border-gray-300 {
804843 --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));
806850 }
807851
808852 .bg-blue-500 {
809853 --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));
811855 }
812856
813857 .bg-gordonmascot-hblue {
814858 --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));
816860 }
817861
818862 .bg-gordonmascot-lblue {
819863 --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));
821870 }
822871
823872 .bg-gray-100 {
824873 --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));
826875 }
827876
828877 .bg-gray-300 {
829878 --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));
831890 }
832891
833892 .bg-slate-100 {
834893 --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));
836895 }
837896
838897 .bg-slate-200 {
839898 --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));
841900 }
842901
843902 .bg-transparent {
@@ -846,12 +905,12 @@ video {
846905
847906 .bg-white {
848907 --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));
850909 }
851910
852911 .bg-zinc-200 {
853912 --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));
855914 }
856915
857916 .p-0 {
@@ -871,6 +930,11 @@ video {
871930 padding-right: 0.5rem;
872931 }
873932
933+.px-3 {
934+ padding-left: 0.75rem;
935+ padding-right: 0.75rem;
936+}
937+
874938 .px-4 {
875939 padding-left: 1rem;
876940 padding-right: 1rem;
@@ -891,6 +955,11 @@ video {
891955 padding-bottom: 0.5rem;
892956 }
893957
958+.py-3 {
959+ padding-top: 0.75rem;
960+ padding-bottom: 0.75rem;
961+}
962+
894963 .pb-4 {
895964 padding-bottom: 1rem;
896965 }
@@ -955,6 +1024,10 @@ video {
9551024 font-weight: 700;
9561025 }
9571026
1027+.font-medium {
1028+ font-weight: 500;
1029+}
1030+
9581031 .font-semibold {
9591032 font-weight: 600;
9601033 }
@@ -965,67 +1038,72 @@ video {
9651038
9661039 .text-blue-600 {
9671040 --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));
9691042 }
9701043
9711044 .text-blue-800 {
9721045 --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));
9741047 }
9751048
9761049 .text-gordonmascot-hblue {
9771050 --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));
9791052 }
9801053
9811054 .text-gordonmascot-mblue {
9821055 --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));
9841057 }
9851058
9861059 .text-gray-100 {
9871060 --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));
9891062 }
9901063
9911064 .text-gray-400 {
9921065 --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));
9941067 }
9951068
9961069 .text-gray-500 {
9971070 --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));
9991077 }
10001078
10011079 .text-gray-800 {
10021080 --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));
10041082 }
10051083
10061084 .text-green-300 {
10071085 --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));
10091087 }
10101088
10111089 .text-red-500 {
10121090 --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));
10141092 }
10151093
10161094 .text-red-600 {
10171095 --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));
10191097 }
10201098
1021-.text-slate-100 {
1099+.text-red-700 {
10221100 --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));
10241102 }
10251103
10261104 .text-white {
10271105 --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));
10291107 }
10301108
10311109 .shadow {
@@ -1034,6 +1112,12 @@ video {
10341112 box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
10351113 }
10361114
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+
10371121 .outline-none {
10381122 outline: 2px solid transparent;
10391123 outline-offset: 2px;
@@ -1047,6 +1131,14 @@ video {
10471131 transition-duration: 150ms;
10481132 }
10491133
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+
10501142 /* Import IBM Plex Fonts */
10511143
10521144 @font-face {
@@ -1185,30 +1277,54 @@ video {
11851277
11861278 .hover\:bg-blue-700:hover {
11871279 --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));
11891286 }
11901287
11911288 .hover\:bg-gordonmascot-mblue:hover {
11921289 --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));
11941291 }
11951292
11961293 .hover\:bg-slate-100:hover {
11971294 --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));
11991296 }
12001297
12011298 .hover\:text-gordonmascot-hblue:hover {
12021299 --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));
12041301 }
12051302
12061303 .hover\:text-red-900:hover {
12071304 --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));
12091321 }
12101322
12111323 @media (min-width: 640px) {
1324+ .sm\:inline {
1325+ display: inline;
1326+ }
1327+
12121328 .sm\:h-24 {
12131329 height: 6rem;
12141330 }
@@ -1223,10 +1339,6 @@ video {
12231339 height: 4rem;
12241340 }
12251341
1226- .md\:w-1\/2 {
1227- width: 50%;
1228- }
1229-
12301342 .md\:w-2\/6 {
12311343 width: 33.333333%;
12321344 }
M package.json +3 -4
@@ -1,16 +1,15 @@
11 {
22 "devDependencies": {
33 "@catppuccin/tailwindcss": "^0.1.6",
4- "prettier": "^3.3.3",
4+ "prettier": "^3.5.3",
55 "prettier-plugin-tailwindcss": "^0.5.14",
6- "tailwindcss": "^3.4.14"
6+ "tailwindcss": "^3.4.17"
77 },
88 "scripts": {
99 "build:css": "tailwindcss build src/styles.css -o dist/styles.css",
1010 "dev:css": "tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css --watch"
1111 },
1212 "dependencies": {
13- "caniuse-lite": "^1.0.30001669",
14- "dotenv": "^16.4.5"
13+ "dotenv": "^16.4.7"
1514 }
1615 }

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