detached 8d0d173

feat: Implement ACME certificate management

- Refactored token initialization to remove unnecessary logging.
- Updated database creation function to support transactions.
- Added a comprehensive ACME certificate management system, including user registration, certificate obtaining, and renewal.
- Enhanced database schema to accommodate new fields for ACME configuration in the Domain struct.
- Introduced a new file for ACME-related operations and integrated it with existing proxy logic.
- Implemented database update logic to ensure schema consistency with struct definitions.
- Added detailed logging for better traceability of ACME operations and database interactions.
commit
8d0d1736a2ed5c3df07fe57d5ca0f7d400287dc5
author
bamen · · 2025-03-29 08:05 UTC
parent
da25f67
signature
unsigned
26 files · +3530 -3073
M go.mod +164 -4
@@ -13,6 +13,7 @@ require (
13 github.com/docker/docker v28.0.1+incompatible13 github.com/docker/docker v28.0.1+incompatible
14 github.com/docker/go-connections v0.5.014 github.com/docker/go-connections v0.5.0
15 github.com/fatih/color v1.18.015 github.com/fatih/color v1.18.0
16+ github.com/go-acme/lego/v4 v4.22.2
16 github.com/gorilla/sessions v1.4.017 github.com/gorilla/sessions v1.4.0
17 github.com/joho/godotenv v1.5.118 github.com/joho/godotenv v1.5.1
18 github.com/labstack/echo-contrib v0.17.219 github.com/labstack/echo-contrib v0.17.2
@@ -25,21 +26,180 @@ require (
25 )26 )
26 27
27 require (28 require (
29+ cloud.google.com/go/auth v0.13.0 // indirect
30+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
31+ cloud.google.com/go/compute/metadata v0.6.0 // indirect
32+ github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
33+ github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
34+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect
35+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect
36+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
37+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
38+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
39+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
40+ github.com/Azure/go-autorest v14.2.0+incompatible // indirect
41+ github.com/Azure/go-autorest/autorest v0.11.29 // indirect
42+ github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect
43+ github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect
44+ github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
45+ github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
46+ github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
47+ github.com/Azure/go-autorest/logger v0.2.1 // indirect
48+ github.com/Azure/go-autorest/tracing v0.6.0 // indirect
49+ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
50+ github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
28 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect51 github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
52+ github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
53+ github.com/aliyun/alibaba-cloud-sdk-go v1.63.72 // indirect
29 github.com/andybalholm/brotli v1.1.0 // indirect54 github.com/andybalholm/brotli v1.1.0 // indirect
55+ github.com/aws/aws-sdk-go-v2 v1.32.7 // indirect
56+ github.com/aws/aws-sdk-go-v2/config v1.28.7 // indirect
57+ github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect
58+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect
59+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect
60+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect
61+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
62+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
63+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect
64+ github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.8 // indirect
65+ github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4 // indirect
66+ github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect
67+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect
68+ github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect
69+ github.com/aws/smithy-go v1.22.1 // indirect
70+ github.com/benbjohnson/clock v1.3.0 // indirect
71+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
30 github.com/cenkalti/backoff/v4 v4.3.0 // indirect72 github.com/cenkalti/backoff/v4 v4.3.0 // indirect
31 github.com/cespare/xxhash/v2 v2.3.0 // indirect73 github.com/cespare/xxhash/v2 v2.3.0 // indirect
74+ github.com/civo/civogo v0.3.11 // indirect
32 github.com/cli/browser v1.3.0 // indirect75 github.com/cli/browser v1.3.0 // indirect
33- github.com/fsnotify/fsnotify v1.7.0 // indirect76+ github.com/cloudflare/cloudflare-go v0.112.0 // indirect
77+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
78+ github.com/dimchansky/utfbom v1.1.1 // indirect
79+ github.com/dnsimple/dnsimple-go v1.7.0 // indirect
80+ github.com/exoscale/egoscale/v3 v3.1.7 // indirect
81+ github.com/fatih/structs v1.1.0 // indirect
82+ github.com/fsnotify/fsnotify v1.8.0 // indirect
83+ github.com/gabriel-vasile/mimetype v1.4.2 // indirect
84+ github.com/ghodss/yaml v1.0.0 // indirect
85+ github.com/go-errors/errors v1.0.1 // indirect
86+ github.com/go-jose/go-jose/v4 v4.0.4 // indirect
87+ github.com/go-playground/locales v0.14.1 // indirect
88+ github.com/go-playground/universal-translator v0.18.1 // indirect
89+ github.com/go-playground/validator/v10 v10.16.0 // indirect
90+ github.com/go-resty/resty/v2 v2.16.2 // indirect
91+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
92+ github.com/goccy/go-json v0.10.4 // indirect
93+ github.com/gofrs/flock v0.12.1 // indirect
94+ github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
95+ github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
96+ github.com/google/go-querystring v1.1.0 // indirect
97+ github.com/google/s2a-go v0.1.8 // indirect
98+ github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
99+ github.com/googleapis/gax-go/v2 v2.14.0 // indirect
100+ github.com/gophercloud/gophercloud v1.14.1 // indirect
101+ github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
102+ github.com/hashicorp/errwrap v1.0.0 // indirect
103+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
104+ github.com/hashicorp/go-multierror v1.1.1 // indirect
105+ github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
106+ github.com/hashicorp/go-uuid v1.0.3 // indirect
107+ github.com/hashicorp/hcl v1.0.0 // indirect
108+ github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.128 // indirect
109+ github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
110+ github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
111+ github.com/jmespath/go-jmespath v0.4.0 // indirect
112+ github.com/json-iterator/go v1.1.12 // indirect
113+ github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
34 github.com/klauspost/compress v1.18.0 // indirect114 github.com/klauspost/compress v1.18.0 // indirect
35 github.com/klauspost/cpuid/v2 v2.2.10 // indirect115 github.com/klauspost/cpuid/v2 v2.2.10 // indirect
116+ github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
117+ github.com/kylelemons/godebug v1.1.0 // indirect
118+ github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
119+ github.com/labbsr0x/goh v1.0.1 // indirect
120+ github.com/leodido/go-urn v1.2.4 // indirect
121+ github.com/linode/linodego v1.44.0 // indirect
122+ github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
123+ github.com/liquidweb/liquidweb-go v1.6.4 // indirect
124+ github.com/magiconair/properties v1.8.7 // indirect
125+ github.com/miekg/dns v1.1.62 // indirect
126+ github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
127+ github.com/mitchellh/go-homedir v1.1.0 // indirect
128+ github.com/mitchellh/mapstructure v1.5.0 // indirect
129+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
130+ github.com/modern-go/reflect2 v1.0.2 // indirect
131+ github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
36 github.com/natefinch/atomic v1.0.1 // indirect132 github.com/natefinch/atomic v1.0.1 // indirect
133+ github.com/nrdcg/auroradns v1.1.0 // indirect
134+ github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect
135+ github.com/nrdcg/desec v0.10.0 // indirect
136+ github.com/nrdcg/dnspod-go v0.4.0 // indirect
137+ github.com/nrdcg/freemyip v0.3.0 // indirect
138+ github.com/nrdcg/goacmedns v0.2.0 // indirect
139+ github.com/nrdcg/goinwx v0.10.0 // indirect
140+ github.com/nrdcg/mailinabox v0.2.0 // indirect
141+ github.com/nrdcg/namesilo v0.2.1 // indirect
142+ github.com/nrdcg/nodion v0.1.0 // indirect
143+ github.com/nrdcg/porkbun v0.4.0 // indirect
144+ github.com/nzdjb/go-metaname v1.0.0 // indirect
145+ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
146+ github.com/oracle/oci-go-sdk/v65 v65.81.1 // indirect
147+ github.com/ovh/go-ovh v1.6.0 // indirect
148+ github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
149+ github.com/pelletier/go-toml/v2 v2.1.0 // indirect
150+ github.com/peterhellberg/link v1.2.0 // indirect
151+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
152+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
153+ github.com/pquerna/otp v1.4.0 // indirect
154+ github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
155+ github.com/sacloud/api-client-go v0.2.10 // indirect
156+ github.com/sacloud/go-http v0.1.8 // indirect
157+ github.com/sacloud/iaas-api-go v1.14.0 // indirect
158+ github.com/sacloud/packages-go v0.0.10 // indirect
159+ github.com/sagikazarmark/locafero v0.4.0 // indirect
160+ github.com/sagikazarmark/slog-shim v0.1.0 // indirect
161+ github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 // indirect
162+ github.com/selectel/domains-go v1.1.0 // indirect
163+ github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect
164+ github.com/shopspring/decimal v1.3.1 // indirect
165+ github.com/sirupsen/logrus v1.9.3 // indirect
166+ github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
167+ github.com/softlayer/softlayer-go v1.1.7 // indirect
168+ github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
169+ github.com/sony/gobreaker v0.5.0 // indirect
170+ github.com/sourcegraph/conc v0.3.0 // indirect
171+ github.com/spf13/afero v1.11.0 // indirect
172+ github.com/spf13/cast v1.6.0 // indirect
173+ github.com/spf13/viper v1.18.2 // indirect
174+ github.com/stretchr/testify v1.10.0 // indirect
175+ github.com/subosito/gotenv v1.6.0 // indirect
176+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1065 // indirect
177+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1065 // indirect
178+ github.com/tjfoc/gmsm v1.4.1 // indirect
179+ github.com/transip/gotransip/v6 v6.26.0 // indirect
180+ github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect
181+ github.com/vinyldns/go-vinyldns v0.9.16 // indirect
182+ github.com/volcengine/volc-sdk-golang v1.0.189 // indirect
183+ github.com/vultr/govultr/v3 v3.9.1 // indirect
184+ github.com/yandex-cloud/go-genproto v0.0.0-20241220122821-aeb3b05efd1c // indirect
185+ github.com/yandex-cloud/go-sdk v0.0.0-20241220131134-2393e243c134 // indirect
37 github.com/zeebo/xxh3 v1.0.2 // indirect186 github.com/zeebo/xxh3 v1.0.2 // indirect
38 go.mongodb.org/mongo-driver v1.17.3 // indirect187 go.mongodb.org/mongo-driver v1.17.3 // indirect
39 go.opentelemetry.io/auto/sdk v1.1.0 // indirect188 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
189+ go.uber.org/atomic v1.11.0 // indirect
190+ go.uber.org/multierr v1.9.0 // indirect
191+ go.uber.org/ratelimit v0.3.0 // indirect
40 golang.org/x/mod v0.24.0 // indirect192 golang.org/x/mod v0.24.0 // indirect
193+ golang.org/x/oauth2 v0.24.0 // indirect
41 golang.org/x/tools v0.31.0 // indirect194 golang.org/x/tools v0.31.0 // indirect
42- google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect195+ google.golang.org/api v0.214.0 // indirect
196+ google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect
197+ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
198+ google.golang.org/grpc v1.69.2 // indirect
199+ google.golang.org/protobuf v1.36.1 // indirect
200+ gopkg.in/ini.v1 v1.67.0 // indirect
201+ gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect
202+ gopkg.in/yaml.v2 v2.4.0 // indirect
43 )203 )
44 204
45 require (205 require (
@@ -78,7 +238,7 @@ require (
78 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // direct238 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // direct
79 golang.org/x/sync v0.12.0 // indirect239 golang.org/x/sync v0.12.0 // indirect
80 golang.org/x/term v0.30.0 // indirect240 golang.org/x/term v0.30.0 // indirect
81- google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect241+ google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
82 modernc.org/libc v1.61.13 // indirect242 modernc.org/libc v1.61.13 // indirect
83 modernc.org/mathutil v1.7.1 // indirect243 modernc.org/mathutil v1.7.1 // indirect
84 modernc.org/memory v1.8.2 // indirect244 modernc.org/memory v1.8.2 // indirect
@@ -104,7 +264,7 @@ require (
104 github.com/pkg/errors v0.9.1 // indirect264 github.com/pkg/errors v0.9.1 // indirect
105 github.com/valyala/bytebufferpool v1.0.0 // indirect265 github.com/valyala/bytebufferpool v1.0.0 // indirect
106 github.com/valyala/fasttemplate v1.2.2 // indirect266 github.com/valyala/fasttemplate v1.2.2 // indirect
107- golang.org/x/crypto v0.36.0267+ golang.org/x/crypto v0.36.0 // indirect
108 golang.org/x/net v0.37.0 // indirect268 golang.org/x/net v0.37.0 // indirect
109 golang.org/x/sys v0.31.0 // indirect269 golang.org/x/sys v0.31.0 // indirect
110 golang.org/x/text v0.23.0 // indirect270 golang.org/x/text v0.23.0 // indirect
M go.sum +1236 -8 generated file: collapsed
M internal/cli/cmd/proxy.go +6 -7
@@ -134,11 +134,8 @@ func NewProxyCommand(a *server.App) *cobra.Command {
134 }134 }
135 135
136 // List routes from the database136 // List routes from the database
137- rows, err := a.DB.Query(`137+ // Use the query from the instantiated proxy queries
138- SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.active138+ rows, err := a.DB.Query(p.Queries.GetAllRoutes)
139- FROM proxy_route pr
140- JOIN domain d ON pr.domain_id = d.id
141- `)
142 if err != nil {139 if err != nil {
143 log.Fatal("Failed to query routes:", err)140 log.Fatal("Failed to query routes:", err)
144 }141 }
@@ -150,9 +147,11 @@ func NewProxyCommand(a *server.App) *cobra.Command {
150 147
151 var count int148 var count int
152 for rows.Next() {149 for rows.Next() {
153- var domain, containerID, containerIP, containerPort string150+ // Adjust variables to match the columns selected by GetAllRoutes
151+ var id, domain, containerID, containerIP, containerPort, protocol, path string
154 var active bool152 var active bool
155- if err := rows.Scan(&domain, &containerID, &containerIP, &containerPort, &active); err != nil {153+ // Adjust scan targets
154+ if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil {
156 log.Fatal("Failed to scan row:", err)155 log.Fatal("Failed to scan row:", err)
157 }156 }
158 157
M internal/common/init_config.go +18 -14
@@ -74,20 +74,24 @@ type ContainerEngineConfig struct {
74 }74 }
75 75
76 type ReverseProxyConfig struct {76 type ReverseProxyConfig struct {
77- Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled77+ Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
78- Port string `yaml:"port"` // Port for the reverse proxy to listen on78+ Port string `yaml:"port"` // Port for the reverse proxy to listen on
79- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS79+ HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
80- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates80+ CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
81- AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates81+ Email string `yaml:"email"` // Email for Let's Encrypt
82- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates82+ LetsEncryptMode string `yaml:"letsEncryptMode"` // staging or production
83- LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production"83+ SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy
84- Email string `yaml:"email"` // Email for Let's Encrypt84+ GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
85- CacheSize int `yaml:"cacheSize"` // Size of the certificate cache85+ AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates
86- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds86+ RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
87- EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging (default: true)87+ CacheSize int `yaml:"cacheSize"` // Size of the certificate cache
88- EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware (default: false)88+ EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging
89- DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies (default: false)89+ EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware
90- SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy (default: false)90+ DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies
91+ DefaultChallengeType string `yaml:"defaultChallengeType"` // http-01 or dns-01
92+ DefaultHttpChallengePort string `yaml:"defaultHttpChallengePort"`
93+ DefaultDnsChallengePropagationTimeout int `yaml:"defaultDnsChallengePropagationTimeout"`
94+ DefaultDnsChallengePollingInterval int `yaml:"defaultDnsChallengePollingInterval"`
91 }95 }
92 96
93 // Default values97 // Default values
M internal/db/db_struct.go +22 -5
@@ -1,5 +1,9 @@
1 package db1 package db
2 2
3+import (
4+ "database/sql"
5+)
6+
3 // User struct model7 // User struct model
4 type User struct {8 type User struct {
5 ID string `sql:"id, primary_key"`9 ID string `sql:"id, primary_key"`
@@ -46,11 +50,15 @@ type Clients struct {
46 50
47 // Domain represents a domain configuration for the proxy51 // Domain represents a domain configuration for the proxy
48 type Domain struct {52 type Domain struct {
49- ID string `sql:"id, primary_key"`53+ ID string `sql:"id, primary_key"`
50- Name string `sql:"name"` // e.g. example.com54+ Name string `sql:"name"` // e.g. example.com
51- AccountID string `sql:"account_id, foreign_key=account.id"`55+ AccountID string `sql:"account_id, foreign_key=account.id"`
52- CreatedAt string `sql:"created_at"`56+ CreatedAt string `sql:"created_at"`
53- UpdatedAt string `sql:"updated_at"`57+ UpdatedAt string `sql:"updated_at"`
58+ AcmeEnabled bool `sql:"acme_enabled"` // Whether ACME is enabled for this domain
59+ AcmeChallengeType sql.NullString `sql:"acme_challenge_type"` // e.g. "http-01", "dns-01"
60+ AcmeDnsProvider sql.NullString `sql:"acme_dns_provider"` // DNS provider name if using DNS-01
61+ AcmeDnsCredentialsRef sql.NullString `sql:"acme_dns_credentials_ref"` // Reference to credentials (not raw credentials)
54 }62 }
55 63
56 // Certificate represents a TLS certificate for a domain64 // Certificate represents a TLS certificate for a domain
@@ -79,6 +87,15 @@ type ProxyRoute struct {
79 UpdatedAt string `sql:"updated_at"`87 UpdatedAt string `sql:"updated_at"`
80 }88 }
81 89
90+// AcmeAccount represents the stored ACME account details
91+type AcmeAccount struct {
92+ Email string `sql:"email, primary_key"`
93+ PrivateKey string `sql:"private_key"` // PEM encoded private key
94+ RegistrationInfo sql.NullString `sql:"registration_info"` // JSON marshaled registration resource
95+ CreatedAt string `sql:"created_at"`
96+ UpdatedAt string `sql:"updated_at"`
97+}
98+
82 // GithubUserInfo holds the essential information for a Github user99 // GithubUserInfo holds the essential information for a Github user
83 type GithubUserInfo struct {100 type GithubUserInfo struct {
84 Login string `json:"login"`101 Login string `json:"login"`
M internal/db/queries/account.go +14 -12
@@ -2,29 +2,31 @@ package queries
2 2
3 import (3 import (
4 "database/sql"4 "database/sql"
5+
5 "github.com/bnema/gordon/internal/db"6 "github.com/bnema/gordon/internal/db"
7+ "github.com/google/uuid"
6 )8 )
7 9
8 // AccountQueries contains all SQL queries for account operations10 // AccountQueries contains all SQL queries for account operations
9 type AccountQueries struct {11 type AccountQueries struct {
10- InsertAccount string12+ InsertAccount string
11 CheckAccountExists string13 CheckAccountExists string
12- GetAccountByID string14+ GetAccountByID string
13 }15 }
14 16
15 // NewAccountQueries returns a new instance of AccountQueries17 // NewAccountQueries returns a new instance of AccountQueries
16 func NewAccountQueries() *AccountQueries {18 func NewAccountQueries() *AccountQueries {
17 return &AccountQueries{19 return &AccountQueries{
18- InsertAccount: "INSERT INTO account (id, user_id) VALUES (?, ?)",20+ InsertAccount: "INSERT INTO account (id, user_id) VALUES (?, ?)",
19 CheckAccountExists: "SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)",21 CheckAccountExists: "SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)",
20- GetAccountByID: "SELECT id, user_id FROM account WHERE id = ?",22+ GetAccountByID: "SELECT id, user_id FROM account WHERE id = ?",
21 }23 }
22 }24 }
23 25
24 // CreateDBAccount creates a new account in the database26 // CreateDBAccount creates a new account in the database
25 func CreateDBAccount(database *sql.DB, userID string) (*db.Account, error) {27 func CreateDBAccount(database *sql.DB, userID string) (*db.Account, error) {
26 account := &db.Account{28 account := &db.Account{
27- ID: generateUUID(),29+ ID: uuid.NewString(),
28 UserID: userID,30 UserID: userID,
29 }31 }
30 32
@@ -32,23 +34,23 @@ func CreateDBAccount(database *sql.DB, userID string) (*db.Account, error) {
32 NewAccountQueries().InsertAccount,34 NewAccountQueries().InsertAccount,
33 account.ID, userID,35 account.ID, userID,
34 )36 )
35- 37+
36 return account, err38 return account, err
37 }39 }
38 40
39 // CheckDBAccountExists checks if an account exists in the database41 // CheckDBAccountExists checks if an account exists in the database
40 func CheckDBAccountExists(database *sql.DB, accountID string) (bool, error) {42 func CheckDBAccountExists(database *sql.DB, accountID string) (bool, error) {
41 var exists bool43 var exists bool
42- 44+
43 err := database.QueryRow(45 err := database.QueryRow(
44 NewAccountQueries().CheckAccountExists,46 NewAccountQueries().CheckAccountExists,
45 accountID,47 accountID,
46 ).Scan(&exists)48 ).Scan(&exists)
47- 49+
48 if err != nil {50 if err != nil {
49 return false, err51 return false, err
50 }52 }
51- 53+
52 return exists, nil54 return exists, nil
53 }55 }
54 56
@@ -57,15 +59,15 @@ func GetDBAccountByID(database *sql.DB, accountID string) (*db.Account, error) {
57 account := &db.Account{59 account := &db.Account{
58 ID: accountID,60 ID: accountID,
59 }61 }
60- 62+
61 err := database.QueryRow(63 err := database.QueryRow(
62 NewAccountQueries().GetAccountByID,64 NewAccountQueries().GetAccountByID,
63 accountID,65 accountID,
64 ).Scan(&account.ID, &account.UserID)66 ).Scan(&account.ID, &account.UserID)
65- 67+
66 if err != nil {68 if err != nil {
67 return nil, err69 return nil, err
68 }70 }
69- 71+
70 return account, nil72 return account, nil
71 }73 }
M internal/db/queries/provider.go +94 -17
@@ -2,15 +2,20 @@ package queries
2 2
3 import (3 import (
4 "database/sql"4 "database/sql"
5+
5 "github.com/bnema/gordon/internal/db"6 "github.com/bnema/gordon/internal/db"
7+ "github.com/google/uuid"
6 )8 )
7 9
8 // ProviderQueries contains all SQL queries for provider operations10 // ProviderQueries contains all SQL queries for provider operations
9 type ProviderQueries struct {11 type ProviderQueries struct {
10- InsertProvider string12+ InsertProvider string
11- PopulateProviderFromDB string13+ PopulateProviderFromDB string
12- CheckProviderExists string14+ CheckProviderExists string
13- GetProviderByAccountLogin string15+ GetProviderByAccountLogin string
16+ CountProvidersQuery string
17+ InsertProviderForAccountQuery string
18+ GetUserByProviderLoginQuery string
14 }19 }
15 20
16 // NewProviderQueries returns a new instance of ProviderQueries21 // NewProviderQueries returns a new instance of ProviderQueries
@@ -20,13 +25,82 @@ func NewProviderQueries() *ProviderQueries {
20 PopulateProviderFromDB: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE account_id = ?",25 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 = ?)",26 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 = ?",27 GetProviderByAccountLogin: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE login = ? AND email = ?",
28+ CountProvidersQuery: "SELECT COUNT(*) FROM provider",
29+ InsertProviderForAccountQuery: `
30+ INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email)
31+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
32+ GetUserByProviderLoginQuery: `
33+ SELECT u.id, u.name, u.email, p.account_id
34+ FROM provider p
35+ JOIN account a ON p.account_id = a.id
36+ JOIN user u ON a.user_id = u.id
37+ WHERE p.name = ? AND p.login = ?`,
38+ }
39+}
40+
41+// CountProviders returns the total number of provider records in the database.
42+func CountProviders(database *sql.DB) (int, error) {
43+ var count int
44+ err := database.QueryRow(NewProviderQueries().CountProvidersQuery).Scan(&count)
45+ if err != nil {
46+ return 0, err
47+ }
48+ return count, nil
49+}
50+
51+// Inserts a new provider record linked to an existing account.
52+func InsertProviderForAccount(database *sql.DB, accountID string, providerName string, userInfo *db.GithubUserInfo) error {
53+ providerID := uuid.NewString()
54+ primaryEmail := ""
55+ if len(userInfo.Emails) > 0 {
56+ primaryEmail = userInfo.Emails[0]
57+ }
58+
59+ _, err := database.Exec(
60+ NewProviderQueries().InsertProviderForAccountQuery,
61+ providerID,
62+ accountID,
63+ providerName,
64+ userInfo.Login,
65+ userInfo.AvatarURL,
66+ userInfo.ProfileURL,
67+ primaryEmail,
68+ )
69+ return err
70+}
71+
72+// Retrieves the User and Account ID associated with a specific provider login.
73+func GetUserByProviderLogin(database *sql.DB, providerName string, providerLogin string) (*db.User, string, error) {
74+ user := &db.User{}
75+ var accountID string
76+
77+ err := database.QueryRow(
78+ NewProviderQueries().GetUserByProviderLoginQuery,
79+ providerName,
80+ providerLogin,
81+ ).Scan(
82+ &user.ID,
83+ &user.Name,
84+ &user.Email,
85+ &accountID,
86+ )
87+
88+ if err != nil {
89+ return nil, "", err
23 }90 }
91+
92+ return user, accountID, nil
24 }93 }
25 94
26 // CreateDBGitHubProvider creates a new GitHub provider in the database.95 // CreateDBGitHubProvider creates a new GitHub provider in the database.
27 func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.GithubUserInfo) error {96 func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.GithubUserInfo) error {
28- providerID := generateUUID()97+ providerID := uuid.NewString()
29- 98+
99+ primaryEmail := ""
100+ if len(userInfo.Emails) > 0 {
101+ primaryEmail = userInfo.Emails[0]
102+ }
103+
30 _, err := database.Exec(104 _, err := database.Exec(
31 NewProviderQueries().InsertProvider,105 NewProviderQueries().InsertProvider,
32 providerID,106 providerID,
@@ -35,16 +109,16 @@ func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.Git
35 userInfo.Login,109 userInfo.Login,
36 userInfo.AvatarURL,110 userInfo.AvatarURL,
37 userInfo.ProfileURL,111 userInfo.ProfileURL,
38- userInfo.Emails[0],112+ primaryEmail,
39 )113 )
40- 114+
41 return err115 return err
42 }116 }
43 117
44 // PopulateDBProviderFromDB populates provider information from the database.118 // PopulateDBProviderFromDB populates provider information from the database.
45 func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider, error) {119 func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider, error) {
46 provider := &db.Provider{}120 provider := &db.Provider{}
47- 121+
48 err := database.QueryRow(122 err := database.QueryRow(
49 NewProviderQueries().PopulateProviderFromDB,123 NewProviderQueries().PopulateProviderFromDB,
50 accountID,124 accountID,
@@ -57,35 +131,38 @@ func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider,
57 &provider.ProfileURL,131 &provider.ProfileURL,
58 &provider.Email,132 &provider.Email,
59 )133 )
60- 134+
61 if err != nil {135 if err != nil {
62 return nil, err136 return nil, err
63 }137 }
64- 138+
65 return provider, nil139 return provider, nil
66 }140 }
67 141
68 // CheckDBProviderExists checks if a provider exists in the database.142 // CheckDBProviderExists checks if a provider exists in the database.
69 func CheckDBProviderExists(database *sql.DB, login string, email string) (bool, error) {143 func CheckDBProviderExists(database *sql.DB, login string, email string) (bool, error) {
70 var exists bool144 var exists bool
71- 145+
72 err := database.QueryRow(146 err := database.QueryRow(
73 NewProviderQueries().CheckProviderExists,147 NewProviderQueries().CheckProviderExists,
74 login,148 login,
75 email,149 email,
76 ).Scan(&exists)150 ).Scan(&exists)
77- 151+
78 if err != nil {152 if err != nil {
153+ if err == sql.ErrNoRows {
154+ return false, nil
155+ }
79 return false, err156 return false, err
80 }157 }
81- 158+
82 return exists, nil159 return exists, nil
83 }160 }
84 161
85 // GetProviderByLoginAndEmail returns a provider from the database by login and email.162 // GetProviderByLoginAndEmail returns a provider from the database by login and email.
86 func GetProviderByLoginAndEmail(database *sql.DB, login string, email string) (*db.Provider, error) {163 func GetProviderByLoginAndEmail(database *sql.DB, login string, email string) (*db.Provider, error) {
87 provider := &db.Provider{}164 provider := &db.Provider{}
88- 165+
89 err := database.QueryRow(166 err := database.QueryRow(
90 NewProviderQueries().GetProviderByAccountLogin,167 NewProviderQueries().GetProviderByAccountLogin,
91 login,168 login,
@@ -99,10 +176,10 @@ func GetProviderByLoginAndEmail(database *sql.DB, login string, email string) (*
99 &provider.ProfileURL,176 &provider.ProfileURL,
100 &provider.Email,177 &provider.Email,
101 )178 )
102- 179+
103 if err != nil {180 if err != nil {
104 return nil, err181 return nil, err
105 }182 }
106- 183+
107 return provider, nil184 return provider, nil
108 }185 }
M internal/db/queries/proxy.go +103 -15
@@ -3,18 +3,36 @@ package queries
3 // ProxyQueries contains all SQL queries used by the proxy package3 // ProxyQueries contains all SQL queries used by the proxy package
4 type ProxyQueries struct {4 type ProxyQueries struct {
5 // Route queries5 // Route queries
6- GetActiveRoutes string6+ GetActiveRoutes string
7- GetRouteByDomain string7+ GetRouteByDomain string
8- InsertDomain string8+ GetRouteIDAndContainerIDByDomainName string
9- GetDomainByName string9+ GetRouteIPByID string
10- GetFirstAccount string10+ InsertDomain string
11- UpdateRoute string11+ GetDomainByName string
12- InsertRoute string12+ GetFirstAccount string
13- DeleteRouteByDomainID string13+ UpdateRoute string
14- DeleteDomainByID string14+ UpdateRecreatedRoute string
15- UpdateRouteIP string15+ InsertRoute string
16- GetAllRoutes string16+ DeleteRouteByDomainID string
17- MarkRouteInactive string17+ DeleteDomainByID string
18+ UpdateRouteIP string
19+ GetAllRoutes string
20+ MarkRouteInactive string
21+
22+ // ACME queries
23+ GetDomainAcmeConfig string
24+ UpdateDomainAcmeConfig string
25+ GetCertificateByDomain string
26+ UpdateCertificate string
27+
28+ // Admin domain queries
29+ CheckDomainExists string
30+ CreateAdminDomain string
31+ UpdateAdminDomainAcme string
32+
33+ // ACME Account queries (NEW)
34+ GetAcmeAccountByEmail string
35+ InsertOrReplaceAcmeAccount string
18 }36 }
19 37
20 // NewProxyQueries returns a new instance of ProxyQueries38 // NewProxyQueries returns a new instance of ProxyQueries
@@ -29,9 +47,20 @@ func NewProxyQueries() *ProxyQueries {
29 `,47 `,
30 GetRouteByDomain: `48 GetRouteByDomain: `
31 SELECT id, container_id, container_port 49 SELECT id, container_id, container_port
32- FROM proxy_route 50+ FROM proxy_route
33 WHERE domain_id = ?51 WHERE domain_id = ?
34 `,52 `,
53+ GetRouteIDAndContainerIDByDomainName: `
54+ SELECT pr.id, pr.container_id
55+ FROM proxy_route pr
56+ JOIN domain d ON pr.domain_id = d.id
57+ WHERE d.name = ?
58+ `,
59+ GetRouteIPByID: `
60+ SELECT container_ip
61+ FROM proxy_route
62+ WHERE id = ?
63+ `,
35 InsertDomain: `64 InsertDomain: `
36 INSERT INTO domain (id, name, account_id, created_at, updated_at) 65 INSERT INTO domain (id, name, account_id, created_at, updated_at)
37 VALUES (?, ?, ?, ?, ?)66 VALUES (?, ?, ?, ?, ?)
@@ -53,6 +82,11 @@ func NewProxyQueries() *ProxyQueries {
53 updated_at = ? 82 updated_at = ?
54 WHERE id = ?83 WHERE id = ?
55 `,84 `,
85+ UpdateRecreatedRoute: `
86+ UPDATE proxy_route
87+ SET container_id = ?, container_ip = ?, container_port = ?, updated_at = ?
88+ WHERE id = ?
89+ `,
56 InsertRoute: `90 InsertRoute: `
57 INSERT INTO proxy_route (91 INSERT INTO proxy_route (
58 id, domain_id, container_id, container_ip, container_port, 92 id, domain_id, container_id, container_ip, container_port,
@@ -70,13 +104,67 @@ func NewProxyQueries() *ProxyQueries {
70 WHERE domain_id = ? AND container_id = ?104 WHERE domain_id = ? AND container_id = ?
71 `,105 `,
72 GetAllRoutes: `106 GetAllRoutes: `
73- SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path107+ SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active
74 FROM proxy_route pr108 FROM proxy_route pr
75 JOIN domain d ON pr.domain_id = d.id109 JOIN domain d ON pr.domain_id = d.id
76- WHERE pr.active = 1
77 `,110 `,
78 MarkRouteInactive: `111 MarkRouteInactive: `
79 UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ?112 UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ?
80 `,113 `,
114+
115+ // ACME queries
116+ GetDomainAcmeConfig: `
117+ SELECT acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref
118+ FROM domain WHERE name = ?
119+ `,
120+ UpdateDomainAcmeConfig: `
121+ UPDATE domain SET
122+ acme_enabled = ?,
123+ acme_challenge_type = ?,
124+ acme_dns_provider = ?,
125+ acme_dns_credentials_ref = ?,
126+ updated_at = ?
127+ WHERE name = ?
128+ `,
129+ GetCertificateByDomain: `
130+ SELECT c.cert_file, c.key_file, c.issued_at, c.expires_at, c.issuer, c.status
131+ FROM certificate c
132+ JOIN domain d ON c.domain_id = d.id
133+ WHERE d.name = ?
134+ `,
135+ UpdateCertificate: `
136+ INSERT OR REPLACE INTO certificate (
137+ id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status
138+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
139+ `,
140+
141+ // Admin domain queries
142+ CheckDomainExists: `
143+ SELECT EXISTS(SELECT 1 FROM domain WHERE name = ?)
144+ `,
145+ CreateAdminDomain: `
146+ INSERT INTO domain (
147+ id, name, account_id, created_at, updated_at, acme_enabled, acme_challenge_type
148+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
149+ `,
150+ UpdateAdminDomainAcme: `
151+ UPDATE domain SET
152+ acme_enabled = 1,
153+ acme_challenge_type = 'http-01',
154+ updated_at = ?
155+ WHERE name = ?
156+ `,
157+
158+ // ACME Account queries (NEW)
159+ GetAcmeAccountByEmail: `
160+ SELECT private_key, registration_info
161+ FROM acme_account
162+ WHERE email = ?
163+ `,
164+ InsertOrReplaceAcmeAccount: `
165+ INSERT OR REPLACE INTO acme_account (
166+ email, private_key, registration_info, created_at, updated_at
167+ ) VALUES (?, ?, ?, ?, ?)
168+ `,
81 }169 }
82 }170 }
M internal/db/queries/sessions.go +80 -32
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/gordon/internal/db"8 "github.com/bnema/gordon/internal/db"
9 "github.com/bnema/gordon/pkg/logger"9 "github.com/bnema/gordon/pkg/logger"
10+ "github.com/google/uuid"
10 )11 )
11 12
12 // SessionQueries contains all SQL queries for session operations13 // SessionQueries contains all SQL queries for session operations
@@ -38,18 +39,18 @@ func NewSessionQueries() *SessionQueries {
38 // CreateDBSession creates a session for the user.39 // CreateDBSession creates a session for the user.
39 func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, accountID string) (*db.Sessions, error) {40 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 expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
41- sessionID := generateUUID()42+ sessionID := uuid.NewString()
42- 43+
43 _, err := database.Exec(44 _, err := database.Exec(
44 NewSessionQueries().InsertSession,45 NewSessionQueries().InsertSession,
45 sessionID, accountID, accessToken, browserInfo, expireTime, true,46 sessionID, accountID, accessToken, browserInfo, expireTime, true,
46 )47 )
47- 48+
48 if err != nil {49 if err != nil {
49 logger.Error("Failed to create session in database", "error", err, "session_id", sessionID, "account_id", accountID)50 logger.Error("Failed to create session in database", "error", err, "session_id", sessionID, "account_id", accountID)
50 return nil, err51 return nil, err
51 }52 }
52- 53+
53 // Create a Sessions struct to return54 // Create a Sessions struct to return
54 session := &db.Sessions{55 session := &db.Sessions{
55 ID: sessionID,56 ID: sessionID,
@@ -59,7 +60,7 @@ func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, a
59 Expires: expireTime,60 Expires: expireTime,
60 IsOnline: true,61 IsOnline: true,
61 }62 }
62- 63+
63 logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)64 logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)
64 return session, nil65 return session, nil
65 }66 }
@@ -97,51 +98,98 @@ func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string)
97 // UpdateDBSession updates an existing session with a new access token98 // UpdateDBSession updates an existing session with a new access token
98 func UpdateDBSession(database *sql.DB, sessionID string, accessToken string) error {99 func UpdateDBSession(database *sql.DB, sessionID string, accessToken string) error {
99 expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)100 expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
100- 101+
101 _, err := database.Exec(102 _, err := database.Exec(
102 NewSessionQueries().UpdateSessionToken,103 NewSessionQueries().UpdateSessionToken,
103 accessToken,104 accessToken,
104 expireTime,105 expireTime,
105 sessionID,106 sessionID,
106 )107 )
107- 108+
108 if err != nil {109 if err != nil {
109 logger.Error("Failed to update session in database", "error", err, "session_id", sessionID)110 logger.Error("Failed to update session in database", "error", err, "session_id", sessionID)
110 return err111 return err
111 }112 }
112- 113+
113 logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime)114 logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime)
114 return nil115 return nil
115 }116 }
116 117
117-// CreateOrUpdateDBSession creates a new session or updates an existing one118+// CreateOrUpdateSession creates a new session or updates an existing one
118-func CreateOrUpdateDBSession(database *sql.DB, accessToken string, browserInfo string, accountID string) error {119+func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken string, browserInfo string) (*db.Sessions, error) {
119- // Check if the session exists using the access token and browser info120+ // Check if a session exists for this account with the same browser info
120- session, err := GetDBUserSession(database, accessToken, browserInfo)121+ // (Token might change, so check by account/browser)
122+ existingSession, err := GetSessionByAccountAndBrowser(database, accountID, browserInfo)
121 if err != nil && err != sql.ErrNoRows {123 if err != nil && err != sql.ErrNoRows {
122- logger.Warn("Failed to check for session", "error", err)124+ logger.Error("Failed to check for existing session", "error", err, "accountID", accountID)
123- return fmt.Errorf("error checking for session: %w", err)125+ return nil, fmt.Errorf("error checking for existing session: %w", err)
124 }126 }
125 127
126- // If the session exists, update the session128+ expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
127- if session != nil {129+
128- err := UpdateDBSession(database, session.ID, accessToken)130+ // If the session exists, update its token and expiry
131+ if existingSession != nil {
132+ logger.Debug("Existing session found, updating token and expiry", "sessionID", existingSession.ID, "accountID", accountID)
133+ _, err := database.Exec(
134+ NewSessionQueries().UpdateSessionToken,
135+ accessToken,
136+ expireTime,
137+ existingSession.ID,
138+ )
129 if err != nil {139 if err != nil {
130- logger.Warn("Failed to update session", "error", err)140+ logger.Error("Failed to update existing session", "error", err, "session_id", existingSession.ID)
131- return fmt.Errorf("error updating session: %w", err)141+ return nil, fmt.Errorf("error updating session: %w", err)
132 }142 }
133- logger.Debug("Session updated in database", "accountID", accountID, "sessionID", session.ID)143+ // Update the struct we have in memory
144+ existingSession.AccessToken = accessToken
145+ existingSession.Expires = expireTime
146+ existingSession.IsOnline = true // Ensure it's marked online
147+ logger.Info("Session updated successfully", "sessionID", existingSession.ID)
148+ return existingSession, nil
134 } else {149 } else {
135- // If the session does not exist, create the session150+ // If the session does not exist, create a new one
136- _, err := CreateDBSession(database, browserInfo, accessToken, accountID)151+ logger.Debug("No existing session found, creating new session", "accountID", accountID)
152+ sessionID := uuid.NewString() // Use uuid directly
153+ _, err := database.Exec(
154+ NewSessionQueries().InsertSession,
155+ sessionID, accountID, accessToken, browserInfo, expireTime, true,
156+ )
137 if err != nil {157 if err != nil {
138- logger.Warn("Failed to create session", "error", err)158+ logger.Error("Failed to create new session", "error", err, "accountID", accountID)
139- return fmt.Errorf("error creating session: %w", err)159+ return nil, fmt.Errorf("error creating session: %w", err)
160+ }
161+ // Create the session struct to return
162+ newSession := &db.Sessions{
163+ ID: sessionID,
164+ AccountID: accountID,
165+ AccessToken: accessToken,
166+ BrowserInfo: browserInfo,
167+ Expires: expireTime,
168+ IsOnline: true,
140 }169 }
141- logger.Debug("New session created in database", "accountID", accountID)170+ logger.Info("New session created successfully", "sessionID", newSession.ID)
171+ return newSession, nil
142 }172 }
173+}
143 174
144- return nil175+// GetSessionByAccountAndBrowser finds a session by account ID and browser info
176+func GetSessionByAccountAndBrowser(database *sql.DB, accountID string, browserInfo string) (*db.Sessions, error) {
177+ session := &db.Sessions{}
178+ // Define the query inline or add to SessionQueries struct
179+ query := "SELECT id, account_id, access_token, browser_info, expires, is_online FROM sessions WHERE account_id = ? AND browser_info = ?"
180+ err := database.QueryRow(query, accountID, browserInfo).Scan(
181+ &session.ID,
182+ &session.AccountID,
183+ &session.AccessToken,
184+ &session.BrowserInfo,
185+ &session.Expires,
186+ &session.IsOnline,
187+ )
188+ if err != nil {
189+ // Return sql.ErrNoRows if not found
190+ return nil, err
191+ }
192+ return session, nil
145 }193 }
146 194
147 // DeleteDBSession deletes the session from the database.195 // DeleteDBSession deletes the session from the database.
@@ -151,12 +199,12 @@ func DeleteDBSession(database *sql.DB, accountID string, sessionID string) error
151 accountID,199 accountID,
152 sessionID,200 sessionID,
153 )201 )
154- 202+
155 if err != nil {203 if err != nil {
156 logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID)204 logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID)
157 return err205 return err
158 }206 }
159- 207+
160 logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID)208 logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID)
161 return nil209 return nil
162 }210 }
@@ -194,12 +242,12 @@ func ExtendSessionExpiration(database *sql.DB, accountID string, sessionID strin
194 accountID,242 accountID,
195 sessionID,243 sessionID,
196 )244 )
197- 245+
198 if err != nil {246 if err != nil {
199 logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID)247 logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID)
200 return err248 return err
201 }249 }
202- 250+
203 logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime)251 logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime)
204 return nil252 return nil
205 }253 }
@@ -227,12 +275,12 @@ func InvalidateDBSession(database *sql.DB, sessionID string) error {
227 false,275 false,
228 sessionID,276 sessionID,
229 )277 )
230- 278+
231 if err != nil {279 if err != nil {
232 logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID)280 logger.Error("Failed to invalidate session", "error", err, "session_id", sessionID)
233 return err281 return err
234 }282 }
235- 283+
236 logger.Debug("Session invalidated", "sessionID", sessionID)284 logger.Debug("Session invalidated", "sessionID", sessionID)
237 return nil285 return nil
238 }286 }
M internal/db/queries/user.go +66 -69
@@ -3,35 +3,61 @@ package queries
3 import (3 import (
4 "database/sql"4 "database/sql"
5 "fmt"5 "fmt"
6- "time"
7 6
8 "github.com/bnema/gordon/internal/db"7 "github.com/bnema/gordon/internal/db"
9 "github.com/bnema/gordon/pkg/logger"8 "github.com/bnema/gordon/pkg/logger"
10 "github.com/google/uuid"9 "github.com/google/uuid"
11 )10 )
12 11
13-func generateUUID() string {
14- return uuid.New().String()
15-}
16-
17 // UserQueries contains all SQL queries for user operations12 // UserQueries contains all SQL queries for user operations
18 type UserQueries struct {13 type UserQueries struct {
19- InsertUser string14+ InsertUser string
20- CheckUserExists string15+ CheckUserExists string
21- GetUserByProvider string16+ GetUserByProvider string
22- GetAccountCount string17+ GetAccountCount string
18+ GetFirstUserAndAccountQuery string
19+ UpdateUserDetailsQuery string
23 }20 }
24 21
25 // NewUserQueries returns a new instance of UserQueries22 // NewUserQueries returns a new instance of UserQueries
26 func NewUserQueries() *UserQueries {23 func NewUserQueries() *UserQueries {
27 return &UserQueries{24 return &UserQueries{
28 InsertUser: "INSERT INTO user (id, name, email) VALUES (?, ?, ?)",25 InsertUser: "INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
29- CheckUserExists: "SELECT id FROM user",26+ CheckUserExists: "SELECT EXISTS(SELECT 1 FROM user LIMIT 1)",
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 = ?",27 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",28 GetAccountCount: "SELECT COUNT(*) FROM account",
29+ GetFirstUserAndAccountQuery: `
30+ SELECT u.id, u.name, u.email, a.id
31+ FROM user u
32+ JOIN account a ON u.id = a.user_id
33+ ORDER BY u.rowid ASC -- Rely on implicit rowid for 'first'
34+ LIMIT 1`,
35+ UpdateUserDetailsQuery: "UPDATE user SET name = ?, email = ? WHERE id = ?",
32 }36 }
33 }37 }
34 38
39+// GetFirstUserAndAccount retrieves the first user and their associated account ID. Assumes this is the seeded admin.
40+func GetFirstUserAndAccount(database *sql.DB) (*db.User, string, error) {
41+ user := &db.User{}
42+ var accountID string
43+ err := database.QueryRow(NewUserQueries().GetFirstUserAndAccountQuery).Scan(
44+ &user.ID,
45+ &user.Name,
46+ &user.Email,
47+ &accountID,
48+ )
49+ if err != nil {
50+ return nil, "", err
51+ }
52+ return user, accountID, nil
53+}
54+
55+// UpdateUserDetails updates the name and email for a specific user ID.
56+func UpdateUserDetails(database *sql.DB, userID string, newName string, newEmail string) error {
57+ _, err := database.Exec(NewUserQueries().UpdateUserDetailsQuery, newName, newEmail, userID)
58+ return err
59+}
60+
35 // CreateUser creates a new user along with the associated account, provider, and session.61 // CreateUser creates a new user along with the associated account, provider, and session.
36 func CreateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {62 func CreateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
37 // Check if a user already exists. If so, return an error.63 // Check if a user already exists. If so, return an error.
@@ -58,7 +84,7 @@ func CreateUser(database *sql.DB, accessToken string, browserInfo string, userIn
58 logger.Info("Database account created successfully", "account_id", account.ID)84 logger.Info("Database account created successfully", "account_id", account.ID)
59 85
60 logger.Debug("Creating GitHub provider for account", "account_id", account.ID)86 logger.Debug("Creating GitHub provider for account", "account_id", account.ID)
61- err = CreateDBGitHubProvider(database, account.ID, userInfo)87+ err = InsertProviderForAccount(database, account.ID, "github", userInfo)
62 if err != nil {88 if err != nil {
63 logger.Error("Failed to create GitHub provider", "error", err)89 logger.Error("Failed to create GitHub provider", "error", err)
64 return nil, "", nil, err90 return nil, "", nil, err
@@ -66,7 +92,7 @@ func CreateUser(database *sql.DB, accessToken string, browserInfo string, userIn
66 logger.Info("GitHub provider created successfully")92 logger.Info("GitHub provider created successfully")
67 93
68 logger.Debug("Creating session for account", "account_id", account.ID)94 logger.Debug("Creating session for account", "account_id", account.ID)
69- session, err := CreateDBSession(database, browserInfo, accessToken, account.ID)95+ session, err := CreateOrUpdateSession(database, account.ID, accessToken, browserInfo)
70 if err != nil {96 if err != nil {
71 logger.Error("Failed to create session", "error", err)97 logger.Error("Failed to create session", "error", err)
72 return nil, "", nil, err98 return nil, "", nil, err
@@ -78,10 +104,15 @@ func CreateUser(database *sql.DB, accessToken string, browserInfo string, userIn
78 104
79 // CreateDBUser creates a new user in the database105 // CreateDBUser creates a new user in the database
80 func CreateDBUser(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, error) {106 func CreateDBUser(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, error) {
107+ primaryEmail := ""
108+ if len(userInfo.Emails) > 0 {
109+ primaryEmail = userInfo.Emails[0]
110+ }
111+
81 user := &db.User{112 user := &db.User{
82- ID: generateUUID(),113+ ID: uuid.NewString(),
83 Name: userInfo.Login,114 Name: userInfo.Login,
84- Email: userInfo.Emails[0],115+ Email: primaryEmail,
85 }116 }
86 117
87 _, err := database.Exec(118 _, err := database.Exec(
@@ -93,34 +124,29 @@ func CreateDBUser(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, erro
93 124
94 // CheckDBUserExists checks if any user exists in the database125 // CheckDBUserExists checks if any user exists in the database
95 func CheckDBUserExists(database *sql.DB) (bool, error) {126 func CheckDBUserExists(database *sql.DB) (bool, error) {
96- var userID string127+ var exists bool
97- err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&userID)128+ err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&exists)
98 if err != nil {129 if err != nil {
99- if err == sql.ErrNoRows {
100- return false, nil
101- }
102 return false, err130 return false, err
103 }131 }
104-132+ return exists, nil
105- if userID != "" {
106- return true, nil
107- }
108-
109- return false, nil
110 }133 }
111 134
112 // CheckDBUserIsGood checks if the user matches the provided GitHub info135 // CheckDBUserIsGood checks if the user matches the provided GitHub info
113 func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) {136 func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) {
114 login := userInfo.Login137 login := userInfo.Login
115- email := userInfo.Emails[0]138+ primaryEmail := ""
139+ if len(userInfo.Emails) > 0 {
140+ primaryEmail = userInfo.Emails[0]
141+ }
116 142
117 user := &db.User{}143 user := &db.User{}
118- 144+
119 // Check if the user exists based on github login and email145 // Check if the user exists based on github login and email
120- err := database.QueryRow(NewUserQueries().GetUserByProvider, login, email).Scan(146+ err := database.QueryRow(NewUserQueries().GetUserByProvider, login, primaryEmail).Scan(
121 &user.ID, &user.Name, &user.Email,147 &user.ID, &user.Name, &user.Email,
122 )148 )
123- 149+
124 if err != nil {150 if err != nil {
125 if err == sql.ErrNoRows {151 if err == sql.ErrNoRows {
126 return nil, false, nil152 return nil, false, nil
@@ -138,51 +164,22 @@ func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User,
138 // UpdateUser updates the user session with new access token164 // 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) {165 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 ID166 // First, get the user info to get their account ID
141- user, isGood, err := CheckDBUserIsGood(database, userInfo)167+ user, accountID, err := GetUserByProviderLogin(database, "github", userInfo.Login)
142 if err != nil {168 if err != nil {
143- return nil, "", nil, fmt.Errorf("error checking user: %w", err)169+ if err == sql.ErrNoRows {
144- }170+ return nil, "", nil, fmt.Errorf("user not found in database for provider login '%s'", userInfo.Login)
145- 171+ }
146- if !isGood {172+ return nil, "", nil, fmt.Errorf("error checking user by provider login: %w", err)
147- return nil, "", nil, fmt.Errorf("user not found in database")
148 }173 }
149- 174+
150- // Get the account ID for this user175+ // If user found, update/create their session
151- var accountID string176+ session, err := CreateOrUpdateSession(database, accountID, accessToken, browserInfo)
152- err = database.QueryRow("SELECT id FROM account WHERE user_id = ?", user.ID).Scan(&accountID)
153 if err != nil {177 if err != nil {
154- return nil, "", nil, fmt.Errorf("error getting account ID: %w", err)178+ return nil, "", nil, fmt.Errorf("error creating/updating session: %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)
161- }
162-
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 }179 }
185 180
181+ logger.Debug("Session updated/created in database", "accountID", accountID, "sessionID", session.ID)
182+
186 return user, accountID, session, nil183 return user, accountID, session, nil
187 }184 }
188 185
M internal/httpserve/handlers/endpoint_deploy.go +1 -4
@@ -573,10 +573,7 @@ func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targe
573 573
574 // Verify the database update with a direct query574 // Verify the database update with a direct query
575 var updatedContainerIP string575 var updatedContainerIP string
576- err = a.GetDB().QueryRow(`576+ err = a.GetDB().QueryRow(p.Queries.GetRouteIPByID, existingRouteID).Scan(&updatedContainerIP)
577- SELECT container_ip
578- FROM proxy_route
579- WHERE id = ?`, existingRouteID).Scan(&updatedContainerIP)
580 577
581 if err == nil && updatedContainerIP != containerIP {578 if err == nil && updatedContainerIP != containerIP {
582 logger.Warn("Database verification failed: container IP mismatch",579 logger.Warn("Database verification failed: container IP mismatch",
M internal/httpserve/handlers/login.go +87 -55
@@ -1,6 +1,7 @@
1 package handlers1 package handlers
2 2
3 import (3 import (
4+ "database/sql"
4 "encoding/base64"5 "encoding/base64"
5 "encoding/json"6 "encoding/json"
6 "fmt"7 "fmt"
@@ -257,88 +258,119 @@ func getSession(c echo.Context) (*sessions.Session, error) {
257 258
258 // handleUser handles the user creation or update259 // handleUser handles the user creation or update
259 func handleUser(c echo.Context, a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {260 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+ logger.Debug("Starting handleUser function", "github_login", userInfo.Login)
261- userExists, err := queries.CheckDBUserExists(a.DB)262+
263+ // --- Check provider count ---
264+ providerCount, err := queries.CountProviders(a.DB)
262 if err != nil {265 if err != nil {
263- logger.Error("Error checking if user exists", "error", err)266+ logger.Error("Error checking provider count", "error", err)
264- return nil, "", nil, fmt.Errorf("could not check if user exists: %w", err)267+ return nil, "", nil, fmt.Errorf("could not check provider count: %w", err)
265 }268 }
269+ logger.Debug("Provider count check result", "count", providerCount)
266 270
267- logger.Debug("User exists check result", "user_exists", userExists)271+ // --- Case 1: First ever GitHub login ---
272+ if providerCount == 0 {
273+ logger.Info("First GitHub login detected. Linking to seeded admin account.")
268 274
269- if !userExists {275+ // Compare the Gordon token (still need admin token for the *very first* setup)
270- // if it is a new user creation we compare the gordon token
271- logger.Debug("No user exists, attempting to create new user")
272 err := CompareGordonToken(c, a)276 err := CompareGordonToken(c, a)
273 if err != nil {277 if err != nil {
274- logger.Error("Token comparison failed", "error", err)278+ logger.Error("Admin token comparison failed for first login", "error", err)
275- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Token is empty or not valid")279+ return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Admin token is empty or not valid for initial setup")
276 }280 }
277- logger.Debug("Token comparison successful, proceeding with user creation")281+ logger.Debug("Admin token comparison successful for first login.")
278 282
279- user, accountID, session, err := createUser(a, accessToken, browserInfo, userInfo)283+ // --- Link to Seeded Admin ---
284+ seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo)
280 if err != nil {285 if err != nil {
281- logger.Error("Failed to create user", "error", err)286+ logger.Error("Failed to link provider to seeded admin", "error", err)
282 return nil, "", nil, err287 return nil, "", nil, err
283 }288 }
284- logger.Info("Successfully created new user", "user_id", user.ID, "user_name", user.Name, "account_id", accountID)289+ logger.Info("Successfully linked GitHub provider to seeded admin account", "user_id", seededUser.ID, "account_id", seededAccountID)
285- return user, accountID, session, nil290+ return seededUser, seededAccountID, session, nil
286 }291 }
287 292
288- if userExists {293+ // --- Case 2: Subsequent GitHub logins ---
289- // if it is an existing user we compare the login and email to see if it is the same user294+ if providerCount > 0 {
290- logger.Debug("User exists, checking if GitHub info matches existing user")295+ logger.Debug("Existing provider(s) found. Checking if incoming GitHub login matches.")
291- _, isGoodUser, err := queries.CheckDBUserIsGood(a.DB, userInfo)
292- if err != nil {
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)
295- }
296 296
297- logger.Debug("User check result", "is_good_user", isGoodUser)297+ // --- Check if incoming GitHub user matches an existing provider ---
298-298+ existingUser, existingAccountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
299- if !isGoodUser {299+ if err != nil {
300- logger.Warn("Unauthorized user", "github_login", userInfo.Login)300+ // Handle sql.ErrNoRows specifically: means this GitHub user is not linked yet.
301- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized user")301+ if err == sql.ErrNoRows {
302+ logger.Warn("Unauthorized GitHub user trying to log in (no matching provider found).", "github_login", userInfo.Login)
303+ // For now, deny access. Future: could allow multiple users if desired.
304+ return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "This GitHub account is not authorized.")
305+ }
306+ // Handle other potential database errors
307+ logger.Error("Error checking for existing provider by login", "error", err)
308+ return nil, "", nil, fmt.Errorf("could not check existing provider: %w", err)
302 }309 }
310+ logger.Debug("Incoming GitHub login matches existing user.", "github_login", userInfo.Login, "user_id", existingUser.ID, "account_id", existingAccountID)
303 311
304- if isGoodUser {312+ // --- Update Session for Existing User ---
305- // Update the user313+ // The user is valid and matches the existing provider, just update their session.
306- logger.Debug("User is authorized, updating session")314+ session, err := queries.CreateOrUpdateSession(a.DB, existingAccountID, accessToken, browserInfo)
307- updatedUser, accountID, session, err := updateUser(a, accessToken, browserInfo, userInfo)315+ if err != nil {
308- if err != nil {316+ logger.Error("Could not create/update session for existing user", "error", err)
309- logger.Error("Could not update user", "error", err)317+ return nil, "", nil, fmt.Errorf("could not update session: %w", err)
310- return nil, "", nil, fmt.Errorf("could not update user: %w", err)
311- }
312- logger.Info("Successfully updated user session", "user_id", updatedUser.ID, "account_id", accountID)
313- return updatedUser, accountID, session, nil
314 }318 }
319+ logger.Info("Successfully created/updated session for existing user", "user_id", existingUser.ID, "account_id", existingAccountID)
320+ return existingUser, existingAccountID, session, nil
315 }321 }
316 322
317- logger.Error("Could not handle user - reached end of function without returning")323+ // Should not be reached if logic is correct
324+ logger.Error("Could not handle user - reached end of function unexpectedly", "provider_count", providerCount)
318 return nil, "", nil, fmt.Errorf("could not handle user")325 return nil, "", nil, fmt.Errorf("could not handle user")
319 }326 }
320 327
321-// createUser creates a new user in the database328+// --- NEW FUNCTION: linkProviderToSeededAdmin ---
322-func createUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {329+// Finds the seeded admin, updates its details, creates the provider and session.
323- logger.Debug("Starting createUser function", "github_login", userInfo.Login)330+func linkProviderToSeededAdmin(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
324- user, accountID, session, err := queries.CreateUser(a.DB, accessToken, browserInfo, userInfo)331+ logger.Debug("Starting linkProviderToSeededAdmin", "github_login", userInfo.Login)
332+
333+ // 1. Get the seeded user and account ID (assuming it's the only one)
334+ seededUser, seededAccountID, err := queries.GetFirstUserAndAccount(a.DB)
325 if err != nil {335 if err != nil {
326- logger.Error("Failed to create user in database", "error", err)336+ logger.Error("Failed to get seeded user/account", "error", err)
327- return nil, "", nil, fmt.Errorf("could not create user: %w", err)337+ return nil, "", nil, fmt.Errorf("critical: could not find seeded admin account: %w", err)
328 }338 }
329- logger.Info("User created successfully", "user_id", user.ID, "name", user.Name, "account_id", accountID)339+ logger.Debug("Found seeded admin", "user_id", seededUser.ID, "account_id", seededAccountID, "original_email", seededUser.Email)
330- return user, accountID, session, nil
331-}
332 340
333-// updateUser updates an existing user in the database341+ // 2. Update the seeded user's details with GitHub info
334-func updateUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {342+ newEmail := seededUser.Email // Keep original unless GitHub provides one
335- user, accountID, session, err := queries.UpdateUser(a.DB, accessToken, browserInfo, userInfo)343+ if len(userInfo.Emails) > 0 && userInfo.Emails[0] != "" {
344+ newEmail = userInfo.Emails[0]
345+ }
346+ newName := userInfo.Login
347+
348+ err = queries.UpdateUserDetails(a.DB, seededUser.ID, newName, newEmail)
349+ if err != nil {
350+ logger.Error("Failed to update seeded user details", "user_id", seededUser.ID, "error", err)
351+ return nil, "", nil, fmt.Errorf("could not update seeded user details: %w", err)
352+ }
353+ logger.Info("Updated seeded user details", "user_id", seededUser.ID, "new_name", newName, "new_email", newEmail)
354+ seededUser.Name = newName
355+ seededUser.Email = newEmail
356+
357+ // 3. Create the Provider record linked to the seeded account
358+ err = queries.InsertProviderForAccount(a.DB, seededAccountID, "github", userInfo)
336 if err != nil {359 if err != nil {
337- logger.Error("Could not update user", "error", err)360+ logger.Error("Failed to insert provider record for seeded account", "account_id", seededAccountID, "error", err)
338- return nil, "", nil, fmt.Errorf("could not update user: %w", err)361+ return nil, "", nil, fmt.Errorf("could not insert provider record: %w", err)
339 }362 }
340- logger.Info("User updated successfully", "user_id", user.ID, "account_id", accountID)363+ logger.Info("Inserted provider record", "account_id", seededAccountID, "provider", "github", "login", userInfo.Login)
341- return user, accountID, session, nil364+
365+ // 4. Create/Update the Session record linked to the seeded account
366+ session, err := queries.CreateOrUpdateSession(a.DB, seededAccountID, accessToken, browserInfo)
367+ if err != nil {
368+ logger.Error("Failed to create/update session for seeded account", "account_id", seededAccountID, "error", err)
369+ return nil, "", nil, fmt.Errorf("could not create/update session: %w", err)
370+ }
371+ logger.Info("Created/Updated session record", "account_id", seededAccountID, "session_id", session.ID)
372+
373+ return seededUser, seededAccountID, session, nil
342 }374 }
343 375
344 // setSessionValues sets the session values for the user376 // setSessionValues sets the session values for the user
M internal/httpserve/handlers/session_db.go +65 -27
@@ -1,6 +1,7 @@
1 package handlers1 package handlers
2 2
3 import (3 import (
4+ "database/sql"
4 "fmt"5 "fmt"
5 "time"6 "time"
6 7
@@ -16,67 +17,104 @@ func ValidateSessionAndUser(c echo.Context, a *server.App) error {
16 17
17 sess, err := session.Get("session", c)18 sess, err := session.Get("session", c)
18 if err != nil {19 if err != nil {
19- logger.Warn("Failed to get session", "error", err)20+ logger.Warn("Failed to get session from cookie store", "error", err)
20 return fmt.Errorf("failed to get session: %w", err)21 return fmt.Errorf("failed to get session: %w", err)
21 }22 }
22 23
23 accountID, ok := sess.Values["accountID"].(string)24 accountID, ok := sess.Values["accountID"].(string)
24- if !ok {25+ if !ok || accountID == "" {
25- logger.Warn("Account ID not found or invalid type")26+ logger.Warn("Account ID not found in session or is empty")
26- return fmt.Errorf("accountID not found or invalid type")27+ return fmt.Errorf("accountID not found or invalid")
27 }28 }
28 29
29 sessionID, ok := sess.Values["sessionID"].(string)30 sessionID, ok := sess.Values["sessionID"].(string)
30- if !ok {31+ if !ok || sessionID == "" {
31- logger.Warn("Session ID not found or invalid type")32+ logger.Warn("Session ID not found in session or is empty")
32- return fmt.Errorf("sessionID not found or invalid type")33+ return fmt.Errorf("sessionID not found or invalid")
34+ }
35+
36+ authenticated, ok := sess.Values["authenticated"].(bool)
37+ if !ok || !authenticated {
38+ logger.Warn("User not authenticated according to session cookie", "ok", ok, "authenticated_val", sess.Values["authenticated"])
39+ return fmt.Errorf("user not authenticated")
33 }40 }
34 41
35 accountExists, err := IsAccountIDInDB(a, accountID)42 accountExists, err := IsAccountIDInDB(a, accountID)
36- if err != nil || !accountExists {43+ if err != nil {
37- logger.Warn("Account does not exist")44+ logger.Error("Failed to check account existence", "error", err, "accountID", accountID)
45+ return fmt.Errorf("failed checking account existence: %w", err)
46+ }
47+ if !accountExists {
48+ logger.Warn("Account associated with session not found in DB", "accountID", accountID)
38 return fmt.Errorf("account does not exist")49 return fmt.Errorf("account does not exist")
39 }50 }
40 51
41- sessionExpired, err := IsSessionExpiredInDB(a, accountID, sessionID)52+ isValidInDB, err := IsSessionValidInDB(a, sessionID)
42- if err != nil || sessionExpired {53+ if err != nil {
43- logger.Warn("Session is expired")54+ logger.Error("Failed to check session validity in DB", "error", err, "sessionID", sessionID)
44- return fmt.Errorf("session is expired")55+ return fmt.Errorf("failed checking session validity in DB: %w", err)
56+ }
57+ if !isValidInDB {
58+ logger.Warn("Session is invalid in DB (expired or not online)", "sessionID", sessionID)
59+ return fmt.Errorf("session invalid or expired")
45 }60 }
46 61
47- // Extend session expiration conditionally, e.g., if it's close to expiring
48 err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, time.Now().Add(sessionExpiryTime))62 err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, time.Now().Add(sessionExpiryTime))
49 if err != nil {63 if err != nil {
50- logger.Warn("Failed to extend session", "error", err)64+ logger.Error("Failed to extend session in DB", "error", err, "accountID", accountID, "sessionID", sessionID)
51- return fmt.Errorf("failed to extend session: %v", err)65+ return fmt.Errorf("failed to extend session: %w", err)
66+ }
67+ sess.Values["expires"] = time.Now().Add(sessionExpiryTime).Format(time.RFC3339)
68+ if err := sess.Save(c.Request(), c.Response()); err != nil {
69+ logger.Error("Failed to save updated expiry to cookie session", "error", err, "sessionID", sessionID)
52 }70 }
53 71
54- logger.Debug("Session extended successfully", "accountID", accountID, "sessionID", sessionID)72+ logger.Debug("Session validated and extended successfully", "accountID", accountID, "sessionID", sessionID)
55-
56 return nil73 return nil
57 }74 }
58 75
59-func IsSessionExpiredInDB(a *server.App, accountID string, sessionID string) (bool, error) {76+func IsSessionValidInDB(a *server.App, sessionID string) (bool, error) {
60- currentTime := time.Now()77+ var dbExpiresStr string
61- expirationTime, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime)78+ var isOnline bool
79+
80+ query := "SELECT expires, is_online FROM sessions WHERE id = ?"
81+ err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isOnline)
82+
62 if err != nil {83 if err != nil {
63- logger.Warn("Failed to get session expiration time", "error", err)84+ if err == sql.ErrNoRows {
85+ logger.Warn("Session not found in DB", "sessionID", sessionID)
86+ return false, nil
87+ }
88+ logger.Error("Failed to query session validity from DB", "error", err, "sessionID", sessionID)
64 return false, err89 return false, err
65 }90 }
66 91
67- logger.Debug("Session expiration time for account", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTime)92+ if !isOnline {
68- return currentTime.After(expirationTime), nil93+ logger.Warn("Session found in DB but marked as not online", "sessionID", sessionID)
94+ return false, nil
95+ }
96+
97+ expirationTime, err := time.Parse(time.RFC3339, dbExpiresStr)
98+ if err != nil {
99+ logger.Error("Failed to parse session expiration time from DB", "error", err, "sessionID", sessionID, "expires_str", dbExpiresStr)
100+ return false, fmt.Errorf("invalid expiry format in DB: %w", err)
101+ }
102+
103+ if time.Now().After(expirationTime) {
104+ logger.Warn("Session found in DB but expired", "sessionID", sessionID, "expires_at", expirationTime)
105+ return false, nil
106+ }
107+
108+ return true, nil
69 }109 }
70 110
71 func IsAccountIDInDB(a *server.App, accountID string) (bool, error) {111 func IsAccountIDInDB(a *server.App, accountID string) (bool, error) {
72 accountExists, err := queries.CheckDBAccountExists(a.DB, accountID)112 accountExists, err := queries.CheckDBAccountExists(a.DB, accountID)
73 if err != nil {113 if err != nil {
74- logger.Warn("Failed to check account existence", "error", err)114+ logger.Error("Failed to check account existence", "error", err, "accountID", accountID)
75 return false, err115 return false, err
76 }116 }
77-
78 logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists)117 logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists)
79-
80 return accountExists, nil118 return accountExists, nil
81 }119 }
82 120
A internal/proxy/acme.go +546 -0
@@ -0,0 +1,546 @@
1+package proxy
2+
3+import (
4+ "crypto"
5+ "crypto/tls"
6+ "database/sql"
7+ "encoding/json"
8+ "fmt"
9+ "os"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/gordon/internal/db/queries"
14+ "github.com/bnema/gordon/internal/interfaces"
15+ "github.com/bnema/gordon/pkg/logger"
16+ "github.com/go-acme/lego/v4/certcrypto"
17+ "github.com/go-acme/lego/v4/certificate"
18+ "github.com/go-acme/lego/v4/challenge/http01"
19+ "github.com/go-acme/lego/v4/lego"
20+ "github.com/go-acme/lego/v4/providers/dns"
21+ "github.com/go-acme/lego/v4/registration"
22+)
23+
24+// CertManagerConfig holds configuration for the certificate manager
25+type CertManagerConfig struct {
26+ CertDir string
27+ Email string
28+ Mode string
29+ SkipCertificates bool
30+ BehindTLSProxy bool
31+ AdminDomain string
32+ RootDomain string
33+ HttpPort string
34+}
35+
36+// CertificateManager handles ACME certificate operations
37+type CertificateManager struct {
38+ config CertManagerConfig
39+ client *lego.Client
40+ user *AcmeUser
41+ db *sql.DB
42+ queries *queries.ProxyQueries
43+}
44+
45+// AcmeUser implements the registration.User interface
46+type AcmeUser struct {
47+ Email string `json:"email"`
48+ Registration *registration.Resource `json:"registration"`
49+ key crypto.PrivateKey `json:"-"` // Exclude key from JSON marshal
50+}
51+
52+// GetEmail returns the user's email
53+func (u *AcmeUser) GetEmail() string {
54+ return u.Email
55+}
56+
57+// GetRegistration returns the user's registration
58+func (u *AcmeUser) GetRegistration() *registration.Resource {
59+ return u.Registration
60+}
61+
62+// GetPrivateKey returns the user's private key
63+func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
64+ return u.key
65+}
66+
67+// NewCertificateManager creates a new certificate manager
68+func NewCertificateManager(config CertManagerConfig, app interfaces.AppInterface, queries *queries.ProxyQueries) (*CertificateManager, error) {
69+ logger.Debug("Initializing certificate manager", "config", config)
70+
71+ db := app.GetDB() // Get DB connection
72+
73+ // --- User Loading/Creation ---
74+ user, err := loadAcmeUserFromDB(config.Email, db, queries)
75+
76+ if err != nil {
77+ // Check if the error is "user not found"
78+ if err == sql.ErrNoRows {
79+ logger.Info("No existing ACME user found in DB, creating new one.", "email", config.Email)
80+
81+ // Create a new key
82+ privateKey, keyErr := certcrypto.GeneratePrivateKey(certcrypto.RSA2048)
83+ if keyErr != nil {
84+ return nil, fmt.Errorf("failed to generate private key: %w", keyErr)
85+ }
86+
87+ user = &AcmeUser{
88+ Email: config.Email,
89+ key: privateKey,
90+ // Registration will be nil initially
91+ }
92+
93+ // Save the new user (key only for now, registration later)
94+ if saveErr := saveAcmeUserToDB(user, db, queries); saveErr != nil {
95+ // Log the error but proceed, registration might fix it or fail later
96+ logger.Error("Failed to save newly generated ACME user key to DB", "error", saveErr)
97+ // Depending on policy, might want to return error here:
98+ // return nil, fmt.Errorf("failed to save initial ACME user: %w", saveErr)
99+ }
100+ } else {
101+ // Handle other DB errors during load
102+ return nil, fmt.Errorf("failed to load ACME user from DB: %w", err)
103+ }
104+ } else {
105+ logger.Debug("Loaded existing ACME user from DB", "email", user.Email)
106+ }
107+ // --- End User Loading/Creation ---
108+
109+ // Create the lego config using the loaded/created user
110+ legoConfig := lego.NewConfig(user)
111+ legoConfig.Certificate.KeyType = certcrypto.RSA2048 // Match key type used
112+
113+ // Set the server based on mode
114+ if config.Mode == "staging" {
115+ logger.Info("Using Let's Encrypt staging environment")
116+ legoConfig.CADirURL = lego.LEDirectoryStaging
117+ } else {
118+ logger.Info("Using Let's Encrypt production environment")
119+ legoConfig.CADirURL = lego.LEDirectoryProduction
120+ }
121+
122+ // Create the lego client
123+ client, err := lego.NewClient(legoConfig)
124+ if err != nil {
125+ return nil, fmt.Errorf("failed to create lego client: %w", err)
126+ }
127+
128+ // --- ACME User Registration ---
129+ if user.Registration == nil {
130+ logger.Info("ACME user registration not found, attempting to register", "email", user.Email)
131+ reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
132+ if err != nil {
133+ // Check if it's an "already registered" error, which is fine
134+ // Note: Lego might handle this implicitly, but explicit check is safer.
135+ // The exact error type/message might vary depending on lego version and ACME server.
136+ // We might need to refine this error check based on observed behavior.
137+ if strings.Contains(err.Error(), "urn:ietf:params:acme:error:accountDoesNotExist") {
138+ // This might indicate a mismatch between loaded key and LE's record.
139+ // We might need to re-create the key and re-register, or query the account by key.
140+ // For now, just log and return error.
141+ logger.Error("ACME account registration failed: Account does not exist (key mismatch?)", "error", err)
142+ return nil, fmt.Errorf("failed to register ACME user (account not found): %w", err)
143+ } else if strings.Contains(err.Error(), "user already exists") { // Example, adjust as needed
144+ logger.Info("ACME user is already registered with the server.")
145+ // Try to resolve the account details if possible
146+ // reg, err = client.Registration.ResolveAccountByKey() // Requires lego client supporting this
147+ // if err != nil {
148+ // logger.Error("Failed to resolve existing ACME account by key", "error", err)
149+ // return nil, fmt.Errorf("failed to resolve existing ACME account: %w", err)
150+ // }
151+ // For now, let's assume if registration failed with "exists", we might need manual intervention
152+ // or a more sophisticated recovery mechanism. Returning error for safety.
153+ // If Obtain works despite this, we can reconsider.
154+ logger.Error("ACME user registration failed, but server indicates user exists. Manual check might be needed.", "error", err)
155+ return nil, fmt.Errorf("failed to register ACME user (already exists conflict): %w", err)
156+ } else {
157+ // Handle other registration errors
158+ logger.Error("ACME user registration failed", "error", err)
159+ return nil, fmt.Errorf("failed to register ACME user: %w", err)
160+ }
161+ } else {
162+ logger.Info("ACME user registered successfully", "uri", reg.URI)
163+ user.Registration = reg // Store the registration resource
164+
165+ // Save the user again, now with registration info
166+ if saveErr := saveAcmeUserToDB(user, db, queries); saveErr != nil {
167+ logger.Error("Failed to save ACME user registration info to DB", "error", saveErr)
168+ // Continue, as registration itself succeeded, but log the failure
169+ } else {
170+ logger.Debug("Saved ACME user registration info to DB")
171+ }
172+ }
173+ } else {
174+ logger.Debug("Existing ACME user registration loaded from DB", "uri", user.Registration.URI)
175+ // Optional: Could add a check here to ensure the registration is still valid
176+ }
177+ // --- End ACME User Registration ---
178+
179+ // Create the certificate manager struct
180+ cm := &CertificateManager{
181+ config: config,
182+ user: user,
183+ client: client,
184+ db: db,
185+ queries: queries,
186+ }
187+
188+ return cm, nil
189+}
190+
191+// loadAcmeUserFromDB loads the user's private key and registration info from the DB
192+func loadAcmeUserFromDB(email string, db *sql.DB, queries *queries.ProxyQueries) (*AcmeUser, error) {
193+ var privateKeyPEM string
194+ var registrationJSON sql.NullString
195+
196+ err := db.QueryRow(queries.GetAcmeAccountByEmail, email).Scan(&privateKeyPEM, &registrationJSON)
197+ if err != nil {
198+ if err == sql.ErrNoRows {
199+ logger.Debug("ACME user not found in DB", "email", email)
200+ return nil, sql.ErrNoRows // Return specific error
201+ }
202+ return nil, fmt.Errorf("failed to query ACME user: %w", err)
203+ }
204+
205+ // Parse the private key
206+ privateKey, err := certcrypto.ParsePEMPrivateKey([]byte(privateKeyPEM))
207+ if err != nil {
208+ return nil, fmt.Errorf("failed to parse private key from DB: %w", err)
209+ }
210+
211+ user := &AcmeUser{
212+ Email: email,
213+ key: privateKey,
214+ }
215+
216+ // Load the registration info if it exists
217+ if registrationJSON.Valid && registrationJSON.String != "" {
218+ // Need to unmarshal into a registration.Resource pointer
219+ reg := &registration.Resource{}
220+ err = json.Unmarshal([]byte(registrationJSON.String), reg)
221+ if err != nil {
222+ // Log the error but potentially continue without registration? Or return error?
223+ // Returning error is safer.
224+ logger.Error("Failed to unmarshal ACME registration info from DB", "email", email, "error", err)
225+ return nil, fmt.Errorf("failed to unmarshal registration info: %w", err)
226+ }
227+ user.Registration = reg // Assign the pointer
228+ logger.Debug("Loaded ACME user registration info from DB", "email", email, "uri", user.Registration.URI)
229+ } else {
230+ logger.Debug("ACME user registration info not found or null in DB", "email", email)
231+ }
232+
233+ return user, nil
234+}
235+
236+// saveAcmeUserToDB saves the user's private key and registration info to the DB
237+func saveAcmeUserToDB(user *AcmeUser, db *sql.DB, queries *queries.ProxyQueries) error {
238+ if user.key == nil {
239+ return fmt.Errorf("cannot save user without a private key")
240+ }
241+
242+ // Encode private key to PEM
243+ privateKeyPEM := certcrypto.PEMEncode(user.key)
244+
245+ // Marshal registration info to JSON (handle nil)
246+ var registrationJSON sql.NullString
247+ if user.Registration != nil {
248+ regBytes, err := json.Marshal(user.Registration)
249+ if err != nil {
250+ return fmt.Errorf("failed to marshal registration info: %w", err)
251+ }
252+ registrationJSON = sql.NullString{String: string(regBytes), Valid: true}
253+ }
254+
255+ now := time.Now().Format(time.RFC3339)
256+
257+ // Use InsertOrReplace query
258+ _, err := db.Exec(queries.InsertOrReplaceAcmeAccount,
259+ user.Email,
260+ string(privateKeyPEM),
261+ registrationJSON, // Pass the NullString directly
262+ now, // created_at (will be updated on replace)
263+ now, // updated_at
264+ )
265+
266+ if err != nil {
267+ return fmt.Errorf("failed to save ACME user to DB: %w", err)
268+ }
269+
270+ logger.Debug("Saved ACME user details to DB", "email", user.Email)
271+ return nil
272+}
273+
274+// SetupChallengeProvider sets up the appropriate challenge provider for a domain
275+func (cm *CertificateManager) SetupChallengeProvider(domainName string, proxy *Proxy) error {
276+ // Get domain configuration from database
277+ var domain struct {
278+ AcmeEnabled bool
279+ AcmeChallengeType sql.NullString
280+ AcmeDnsProvider sql.NullString
281+ AcmeDnsCredentialsRef sql.NullString
282+ }
283+
284+ err := proxy.app.GetDB().QueryRow(`
285+ SELECT acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref
286+ FROM domain WHERE name = ?
287+ `, domainName).Scan(&domain.AcmeEnabled, &domain.AcmeChallengeType, &domain.AcmeDnsProvider, &domain.AcmeDnsCredentialsRef)
288+
289+ if err != nil {
290+ return fmt.Errorf("failed to get domain configuration: %w", err)
291+ }
292+
293+ // Determine challenge type
294+ challengeType := domain.AcmeChallengeType.String
295+ if !domain.AcmeChallengeType.Valid {
296+ challengeType = proxy.config.DefaultChallengeType
297+ }
298+
299+ // Set up the appropriate challenge provider
300+ switch challengeType {
301+ case "http-01":
302+ // Use the configured HTTP challenge port
303+ port := proxy.config.DefaultHttpChallengePort
304+ if port == "" {
305+ port = "80"
306+ }
307+ provider := http01.NewProviderServer("", port)
308+ if err := cm.client.Challenge.SetHTTP01Provider(provider); err != nil {
309+ return fmt.Errorf("failed to set HTTP-01 provider: %w", err)
310+ }
311+
312+ case "dns-01":
313+ if domain.AcmeDnsProvider.String == "" {
314+ return fmt.Errorf("DNS provider not configured for domain %s", domainName)
315+ }
316+
317+ // Get DNS provider credentials from environment
318+ creds := cm.getDNSProviderCredentials(domain.AcmeDnsCredentialsRef.String)
319+ if creds == nil {
320+ return fmt.Errorf("DNS provider credentials not found for domain %s", domainName)
321+ }
322+
323+ // Create and configure the DNS provider
324+ provider, err := dns.NewDNSChallengeProviderByName(domain.AcmeDnsProvider.String)
325+ if err != nil {
326+ return fmt.Errorf("failed to create DNS provider: %w", err)
327+ }
328+
329+ // Configure the provider with credentials
330+ if err := provider.Present(domainName, "", ""); err != nil {
331+ return fmt.Errorf("failed to configure DNS provider: %w", err)
332+ }
333+
334+ // Set DNS timeouts
335+ if dnsProvider, ok := provider.(interface {
336+ SetPropagationTimeout(time.Duration)
337+ SetPollingInterval(time.Duration)
338+ }); ok {
339+ dnsProvider.SetPropagationTimeout(
340+ time.Duration(proxy.config.DefaultDnsChallengePropagationTimeout) * time.Second,
341+ )
342+ dnsProvider.SetPollingInterval(
343+ time.Duration(proxy.config.DefaultDnsChallengePollingInterval) * time.Second,
344+ )
345+ }
346+
347+ if err := cm.client.Challenge.SetDNS01Provider(provider); err != nil {
348+ return fmt.Errorf("failed to set DNS-01 provider: %w", err)
349+ }
350+
351+ default:
352+ return fmt.Errorf("unsupported challenge type: %s", challengeType)
353+ }
354+
355+ return nil
356+}
357+
358+// GetKeyAuthorization returns the key authorization for a token
359+func (cm *CertificateManager) GetKeyAuthorization(token string) (string, error) {
360+ // Create a new HTTP-01 provider server
361+ provider := http01.NewProviderServer("", cm.config.HttpPort)
362+
363+ // Present the challenge
364+ err := provider.Present("", token, "")
365+ if err != nil {
366+ return "", fmt.Errorf("failed to present challenge: %w", err)
367+ }
368+
369+ // Get the key authorization from the provider
370+ path := http01.ChallengePath(token)
371+ return path, nil
372+}
373+
374+// ObtainCertificate obtains a new certificate for a domain
375+func (cm *CertificateManager) ObtainCertificate(domainName string, proxy *Proxy) (*certificate.Resource, error) {
376+ logger.Debug("Obtaining certificate for domain", "domain", domainName)
377+
378+ // Set up the challenge provider
379+ if err := cm.SetupChallengeProvider(domainName, proxy); err != nil {
380+ return nil, fmt.Errorf("failed to set up challenge provider: %w", err)
381+ }
382+
383+ // Request the certificate
384+ request := certificate.ObtainRequest{
385+ Domains: []string{domainName},
386+ Bundle: true,
387+ }
388+
389+ cert, err := cm.client.Certificate.Obtain(request)
390+ if err != nil {
391+ return nil, fmt.Errorf("failed to obtain certificate: %w", err)
392+ }
393+
394+ // Update the certificate in the database
395+ if err := cm.updateCertificateInDB(domainName, cert, proxy); err != nil {
396+ logger.Error("Failed to update certificate in database", "error", err)
397+ }
398+
399+ return cert, nil
400+}
401+
402+// updateCertificateInDB updates the certificate information in the database
403+func (cm *CertificateManager) updateCertificateInDB(domainName string, cert *certificate.Resource, proxy *Proxy) error {
404+ // Get the domain ID
405+ var domainID string
406+ err := proxy.app.GetDB().QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID)
407+ if err != nil {
408+ return fmt.Errorf("failed to get domain ID: %w", err)
409+ }
410+
411+ // Parse the certificate to get expiry date
412+ x509Cert, err := certcrypto.ParsePEMCertificate(cert.Certificate)
413+ if err != nil {
414+ return fmt.Errorf("failed to parse certificate: %w", err)
415+ }
416+
417+ // Update or insert the certificate
418+ _, err = proxy.app.GetDB().Exec(`
419+ INSERT OR REPLACE INTO certificate (
420+ id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status
421+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
422+ `,
423+ cert.Domain,
424+ domainID,
425+ string(cert.Certificate),
426+ string(cert.PrivateKey),
427+ time.Now().Format(time.RFC3339),
428+ x509Cert.NotAfter.Format(time.RFC3339),
429+ "Let's Encrypt",
430+ "valid",
431+ )
432+
433+ if err != nil {
434+ return fmt.Errorf("failed to update certificate in database: %w", err)
435+ }
436+
437+ return nil
438+}
439+
440+// RenewCertificate renews an existing certificate
441+func (cm *CertificateManager) RenewCertificate(domainName string, proxy *Proxy) (*certificate.Resource, error) {
442+ logger.Debug("Renewing certificate for domain", "domain", domainName)
443+
444+ // Set up the challenge provider
445+ if err := cm.SetupChallengeProvider(domainName, proxy); err != nil {
446+ return nil, fmt.Errorf("failed to set up challenge provider: %w", err)
447+ }
448+
449+ // Get the existing certificate from the database
450+ var certFile, keyFile string
451+ err := proxy.app.GetDB().QueryRow(`
452+ SELECT cert_file, key_file FROM certificate c
453+ JOIN domain d ON c.domain_id = d.id
454+ WHERE d.name = ?
455+ `, domainName).Scan(&certFile, &keyFile)
456+ if err != nil {
457+ return nil, fmt.Errorf("failed to get existing certificate: %w", err)
458+ }
459+
460+ // Create certificate resource for renewal
461+ cert := &certificate.Resource{
462+ Domain: domainName,
463+ Certificate: []byte(certFile),
464+ PrivateKey: []byte(keyFile),
465+ }
466+
467+ // Renew the certificate
468+ _, err = cm.client.Certificate.Renew(*cert, true, false, "")
469+ if err != nil {
470+ return nil, fmt.Errorf("failed to renew certificate: %w", err)
471+ }
472+
473+ // Update the certificate in the database
474+ if err := cm.updateCertificateInDB(domainName, cert, proxy); err != nil {
475+ logger.Error("Failed to update certificate in database", "error", err)
476+ }
477+
478+ return cert, nil
479+}
480+
481+// GetCertificateForTLS returns a certificate for TLS handshake
482+func (cm *CertificateManager) GetCertificateForTLS(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
483+ logger.Debug("Getting certificate for TLS handshake", "server_name", hello.ServerName)
484+
485+ // Get the certificate resource
486+ cert, err := cm.GetCertificate(hello.ServerName)
487+ if err != nil {
488+ return nil, fmt.Errorf("failed to get certificate: %w", err)
489+ }
490+
491+ // Convert to tls.Certificate
492+ tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
493+ if err != nil {
494+ return nil, fmt.Errorf("failed to parse certificate: %w", err)
495+ }
496+
497+ return &tlsCert, nil
498+}
499+
500+// GetCertificate retrieves a certificate from the database
501+func (cm *CertificateManager) GetCertificate(domainName string) (*certificate.Resource, error) {
502+ // Try to obtain the certificate if it doesn't exist
503+ request := certificate.ObtainRequest{
504+ Domains: []string{domainName},
505+ Bundle: true,
506+ }
507+ cert, err := cm.client.Certificate.Obtain(request)
508+ if err != nil {
509+ return nil, fmt.Errorf("failed to obtain certificate: %w", err)
510+ }
511+ return cert, nil
512+}
513+
514+// GetAutocertManager returns the autocert manager
515+func (cm *CertificateManager) GetAutocertManager() *tls.Config {
516+ return &tls.Config{
517+ GetCertificate: cm.GetCertificateForTLS,
518+ MinVersion: tls.VersionTLS12,
519+ }
520+}
521+
522+// getDNSProviderCredentials retrieves DNS provider credentials from environment variables
523+func (cm *CertificateManager) getDNSProviderCredentials(ref string) map[string]string {
524+ if ref == "" {
525+ return nil
526+ }
527+
528+ creds := make(map[string]string)
529+ prefix := fmt.Sprintf("GORDON_DNS_CRED_%s_", ref)
530+
531+ for _, env := range os.Environ() {
532+ if strings.HasPrefix(env, prefix) {
533+ parts := strings.SplitN(env, "=", 2)
534+ if len(parts) == 2 {
535+ key := strings.TrimPrefix(parts[0], prefix)
536+ creds[key] = parts[1]
537+ }
538+ }
539+ }
540+
541+ if len(creds) == 0 {
542+ return nil
543+ }
544+
545+ return creds
546+}
D internal/proxy/certificates.go +0 -2280 large diff (2280 lines changed): collapsed
M internal/proxy/containers.go +20 -49
@@ -111,63 +111,34 @@ func (p *Proxy) scanContainersAndCheckCertificates() error {
111 111
112 // Check certificates for each domain112 // Check certificates for each domain
113 for _, domain := range domains {113 for _, domain := range domains {
114- // Check certificate for the full domain114+ // With the refactored CertificateManager, we don't need to manually check
115- hasCert, certType := p.checkCertificateInCache(domain)115+ // the cache here. We can just attempt to ensure a certificate exists
116+ // by calling requestDomainCertificate, which delegates to GetCertificate.
117+ // GetCertificate will use autocert's cache and request a new cert only if needed.
116 118
117- if certType == "staging" && p.config.LetsEncryptMode == "production" {119+ logger.Debug("Ensuring certificate exists for domain",
118- logger.Info("Found staging certificate but production mode is enabled",120+ "domain", domain,
119- "domain", domain,121+ "container", containerName)
120- "action", "will request new production certificate")
121- }
122 122
123- if hasCert {123+ // Request certificate (non-blocking).
124- logger.Debug("Certificate exists for domain",124+ // GetCertificate handles checking cache/validity and requesting if necessary.
125- "domain", domain,125+ go p.requestDomainCertificate(domain) // Simplified call
126- "container", containerName)
127- } else {
128- logger.Info("Certificate not found in cache, requesting",
129- "domain", domain,
130- "container", containerName)
131-
132- // Request certificate (non-blocking)
133- go func(domain string) {
134- // Set flag to indicate we're processing a specific domain
135- p.processingSpecificDomain = true
136- // Try to request certificate for domain
137- p.requestDomainCertificate(domain)
138- // Reset flag when done
139- p.processingSpecificDomain = false
140- }(domain)
141- }
142 126
143- // If this is a subdomain, check the parent domain too127+ // If this is a subdomain, ensure the parent domain cert exists too
144 parts := strings.Split(domain, ".")128 parts := strings.Split(domain, ".")
145 if len(parts) >= 3 {129 if len(parts) >= 3 {
146- // This is a subdomain (e.g., sub.domain.com)
147 parentDomain := strings.Join(parts[1:], ".")130 parentDomain := strings.Join(parts[1:], ".")
148 131
149 // Only check parent domain if it's not an ordinary TLD132 // Only check parent domain if it's not an ordinary TLD
150- if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) {133+ // and not the same as the root domain (to avoid redundant checks)
151- hasCert, certType := p.checkCertificateInCache(parentDomain)134+ if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) && parentDomain != p.app.GetConfig().Http.Domain {
152- if certType == "staging" && p.config.LetsEncryptMode == "production" {135+ logger.Debug("Ensuring certificate exists for parent domain",
153- logger.Info("Found staging certificate but production mode is enabled",136+ "parent_domain", parentDomain,
154- "domain", parentDomain,137+ "original_domain", domain,
155- "action", "will request new production certificate")138+ "container", containerName)
156- }139+
157- if !hasCert {140+ // Request certificate for parent domain also
158- logger.Info("Certificate not found for parent domain",141+ go p.requestDomainCertificate(parentDomain) // Simplified call
159- "domain", parentDomain,
160- "container", containerName)
161-
162- // Request certificate for parent domain also
163- go func(domain string) {
164- // Set flag to indicate we're processing a specific domain
165- p.processingSpecificDomain = true
166- p.requestDomainCertificate(domain)
167- // Reset flag when done
168- p.processingSpecificDomain = false
169- }(parentDomain)
170- }
171 }142 }
172 }143 }
173 }144 }
M internal/proxy/core.go +228 -40
@@ -1,11 +1,16 @@
1 package proxy1 package proxy
2 2
3 import (3 import (
4+ "context"
5+ "fmt"
4 "net"6 "net"
7+ "os"
8+ "strings"
5 "sync"9 "sync"
6 "time"10 "time"
7 11
8 "crypto/tls"12 "crypto/tls"
13+ "database/sql"
9 "net/http"14 "net/http"
10 15
11 "github.com/bnema/gordon/internal/common"16 "github.com/bnema/gordon/internal/common"
@@ -14,7 +19,6 @@ import (
14 "github.com/bnema/gordon/pkg/docker"19 "github.com/bnema/gordon/pkg/docker"
15 "github.com/bnema/gordon/pkg/logger"20 "github.com/bnema/gordon/pkg/logger"
16 "github.com/labstack/echo/v4"21 "github.com/labstack/echo/v4"
17- "golang.org/x/crypto/acme/autocert"
18 )22 )
19 23
20 // - core.go - Core proxy structure and constructor24 // - core.go - Core proxy structure and constructor
@@ -29,39 +33,32 @@ type Proxy struct {
29 config common.ReverseProxyConfig33 config common.ReverseProxyConfig
30 app interfaces.AppInterface34 app interfaces.AppInterface
31 httpsServer *echo.Echo35 httpsServer *echo.Echo
32- httpServer *echo.Echo
33 routes map[string]*ProxyRouteInfo36 routes map[string]*ProxyRouteInfo
34 mu sync.RWMutex37 mu sync.RWMutex
35- certManager *autocert.Manager
36- fallbackCert *tls.Certificate // Fallback self-signed certificate
37 serverStarted bool38 serverStarted bool
39+ ctx context.Context // Context for storing shared data
38 40
39- // Flag to track specific domain certificate operations41+ // Flag to track specific domain certificate operations (May need review if CertManager handles this)
40 processingSpecificDomain bool42 processingSpecificDomain bool
41 43
42 // Store Gordon's own container ID at startup for reliable identification44 // Store Gordon's own container ID at startup for reliable identification
43 gordonContainerID string45 gordonContainerID string
44 46
45- reverseProxyClient *http.Client47+ reverseProxyClient *http.Client
46- processingGlobalCert bool48+ dbMaxRetries int
47- dbMaxRetries int49+ dbRetryBaseDelay time.Duration
48- dbRetryBaseDelay time.Duration50+ shutdown chan struct{} // Channel for signaling shutdown to background goroutines
49- acmeManager *autocert.Manager
50- acmePerDomainRefreshMutex sync.Mutex
51- acmeDirCache autocert.DirCache
52- shutdown chan struct{} // Channel for signaling shutdown to background goroutines
53 51
54 // Track recently created containers to prevent recreation logic52 // Track recently created containers to prevent recreation logic
55 recentContainers map[string]time.Time53 recentContainers map[string]time.Time
56 recentContainersMu sync.RWMutex54 recentContainersMu sync.RWMutex
57 containerCooldownPeriod time.Duration55 containerCooldownPeriod time.Duration
58 56
59- // Upstream proxy detection57+ // Certificate Management
60- upstreamProxyDetected bool58+ certificateManager *CertificateManager // New refactored manager
61- upstreamProxyMu sync.RWMutex
62 59
63- // SQL queries60+ // SQL Queries
64- queries *queries.ProxyQueries61+ Queries *queries.ProxyQueries
65 }62 }
66 63
67 // NewProxy creates a new instance of the reverse proxy64 // NewProxy creates a new instance of the reverse proxy
@@ -80,6 +77,7 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
80 app: app,77 app: app,
81 serverStarted: false,78 serverStarted: false,
82 shutdown: make(chan struct{}),79 shutdown: make(chan struct{}),
80+ ctx: context.Background(),
83 }, nil81 }, nil
84 }82 }
85 83
@@ -96,6 +94,7 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
96 app: app,94 app: app,
97 serverStarted: false,95 serverStarted: false,
98 shutdown: make(chan struct{}),96 shutdown: make(chan struct{}),
97+ ctx: context.Background(),
99 }, nil98 }, nil
100 }99 }
101 100
@@ -103,10 +102,6 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
103 httpsServer := echo.New()102 httpsServer := echo.New()
104 httpsServer.HideBanner = true103 httpsServer.HideBanner = true
105 104
106- // Set up the echo server for HTTP traffic (redirects to HTTPS)
107- httpServer := echo.New()
108- httpServer.HideBanner = true
109-
110 // Initialize container storage and cooldown settings105 // Initialize container storage and cooldown settings
111 recentContainers := make(map[string]time.Time)106 recentContainers := make(map[string]time.Time)
112 containerCooldownPeriod := time.Duration(10) * time.Second107 containerCooldownPeriod := time.Duration(10) * time.Second
@@ -145,10 +140,10 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
145 140
146 // Create the proxy instance141 // Create the proxy instance
147 p := &Proxy{142 p := &Proxy{
148- config: config,143+ config: config,
149- app: app,144+ app: app,
150- httpsServer: httpsServer,145+ httpsServer: httpsServer,
151- httpServer: httpServer,146+ // httpServer: httpServer, // Removed assignment
152 routes: make(map[string]*ProxyRouteInfo),147 routes: make(map[string]*ProxyRouteInfo),
153 reverseProxyClient: reverseProxyClient,148 reverseProxyClient: reverseProxyClient,
154 dbMaxRetries: dbMaxRetries,149 dbMaxRetries: dbMaxRetries,
@@ -156,20 +151,51 @@ func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
156 recentContainers: recentContainers,151 recentContainers: recentContainers,
157 containerCooldownPeriod: containerCooldownPeriod,152 containerCooldownPeriod: containerCooldownPeriod,
158 shutdown: make(chan struct{}),153 shutdown: make(chan struct{}),
159- queries: proxyQueries,154+ Queries: proxyQueries,
155+ ctx: context.Background(),
156+ }
157+
158+ // --- Certificate Manager Setup ---
159+ // Determine cert directory
160+ certDir := config.CertDir
161+ if certDir == "" {
162+ certDir = app.GetConfig().General.StorageDir + "/certs"
160 }163 }
161 164
162- // Setup the certificate manager for HTTPS165+ // Initial upstream proxy detection (before creating CertManager)
163- p.setupCertManager()166+ initialBehindTLSProxy := detectInitialUpstreamProxy(&config)
164 167
165- // Verify the certificate manager was properly initialized168+ // Create Certificate Manager Config
166- if p.certManager == nil {169+ certManagerConfig := CertManagerConfig{
167- logger.Error("Certificate manager initialization failed",170+ CertDir: certDir,
168- "solution", "Check certificate directory permissions and configuration")171+ Email: config.Email,
172+ Mode: config.LetsEncryptMode, // staging or production
173+ SkipCertificates: config.SkipCertificates,
174+ BehindTLSProxy: initialBehindTLSProxy, // Pass initial detection result
175+ AdminDomain: app.GetConfig().Http.FullDomain(),
176+ RootDomain: app.GetConfig().Http.Domain,
177+ // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy
178+ }
179+
180+ // Create the Certificate Manager
181+ certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries)
182+ if err != nil {
183+ logger.Error("Failed to initialize Certificate Manager", "error", err)
184+ // Return an error to prevent starting a broken proxy
185+ return nil, fmt.Errorf("failed to initialize certificate manager: %w", err)
169 } else {186 } else {
170 logger.Debug("Certificate manager successfully initialized")187 logger.Debug("Certificate manager successfully initialized")
188+ p.certificateManager = certMgr // Store the new manager
189+ }
190+
191+ // Ensure admin domain has proper ACME configuration
192+ if err := p.ensureAdminDomainConfig(); err != nil {
193+ logger.Error("Failed to ensure admin domain configuration", "error", err)
194+ return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err)
171 }195 }
172 196
197+ // --- End Certificate Manager Setup ---
198+
173 // Detect our container ID for later use199 // Detect our container ID for later use
174 p.detectGordonContainer()200 p.detectGordonContainer()
175 201
@@ -181,13 +207,6 @@ func (p *Proxy) Close() {
181 // Signal all background goroutines to stop207 // Signal all background goroutines to stop
182 close(p.shutdown)208 close(p.shutdown)
183 209
184- // Close the HTTP and HTTPS servers if they exist
185- if p.httpServer != nil {
186- if err := p.httpServer.Close(); err != nil {
187- logger.Error("Error closing HTTP server", "error", err)
188- }
189- }
190-
191 if p.httpsServer != nil {210 if p.httpsServer != nil {
192 if err := p.httpsServer.Close(); err != nil {211 if err := p.httpsServer.Close(); err != nil {
193 logger.Error("Error closing HTTPS server", "error", err)212 logger.Error("Error closing HTTPS server", "error", err)
@@ -196,3 +215,172 @@ func (p *Proxy) Close() {
196 215
197 logger.Info("Proxy resources cleaned up")216 logger.Info("Proxy resources cleaned up")
198 }217 }
218+
219+// detectInitialUpstreamProxy checks environment variables for signs of an upstream proxy
220+// This is used during initialization before the CertificateManager is created.
221+func detectInitialUpstreamProxy(config *common.ReverseProxyConfig) bool {
222+ // Check if detection is explicitly disabled
223+ if !config.DetectUpstreamProxy {
224+ logger.Debug("Upstream proxy detection disabled by config")
225+ return false
226+ }
227+
228+ // Check for common environment variables set by upstream proxies
229+ if os.Getenv("HTTPS") == "on" ||
230+ os.Getenv("HTTP_X_FORWARDED_PROTO") == "https" ||
231+ os.Getenv("HTTP_X_FORWARDED_SSL") == "on" {
232+ logger.Info("Initial upstream TLS-terminating proxy detected via environment variables",
233+ "detection_method", "environment_variables",
234+ "https", os.Getenv("HTTPS"),
235+ "x_forwarded_proto", os.Getenv("HTTP_X_FORWARDED_PROTO"),
236+ "x_forwarded_ssl", os.Getenv("HTTP_X_FORWARDED_SSL"))
237+ return true
238+ }
239+
240+ // Check Cloudflare headers
241+ if cfVisitor := os.Getenv("HTTP_CF_VISITOR"); cfVisitor != "" {
242+ if strings.Contains(cfVisitor, "\"scheme\":\"https\"") {
243+ logger.Info("Initial Cloudflare proxy detected via environment variables",
244+ "detection_method", "cloudflare_headers",
245+ "cf_visitor", cfVisitor)
246+ return true
247+ }
248+ }
249+
250+ logger.Debug("No initial upstream proxy detected via environment variables")
251+ return false
252+}
253+
254+// requestDomainCertificate requests a certificate for a domain
255+func (p *Proxy) requestDomainCertificate(domainName string) error {
256+ logger.Debug("Requesting certificate for domain", "domain", domainName)
257+
258+ // Check if we're already processing this domain
259+ if p.processingSpecificDomain {
260+ logger.Debug("Already processing a specific domain, skipping", "domain", domainName)
261+ return nil
262+ }
263+
264+ // Get domain configuration
265+ var acmeEnabled bool
266+ // Use sql.NullString for potentially nullable string columns
267+ var acmeChallengeType, acmeDnsProvider, acmeDnsCredentialsRef sql.NullString
268+ err := p.app.GetDB().QueryRow(p.Queries.GetDomainAcmeConfig, domainName).Scan(
269+ &acmeEnabled, &acmeChallengeType, &acmeDnsProvider, &acmeDnsCredentialsRef,
270+ )
271+ if err != nil {
272+ // Handle case where the domain might not exist in the acme_configs table yet
273+ if err == sql.ErrNoRows {
274+ logger.Debug("No ACME configuration found for domain, assuming ACME is disabled", "domain", domainName)
275+ return nil // Treat as ACME disabled if no config exists
276+ }
277+ logger.Error("Failed to get domain ACME configuration", "domain", domainName, "error", err)
278+ return err
279+ }
280+
281+ if !acmeEnabled {
282+ logger.Debug("ACME not enabled for domain", "domain", domainName)
283+ return nil
284+ }
285+
286+ // Check if certificate exists and is valid
287+ var certFile, keyFile string
288+ var issuedAtStr, expiresAtStr string // Read timestamps as strings first
289+ var issuer, status string
290+ var issuedAt, expiresAt time.Time // Keep these for use after parsing
291+
292+ // Scan from DB, reading timestamps as strings
293+ err = p.app.GetDB().QueryRow(p.Queries.GetCertificateByDomain, domainName).Scan(
294+ &certFile, &keyFile, &issuedAtStr, &expiresAtStr, &issuer, &status,
295+ )
296+
297+ // Handle potential sql.ErrNoRows if the certificate doesn't exist yet
298+ if err != nil && err != sql.ErrNoRows {
299+ logger.Error("Failed to check existing certificate", "domain", domainName, "error", err)
300+ return err
301+ }
302+
303+ // If a certificate was found (no error or ErrNoRows), parse the timestamps
304+ if err == nil {
305+ issuedAt, err = time.Parse(time.RFC3339, issuedAtStr)
306+ if err != nil {
307+ logger.Error("Failed to parse issued_at timestamp from DB", "domain", domainName, "value", issuedAtStr, "error", err)
308+ // Return the parse error as it indicates data corruption or an unexpected format
309+ return fmt.Errorf("failed to parse issued_at for domain %s: %w", domainName, err)
310+ }
311+ expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
312+ if err != nil {
313+ logger.Error("Failed to parse expires_at timestamp from DB", "domain", domainName, "value", expiresAtStr, "error", err)
314+ // Return the parse error
315+ return fmt.Errorf("failed to parse expires_at for domain %s: %w", domainName, err)
316+ }
317+ logger.Debug("Successfully scanned and parsed certificate timestamps", "domain", domainName, "issued", issuedAt, "expires", expiresAt)
318+ }
319+ // Note: If err was sql.ErrNoRows, issuedAt/expiresAt will remain zero time.Time, which is handled correctly later.
320+
321+ // If certificate exists and is valid
322+ if err == nil && status == "valid" {
323+ // Check if certificate needs renewal
324+ renewBefore := time.Duration(p.config.RenewBefore) * 24 * time.Hour
325+ if time.Now().Add(renewBefore).Before(expiresAt) {
326+ logger.Debug("Certificate is still valid and does not need renewal",
327+ "domain", domainName,
328+ "expires_at", expiresAt,
329+ "renew_before_days", p.config.RenewBefore)
330+ return nil // Certificate is valid, no action needed
331+ }
332+ logger.Debug("Certificate requires renewal", "domain", domainName, "expires_at", expiresAt)
333+ } else if err == sql.ErrNoRows {
334+ logger.Debug("No existing certificate found for domain", "domain", domainName)
335+ } else {
336+ logger.Debug("Existing certificate is not valid", "domain", domainName, "status", status, "scan_error", err)
337+ }
338+
339+ // --- Proceed with obtaining/renewing certificate ---
340+
341+ // Check if required ACME config fields are present now that we know ACME is enabled
342+ if !acmeChallengeType.Valid || acmeChallengeType.String == "" {
343+ err := fmt.Errorf("ACME challenge type is missing for domain %s", domainName)
344+ logger.Error(err.Error())
345+ return err
346+ }
347+ // If DNS challenge, provider and credentials ref are required
348+ if acmeChallengeType.String == "dns-01" {
349+ if !acmeDnsProvider.Valid || acmeDnsProvider.String == "" {
350+ err := fmt.Errorf("ACME DNS provider is missing for domain %s using dns-01 challenge", domainName)
351+ logger.Error(err.Error())
352+ return err
353+ }
354+ if !acmeDnsCredentialsRef.Valid || acmeDnsCredentialsRef.String == "" {
355+ err := fmt.Errorf("ACME DNS credentials reference is missing for domain %s using dns-01 challenge", domainName)
356+ logger.Error(err.Error())
357+ return err
358+ }
359+ // Log the DNS provider being used (optional, but helpful for debugging)
360+ logger.Debug("Using DNS provider for ACME challenge", "domain", domainName, "provider", acmeDnsProvider.String)
361+ }
362+
363+ // Set processing flag
364+ p.processingSpecificDomain = true
365+ defer func() {
366+ p.processingSpecificDomain = false
367+ }()
368+
369+ // Request new certificate using the CertificateManager
370+ cert, err := p.certificateManager.ObtainCertificate(domainName, p) // Pass 'p' (Proxy instance)
371+ if err != nil {
372+ logger.Error("Failed to obtain/renew certificate", "domain", domainName, "error", err)
373+ // Update certificate status to 'failed' in DB?
374+ // _, dbErr := p.app.GetDB().Exec(p.Queries.UpdateCertificateStatus, "failed", domainName)
375+ // if dbErr != nil {
376+ // logger.Error("Failed to update certificate status after obtain failure", "domain", domainName, "db_error", dbErr)
377+ // }
378+ return err // Return the obtain error
379+ }
380+
381+ logger.Info("Successfully obtained certificate",
382+ "domain", domainName,
383+ "cert_url", cert.CertURL)
384+
385+ return nil
386+}
M internal/proxy/db_helpers.go +60 -0
@@ -2,8 +2,11 @@ package proxy
2 2
3 import (3 import (
4 "database/sql"4 "database/sql"
5+ "fmt"
6+ "time"
5 7
6 "github.com/bnema/gordon/internal/db"8 "github.com/bnema/gordon/internal/db"
9+ "github.com/bnema/gordon/pkg/logger"
7 )10 )
8 11
9 // dbExecWithRetry performs a database execution with retry logic for handling locked database12 // dbExecWithRetry performs a database execution with retry logic for handling locked database
@@ -25,3 +28,60 @@ func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) {
25 func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) {28 func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) {
26 return db.TxExecWithRetry(tx, query, args...)29 return db.TxExecWithRetry(tx, query, args...)
27 }30 }
31+
32+// ensureAdminDomainConfig ensures the admin domain has proper ACME configuration in the database
33+func (p *Proxy) ensureAdminDomainConfig() error {
34+ adminDomain := p.app.GetConfig().Http.FullDomain()
35+ adminAccountID := "" // Variable to store the admin account ID
36+
37+ // Check if domain exists
38+ var exists bool
39+ err := p.app.GetDB().QueryRow(p.Queries.CheckDomainExists, adminDomain).Scan(&exists)
40+ if err != nil {
41+ return fmt.Errorf("failed to check admin domain existence: %w", err)
42+ }
43+
44+ // Get the first account ID (assumed to be the admin)
45+ err = p.app.GetDB().QueryRow(p.Queries.GetFirstAccount).Scan(&adminAccountID)
46+ if err != nil {
47+ if err == sql.ErrNoRows {
48+ // This case should ideally not happen if account setup is done first
49+ logger.Error("No accounts found in the database. Cannot associate admin domain.")
50+ return fmt.Errorf("no accounts found in database")
51+ }
52+ return fmt.Errorf("failed to get admin account ID: %w", err)
53+ }
54+ if adminAccountID == "" {
55+ // Sanity check
56+ logger.Error("Admin account ID is empty. Cannot associate admin domain.")
57+ return fmt.Errorf("admin account ID is empty")
58+ }
59+
60+ if !exists {
61+ // Create the admin domain with proper ACME config and account ID
62+ now := time.Now().Format(time.RFC3339)
63+ _, err = p.app.GetDB().Exec(p.Queries.CreateAdminDomain,
64+ adminDomain, // ID (assuming domain name is unique ID here?)
65+ adminDomain, // Name
66+ adminAccountID, // Account ID (NEW)
67+ now, // Created At
68+ now, // Updated At
69+ true, // ACME Enabled
70+ "http-01", // ACME Challenge Type
71+ )
72+ if err != nil {
73+ return fmt.Errorf("failed to create admin domain: %w", err)
74+ }
75+ logger.Info("Created admin domain with ACME configuration", "domain", adminDomain, "account_id", adminAccountID)
76+ } else {
77+ // Update existing domain to ensure ACME is enabled (no need to update account_id here, assume it's set correctly)
78+ now := time.Now().Format(time.RFC3339)
79+ _, err = p.app.GetDB().Exec(p.Queries.UpdateAdminDomainAcme, now, adminDomain)
80+ if err != nil {
81+ return fmt.Errorf("failed to update admin domain ACME config: %w", err)
82+ }
83+ logger.Info("Updated admin domain ACME configuration", "domain", adminDomain)
84+ }
85+
86+ return nil
87+}
M internal/proxy/middleware.go +18 -14
@@ -21,22 +21,20 @@ const RequestIDKey = "request_id"
21 21
22 // setupMiddleware configures middleware for both HTTP and HTTPS servers22 // setupMiddleware configures middleware for both HTTP and HTTPS servers
23 func (p *Proxy) setupMiddleware() {23 func (p *Proxy) setupMiddleware() {
24- // Configure Echo to trust X-Forwarded-* headers24+ // Configure Echo IP extraction. Since the proxy is edge-facing (directly exposed),
25- // Since we are the reverse proxy, we need to make sure we identify client IPs correctly25+ // ignore X-Forwarded-For and use the direct remote address.
26- p.httpsServer.IPExtractor = echo.ExtractIPFromXFFHeader()26+ p.httpsServer.IPExtractor = echo.ExtractIPDirect()
27- p.httpServer.IPExtractor = echo.ExtractIPFromXFFHeader()
28 27
29 // Add UUID generator middleware for both HTTP and HTTPS servers28 // Add UUID generator middleware for both HTTP and HTTPS servers
30 p.httpsServer.Use(p.createRequestIDMiddleware())29 p.httpsServer.Use(p.createRequestIDMiddleware())
31- p.httpServer.Use(p.createRequestIDMiddleware())
32 30
33 // Check if rate limiting is disabled via configuration31 // Check if rate limiting is disabled via configuration
34 if p.config.EnableRateLimit {32 if p.config.EnableRateLimit {
35 // Create a new Starskey rate limiter store for HTTPS server33 // Create a new Starskey rate limiter store for HTTPS server
36 httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(34 httpsRateLimiter, err := kv.NewStarskeyRateLimiterStore(
37 "./data/ratelimiter/https", // Path to store rate limit data35 "./data/ratelimiter/https", // Path to store rate limit data
38- 10, // Rate: 10 requests per second (lowered from 10)36+ 10, // Rate: 10 requests per second
39- 30, // Burst: 30 requests (lowered from 30)37+ 30, // Burst: 30 requests
40 3*time.Minute, // Expires in 3 minutes38 3*time.Minute, // Expires in 3 minutes
41 )39 )
42 if err != nil {40 if err != nil {
@@ -46,12 +44,14 @@ func (p *Proxy) setupMiddleware() {
46 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{44 p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
47 Store: middleware.NewRateLimiterMemoryStoreWithConfig(45 Store: middleware.NewRateLimiterMemoryStoreWithConfig(
48 middleware.RateLimiterMemoryStoreConfig{46 middleware.RateLimiterMemoryStoreConfig{
49- Rate: rate.Limit(10), // 10 requests per second (lowered from 10)47+ Rate: rate.Limit(10), // 10 requests per second
50- Burst: 30, // Burst of 30 requests (lowered from 30)48+ Burst: 30, // Burst of 30 requests
51 ExpiresIn: 3 * time.Minute, // Store expiration49 ExpiresIn: 3 * time.Minute, // Store expiration
52 },50 },
53 ),51 ),
54 DenyHandler: func(context echo.Context, identifier string, err error) error {52 DenyHandler: func(context echo.Context, identifier string, err error) error {
53+ logger.Info("[RateLimit Debug] DenyHandler invoked for Memory Fallback", "identifier", identifier, "error", err.Error())
54+
55 logger.Warn("Rate limit exceeded",55 logger.Warn("Rate limit exceeded",
56 "ip", identifier,56 "ip", identifier,
57 "path", context.Request().URL.Path,57 "path", context.Request().URL.Path,
@@ -68,6 +68,8 @@ func (p *Proxy) setupMiddleware() {
68 Store: httpsRateLimiter,68 Store: httpsRateLimiter,
69 IdentifierExtractor: func(ctx echo.Context) (string, error) {69 IdentifierExtractor: func(ctx echo.Context) (string, error) {
70 id := ctx.RealIP()70 id := ctx.RealIP()
71+ logger.Info("[RateLimit Debug] IdentifierExtractor called", "ip_identified", id, "remote_addr", ctx.Request().RemoteAddr)
72+
71 logger.Debug("Rate limiting HTTPS request",73 logger.Debug("Rate limiting HTTPS request",
72 "ip", id,74 "ip", id,
73 "remote_addr", ctx.Request().RemoteAddr)75 "remote_addr", ctx.Request().RemoteAddr)
@@ -78,6 +80,8 @@ func (p *Proxy) setupMiddleware() {
78 return id, nil80 return id, nil
79 },81 },
80 DenyHandler: func(context echo.Context, identifier string, err error) error {82 DenyHandler: func(context echo.Context, identifier string, err error) error {
83+ logger.Info("[RateLimit Debug] DenyHandler invoked for Starskey", "identifier", identifier, "error", err.Error())
84+
81 logger.Warn("HTTPS rate limit exceeded",85 logger.Warn("HTTPS rate limit exceeded",
82 "ip", identifier,86 "ip", identifier,
83 "path", context.Request().URL.Path,87 "path", context.Request().URL.Path,
@@ -124,7 +128,7 @@ func (p *Proxy) setupMiddleware() {
124 logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err)128 logger.Error("Failed to create HTTP rate limiter store, falling back to memory store", "error", err)
125 129
126 // Fallback to memory store if Starskey store creation fails130 // Fallback to memory store if Starskey store creation fails
127- p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{131+ p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
128 Store: middleware.NewRateLimiterMemoryStoreWithConfig(132 Store: middleware.NewRateLimiterMemoryStoreWithConfig(
129 middleware.RateLimiterMemoryStoreConfig{133 middleware.RateLimiterMemoryStoreConfig{
130 Rate: rate.Limit(2), // 2 requests per second (lowered from 10)134 Rate: rate.Limit(2), // 2 requests per second (lowered from 10)
@@ -145,7 +149,7 @@ func (p *Proxy) setupMiddleware() {
145 logger.Debug("Using Starskey rate limiter for HTTP server")149 logger.Debug("Using Starskey rate limiter for HTTP server")
146 150
147 // Add custom Starskey rate limiter middleware151 // Add custom Starskey rate limiter middleware
148- p.httpServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{152+ p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
149 Store: httpRateLimiter,153 Store: httpRateLimiter,
150 IdentifierExtractor: func(ctx echo.Context) (string, error) {154 IdentifierExtractor: func(ctx echo.Context) (string, error) {
151 id := ctx.RealIP()155 id := ctx.RealIP()
@@ -180,16 +184,16 @@ func (p *Proxy) setupMiddleware() {
180 184
181 // Add custom logger middleware with request ID for HTTP server if enabled in config185 // Add custom logger middleware with request ID for HTTP server if enabled in config
182 if p.config.EnableHttpLogs {186 if p.config.EnableHttpLogs {
183- p.httpServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{187+ p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
184 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",188 Format: `{"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}","status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}","bytes_in":${bytes_in},"bytes_out":${bytes_out}}` + "\n",
185 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",189 CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
186 Skipper: func(c echo.Context) bool {190 Skipper: func(c echo.Context) bool {
187 return !p.config.EnableHttpLogs191 return !p.config.EnableHttpLogs
188 },192 },
189 }))193 }))
190- logger.Debug("HTTP request logging enabled for HTTP server")194+ logger.Debug("HTTP request logging enabled for HTTPS server")
191 } else {195 } else {
192- logger.Debug("HTTP request logging disabled for HTTP server")196+ logger.Debug("HTTP request logging disabled for HTTPS server")
193 }197 }
194 }198 }
195 199
M internal/proxy/routes.go +53 -43
@@ -52,7 +52,7 @@ func (p *Proxy) loadRoutes() error {
52 }52 }
53 53
54 // Query the database for active proxy routes using retry mechanism54 // Query the database for active proxy routes using retry mechanism
55- rows, err := p.dbQueryWithRetry(p.queries.GetActiveRoutes)55+ rows, err := p.dbQueryWithRetry(p.Queries.GetActiveRoutes)
56 if err != nil {56 if err != nil {
57 if err == sql.ErrNoRows {57 if err == sql.ErrNoRows {
58 logger.Info("No active proxy routes found")58 logger.Info("No active proxy routes found")
@@ -163,11 +163,6 @@ func (p *Proxy) configureRoutes() {
163 if p.httpsServer != nil {163 if p.httpsServer != nil {
164 p.httpsServer.Any("/*", p.proxyRequest)164 p.httpsServer.Any("/*", p.proxyRequest)
165 }165 }
166-
167- // Also add the handler to the HTTP server for HTTP-01 challenges
168- if p.httpServer != nil {
169- p.httpServer.Any("/*", p.proxyRequest)
170- }
171 }166 }
172 167
173 // AddRoute adds a new route to the database and reloads the proxy168 // AddRoute adds a new route to the database and reloads the proxy
@@ -230,7 +225,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
230 225
231 // Get the account ID - default to the first account if available226 // Get the account ID - default to the first account if available
232 var accountID string227 var accountID string
233- err = tx.QueryRow(p.queries.GetFirstAccount).Scan(&accountID)228+ err = tx.QueryRow(p.Queries.GetFirstAccount).Scan(&accountID)
234 if err != nil {229 if err != nil {
235 if err == sql.ErrNoRows {230 if err == sql.ErrNoRows {
236 logger.Warn("No account found when adding route, domains will have NULL account_id")231 logger.Warn("No account found when adding route, domains will have NULL account_id")
@@ -242,14 +237,14 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
242 237
243 // Check if the domain exists238 // Check if the domain exists
244 var domainID string239 var domainID string
245- err = tx.QueryRow(p.queries.GetDomainByName, hostname).Scan(&domainID)240+ err = tx.QueryRow(p.Queries.GetDomainByName, hostname).Scan(&domainID)
246 if err != nil {241 if err != nil {
247 if err == sql.ErrNoRows {242 if err == sql.ErrNoRows {
248 // Domain doesn't exist, create it243 // Domain doesn't exist, create it
249 domainID = uuid.New().String()244 domainID = uuid.New().String()
250 now := time.Now().Format(time.RFC3339)245 now := time.Now().Format(time.RFC3339)
251 _, err = txExecWithRetry(tx,246 _, err = txExecWithRetry(tx,
252- p.queries.InsertDomain,247+ p.Queries.InsertDomain,
253 domainID, hostname, accountID, now, now,248 domainID, hostname, accountID, now, now,
254 )249 )
255 if err != nil {250 if err != nil {
@@ -278,7 +273,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
278 // Domain part doesn't exist, create it273 // Domain part doesn't exist, create it
279 domainPartID = uuid.New().String()274 domainPartID = uuid.New().String()
280 _, err = txExecWithRetry(tx,275 _, err = txExecWithRetry(tx,
281- p.queries.InsertDomain,276+ p.Queries.InsertDomain,
282 domainPartID, domainPart, accountID, now, now,277 domainPartID, domainPart, accountID, now, now,
283 )278 )
284 if err != nil {279 if err != nil {
@@ -305,7 +300,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
305 var existingRouteID string300 var existingRouteID string
306 var existingContainerID string301 var existingContainerID string
307 var existingContainerPort string302 var existingContainerPort string
308- err = tx.QueryRow(p.queries.GetRouteByDomain, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort)303+ err = tx.QueryRow(p.Queries.GetRouteByDomain, domainID).Scan(&existingRouteID, &existingContainerID, &existingContainerPort)
309 304
310 if err == nil {305 if err == nil {
311 // Route exists, update it306 // Route exists, update it
@@ -326,16 +321,9 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
326 "new_port", containerPort)321 "new_port", containerPort)
327 }322 }
328 323
324+ // Use the UpdateRoute query from the queries package
329 _, err = txExecWithRetry(tx,325 _, err = txExecWithRetry(tx,
330- `UPDATE proxy_route SET 326+ p.Queries.UpdateRoute,
331- container_id = ?,
332- container_ip = ?,
333- container_port = ?,
334- protocol = ?,
335- path = ?,
336- active = ?,
337- updated_at = ?
338- WHERE id = ?`,
339 containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID,327 containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID,
340 )328 )
341 if err != nil {329 if err != nil {
@@ -349,7 +337,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
349 routeID := uuid.New().String()337 routeID := uuid.New().String()
350 now := time.Now().Format(time.RFC3339)338 now := time.Now().Format(time.RFC3339)
351 _, err = txExecWithRetry(tx,339 _, err = txExecWithRetry(tx,
352- p.queries.InsertRoute,340+ p.Queries.InsertRoute,
353 routeID, domainID, containerID, containerIP, containerPort,341 routeID, domainID, containerID, containerIP, containerPort,
354 protocol, path, true, now, now,342 protocol, path, true, now, now,
355 )343 )
@@ -414,7 +402,7 @@ func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, pr
414 // Set the flag to indicate we're processing a specific domain402 // Set the flag to indicate we're processing a specific domain
415 p.processingSpecificDomain = true403 p.processingSpecificDomain = true
416 // Try to request certificate for domain404 // Try to request certificate for domain
417- p.requestDomainCertificate(domain)405+ p.requestCertificateForDomain(domain)
418 // Reset the flag after we're done406 // Reset the flag after we're done
419 p.processingSpecificDomain = false407 p.processingSpecificDomain = false
420 }(hostname)408 }(hostname)
@@ -442,7 +430,7 @@ func (p *Proxy) RemoveRoute(domainName string) error {
442 430
443 // Get the domain ID431 // Get the domain ID
444 var domainID string432 var domainID string
445- err = tx.QueryRow(p.queries.GetDomainByName, domainName).Scan(&domainID)433+ err = tx.QueryRow(p.Queries.GetDomainByName, domainName).Scan(&domainID)
446 if err != nil {434 if err != nil {
447 if err == sql.ErrNoRows {435 if err == sql.ErrNoRows {
448 return fmt.Errorf("domain not found: %s", domainName)436 return fmt.Errorf("domain not found: %s", domainName)
@@ -451,13 +439,13 @@ func (p *Proxy) RemoveRoute(domainName string) error {
451 }439 }
452 440
453 // Delete the route with retry441 // Delete the route with retry
454- _, err = txExecWithRetry(tx, p.queries.DeleteRouteByDomainID, domainID)442+ _, err = txExecWithRetry(tx, p.Queries.DeleteRouteByDomainID, domainID)
455 if err != nil {443 if err != nil {
456 return fmt.Errorf("failed to delete route: %w", err)444 return fmt.Errorf("failed to delete route: %w", err)
457 }445 }
458 446
459 // Delete the domain with retry447 // Delete the domain with retry
460- _, err = txExecWithRetry(tx, p.queries.DeleteDomainByID, domainID)448+ _, err = txExecWithRetry(tx, p.Queries.DeleteDomainByID, domainID)
461 if err != nil {449 if err != nil {
462 return fmt.Errorf("failed to delete domain: %w", err)450 return fmt.Errorf("failed to delete domain: %w", err)
463 }451 }
@@ -521,7 +509,7 @@ func (p *Proxy) Reload() error {
521 newRoutes := make(map[string]*ProxyRouteInfo)509 newRoutes := make(map[string]*ProxyRouteInfo)
522 510
523 // Load routes from the database into the new map511 // Load routes from the database into the new map
524- rows, err := p.app.GetDB().Query(p.queries.GetActiveRoutes)512+ rows, err := p.app.GetDB().Query(p.Queries.GetActiveRoutes)
525 if err != nil {513 if err != nil {
526 if err == sql.ErrNoRows {514 if err == sql.ErrNoRows {
527 logger.Info("No active proxy routes found during reload")515 logger.Info("No active proxy routes found during reload")
@@ -644,12 +632,6 @@ func (p *Proxy) Reload() error {
644 // Rebuild route configuration632 // Rebuild route configuration
645 p.configureRoutes()633 p.configureRoutes()
646 634
647- // Force recreation of the route handlers if servers are initialized
648- if p.httpServer != nil {
649- logger.Debug("Refreshing HTTP routes")
650- p.httpServer.Routes()
651- }
652-
653 if p.httpsServer != nil {635 if p.httpsServer != nil {
654 logger.Debug("Refreshing HTTPS routes")636 logger.Debug("Refreshing HTTPS routes")
655 p.httpsServer.Routes()637 p.httpsServer.Routes()
@@ -726,7 +708,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
726 // Next, update the database708 // Next, update the database
727 // Get the domain ID with retry709 // Get the domain ID with retry
728 var domainID string710 var domainID string
729- rows, err := p.app.GetDB().Query(p.queries.GetDomainByName, domain)711+ rows, err := p.app.GetDB().Query(p.Queries.GetDomainByName, domain)
730 if err != nil {712 if err != nil {
731 logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)713 logger.Error("Failed to query domain for IP update", "domain", domain, "error", err)
732 return fmt.Errorf("failed to query domain: %w", err)714 return fmt.Errorf("failed to query domain: %w", err)
@@ -747,7 +729,7 @@ func (p *Proxy) ForceUpdateRouteIP(domain string, newIP string) error {
747 729
748 // Update the proxy_route record with retry and ensure it's marked as active730 // Update the proxy_route record with retry and ensure it's marked as active
749 now := time.Now().Format(time.RFC3339)731 now := time.Now().Format(time.RFC3339)
750- result, err := p.app.GetDB().Exec(p.queries.UpdateRouteIP, newIP, now, domainID, containerID)732+ result, err := p.app.GetDB().Exec(p.Queries.UpdateRouteIP, newIP, now, domainID, containerID)
751 733
752 if err != nil {734 if err != nil {
753 logger.Error("Failed to update IP in database", "domain", domain, "error", err)735 logger.Error("Failed to update IP in database", "domain", domain, "error", err)
@@ -817,7 +799,7 @@ func (p *Proxy) FindRoutesByOldName(containerName string) map[string]*ProxyRoute
817 result := make(map[string]*ProxyRouteInfo)799 result := make(map[string]*ProxyRouteInfo)
818 800
819 // Query the database with retry to find if any route might be associated with this container name801 // Query the database with retry to find if any route might be associated with this container name
820- rows, err := p.app.GetDB().Query(p.queries.GetAllRoutes)802+ rows, err := p.app.GetDB().Query(p.Queries.GetAllRoutes)
821 if err != nil {803 if err != nil {
822 logger.Error("Failed to query database for routes by old name", "error", err)804 logger.Error("Failed to query database for routes by old name", "error", err)
823 return result805 return result
@@ -1182,7 +1164,7 @@ func (p *Proxy) StartContainerEventListener() error {
1182 1164
1183 // Update the IP in the database directly1165 // Update the IP in the database directly
1184 now := time.Now().Format(time.RFC3339)1166 now := time.Now().Format(time.RFC3339)
1185- _, err = p.dbExecWithRetry(p.queries.UpdateRouteIP,1167+ _, err = p.dbExecWithRetry(p.Queries.UpdateRouteIP,
1186 containerIP, now, domainID, containerID)1168 containerIP, now, domainID, containerID)
1187 1169
1188 if err != nil {1170 if err != nil {
@@ -1374,7 +1356,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1374 for _, domain := range domains {1356 for _, domain := range domains {
1375 // First, get the domain ID1357 // First, get the domain ID
1376 var domainID string1358 var domainID string
1377- err := tx.QueryRow(p.queries.GetDomainByName, domain).Scan(&domainID)1359+ err := tx.QueryRow(p.Queries.GetDomainByName, domain).Scan(&domainID)
1378 if err != nil {1360 if err != nil {
1379 logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)1361 logger.Error("Failed to get domain ID for inactive route", "domain", domain, "error", err)
1380 continue1362 continue
@@ -1382,7 +1364,7 @@ func (p *Proxy) markRoutesInactive(domains []string) {
1382 1364
1383 // Update the route to mark it as inactive1365 // Update the route to mark it as inactive
1384 now := time.Now().Format(time.RFC3339)1366 now := time.Now().Format(time.RFC3339)
1385- _, err = tx.Exec(p.queries.MarkRouteInactive, now, domainID)1367+ _, err = tx.Exec(p.Queries.MarkRouteInactive, now, domainID)
1386 if err != nil {1368 if err != nil {
1387 logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)1369 logger.Error("Failed to mark route as inactive in database", "domain", domain, "error", err)
1388 continue1370 continue
@@ -1785,11 +1767,11 @@ func (p *Proxy) proxyRequest(c echo.Context) error {
1785 host = strings.TrimSuffix(strings.ToLower(host), ".")1767 host = strings.TrimSuffix(strings.ToLower(host), ".")
1786 1768
1787 // Debug log the incoming request1769 // Debug log the incoming request
1788- logger.Debug("Proxy request received",1770+ // logger.Debug("Proxy request received",
1789- "host", host,1771+ // "host", host,
1790- "method", c.Request().Method,1772+ // "method", c.Request().Method,
1791- "path", c.Request().URL.Path,1773+ // "path", c.Request().URL.Path,
1792- "client_ip", c.RealIP())1774+ // "client_ip", c.RealIP())
1793 1775
1794 // Get the route for this host1776 // Get the route for this host
1795 p.mu.RLock()1777 p.mu.RLock()
@@ -2086,3 +2068,31 @@ func (p *Proxy) IsContainerInCooldownPeriod(containerID string) bool {
2086 // Check if the container is still in its cooldown period2068 // Check if the container is still in its cooldown period
2087 return time.Since(timestamp) < p.containerCooldownPeriod2069 return time.Since(timestamp) < p.containerCooldownPeriod
2088 }2070 }
2071+
2072+// Add specific domain handling to use our new certificate manager
2073+
2074+// requestCertificateForDomain requests a certificate for a specific domain using our new refactored system
2075+func (p *Proxy) requestCertificateForDomain(domain string) {
2076+ // Use the certificate manager stored on the Proxy struct
2077+ if p.certificateManager != nil {
2078+ // Request the certificate using the new manager
2079+ _, err := p.certificateManager.GetCertificate(domain)
2080+ if err != nil {
2081+ logger.Error("Failed to get certificate with new manager",
2082+ "domain", domain,
2083+ "error", err)
2084+ } else {
2085+ logger.Info("Successfully secured certificate for domain",
2086+ "domain", domain)
2087+ }
2088+ return
2089+ }
2090+
2091+ // Fall back to old method if new manager not available
2092+ // logger.Debug("Using legacy certificate manager for domain",
2093+ // "domain", domain)
2094+ // p.requestDomainCertificateRefactored(domain) // Removed fallback to non-existent method
2095+
2096+ // If manager is nil, log an error (should have been caught earlier)
2097+ logger.Error("requestCertificateForDomain called but certificateManager is nil", "domain", domain)
2098+}
M internal/proxy/server.go +123 -207
@@ -11,8 +11,6 @@ import (
11 11
12 "github.com/bnema/gordon/pkg/docker"12 "github.com/bnema/gordon/pkg/docker"
13 "github.com/bnema/gordon/pkg/logger"13 "github.com/bnema/gordon/pkg/logger"
14- "github.com/labstack/echo/v4"
15- "golang.org/x/crypto/acme"
16 )14 )
17 15
18 // This file contains server operations functions for the proxy.16 // This file contains server operations functions for the proxy.
@@ -153,205 +151,12 @@ func (p *Proxy) Start() error {
153 // Configure routes151 // Configure routes
154 p.configureRoutes()152 p.configureRoutes()
155 153
156- // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges154+ // Configure HTTPS and HTTP servers using our refactored method
157- // and redirect everything else to HTTPS155+ if err := p.StartHTTPSServer(); err != nil {
158- p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil)))156+ logger.Error("Failed to start HTTPS server", "error", err)
159-157+ return err
160- // Create a custom HTTPS server with proper TLS config
161- httpsServer := &http.Server{
162- Addr: fmt.Sprintf(":%s", p.config.Port),
163- Handler: p.httpsServer,
164- TLSConfig: &tls.Config{
165- GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
166- // Log SNI information in debug mode
167- if hello.ServerName != "" {
168- logger.Debug("TLS handshake with SNI",
169- "server_name", hello.ServerName)
170- }
171-
172- // Verify certificate manager is properly initialized
173- if p.certManager == nil {
174- logger.Error("TLS certificate manager is nil during handshake - certificate initialization failed",
175- "server_name", hello.ServerName,
176- "solution", "Check certificate configuration and logs for setup errors")
177-
178- // Use fallback certificate if we have one
179- if p.fallbackCert != nil {
180- logger.Warn("Using fallback self-signed certificate due to missing certificate manager",
181- "server_name", hello.ServerName)
182- return p.fallbackCert, nil
183- }
184-
185- return nil, fmt.Errorf("certificate manager not initialized")
186- }
187-
188- // Try to get the certificate from the autocert manager
189- logger.Debug("Attempting to get certificate from manager",
190- "server_name", hello.ServerName,
191- "has_fallback", p.fallbackCert != nil)
192-
193- // First try checking if we have a valid certificate in cache
194- cacheKey := "cert-" + hello.ServerName
195- certCache := p.certManager.Cache
196- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
197- defer cancel()
198-
199- if certCache != nil {
200- certData, err := certCache.Get(ctx, cacheKey)
201- if err == nil && len(certData) > 0 {
202- logger.Debug("Found certificate in cache", "domain", hello.ServerName)
203- // Use directly if found in cache - skip the potentially slower autocert.Manager GetCertificate
204- keyKey := "key-" + hello.ServerName
205- keyData, keyErr := certCache.Get(ctx, keyKey)
206- if keyErr == nil && len(keyData) > 0 {
207- cert, err := tls.X509KeyPair(certData, keyData)
208- if err == nil {
209- logger.Debug("Successfully loaded certificate from cache",
210- "domain", hello.ServerName)
211- return &cert, nil
212- }
213- logger.Error("Failed to create X509KeyPair from cache",
214- "domain", hello.ServerName,
215- "error", err)
216- } else {
217- logger.Error("Certificate found in cache but key is missing or invalid",
218- "domain", hello.ServerName,
219- "key_error", keyErr)
220- }
221- } else if err != nil {
222- logger.Debug("Certificate not found in cache during TLS handshake",
223- "domain", hello.ServerName,
224- "error", err)
225-
226- // Check if we have it on disk but not in cache (unlikely after preloading)
227- hasOnDisk, certType := p.checkCertificateInFilesystem(hello.ServerName)
228- if hasOnDisk {
229- logger.Warn("Certificate found on disk but not in cache - this should not happen after preloading",
230- "domain", hello.ServerName,
231- "type", certType,
232- "solution", "Check preloadCertificates implementation")
233- }
234- }
235- }
236-
237- // If not in cache, use the standard GetCertificate method
238- // Let's Encrypt may be contacted if no certificate exists
239- cert, err := p.certManager.GetCertificate(hello)
240-
241- if err != nil {
242- logger.Error("Error getting certificate from manager",
243- "server_name", hello.ServerName,
244- "error", err)
245-
246- // Check for rate limit errors
247- if strings.Contains(err.Error(), "too many certificates") ||
248- strings.Contains(err.Error(), "rateLimited") {
249- logger.Error("Let's Encrypt rate limit hit during TLS handshake",
250- "domain", hello.ServerName,
251- "solution", "Wait for rate limit to expire or manually add certificate")
252- }
253-
254- // Try manually loading certificate files directly as a fallback before using fallback cert
255- if manualCert, manualErr := p.manuallyLoadCertificate(hello.ServerName); manualErr == nil {
256- logger.Info("Successfully loaded certificate manually as fallback",
257- "domain", hello.ServerName)
258- return manualCert, nil
259- } else {
260- logger.Debug("Manual certificate loading also failed",
261- "domain", hello.ServerName,
262- "error", manualErr)
263- }
264-
265- // If we can't get a certificate and we have a fallback, use it for the admin domain
266- if hello.ServerName == p.app.GetConfig().Http.FullDomain() && p.fallbackCert != nil {
267- logger.Debug("Using fallback certificate for admin domain",
268- "domain", hello.ServerName,
269- "error", err)
270- return p.fallbackCert, nil
271- }
272- } else if cert == nil {
273- logger.Error("Certificate manager returned nil certificate without error",
274- "server_name", hello.ServerName)
275-
276- // Try manually loading certificate files directly as a fallback
277- if manualCert, manualErr := p.manuallyLoadCertificate(hello.ServerName); manualErr == nil {
278- logger.Info("Successfully loaded certificate manually when autocert returned nil",
279- "domain", hello.ServerName)
280- return manualCert, nil
281- }
282-
283- // If we can't get a certificate and we have a fallback, use it for the admin domain
284- if hello.ServerName == p.app.GetConfig().Http.FullDomain() && p.fallbackCert != nil {
285- logger.Debug("Using fallback certificate for admin domain",
286- "domain", hello.ServerName)
287- return p.fallbackCert, nil
288- }
289- } else {
290- logger.Debug("Successfully obtained certificate from manager",
291- "server_name", hello.ServerName)
292- }
293-
294- return cert, err
295- },
296- MinVersion: tls.VersionTLS12,
297- // Add server name to use when client doesn't send SNI
298- ServerName: p.app.GetConfig().Http.FullDomain(),
299- // Add support for TLS-ALPN-01 challenges
300- NextProtos: []string{acme.ALPNProto},
301- },
302- // Add increased timeouts to prevent hanging connections
303- ReadTimeout: 60 * time.Second,
304- WriteTimeout: 60 * time.Second,
305- IdleTimeout: 120 * time.Second,
306- ReadHeaderTimeout: 30 * time.Second, // Add header timeout
307 }158 }
308 159
309- // Store the HTTPS server for later use in Stop()
310- p.httpsServer.Server = httpsServer
311-
312- // Create a custom HTTP server
313- httpServer := &http.Server{
314- Addr: fmt.Sprintf(":%s", p.config.HttpPort),
315- Handler: p.httpServer,
316- ReadTimeout: 30 * time.Second,
317- WriteTimeout: 30 * time.Second,
318- IdleTimeout: 120 * time.Second,
319- }
320-
321- p.serverStarted = true
322-
323- // Start HTTPS server with more error details
324- go func() {
325- logger.Info("Starting HTTPS server", "address", httpsServer.Addr)
326- // Using empty strings for cert and key files since we're using GetCertificate
327- if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
328- logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)
329-
330- // Print more diagnostic information
331- if strings.Contains(err.Error(), "bind: address already in use") {
332- logger.Error("Port 443 is already in use by another process. Check if another web server is running.",
333- "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port")
334- } else if strings.Contains(err.Error(), "permission denied") {
335- logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.",
336- "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping")
337- } else if strings.Contains(err.Error(), "certificate") {
338- logger.Error("Certificate-related error when starting HTTPS server",
339- "solution", "Check certificate files and permissions in the certificate directory")
340- }
341- }
342- }()
343-
344- // Start HTTP server
345- go func() {
346- logger.Info("Starting HTTP server", "address", httpServer.Addr)
347- if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
348- logger.Error("HTTP server failed", "error", err)
349- }
350- }()
351-
352- // Test admin connection after server starts
353- go p.TestAdminConnectionLater()
354-
355 return nil160 return nil
356 }161 }
357 162
@@ -381,12 +186,12 @@ func (p *Proxy) Stop() error {
381 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)186 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
382 defer cancel()187 defer cancel()
383 188
384- // Shutdown HTTP server if it's running189+ // Removed HTTP server shutdown logic
385- if p.httpServer != nil {190+ // if p.httpServer != nil {
386- if err := p.httpServer.Shutdown(ctx); err != nil {191+ // if err := p.httpServer.Shutdown(ctx); err != nil {
387- logger.Error("Failed to gracefully shutdown HTTP server", "error", err)192+ // logger.Error("Failed to gracefully shutdown HTTP server", "error", err)
388- }193+ // }
389- }194+ // }
390 195
391 // Shutdown HTTPS server if it's running196 // Shutdown HTTPS server if it's running
392 if p.httpsServer != nil {197 if p.httpsServer != nil {
@@ -536,9 +341,9 @@ func (p *Proxy) checkExternalPortAccess() {
536 }341 }
537 342
538 // Verify TLS settings are consistent343 // Verify TLS settings are consistent
539- if p.certManager == nil {344+ if p.certificateManager == nil { // Updated check
540 logger.Error("Certificate manager is nil - TLS will not work correctly",345 logger.Error("Certificate manager is nil - TLS will not work correctly",
541- "solution", "Check setupCertManager implementation")346+ "solution", "Check NewProxy implementation and logs")
542 }347 }
543 348
544 // First try a basic TCP connection to port 443 to test if it's open349 // First try a basic TCP connection to port 443 to test if it's open
@@ -654,3 +459,114 @@ func (p *Proxy) checkExternalPortAccess() {
654 // logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon")459 // logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon")
655 // return "gordon"460 // return "gordon"
656 // }461 // }
462+
463+// StartHTTPSServer starts the HTTPS server
464+func (p *Proxy) StartHTTPSServer() error {
465+ logger.Info("Starting HTTPS server",
466+ "port", p.config.Port,
467+ "mode", p.config.LetsEncryptMode)
468+
469+ // Verify the certificate manager is initialized (it's done in NewProxy now)
470+ if p.certificateManager == nil {
471+ // This should ideally not happen if NewProxy succeeded
472+ logger.Error("Certificate manager is nil in StartHTTPSServer, cannot start HTTPS")
473+ return fmt.Errorf("certificate manager not initialized")
474+ }
475+ logger.Info("Using new certificate management system")
476+
477+ // Setup TLS configuration
478+ tlsConfig := &tls.Config{
479+ // Use the helper function that gets the GetCertificate func from the manager
480+ GetCertificate: p.getTLSCertificateFunc(),
481+ // Ensure we use TLS 1.2 or higher
482+ MinVersion: tls.VersionTLS12,
483+ // Optimize cipher suites for security and performance
484+ CipherSuites: []uint16{
485+ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
486+ tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
487+ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
488+ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
489+ tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
490+ tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
491+ },
492+ // Use server's preferred ciphersuite
493+ PreferServerCipherSuites: true,
494+ // Enable HTTP/2
495+ NextProtos: []string{"h2", "http/1.1"},
496+ // Add server name to use when client doesn't send SNI
497+ ServerName: p.app.GetConfig().Http.FullDomain(),
498+ }
499+
500+ // Removed setup for ACME challenge handler on the persistent HTTP server,
501+ // as it's no longer running. Lego's temporary server will handle challenges.
502+ // p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.getACMEHTTPHandler()))
503+
504+ // Create a custom HTTPS server with proper TLS config
505+ httpsServer := &http.Server{
506+ Addr: fmt.Sprintf(":%s", p.config.Port),
507+ Handler: p.httpsServer,
508+ TLSConfig: tlsConfig,
509+ // Add increased timeouts to prevent hanging connections
510+ ReadTimeout: 60 * time.Second,
511+ WriteTimeout: 60 * time.Second,
512+ IdleTimeout: 120 * time.Second,
513+ ReadHeaderTimeout: 30 * time.Second, // Add header timeout
514+ }
515+
516+ // Store the HTTPS server for later use in Stop()
517+ p.httpsServer.Server = httpsServer
518+
519+ // // Create a custom HTTP server
520+ // httpServer := &http.Server{
521+ // Addr: fmt.Sprintf(":%s", p.config.HttpPort),
522+ // Handler: p.httpServer,
523+ // ReadTimeout: 30 * time.Second,
524+ // WriteTimeout: 30 * time.Second,
525+ // IdleTimeout: 120 * time.Second,
526+ // }
527+
528+ p.serverStarted = true
529+
530+ // httpServer is no longer needed as ACME HTTP-01 uses its own temporary server
531+ // httpServer := &http.Server{
532+ // Addr: fmt.Sprintf(":%s", p.config.HttpPort),
533+ // Handler: p.httpServer,
534+ // ReadTimeout: 30 * time.Second,
535+ // WriteTimeout: 30 * time.Second,
536+ // IdleTimeout: 120 * time.Second,
537+ // }
538+
539+ // Start HTTPS server with more error details
540+ go func() {
541+ logger.Info("Starting HTTPS server", "address", httpsServer.Addr)
542+ // Using empty strings for cert and key files since we're using GetCertificate
543+ if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
544+ logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)
545+
546+ // Print more diagnostic information
547+ if strings.Contains(err.Error(), "bind: address already in use") {
548+ logger.Error("Port 443 is already in use by another process. Check if another web server is running.",
549+ "solution", "Stop other services using port 443 or configure Gordon to use a different HTTPS port")
550+ } else if strings.Contains(err.Error(), "permission denied") {
551+ logger.Error("Permission denied when binding to port 443. This often happens when running without root privileges.",
552+ "solution", "Use setcap to allow binding to privileged ports or run in a container with proper port mapping")
553+ } else if strings.Contains(err.Error(), "certificate") {
554+ logger.Error("Certificate-related error when starting HTTPS server",
555+ "solution", "Check certificate files and permissions in the certificate directory")
556+ }
557+ }
558+ }()
559+
560+ // Test admin connection after server starts
561+ go p.TestAdminConnectionLater()
562+
563+ return nil
564+}
565+
566+// getTLSCertificateFunc returns a function that gets certificates for TLS handshakes
567+func (p *Proxy) getTLSCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
568+ return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
569+ logger.Debug("Getting certificate for TLS handshake", "server_name", hello.ServerName)
570+ return p.certificateManager.GetCertificateForTLS(hello)
571+ }
572+}
M internal/server/app_server.go +1 -0
@@ -38,6 +38,7 @@ type DBTables struct {
38 Domain db.Domain `sql:"domain"`38 Domain db.Domain `sql:"domain"`
39 Certificate db.Certificate `sql:"certificate"`39 Certificate db.Certificate `sql:"certificate"`
40 ProxyRoute db.ProxyRoute `sql:"proxy_route"`40 ProxyRoute db.ProxyRoute `sql:"proxy_route"`
41+ AcmeAccount db.AcmeAccount `sql:"acme_account"`
41 }42 }
42 43
43 // GetConfig returns the configuration44 // GetConfig returns the configuration
M internal/server/sqlite_init.go +193 -163
@@ -6,10 +6,14 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "reflect"
10+ "strings"
9 "time"11 "time"
10 12
11 "github.com/bnema/gordon/internal/db"13 "github.com/bnema/gordon/internal/db"
14+ pkgsqlite "github.com/bnema/gordon/pkg/sqlite"
12 "github.com/charmbracelet/log"15 "github.com/charmbracelet/log"
16+ "github.com/google/uuid"
13 _ "modernc.org/sqlite"17 _ "modernc.org/sqlite"
14 )18 )
15 19
@@ -37,113 +41,228 @@ func (a *App) ensureDBDir() error {
37 return nil41 return nil
38 }42 }
39 43
40-// checkDBFile checks if the database file exists.44+// InitializeDB initializes the database in memory and loads data from disk if available
41-func (a *App) checkDBFile(dbPath string) (bool, error) {45+func InitializeDB(a *App) (*sql.DB, error) {
42- if _, err := os.Stat(dbPath); os.IsNotExist(err) {46+ log.Debug("Initializing database")
43- log.Debug("Database file does not exist", "path", dbPath)47+ if err := a.ensureDBDir(); err != nil {
44- return false, nil48+ return nil, fmt.Errorf("failed to ensure DB directory: %w", err)
45- } else if err != nil {
46- return false, err
47 }49 }
48 50
49- // File exists, but let's also check if the tables exist51+ diskDBPath := a.getDiskDBFilePath()
50- db, err := sql.Open("sqlite", dbPath)52+ a.DBPath = diskDBPath
51- if err != nil {53+
52- return false, fmt.Errorf("failed to open DB to check tables: %w", err)54+ // --- Start Refactored Schema Handling ---
55+ log.Debug("Ensuring database schema is up-to-date on disk", "path", diskDBPath)
56+ // UpdateDatabase will create the file and tables if they don't exist,
57+ // or update the schema if they do. It also handles backups.
58+ if err := pkgsqlite.UpdateDatabase(diskDBPath, a.DBTables); err != nil {
59+ return nil, fmt.Errorf("failed to create/update database schema on disk: %w", err)
53 }60 }
54- defer db.Close()61+ log.Debug("Database schema on disk is up-to-date.")
62+ // --- End Refactored Schema Handling ---
55 63
56- // Check if domain table exists64+ // --- BEGIN Seed Admin Account if Necessary ---
57- var tableCount int65+ log.Debug("Checking if admin account seeding is necessary", "path", diskDBPath)
58- err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='domain'").Scan(&tableCount)66+ // Need to temporarily open the disk DB to check and potentially write
67+ seedDB, err := sql.Open("sqlite", diskDBPath)
59 if err != nil {68 if err != nil {
60- return false, fmt.Errorf("failed to check for domain table: %w", err)69+ return nil, fmt.Errorf("failed to open disk DB for seeding check: %w", err)
61 }70 }
62 71
63- if tableCount == 0 {72+ var accountCount int
64- log.Debug("Database file exists but domain table is missing, will reinitialize")73+ err = seedDB.QueryRow("SELECT COUNT(*) FROM account").Scan(&accountCount)
65- // The file exists but doesn't have the required tables, treat as non-existent74+ if err != nil {
66- if err := os.Remove(dbPath); err != nil {75+ seedDB.Close()
67- return false, fmt.Errorf("failed to remove corrupted DB file: %w", err)76+ // If the table doesn't exist yet (shouldn't happen after UpdateDatabase), log it
77+ if pkgsqlite.IsNoSuchTableError(err) {
78+ log.Warn("Account table not found during seeding check, skipping seed.", "error", err)
79+ // Proceed without seeding, assuming UpdateDatabase handled creation correctly
80+ } else {
81+ return nil, fmt.Errorf("failed to query account count from disk DB: %w", err)
82+ }
83+ } else if accountCount == 0 {
84+ log.Info("No accounts found, seeding default admin user and account.")
85+ // Begin transaction for seeding
86+ seedTx, txErr := seedDB.Begin()
87+ if txErr != nil {
88+ seedDB.Close()
89+ return nil, fmt.Errorf("failed to begin transaction for seeding: %w", txErr)
68 }90 }
69- return false, nil
70- }
71 91
72- log.Debug("Database file exists and contains required tables", "path", dbPath)92+ adminUserID := uuid.NewString()
73- return true, nil93+ adminAccountID := uuid.NewString()
74-}94+ adminEmail := "admin@local.host" // Or some other placeholder
95+
96+ // Insert User
97+ _, txErr = seedTx.Exec("INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
98+ adminUserID, "Admin User", adminEmail)
99+ if txErr != nil {
100+ seedTx.Rollback()
101+ seedDB.Close()
102+ return nil, fmt.Errorf("failed to insert default admin user: %w", txErr)
103+ }
75 104
76-// InitializeDB initializes the database in memory and loads data from disk if available105+ // Insert Account
77-func InitializeDB(a *App) (*sql.DB, error) {106+ _, txErr = seedTx.Exec("INSERT INTO account (id, user_id) VALUES (?, ?)",
78- log.Debug("Initializing in-memory database")107+ adminAccountID, adminUserID)
79- if err := a.ensureDBDir(); err != nil {108+ if txErr != nil {
80- return nil, fmt.Errorf("failed to ensure DB directory: %w", err)109+ seedTx.Rollback()
110+ seedDB.Close()
111+ return nil, fmt.Errorf("failed to insert default admin account: %w", txErr)
112+ }
113+
114+ // Commit transaction
115+ txErr = seedTx.Commit()
116+ if txErr != nil {
117+ seedDB.Close()
118+ return nil, fmt.Errorf("failed to commit seeding transaction: %w", txErr)
119+ }
120+ log.Info("Default admin user and account seeded successfully", "user_id", adminUserID, "account_id", adminAccountID)
121+ } else {
122+ log.Debug("Accounts already exist, skipping admin seeding.", "count", accountCount)
81 }123 }
82 124
83- diskDBPath := a.getDiskDBFilePath()125+ // Close the temporary connection used for seeding
84- a.DBPath = diskDBPath126+ if err := seedDB.Close(); err != nil {
127+ // Log error but don't necessarily fail initialization at this point
128+ log.Error("Error closing temporary seed DB connection", "error", err)
129+ }
130+ // --- END Seed Admin Account if Necessary ---
85 131
86- // Create in-memory database connection132+ // Create in-memory database connection (remains the same)
87 log.Debug("Opening in-memory SQLite database")133 log.Debug("Opening in-memory SQLite database")
88 memDB, err := sql.Open("sqlite", "file::memory:?cache=shared")134 memDB, err := sql.Open("sqlite", "file::memory:?cache=shared")
89 if err != nil {135 if err != nil {
90 return nil, fmt.Errorf("failed to open in-memory DB: %w", err)136 return nil, fmt.Errorf("failed to open in-memory DB: %w", err)
91 }137 }
92 138
93- // Test that the in-memory database is working139+ // Test that the in-memory database is working (remains the same)
94 log.Debug("Testing in-memory database connection")140 log.Debug("Testing in-memory database connection")
95 if err := memDB.Ping(); err != nil {141 if err := memDB.Ping(); err != nil {
142+ memDB.Close() // Close memDB if ping fails
96 return nil, fmt.Errorf("failed to ping in-memory database: %w", err)143 return nil, fmt.Errorf("failed to ping in-memory database: %w", err)
97 }144 }
98 145
99- // Check if disk database exists146+ // --- Load from Disk to Memory ---
100- dbExists, err := a.checkDBFile(diskDBPath)147+ // Now that the disk schema is guaranteed to be correct, load it into memory.
148+ // We need to check if the file actually contains data before loading.
149+ // A simple way is to check if the file size is > 0 after UpdateDatabase.
150+ fileInfo, err := os.Stat(diskDBPath)
151+ loadNeeded := true
101 if err != nil {152 if err != nil {
102- return nil, fmt.Errorf("failed to check DB file: %w", err)153+ if os.IsNotExist(err) {
154+ log.Debug("Database file still doesn't exist after schema update (likely first run with no tables?), skipping load from disk.")
155+ loadNeeded = false // Nothing to load
156+ } else {
157+ memDB.Close()
158+ return nil, fmt.Errorf("failed to stat disk DB file after schema update: %w", err)
159+ }
160+ } else if fileInfo.Size() == 0 {
161+ log.Debug("Database file is empty after schema update, skipping load from disk.")
162+ loadNeeded = false // Nothing to load
103 }163 }
104 164
105- if !dbExists {165+ if loadNeeded {
106- log.Debug("Database needs bootstrapping", "path", diskDBPath)166+ log.Debug("Loading database from disk into memory", "path", diskDBPath)
107- // Create tables in memory167+ if err := loadDiskDBToMemory(diskDBPath, memDB); err != nil {
108- log.Debug("Creating tables in memory")168+ memDB.Close()
109- if err := bootstrapDB(memDB); err != nil {169+ return nil, fmt.Errorf("failed to load disk DB into memory: %w", err)
110- return nil, fmt.Errorf("failed to bootstrap in-memory DB: %w", err)170+ }
171+ log.Debug("Successfully loaded disk database into memory")
172+ } else {
173+ // If we didn't load, the memory DB is empty but has the correct schema
174+ // implicitly because UpdateDatabase already ran on the (possibly new) disk file.
175+ // However, loadDiskDBToMemory *also* creates the schema in memory based on the disk file.
176+ // If the disk file was *just* created by UpdateDatabase and is empty,
177+ // loadDiskDBToMemory won't run, and memDB won't have the tables yet.
178+ // We need to ensure the schema exists in memory even if the disk file was initially empty.
179+ // Easiest way: re-apply the schema creation logic to memDB.
180+ log.Debug("Applying schema to empty in-memory database")
181+ memTx, err := memDB.Begin()
182+ if err != nil {
183+ memDB.Close()
184+ return nil, fmt.Errorf("failed to begin transaction on memory DB for schema creation: %w", err)
185+ }
186+ schemaCreated := false
187+ // Iterate through DBTables fields using reflection to get correct table names
188+ dbTablesType := reflect.TypeOf(a.DBTables)
189+ dbTablesValue := reflect.ValueOf(a.DBTables)
190+ if dbTablesType.Kind() == reflect.Ptr {
191+ dbTablesType = dbTablesType.Elem()
192+ dbTablesValue = dbTablesValue.Elem()
111 }193 }
112 194
113- // Save the initialized in-memory database to disk195+ if dbTablesType.Kind() == reflect.Struct {
114- log.Debug("Saving initialized in-memory database to disk")196+ log.Debug("Reflecting DBTables struct for in-memory schema creation", "type", dbTablesType.Name())
115- if err := saveMemDBToDisk(memDB, diskDBPath); err != nil {197+ for i := 0; i < dbTablesType.NumField(); i++ {
116- return nil, fmt.Errorf("failed to save initial in-memory DB to disk: %w", err)198+ field := dbTablesType.Field(i)
199+ fieldValue := dbTablesValue.Field(i).Interface() // e.g., db.ProxyRoute instance
200+ sqlTag := field.Tag.Get("sql")
201+
202+ if sqlTag == "" || sqlTag == "-" {
203+ log.Debug("Skipping field without sql tag in memory schema creation", "field", field.Name)
204+ continue // Skip fields without a sql tag or marked to be ignored
205+ }
206+
207+ tableName := sqlTag // Use the tag directly as the table name
208+ log.Debug("Attempting to create table in memory", "tableName", tableName, "structType", reflect.TypeOf(fieldValue).Name())
209+
210+ if err := pkgsqlite.CreateTableInTx(memTx, fieldValue, tableName); err != nil {
211+ // Ignore "table already exists" errors if somehow they occur
212+ if !strings.Contains(strings.ToLower(err.Error()), "already exists") {
213+ log.Error("Failed to create table in memory", "table", tableName, "error", err)
214+ memTx.Rollback()
215+ memDB.Close()
216+ return nil, fmt.Errorf("failed to create table %s in memory: %w", tableName, err)
217+ } else {
218+ log.Debug("Table already exists in memory, skipping creation", "table", tableName)
219+ }
220+ } else {
221+ log.Debug("Successfully created table in memory", "table", tableName)
222+ schemaCreated = true
223+ }
224+ }
225+ } else {
226+ log.Error("a.DBTables is not a struct, cannot create schema in memory")
227+ memDB.Close()
228+ return nil, fmt.Errorf("internal error: DBTables is not a struct type (%T)", a.DBTables)
117 }229 }
118 230
119- log.Debug("Initialized new in-memory database and saved to disk")231+ if err := memTx.Commit(); err != nil {
120- } else {232+ memDB.Close()
121- // Load existing database from disk into memory233+ return nil, fmt.Errorf("failed to commit schema creation transaction to memory DB: %w", err)
122- log.Debug("Loading existing database from disk into memory", "path", diskDBPath)234+ }
123- if err := loadDiskDBToMemory(diskDBPath, memDB); err != nil {235+ if schemaCreated {
124- return nil, fmt.Errorf("failed to load disk DB into memory: %w", err)236+ log.Debug("Successfully applied schema to in-memory database")
237+ } else {
238+ log.Debug("In-memory schema already existed or no tables defined.")
125 }239 }
126 240
127- log.Debug("Successfully loaded disk database into memory")
128 }241 }
242+ // --- End Load from Disk to Memory ---
129 243
130 a.DB = memDB244 a.DB = memDB
131 245
132- if dbExists {246+ // Populate struct tables if data was loaded
133- // Populate the struct tables from the database247+ if loadNeeded {
134- log.Debug("Populating struct tables from existing database")248+ log.Debug("Populating struct tables from loaded database")
135 if err := PopulateStructWithTables(a); err != nil {249 if err := PopulateStructWithTables(a); err != nil {
136- return nil, err250+ // Don't fail initialization for this, maybe just log an error
251+ log.Error("Failed to populate struct tables after loading DB", "error", err)
252+ // Depending on severity, you might want to return nil, err here
137 }253 }
254+ } else {
255+ log.Debug("Skipping struct table population as no data was loaded from disk.")
138 }256 }
139 257
140- // Create a context for the auto-save routine that will be canceled when the app is shutting down258+ // Create context for auto-save (remains the same)
141 a.DBSaveCtx, a.DBSaveCancel = context.WithCancel(context.Background())259 a.DBSaveCtx, a.DBSaveCancel = context.WithCancel(context.Background())
142 260
143- // Start auto-save routine261+ // Start auto-save routine (remains the same)
144- log.Debug("Starting auto-save routine with interval of", "minutes", AutoSaveInterval)262+ log.Debug("Starting auto-save routine with interval", "minutes", AutoSaveInterval)
145 go autoSaveDB(a.DBSaveCtx, a, AutoSaveInterval)263 go autoSaveDB(a.DBSaveCtx, a, AutoSaveInterval)
146 264
265+ log.Info("Database initialization completed successfully")
147 return memDB, nil266 return memDB, nil
148 }267 }
149 268
@@ -446,105 +565,6 @@ func joinStrings(strings []string, separator string) string {
446 return result565 return result
447 }566 }
448 567
449-// bootstrapDB creates the initial database schema
450-func bootstrapDB(db *sql.DB) error {
451- log.Debug("Bootstrapping database with initial schema")
452-
453- // Begin a transaction
454- tx, err := db.Begin()
455- if err != nil {
456- return fmt.Errorf("error beginning transaction: %w", err)
457- }
458-
459- // Create tables
460- log.Debug("Creating database tables")
461- _, err = tx.Exec(`
462- CREATE TABLE IF NOT EXISTS user (
463- id TEXT PRIMARY KEY,
464- name TEXT,
465- email TEXT
466- );
467- CREATE TABLE IF NOT EXISTS account (
468- id TEXT PRIMARY KEY,
469- user_id TEXT,
470- FOREIGN KEY (user_id) REFERENCES user(id)
471- );
472- CREATE TABLE IF NOT EXISTS sessions (
473- id TEXT PRIMARY KEY,
474- account_id TEXT,
475- browser_info TEXT,
476- access_token TEXT,
477- expires TEXT,
478- is_online INTEGER,
479- FOREIGN KEY (account_id) REFERENCES account(id)
480- );
481- CREATE TABLE IF NOT EXISTS provider (
482- id TEXT PRIMARY KEY,
483- account_id TEXT,
484- name TEXT,
485- login TEXT,
486- avatar_url TEXT,
487- profile_url TEXT,
488- email TEXT,
489- FOREIGN KEY (account_id) REFERENCES account(id)
490- );
491- CREATE TABLE IF NOT EXISTS clients (
492- id TEXT PRIMARY KEY,
493- account_id TEXT,
494- os TEXT,
495- ip TEXT,
496- hostname TEXT,
497- expires TEXT,
498- FOREIGN KEY (account_id) REFERENCES account(id)
499- );
500- CREATE TABLE IF NOT EXISTS domain (
501- id TEXT PRIMARY KEY,
502- name TEXT,
503- account_id TEXT,
504- created_at TEXT,
505- updated_at TEXT,
506- FOREIGN KEY (account_id) REFERENCES account(id)
507- );
508- CREATE TABLE IF NOT EXISTS certificate (
509- id TEXT PRIMARY KEY,
510- domain_id TEXT,
511- cert_file TEXT,
512- key_file TEXT,
513- issued_at TEXT,
514- expires_at TEXT,
515- issuer TEXT,
516- status TEXT,
517- FOREIGN KEY (domain_id) REFERENCES domain(id)
518- );
519- CREATE TABLE IF NOT EXISTS proxy_route (
520- id TEXT PRIMARY KEY,
521- domain_id TEXT,
522- container_id TEXT,
523- container_ip TEXT,
524- container_port TEXT,
525- protocol TEXT,
526- path TEXT,
527- active INTEGER,
528- created_at TEXT,
529- updated_at TEXT,
530- FOREIGN KEY (domain_id) REFERENCES domain(id)
531- );
532- `)
533- if err != nil {
534- tx.Rollback()
535- return fmt.Errorf("error creating tables: %w", err)
536- }
537-
538- // Commit the transaction
539- log.Debug("Committing transaction with initial schema")
540- if err := tx.Commit(); err != nil {
541- return fmt.Errorf("error committing transaction: %w", err)
542- }
543-
544- log.Debug("Database bootstrap completed successfully")
545- return nil
546-}
547-
548 type DBTable interface {568 type DBTable interface {
549 ScanFromRows(rows *sql.Rows) error569 ScanFromRows(rows *sql.Rows) error
550 }570 }
@@ -618,7 +638,17 @@ func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error {
618 }638 }
619 639
620 func (d *DomainWrapper) ScanFromRows(rows *sql.Rows) error {640 func (d *DomainWrapper) ScanFromRows(rows *sql.Rows) error {
621- return rows.Scan(&d.ID, &d.Name, &d.AccountID, &d.CreatedAt, &d.UpdatedAt)641+ return rows.Scan(
642+ &d.ID,
643+ &d.Name,
644+ &d.AccountID,
645+ &d.CreatedAt,
646+ &d.UpdatedAt,
647+ &d.AcmeEnabled,
648+ &d.AcmeChallengeType,
649+ &d.AcmeDnsProvider,
650+ &d.AcmeDnsCredentialsRef,
651+ )
622 }652 }
623 653
624 func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error {654 func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error {
@@ -663,7 +693,7 @@ func PopulateStructWithTables(a *App) error {
663 693
664 // Populate the Domain table694 // Populate the Domain table
665 domainWrapper := &DomainWrapper{Domain: &a.DBTables.Domain}695 domainWrapper := &DomainWrapper{Domain: &a.DBTables.Domain}
666- if err := populateTableFromDB(a, "SELECT id, name, account_id, created_at, updated_at FROM domain", domainWrapper); err != nil {696+ if err := populateTableFromDB(a, "SELECT id, name, account_id, created_at, updated_at, acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref FROM domain", domainWrapper); err != nil {
667 return err697 return err
668 }698 }
669 699
M internal/server/token_init.go +1 -5
@@ -25,11 +25,7 @@ func HandleNewTokenInitialization(a *App) (string, error) {
25 token, err := generateRandomToken(16) // 16 bytes25 token, err := generateRandomToken(16) // 16 bytes
26 if err != nil {26 if err != nil {
27 return "", fmt.Errorf("failed to generate token: %v", err)27 return "", fmt.Errorf("failed to generate token: %v", err)
28- }28+
29-
30- // url = config.Http.SubDomain + config.Http.TopDomain + config.Http.Port + config.Admin.Path
31-
32- fmt.Printf("Login with the new token: %s\n", token)
33 29
34 // Store the token in the config file30 // Store the token in the config file
35 a.Config.General.Token = token31 a.Config.General.Token = token
M pkg/sqlite/db_create.go +3 -3
@@ -7,8 +7,8 @@ import (
7 "strings"7 "strings"
8 )8 )
9 9
10-// CreateTable creates a table in the database based on the given struct.10+// CreateTableInTx creates a table within a transaction based on the given struct.
11-func CreateTable(db *sql.DB, table interface{}, tableName string) error {11+func CreateTableInTx(tx *sql.Tx, table interface{}, tableName string) error {
12 t := reflect.TypeOf(table)12 t := reflect.TypeOf(table)
13 13
14 var fields []string14 var fields []string
@@ -53,7 +53,7 @@ func CreateTable(db *sql.DB, table interface{}, tableName string) error {
53 }53 }
54 createTableSQL += ");"54 createTableSQL += ");"
55 55
56- _, err := db.Exec(createTableSQL)56+ _, err := tx.Exec(createTableSQL)
57 if err != nil {57 if err != nil {
58 return fmt.Errorf("failed to create table %s: %v", tableName, err)58 return fmt.Errorf("failed to create table %s: %v", tableName, err)
59 }59 }
A pkg/sqlite/db_update.go +328 -0
@@ -0,0 +1,328 @@
1+package pkgsqlite
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "time"
11+
12+ "github.com/charmbracelet/log"
13+)
14+
15+// UpdateDatabase compares struct definitions with the actual database schema on disk and updates it accordingly
16+// Accepts the DBTables struct (or similar) containing the table definitions as fields with `sql` tags.
17+func UpdateDatabase(dbPath string, dbTablesStruct interface{}) error {
18+ log.Debug("Starting database schema update check on disk", "path", dbPath)
19+
20+ // Check if the database file exists BEFORE attempting backup
21+ _, statErr := os.Stat(dbPath)
22+ if statErr == nil {
23+ // File exists, proceed with backup
24+ log.Debug("Database file exists, attempting backup.", "path", dbPath)
25+ if err := backupDatabase(dbPath); err != nil {
26+ // If backup fails on an existing file, that's a real problem
27+ return fmt.Errorf("failed to backup existing database: %w", err)
28+ }
29+ } else if os.IsNotExist(statErr) {
30+ // File does not exist, skip backup
31+ log.Debug("Database file doesn't exist, skipping backup.", "path", dbPath)
32+ } else {
33+ // Other error during stat (e.g., permissions), return it
34+ return fmt.Errorf("failed to check database file status before backup: %w", statErr)
35+ }
36+
37+ // Open connection to the disk database
38+ // sql.Open will create the file if it doesn't exist when a connection is made/queried
39+ diskDB, err := sql.Open("sqlite", dbPath)
40+ if err != nil {
41+ return fmt.Errorf("failed to open disk DB for schema update: %w", err)
42+ }
43+ defer diskDB.Close()
44+
45+ // Optional: Ping to ensure the connection is valid and file is created if needed
46+ if err := diskDB.Ping(); err != nil {
47+ // It's possible ping fails if the directory doesn't exist yet, ensure dir first.
48+ // Let's rely on the transaction to create the file implicitly if needed.
49+ // Commenting out ping as it might add complexity here.
50+ // return fmt.Errorf("failed to ping disk DB after opening: %w", err)
51+ }
52+
53+ // Begin transaction on disk DB
54+ tx, err := diskDB.Begin()
55+ if err != nil {
56+ return fmt.Errorf("failed to begin transaction on disk DB: %w", err)
57+ }
58+ // Using defer with a named return error allows checking commit error
59+ var txErr error
60+ defer func() {
61+ if r := recover(); r != nil {
62+ _ = tx.Rollback() // Attempt rollback on panic
63+ // Re-panic or handle as needed
64+ panic(r)
65+ } else if txErr != nil {
66+ log.Debug("Rolling back transaction due to error", "error", txErr)
67+ if rbErr := tx.Rollback(); rbErr != nil {
68+ log.Error("Failed to rollback transaction", "rollback_error", rbErr)
69+ }
70+ }
71+ }()
72+
73+ // Update each table schema using reflection on the provided struct
74+ updated := false
75+ dbTablesType := reflect.TypeOf(dbTablesStruct)
76+ dbTablesValue := reflect.ValueOf(dbTablesStruct)
77+ if dbTablesType.Kind() == reflect.Ptr {
78+ dbTablesType = dbTablesType.Elem()
79+ dbTablesValue = dbTablesValue.Elem()
80+ }
81+
82+ if dbTablesType.Kind() != reflect.Struct {
83+ _ = tx.Rollback() // Attempt rollback before returning error
84+ return fmt.Errorf("UpdateDatabase expects a struct, got %T", dbTablesStruct)
85+ }
86+ log.Debug("Reflecting DBTables struct for disk schema update", "type", dbTablesType.Name(), "numFields", dbTablesType.NumField())
87+
88+ for i := 0; i < dbTablesType.NumField(); i++ {
89+ field := dbTablesType.Field(i)
90+ fieldValue := dbTablesValue.Field(i).Interface() // e.g., db.ProxyRoute instance
91+ sqlTag := field.Tag.Get("sql")
92+
93+ if sqlTag == "" || sqlTag == "-" {
94+ log.Debug("Skipping field without sql tag in schema update", "field", field.Name)
95+ continue // Skip fields without a sql tag or marked to be ignored
96+ }
97+
98+ tableName := sqlTag // Use the tag directly as the table name
99+ log.Debug("Processing field for schema update", "field", field.Name, "tableName", tableName, "structType", reflect.TypeOf(fieldValue).Name())
100+
101+ var tableUpdated bool
102+ // Pass the correct tableName from the tag and the actual struct instance (fieldValue)
103+ tableUpdated, txErr = updateTableSchema(tx, tableName, fieldValue)
104+ if txErr != nil {
105+ // updateTableSchema handles creation if table is missing
106+ // Return the error wrapped, defer will handle rollback
107+ return fmt.Errorf("failed to update/create table '%s' (from field '%s'): %w", tableName, field.Name, txErr)
108+ }
109+ if tableUpdated {
110+ updated = true
111+ }
112+ }
113+
114+ // Commit transaction - Assign error to txErr for defer check
115+ txErr = tx.Commit()
116+ if txErr != nil {
117+ return fmt.Errorf("failed to commit transaction on disk DB: %w", txErr)
118+ }
119+
120+ if updated {
121+ log.Info("Successfully updated/created database schema on disk")
122+ } else {
123+ log.Debug("Database schema on disk is up-to-date")
124+ }
125+ return nil
126+}
127+
128+// updateTableSchema updates a single table's schema based on struct definition
129+// Returns true if the schema was updated or created, false otherwise.
130+func updateTableSchema(tx *sql.Tx, tableName string, structType interface{}) (bool, error) {
131+ log.Debug("Starting schema update check for table", "table", tableName)
132+ updated := false
133+ // Get existing columns
134+ existingColumns, err := getTableColumns(tx, tableName)
135+ if err != nil {
136+ // If the error is "no such table", try creating it
137+ if IsNoSuchTableError(err) {
138+ log.Info("Table does not exist (getTableColumns returned error), attempting to create", "table", tableName)
139+ if createErr := CreateTableInTx(tx, structType, tableName); createErr != nil {
140+ return false, fmt.Errorf("failed to create missing table %s: %w", tableName, createErr)
141+ }
142+ log.Info("Successfully created table", "table", tableName)
143+ return true, nil // Table was created (updated=true)
144+ }
145+ // Otherwise, it's a different error when getting columns
146+ return false, fmt.Errorf("failed to get existing columns for table %s: %w", tableName, err)
147+ }
148+
149+ // Check if getTableColumns succeeded but found no columns.
150+ // Treat this as if the table needs creation.
151+ if len(existingColumns) == 0 {
152+ log.Info("Table exists according to PRAGMA but has no columns, treating as missing/invalid, attempting to create", "table", tableName)
153+ // Attempt to drop the potentially problematic empty/invalid table first (optional but safer)
154+ // We can ignore errors here, as it might not actually exist for DROP.
155+ _, _ = tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName))
156+
157+ // Now create it properly
158+ if createErr := CreateTableInTx(tx, structType, tableName); createErr != nil {
159+ return false, fmt.Errorf("failed to create missing/invalid table %s: %w", tableName, createErr)
160+ }
161+ log.Info("Successfully recreated table", "table", tableName)
162+ return true, nil // Table was created/recreated (updated=true)
163+ }
164+
165+ log.Debug("Existing columns found", "table", tableName, "columns", existingColumns)
166+
167+ // Get struct fields
168+ structFields := getStructFields(structType)
169+ log.Debug("Struct fields found", "table", tableName, "fields", structFields)
170+
171+ // Compare and add missing columns
172+ for fieldName, fieldType := range structFields {
173+ if _, exists := existingColumns[fieldName]; !exists {
174+ sqlType := convertGoTypeToSQLType(fieldType) // Use the helper function
175+ query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", tableName, fieldName, sqlType)
176+
177+ log.Debug("Attempting to add missing column", "table", tableName, "column", fieldName, "type", sqlType, "query", query)
178+ if _, err := tx.Exec(query); err != nil {
179+ if isDuplicateColumnError(err) {
180+ log.Debug("Column already exists (detected via duplicate error), skipping", "table", tableName, "column", fieldName)
181+ } else {
182+ log.Error("Failed to execute ALTER TABLE", "table", tableName, "column", fieldName, "query", query, "error", err)
183+ return false, fmt.Errorf("failed to add column %s to table %s: %w", fieldName, tableName, err)
184+ }
185+ } else {
186+ log.Info("Successfully added column", "table", tableName, "column", fieldName)
187+ updated = true // Column was successfully added
188+ }
189+ }
190+ }
191+
192+ log.Debug("Finished schema update check for table", "table", tableName, "schema_was_modified", updated)
193+ return updated, nil
194+}
195+
196+// getTableColumns returns a map of existing column names in the table
197+func getTableColumns(tx *sql.Tx, tableName string) (map[string]bool, error) {
198+ columns := make(map[string]bool)
199+
200+ query := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
201+ rows, err := tx.Query(query)
202+ if err != nil {
203+ // Specifically check if the error indicates "no such table" from the PRAGMA itself
204+ if IsNoSuchTableError(err) {
205+ // Return the specific error type so the caller can handle it
206+ return nil, err
207+ }
208+ // Otherwise, wrap the generic error
209+ return nil, fmt.Errorf("failed to query table info for %s: %w", tableName, err)
210+ }
211+ defer rows.Close()
212+
213+ scanFailed := false // Flag to track scan errors
214+ for rows.Next() {
215+ var (
216+ cid int
217+ name string
218+ colType string
219+ notnull int
220+ dflt sql.NullString
221+ pk int
222+ )
223+ if err := rows.Scan(&cid, &name, &colType, &notnull, &dflt, &pk); err != nil {
224+ log.Error("Failed to scan column info row", "table", tableName, "error", err)
225+ scanFailed = true // Set flag on scan error
226+ break // Stop processing rows on error
227+ }
228+ columns[name] = true
229+ }
230+
231+ // Check for errors after the loop (including scan errors)
232+ if err := rows.Err(); err != nil {
233+ // Prioritize returning the specific scan error if it happened
234+ if scanFailed {
235+ // We already logged the scan error, return a generic iteration error
236+ return nil, fmt.Errorf("error scanning table info for %s: %w", tableName, err)
237+ }
238+ // Otherwise, return the iteration error from rows.Err()
239+ return nil, fmt.Errorf("error iterating table info for %s: %w", tableName, err)
240+ }
241+
242+ // If scan failed, even if rows.Err() is nil, return an error
243+ if scanFailed {
244+ return nil, fmt.Errorf("failed to scan one or more column info rows for table %s", tableName)
245+ }
246+
247+ // If we get here, PRAGMA succeeded and iteration/scanning completed without error.
248+ // It's safe to return the map (which might be empty) and nil error.
249+ return columns, nil
250+}
251+
252+// getStructFields returns a map of field names to their types from a struct
253+func getStructFields(structType interface{}) map[string]string {
254+ fields := make(map[string]string)
255+ t := reflect.TypeOf(structType)
256+
257+ // If it's a pointer, get the element type
258+ if t.Kind() == reflect.Ptr {
259+ t = t.Elem()
260+ }
261+
262+ // Ensure it's actually a struct before proceeding
263+ if t.Kind() != reflect.Struct {
264+ log.Warn("getStructFields called with non-struct type", "type", t.Name())
265+ return fields // Return empty map
266+ }
267+
268+ for i := 0; i < t.NumField(); i++ {
269+ field := t.Field(i)
270+ sqlTag := field.Tag.Get("sql")
271+ if sqlTag == "" {
272+ continue
273+ }
274+
275+ // Get the SQL column name from the tag
276+ parts := strings.Split(sqlTag, ",")
277+ sqlName := parts[0]
278+
279+ // Store the field type
280+ fields[sqlName] = field.Type.Name()
281+ }
282+
283+ return fields
284+}
285+
286+// backupDatabase creates a backup of the database file
287+// Assumes the dbPath already exists when this is called.
288+func backupDatabase(dbPath string) error {
289+ // Create backup directory if it doesn't exist
290+ backupDir := filepath.Join(filepath.Dir(dbPath), "backups")
291+ if err := os.MkdirAll(backupDir, 0755); err != nil {
292+ return fmt.Errorf("failed to create backup directory: %w", err)
293+ }
294+
295+ // Generate backup filename with timestamp
296+ timestamp := time.Now().Format("20060102_150405")
297+ backupFile := filepath.Join(backupDir, fmt.Sprintf("%s_%s.db", filepath.Base(dbPath), timestamp))
298+
299+ // Read the database file
300+ data, err := os.ReadFile(dbPath)
301+ if err != nil {
302+ // This shouldn't happen now based on the check in UpdateDatabase, but check again.
303+ if os.IsNotExist(err) {
304+ log.Warn("backupDatabase called but file vanished", "path", dbPath)
305+ return nil // File disappeared between check and read? Treat as success.
306+ }
307+ return fmt.Errorf("failed to read database file for backup: %w", err)
308+ }
309+
310+ // Write to backup file
311+ if err := os.WriteFile(backupFile, data, 0644); err != nil {
312+ return fmt.Errorf("failed to write backup file: %w", err)
313+ }
314+
315+ log.Info("Created database backup", "backup_file", backupFile)
316+ return nil
317+}
318+
319+// isDuplicateColumnError checks if the error is due to a column already existing (SQLite specific)
320+func isDuplicateColumnError(err error) bool {
321+ return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate column name")
322+}
323+
324+// IsNoSuchTableError checks if the error is due to a table not existing (Exported: changed i to I)
325+func IsNoSuchTableError(err error) bool {
326+ // Check for the specific SQLite error message
327+ return err != nil && strings.Contains(strings.ToLower(err.Error()), "no such table")
328+}

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