detached cd72440

fix(traffic): keep TLS fallback reload-aware

commit
cd72440393c4f13e5f9cff99bd134463ec81927a
author
brice · · 2026-06-30 07:45 UTC
parent
228f200
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +219 -61
M internal/adapters/in/traffic/tcp.go +30 -9
@@ -162,20 +162,32 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
162162 }()
163163 }
164164
165-func (r *entryPointRuntime) routeToHTTPS(conn net.Conn) bool {
165+func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
166166 r.mu.Lock()
167167 listener := r.tlsHTTPListener
168168 r.mu.Unlock()
169169 if listener == nil {
170170 return false
171171 }
172- if listener.serve(conn) {
172+ if listener.serve(&trackedHTTPConn{Conn: conn, onClose: func() { r.untrack(tracked) }}) {
173173 return true
174174 }
175175 _ = conn.Close()
176176 return true
177177 }
178178
179+type trackedHTTPConn struct {
180+ net.Conn
181+ once sync.Once
182+ onClose func()
183+}
184+
185+func (c *trackedHTTPConn) Close() error {
186+ err := c.Conn.Close()
187+ c.once.Do(c.onClose)
188+ return err
189+}
190+
179191 func (r *entryPointRuntime) acceptLoop() {
180192 defer r.closeAcceptDone()
181193 for {
@@ -220,13 +232,20 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
220232
221233 tracked := &trackedTCPConn{client: client}
222234 r.track(tracked)
223- defer r.untrack(tracked)
235+ untrackOnReturn := true
236+ defer func() {
237+ if untrackOnReturn {
238+ r.untrack(tracked)
239+ }
240+ }()
224241
225242 switch r.entryPointSnapshot().Protocol {
226243 case domain.EntryPointProtocolTCP:
227244 r.handlePlainTCP(tracked, options)
228245 case domain.EntryPointProtocolTLSMux:
229- r.handleTLSMux(tracked, options)
246+ if r.handleTLSMux(tracked, options) {
247+ untrackOnReturn = false
248+ }
230249 default:
231250 r.counters.totalRefused.Add(1)
232251 _ = tracked.client.Close()
@@ -243,23 +262,24 @@ func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options doma
243262 r.proxyToBackend(tracked, tracked.client, backend, options)
244263 }
245264
246-func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) {
265+func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool {
247266 peeked, err := r.peekTLSClientHello(tracked.client, options)
248267 if err != nil {
249268 r.counters.totalErrors.Add(1)
250269 _ = tracked.client.Close()
251- return
270+ return false
252271 }
253272 if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
254273 r.proxyToBackend(tracked, peeked.conn, backend, options)
255- return
274+ return false
256275 }
257- if r.routeToHTTPS(peeked.conn) {
276+ if r.routeToHTTPS(tracked, peeked.conn) {
258277 r.counters.totalAccepted.Add(1)
259- return
278+ return true
260279 }
261280 r.counters.totalRefused.Add(1)
262281 _ = peeked.conn.Close()
282+ return false
263283 }
264284
265285 func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {
@@ -623,6 +643,7 @@ func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trust
623643 r.mu.Unlock()
624644 for _, conn := range stale {
625645 conn.close()
646+ r.untrack(conn)
626647 }
627648 if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
628649 r.stopTLSHTTPServer(r.ctx, 0)
@@ -162,20 +162,32 @@ func (r *entryPointRuntime) replaceTLSHTTPServer(entryPoint domain.EntryPoint, c
162 }()162 }()
163 }163 }
164 164
165-func (r *entryPointRuntime) routeToHTTPS(conn net.Conn) bool {165+func (r *entryPointRuntime) routeToHTTPS(tracked *trackedTCPConn, conn net.Conn) bool {
166 r.mu.Lock()166 r.mu.Lock()
167 listener := r.tlsHTTPListener167 listener := r.tlsHTTPListener
168 r.mu.Unlock()168 r.mu.Unlock()
169 if listener == nil {169 if listener == nil {
170 return false170 return false
171 }171 }
172- if listener.serve(conn) {172+ if listener.serve(&trackedHTTPConn{Conn: conn, onClose: func() { r.untrack(tracked) }}) {
173 return true173 return true
174 }174 }
175 _ = conn.Close()175 _ = conn.Close()
176 return true176 return true
177 }177 }
178 178
179+type trackedHTTPConn struct {
180+ net.Conn
181+ once sync.Once
182+ onClose func()
183+}
184+
185+func (c *trackedHTTPConn) Close() error {
186+ err := c.Conn.Close()
187+ c.once.Do(c.onClose)
188+ return err
189+}
190+
179 func (r *entryPointRuntime) acceptLoop() {191 func (r *entryPointRuntime) acceptLoop() {
180 defer r.closeAcceptDone()192 defer r.closeAcceptDone()
181 for {193 for {
@@ -220,13 +232,20 @@ func (r *entryPointRuntime) handleTCPConn(client net.Conn) {
220 232
221 tracked := &trackedTCPConn{client: client}233 tracked := &trackedTCPConn{client: client}
222 r.track(tracked)234 r.track(tracked)
223- defer r.untrack(tracked)235+ untrackOnReturn := true
236+ defer func() {
237+ if untrackOnReturn {
238+ r.untrack(tracked)
239+ }
240+ }()
224 241
225 switch r.entryPointSnapshot().Protocol {242 switch r.entryPointSnapshot().Protocol {
226 case domain.EntryPointProtocolTCP:243 case domain.EntryPointProtocolTCP:
227 r.handlePlainTCP(tracked, options)244 r.handlePlainTCP(tracked, options)
228 case domain.EntryPointProtocolTLSMux:245 case domain.EntryPointProtocolTLSMux:
229- r.handleTLSMux(tracked, options)246+ if r.handleTLSMux(tracked, options) {
247+ untrackOnReturn = false
248+ }
230 default:249 default:
231 r.counters.totalRefused.Add(1)250 r.counters.totalRefused.Add(1)
232 _ = tracked.client.Close()251 _ = tracked.client.Close()
@@ -243,23 +262,24 @@ func (r *entryPointRuntime) handlePlainTCP(tracked *trackedTCPConn, options doma
243 r.proxyToBackend(tracked, tracked.client, backend, options)262 r.proxyToBackend(tracked, tracked.client, backend, options)
244 }263 }
245 264
246-func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) {265+func (r *entryPointRuntime) handleTLSMux(tracked *trackedTCPConn, options domain.TCPOptions) bool {
247 peeked, err := r.peekTLSClientHello(tracked.client, options)266 peeked, err := r.peekTLSClientHello(tracked.client, options)
248 if err != nil {267 if err != nil {
249 r.counters.totalErrors.Add(1)268 r.counters.totalErrors.Add(1)
250 _ = tracked.client.Close()269 _ = tracked.client.Close()
251- return270+ return false
252 }271 }
253 if backend, ok := r.resolveTLSBackend(peeked.sni); ok {272 if backend, ok := r.resolveTLSBackend(peeked.sni); ok {
254 r.proxyToBackend(tracked, peeked.conn, backend, options)273 r.proxyToBackend(tracked, peeked.conn, backend, options)
255- return274+ return false
256 }275 }
257- if r.routeToHTTPS(peeked.conn) {276+ if r.routeToHTTPS(tracked, peeked.conn) {
258 r.counters.totalAccepted.Add(1)277 r.counters.totalAccepted.Add(1)
259- return278+ return true
260 }279 }
261 r.counters.totalRefused.Add(1)280 r.counters.totalRefused.Add(1)
262 _ = peeked.conn.Close()281 _ = peeked.conn.Close()
282+ return false
263 }283 }
264 284
265 func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {285 func (r *entryPointRuntime) peekTLSClientHello(client net.Conn, options domain.TCPOptions) (peekedTLSConn, error) {
@@ -623,6 +643,7 @@ func (r *entryPointRuntime) updateEntryPoint(entryPoint domain.EntryPoint, trust
623 r.mu.Unlock()643 r.mu.Unlock()
624 for _, conn := range stale {644 for _, conn := range stale {
625 conn.close()645 conn.close()
646+ r.untrack(conn)
626 }647 }
627 if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {648 if previousProtocol == domain.EntryPointProtocolTLSMux && entryPoint.Protocol != domain.EntryPointProtocolTLSMux {
628 r.stopTLSHTTPServer(r.ctx, 0)649 r.stopTLSHTTPServer(r.ctx, 0)
M internal/adapters/in/traffic/tcp_test.go +39 -0
@@ -5,6 +5,7 @@ import (
55 "context"
66 "crypto/tls"
77 "errors"
8+ "fmt"
89 "io"
910 "net"
1011 "net/http"
@@ -213,6 +214,44 @@ func TestTCPPassthroughReloadAppliesTrustedCIDRChange(t *testing.T) {
213214 assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
214215 }
215216
217+func TestTLSMuxReloadClosesActiveUntrustedHTTPSFallbackConnections(t *testing.T) {
218+ graph := domain.TrafficGraph{
219+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
220+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux, TrustedCIDRs: []string{"127.0.0.0/8"}}},
221+ }
222+ manager := NewManager()
223+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
224+ body := "secure"
225+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
226+ _, _ = w.Write([]byte(body))
227+ }), testTLSConfig(t, "app.example.com"))
228+ require.NoError(t, manager.Apply(context.Background(), &graph))
229+ defer shutdownManager(t, manager)
230+
231+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true})
232+ require.NoError(t, err)
233+ defer conn.Close()
234+ reader := bufio.NewReader(conn)
235+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: keep-alive\r\n\r\n"))
236+ require.NoError(t, err)
237+ resp, err := http.ReadResponse(reader, nil)
238+ require.NoError(t, err)
239+ _, err = io.ReadAll(resp.Body)
240+ require.NoError(t, err)
241+ require.NoError(t, resp.Body.Close())
242+
243+ updated := graph
244+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
245+ require.NoError(t, manager.Apply(context.Background(), &updated))
246+
247+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
248+ if err == nil {
249+ _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
250+ _, err = http.ReadResponse(reader, nil)
251+ }
252+ require.Error(t, err)
253+}
254+
216255 func TestTCPPassthroughReloadClosesActiveUntrustedConnections(t *testing.T) {
217256 backend := startTCPEchoServer(t, 0)
218257 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "crypto/tls"6 "crypto/tls"
7 "errors"7 "errors"
8+ "fmt"
8 "io"9 "io"
9 "net"10 "net"
10 "net/http"11 "net/http"
@@ -213,6 +214,44 @@ func TestTCPPassthroughReloadAppliesTrustedCIDRChange(t *testing.T) {
213 assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)214 assertTCPRejected(t, manager, graph.EntryPoints[0].Address, 1)
214 }215 }
215 216
217+func TestTLSMuxReloadClosesActiveUntrustedHTTPSFallbackConnections(t *testing.T) {
218+ graph := domain.TrafficGraph{
219+ Options: domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: time.Second, IdleTimeout: time.Minute, DrainTimeout: time.Second}},
220+ EntryPoints: []domain.EntryPoint{{Name: "websecure", Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux, TrustedCIDRs: []string{"127.0.0.0/8"}}},
221+ }
222+ manager := NewManager()
223+ manager.SetTLSHTTPServer("websecure", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
224+ body := "secure"
225+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
226+ _, _ = w.Write([]byte(body))
227+ }), testTLSConfig(t, "app.example.com"))
228+ require.NoError(t, manager.Apply(context.Background(), &graph))
229+ defer shutdownManager(t, manager)
230+
231+ conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", graph.EntryPoints[0].Address, &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true})
232+ require.NoError(t, err)
233+ defer conn.Close()
234+ reader := bufio.NewReader(conn)
235+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: keep-alive\r\n\r\n"))
236+ require.NoError(t, err)
237+ resp, err := http.ReadResponse(reader, nil)
238+ require.NoError(t, err)
239+ _, err = io.ReadAll(resp.Body)
240+ require.NoError(t, err)
241+ require.NoError(t, resp.Body.Close())
242+
243+ updated := graph
244+ updated.EntryPoints[0].TrustedCIDRs = []string{"192.0.2.0/24"}
245+ require.NoError(t, manager.Apply(context.Background(), &updated))
246+
247+ _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n"))
248+ if err == nil {
249+ _ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
250+ _, err = http.ReadResponse(reader, nil)
251+ }
252+ require.Error(t, err)
253+}
254+
216 func TestTCPPassthroughReloadClosesActiveUntrustedConnections(t *testing.T) {255 func TestTCPPassthroughReloadClosesActiveUntrustedConnections(t *testing.T) {
217 backend := startTCPEchoServer(t, 0)256 backend := startTCPEchoServer(t, 0)
218 graph := tcpGraph(t, freeTCPAddress(t), backend.address)257 graph := tcpGraph(t, freeTCPAddress(t), backend.address)
M internal/app/run.go +94 -52
@@ -261,44 +261,46 @@ type Config struct {
261261
262262 // services holds all the services used by the application.
263263 type services struct {
264- runtime *docker.Runtime
265- eventBus *eventbus.InMemory
266- blobStorage *filesystem.BlobStorage
267- manifestStorage *filesystem.ManifestStorage
268- backupStorage *filesystem.BackupStorage
269- volumeBackupStore out.VolumeBackupStorage
270- volumeBackupCfg domain.VolumeBackupConfig
271- envLoader out.EnvLoader
272- logWriter *logwriter.LogWriter
273- tokenStore out.TokenStore
274- configSvc *config.Service
275- secretSvc *secretsSvc.Service
276- containerSvc *container.Service
277- backupSvc *backup.Service
278- volumeBackupSvc *backup.VolumeService
279- registrySvc *registrySvc.Service
280- healthSvc *health.Service
281- logSvc *logs.Service
282- imageSvc *images.Service
283- volumeSvc *volumesSvc.Service
284- proxySvc *proxy.Service
285- authSvc *auth.Service
286- authHandler *authhandler.Handler
287- adminHandler *admin.Handler
288- internalRegUser string
289- internalRegPass string
290- previewStore *filesystem.PreviewStore
291- previewService *preview.Service
292- envDir string
293- maxBlobChunkSize int64
294- maxBlobSize int64
295- caAdapter *pkiadapter.CA
296- pkiSvc *pkiusecase.Service
297- reloadCoordinator *reloadCoordinator
298- publicTLSSvc in.PublicTLSService
299- publicTLSRuntime publicTLSRuntime
300- trafficManager *trafficadapter.Manager
301- registryHandler interface {
264+ runtime *docker.Runtime
265+ eventBus *eventbus.InMemory
266+ blobStorage *filesystem.BlobStorage
267+ manifestStorage *filesystem.ManifestStorage
268+ backupStorage *filesystem.BackupStorage
269+ volumeBackupStore out.VolumeBackupStorage
270+ volumeBackupCfg domain.VolumeBackupConfig
271+ envLoader out.EnvLoader
272+ logWriter *logwriter.LogWriter
273+ tokenStore out.TokenStore
274+ configSvc *config.Service
275+ secretSvc *secretsSvc.Service
276+ containerSvc *container.Service
277+ backupSvc *backup.Service
278+ volumeBackupSvc *backup.VolumeService
279+ registrySvc *registrySvc.Service
280+ healthSvc *health.Service
281+ logSvc *logs.Service
282+ imageSvc *images.Service
283+ volumeSvc *volumesSvc.Service
284+ proxySvc *proxy.Service
285+ authSvc *auth.Service
286+ authHandler *authhandler.Handler
287+ adminHandler *admin.Handler
288+ httpsProxyHandler http.Handler
289+ internalRegUser string
290+ internalRegPass string
291+ previewStore *filesystem.PreviewStore
292+ previewService *preview.Service
293+ envDir string
294+ maxBlobChunkSize int64
295+ maxBlobSize int64
296+ caAdapter *pkiadapter.CA
297+ pkiSvc *pkiusecase.Service
298+ reloadCoordinator *reloadCoordinator
299+ publicTLSSvc in.PublicTLSService
300+ publicTLSRuntime publicTLSRuntime
301+ trafficManager *trafficadapter.Manager
302+ tlsHTTPEntryPoints map[string]struct{}
303+ registryHandler interface {
302304 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
303305 }
304306 }
@@ -766,6 +768,15 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
766768 if err != nil {
767769 return err
768770 }
771+ var tlsConfig *tls.Config
772+ if reloadCfg.Server.TLSPort != 0 && si.svc.httpsProxyHandler != nil {
773+ var tlsErr error
774+ tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)
775+ if tlsErr != nil {
776+ return tlsErr
777+ }
778+ }
779+ si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
769780 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
770781 return err
771782 }
@@ -2455,6 +2466,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
24552466 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
24562467 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
24572468 }
2469+ svc.httpsProxyHandler = httpsOut
24582470
24592471 return registryOut, proxyOut, httpsOut
24602472 }
@@ -2825,6 +2837,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
28252837 closeStarted(registrySrv)
28262838 return err
28272839 }
2840+ svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
28282841
28292842 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
28302843 // This prevents auto-start races while keeping the TLS mux under one owner.
@@ -3301,13 +3314,24 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
33013314 return httpSrv, httpReady, nil, nil, err
33023315 }
33033316
3304- // Load static cert into a local slice (not tls.Config.Certificates) so the
3305- // certificate selector has full control over priority ordering.
3317+ tlsConfig, err := proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3318+ if err != nil {
3319+ return cleanupHTTP(err)
3320+ }
3321+ if trafficManager == nil {
3322+ return cleanupHTTP(fmt.Errorf("traffic manager is required when server.tls_port is enabled"))
3323+ }
3324+ registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)
3325+
3326+ return httpSrv, httpReady, nil, nil, nil
3327+}
3328+
3329+func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
33063330 var staticCerts []tls.Certificate
33073331 if cfg.Server.TLSCertFile != "" {
33083332 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
33093333 if err != nil {
3310- return cleanupHTTP(fmt.Errorf("load TLS keypair: %w", err))
3334+ return nil, fmt.Errorf("load TLS keypair: %w", err)
33113335 }
33123336 staticCerts = []tls.Certificate{staticCert}
33133337 log.Info().
@@ -3315,32 +3339,50 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
33153339 Str("key", cfg.Server.TLSKeyFile).
33163340 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
33173341 }
3318-
33193342 selector := &certificateSelector{
33203343 staticCerts: prepareStaticTLSCertificates(staticCerts),
33213344 publicTLS: publicTLS,
33223345 localPKI: pkiSvc,
33233346 }
3324-
3325- // HTTPS listener — static cert (if provided) takes priority by SNI match via
3326- // certificateSelector, then public ACME TLS, then on-demand internal CA certs.
3327- tlsConfig := &tls.Config{
3347+ return &tls.Config{
33283348 MinVersion: tls.VersionTLS12,
33293349 GetCertificate: selector.GetCertificate,
33303350 NextProtos: []string{"h2", "http/1.1"},
3351+ }, nil
3352+}
3353+
3354+func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3355+ if manager == nil {
3356+ return previous
33313357 }
3332- if trafficManager == nil {
3333- return cleanupHTTP(fmt.Errorf("traffic manager is required when server.tls_port is enabled"))
3358+ next := tlsMuxHTTPServerNames(cfg)
3359+ for name := range previous {
3360+ if _, ok := next[name]; !ok {
3361+ manager.SetTLSHTTPServer(name, nil, nil)
3362+ }
3363+ }
3364+ if cfg.Server.TLSPort == 0 || httpsHandler == nil || tlsConfig == nil {
3365+ return next
33343366 }
3335- trafficManager.SetTLSHTTPServer(traffic.DefaultTLSEntryPointName, httpsHandler, tlsConfig)
3367+ for name := range next {
3368+ manager.SetTLSHTTPServer(name, httpsHandler, tlsConfig)
3369+ }
3370+ return next
3371+}
3372+
3373+func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
3374+ names := map[string]struct{}{}
3375+ if cfg.Server.TLSPort == 0 {
3376+ return names
3377+ }
3378+ names[traffic.DefaultTLSEntryPointName] = struct{}{}
33363379 for name, entryPoint := range cfg.EntryPoints {
33373380 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}
33383381 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {
3339- trafficManager.SetTLSHTTPServer(name, httpsHandler, tlsConfig)
3382+ names[name] = struct{}{}
33403383 }
33413384 }
3342-
3343- return httpSrv, httpReady, nil, nil, nil
3385+ return names
33443386 }
33453387
33463388 // startServer starts an HTTP server, returning the server instance and a channel
@@ -261,44 +261,46 @@ type Config struct {
261 261
262 // services holds all the services used by the application.262 // services holds all the services used by the application.
263 type services struct {263 type services struct {
264- runtime *docker.Runtime264+ runtime *docker.Runtime
265- eventBus *eventbus.InMemory265+ eventBus *eventbus.InMemory
266- blobStorage *filesystem.BlobStorage266+ blobStorage *filesystem.BlobStorage
267- manifestStorage *filesystem.ManifestStorage267+ manifestStorage *filesystem.ManifestStorage
268- backupStorage *filesystem.BackupStorage268+ backupStorage *filesystem.BackupStorage
269- volumeBackupStore out.VolumeBackupStorage269+ volumeBackupStore out.VolumeBackupStorage
270- volumeBackupCfg domain.VolumeBackupConfig270+ volumeBackupCfg domain.VolumeBackupConfig
271- envLoader out.EnvLoader271+ envLoader out.EnvLoader
272- logWriter *logwriter.LogWriter272+ logWriter *logwriter.LogWriter
273- tokenStore out.TokenStore273+ tokenStore out.TokenStore
274- configSvc *config.Service274+ configSvc *config.Service
275- secretSvc *secretsSvc.Service275+ secretSvc *secretsSvc.Service
276- containerSvc *container.Service276+ containerSvc *container.Service
277- backupSvc *backup.Service277+ backupSvc *backup.Service
278- volumeBackupSvc *backup.VolumeService278+ volumeBackupSvc *backup.VolumeService
279- registrySvc *registrySvc.Service279+ registrySvc *registrySvc.Service
280- healthSvc *health.Service280+ healthSvc *health.Service
281- logSvc *logs.Service281+ logSvc *logs.Service
282- imageSvc *images.Service282+ imageSvc *images.Service
283- volumeSvc *volumesSvc.Service283+ volumeSvc *volumesSvc.Service
284- proxySvc *proxy.Service284+ proxySvc *proxy.Service
285- authSvc *auth.Service285+ authSvc *auth.Service
286- authHandler *authhandler.Handler286+ authHandler *authhandler.Handler
287- adminHandler *admin.Handler287+ adminHandler *admin.Handler
288- internalRegUser string288+ httpsProxyHandler http.Handler
289- internalRegPass string289+ internalRegUser string
290- previewStore *filesystem.PreviewStore290+ internalRegPass string
291- previewService *preview.Service291+ previewStore *filesystem.PreviewStore
292- envDir string292+ previewService *preview.Service
293- maxBlobChunkSize int64293+ envDir string
294- maxBlobSize int64294+ maxBlobChunkSize int64
295- caAdapter *pkiadapter.CA295+ maxBlobSize int64
296- pkiSvc *pkiusecase.Service296+ caAdapter *pkiadapter.CA
297- reloadCoordinator *reloadCoordinator297+ pkiSvc *pkiusecase.Service
298- publicTLSSvc in.PublicTLSService298+ reloadCoordinator *reloadCoordinator
299- publicTLSRuntime publicTLSRuntime299+ publicTLSSvc in.PublicTLSService
300- trafficManager *trafficadapter.Manager300+ publicTLSRuntime publicTLSRuntime
301- registryHandler interface {301+ trafficManager *trafficadapter.Manager
302+ tlsHTTPEntryPoints map[string]struct{}
303+ registryHandler interface {
302 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)304 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
303 }305 }
304 }306 }
@@ -766,6 +768,15 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
766 if err != nil {768 if err != nil {
767 return err769 return err
768 }770 }
771+ var tlsConfig *tls.Config
772+ if reloadCfg.Server.TLSPort != 0 && si.svc.httpsProxyHandler != nil {
773+ var tlsErr error
774+ tlsConfig, tlsErr = proxyTLSConfig(reloadCfg, si.svc.pkiSvc, si.svc.publicTLSSvc, si.log)
775+ if tlsErr != nil {
776+ return tlsErr
777+ }
778+ }
779+ si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
769 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {780 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
770 return err781 return err
771 }782 }
@@ -2455,6 +2466,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2455 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)2466 proxyOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(proxyOut)
2456 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)2467 httpsOut = middleware.AccessLogger(accessWriter, excludeHC, log, trustedNets)(httpsOut)
2457 }2468 }
2469+ svc.httpsProxyHandler = httpsOut
2458 2470
2459 return registryOut, proxyOut, httpsOut2471 return registryOut, proxyOut, httpsOut
2460 }2472 }
@@ -2825,6 +2837,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2825 closeStarted(registrySrv)2837 closeStarted(registrySrv)
2826 return err2838 return err
2827 }2839 }
2840+ svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2828 2841
2829 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.2842 // Wait for the registry and HTTP proxy to bind before applying the traffic graph.
2830 // This prevents auto-start races while keeping the TLS mux under one owner.2843 // This prevents auto-start races while keeping the TLS mux under one owner.
@@ -3301,13 +3314,24 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
3301 return httpSrv, httpReady, nil, nil, err3314 return httpSrv, httpReady, nil, nil, err
3302 }3315 }
3303 3316
3304- // Load static cert into a local slice (not tls.Config.Certificates) so the3317+ tlsConfig, err := proxyTLSConfig(cfg, pkiSvc, publicTLS, log)
3305- // certificate selector has full control over priority ordering.3318+ if err != nil {
3319+ return cleanupHTTP(err)
3320+ }
3321+ if trafficManager == nil {
3322+ return cleanupHTTP(fmt.Errorf("traffic manager is required when server.tls_port is enabled"))
3323+ }
3324+ registerTLSMuxHTTPServers(trafficManager, cfg, httpsHandler, tlsConfig, nil)
3325+
3326+ return httpSrv, httpReady, nil, nil, nil
3327+}
3328+
3329+func proxyTLSConfig(cfg Config, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) (*tls.Config, error) {
3306 var staticCerts []tls.Certificate3330 var staticCerts []tls.Certificate
3307 if cfg.Server.TLSCertFile != "" {3331 if cfg.Server.TLSCertFile != "" {
3308 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)3332 staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile)
3309 if err != nil {3333 if err != nil {
3310- return cleanupHTTP(fmt.Errorf("load TLS keypair: %w", err))3334+ return nil, fmt.Errorf("load TLS keypair: %w", err)
3311 }3335 }
3312 staticCerts = []tls.Certificate{staticCert}3336 staticCerts = []tls.Certificate{staticCert}
3313 log.Info().3337 log.Info().
@@ -3315,32 +3339,50 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv
3315 Str("key", cfg.Server.TLSKeyFile).3339 Str("key", cfg.Server.TLSKeyFile).
3316 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")3340 Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)")
3317 }3341 }
3318-
3319 selector := &certificateSelector{3342 selector := &certificateSelector{
3320 staticCerts: prepareStaticTLSCertificates(staticCerts),3343 staticCerts: prepareStaticTLSCertificates(staticCerts),
3321 publicTLS: publicTLS,3344 publicTLS: publicTLS,
3322 localPKI: pkiSvc,3345 localPKI: pkiSvc,
3323 }3346 }
3324-3347+ return &tls.Config{
3325- // HTTPS listener — static cert (if provided) takes priority by SNI match via
3326- // certificateSelector, then public ACME TLS, then on-demand internal CA certs.
3327- tlsConfig := &tls.Config{
3328 MinVersion: tls.VersionTLS12,3348 MinVersion: tls.VersionTLS12,
3329 GetCertificate: selector.GetCertificate,3349 GetCertificate: selector.GetCertificate,
3330 NextProtos: []string{"h2", "http/1.1"},3350 NextProtos: []string{"h2", "http/1.1"},
3351+ }, nil
3352+}
3353+
3354+func registerTLSMuxHTTPServers(manager *trafficadapter.Manager, cfg Config, httpsHandler http.Handler, tlsConfig *tls.Config, previous map[string]struct{}) map[string]struct{} {
3355+ if manager == nil {
3356+ return previous
3331 }3357 }
3332- if trafficManager == nil {3358+ next := tlsMuxHTTPServerNames(cfg)
3333- return cleanupHTTP(fmt.Errorf("traffic manager is required when server.tls_port is enabled"))3359+ for name := range previous {
3360+ if _, ok := next[name]; !ok {
3361+ manager.SetTLSHTTPServer(name, nil, nil)
3362+ }
3363+ }
3364+ if cfg.Server.TLSPort == 0 || httpsHandler == nil || tlsConfig == nil {
3365+ return next
3334 }3366 }
3335- trafficManager.SetTLSHTTPServer(traffic.DefaultTLSEntryPointName, httpsHandler, tlsConfig)3367+ for name := range next {
3368+ manager.SetTLSHTTPServer(name, httpsHandler, tlsConfig)
3369+ }
3370+ return next
3371+}
3372+
3373+func tlsMuxHTTPServerNames(cfg Config) map[string]struct{} {
3374+ names := map[string]struct{}{}
3375+ if cfg.Server.TLSPort == 0 {
3376+ return names
3377+ }
3378+ names[traffic.DefaultTLSEntryPointName] = struct{}{}
3336 for name, entryPoint := range cfg.EntryPoints {3379 for name, entryPoint := range cfg.EntryPoints {
3337 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}3380 domainEntryPoint := domain.EntryPoint{Name: name, Protocol: entryPoint.Protocol}
3338 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {3381 if trafficManagerOwnsEntryPoint(domainEntryPoint) && domainEntryPoint.Protocol == domain.EntryPointProtocolTLSMux {
3339- trafficManager.SetTLSHTTPServer(name, httpsHandler, tlsConfig)3382+ names[name] = struct{}{}
3340 }3383 }
3341 }3384 }
3342-3385+ return names
3343- return httpSrv, httpReady, nil, nil, nil
3344 }3386 }
3345 3387
3346 // startServer starts an HTTP server, returning the server instance and a channel3388 // startServer starts an HTTP server, returning the server instance and a channel
M internal/app/run_reload_test.go +56 -0
@@ -3,6 +3,9 @@ package app
33 import (
44 "context"
55 "errors"
6+ "net"
7+ "net/http"
8+ "strconv"
69 "sync"
710 "testing"
811 "time"
@@ -13,10 +16,12 @@ import (
1316 "github.com/stretchr/testify/require"
1417
1518 "github.com/bnema/gordon/internal/adapters/in/http/registry"
19+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
1620 "github.com/bnema/gordon/internal/domain"
1721 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
1822 "github.com/bnema/gordon/internal/usecase/container"
1923 "github.com/bnema/gordon/internal/usecase/proxy"
24+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
2025 )
2126
2227 type watchRecorder struct {
@@ -227,6 +232,57 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
227232 }, proxySvc.config)
228233 }
229234
235+func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
236+ ctx := context.Background()
237+ v := viper.New()
238+ v.Set("server.gordon_domain", "reload.example.com")
239+ v.Set("server.registry_port", 5000)
240+
241+ configSvc := cfgusecase.NewService(v, nil)
242+ require.NoError(t, configSvc.Load(ctx))
243+ manager := trafficadapter.NewManager()
244+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
245+ httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
246+ _, _ = w.Write([]byte("reload secure"))
247+ })
248+ pkiSvc := newTestPKIService(t)
249+
250+ customAddress := freeTCPAddress(t)
251+ si := &serviceInit{
252+ ctx: ctx,
253+ v: v,
254+ cfg: Config{},
255+ log: zerowrap.Default(),
256+ svc: &services{
257+ configSvc: configSvc,
258+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
259+ internalRegUser: "gordon",
260+ internalRegPass: "secret",
261+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
262+ trafficManager: manager,
263+ httpsProxyHandler: httpsHandler,
264+ pkiSvc: pkiSvc,
265+ },
266+ }
267+ si.registerReloadCoordinatorHooks()
268+
269+ reloadCfg := Config{}
270+ reloadCfg.Server.GordonDomain = "reload.example.com"
271+ reloadCfg.Server.RegistryPort = 5000
272+ reloadCfg.Server.TLSPort = freeTCPPort(t)
273+ reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{
274+ "custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux},
275+ }
276+
277+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
278+ _, portValue, err := net.SplitHostPort(customAddress)
279+ require.NoError(t, err)
280+ port, err := strconv.Atoi(portValue)
281+ require.NoError(t, err)
282+ body := httpsGetForTest(t, port, "app.example.com")
283+ require.Contains(t, body, "reload secure")
284+}
285+
230286 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
231287 ctx := context.Background()
232288 v := viper.New()
@@ -3,6 +3,9 @@ package app
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "net"
7+ "net/http"
8+ "strconv"
6 "sync"9 "sync"
7 "testing"10 "testing"
8 "time"11 "time"
@@ -13,10 +16,12 @@ import (
13 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
14 17
15 "github.com/bnema/gordon/internal/adapters/in/http/registry"18 "github.com/bnema/gordon/internal/adapters/in/http/registry"
19+ trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
16 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
17 cfgusecase "github.com/bnema/gordon/internal/usecase/config"21 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
18 "github.com/bnema/gordon/internal/usecase/container"22 "github.com/bnema/gordon/internal/usecase/container"
19 "github.com/bnema/gordon/internal/usecase/proxy"23 "github.com/bnema/gordon/internal/usecase/proxy"
24+ traffic "github.com/bnema/gordon/internal/usecase/traffic"
20 )25 )
21 26
22 type watchRecorder struct {27 type watchRecorder struct {
@@ -227,6 +232,57 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
227 }, proxySvc.config)232 }, proxySvc.config)
228 }233 }
229 234
235+func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
236+ ctx := context.Background()
237+ v := viper.New()
238+ v.Set("server.gordon_domain", "reload.example.com")
239+ v.Set("server.registry_port", 5000)
240+
241+ configSvc := cfgusecase.NewService(v, nil)
242+ require.NoError(t, configSvc.Load(ctx))
243+ manager := trafficadapter.NewManager()
244+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
245+ httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
246+ _, _ = w.Write([]byte("reload secure"))
247+ })
248+ pkiSvc := newTestPKIService(t)
249+
250+ customAddress := freeTCPAddress(t)
251+ si := &serviceInit{
252+ ctx: ctx,
253+ v: v,
254+ cfg: Config{},
255+ log: zerowrap.Default(),
256+ svc: &services{
257+ configSvc: configSvc,
258+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
259+ internalRegUser: "gordon",
260+ internalRegPass: "secret",
261+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
262+ trafficManager: manager,
263+ httpsProxyHandler: httpsHandler,
264+ pkiSvc: pkiSvc,
265+ },
266+ }
267+ si.registerReloadCoordinatorHooks()
268+
269+ reloadCfg := Config{}
270+ reloadCfg.Server.GordonDomain = "reload.example.com"
271+ reloadCfg.Server.RegistryPort = 5000
272+ reloadCfg.Server.TLSPort = freeTCPPort(t)
273+ reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{
274+ "custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux},
275+ }
276+
277+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
278+ _, portValue, err := net.SplitHostPort(customAddress)
279+ require.NoError(t, err)
280+ port, err := strconv.Atoi(portValue)
281+ require.NoError(t, err)
282+ body := httpsGetForTest(t, port, "app.example.com")
283+ require.Contains(t, body, "reload secure")
284+}
285+
230 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {286 func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
231 ctx := context.Background()287 ctx := context.Background()
232 v := viper.New()288 v := viper.New()

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