detached 86e5b3b

Make ACME cursor persistence best effort

commit
86e5b3bfb73199a4039eee3940bab3dbd960852e
author
brice · · 2026-05-01 10:54 UTC
parent
90ab417
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +79 -15
M docs/config/server.md +1 -1
@@ -69,7 +69,7 @@ With TLS enabled:
6969 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
7070 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
7171 - The intermediate CA auto-renews before expiry
72-- An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate
72+- An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate
7373 - The root CA is stable across restarts (generated once, persisted to disk)
7474
7575 #### Custom certificates
@@ -69,7 +69,7 @@ With TLS enabled:
69 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`69 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
70 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory70 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
71 - The intermediate CA auto-renews before expiry71 - The intermediate CA auto-renews before expiry
72-- An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate72+- An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate
73 - The root CA is stable across restarts (generated once, persisted to disk)73 - The root CA is stable across restarts (generated once, persisted to disk)
74 74
75 #### Custom certificates75 #### Custom certificates
M internal/adapters/in/http/onboarding/handler_test.go +3 -1
@@ -31,6 +31,8 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) {
3131 h, rootPEM, mobileconfig := newTestHandler()
3232
3333 mux := http.NewServeMux()
34+ mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage)
35+ mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage)
3436 mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage)
3537 mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)
3638 mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)
@@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
9395 func TestHandler_OnboardingPage(t *testing.T) {
9496 srv, _, _ := newTestServer(t)
9597
96- resp, err := http.Get(srv.URL + "/.well-known/gordon/ca")
98+ resp, err := http.Get(srv.URL + "/.well-known/gordon/")
9799 require.NoError(t, err)
98100 defer resp.Body.Close()
99101
@@ -31,6 +31,8 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) {
31 h, rootPEM, mobileconfig := newTestHandler()31 h, rootPEM, mobileconfig := newTestHandler()
32 32
33 mux := http.NewServeMux()33 mux := http.NewServeMux()
34+ mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage)
35+ mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage)
34 mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage)36 mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage)
35 mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)37 mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)
36 mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)38 mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)
@@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
93 func TestHandler_OnboardingPage(t *testing.T) {95 func TestHandler_OnboardingPage(t *testing.T) {
94 srv, _, _ := newTestServer(t)96 srv, _, _ := newTestServer(t)
95 97
96- resp, err := http.Get(srv.URL + "/.well-known/gordon/ca")98+ resp, err := http.Get(srv.URL + "/.well-known/gordon/")
97 require.NoError(t, err)99 require.NoError(t, err)
98 defer resp.Body.Close()100 defer resp.Body.Close()
99 101
M internal/usecase/publictls/service.go +3 -2
@@ -87,7 +87,8 @@ func (s *Service) Load(ctx context.Context) error {
8787 }
8888 state, err := s.deps.Store.LoadState(ctx)
8989 if err != nil {
90- return fmt.Errorf("load certificate store state: %w", err)
90+ s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor")
91+ state = out.CertificateStoreState{}
9192 }
9293 if state.ObtainCursor < 0 {
9394 state.ObtainCursor = 0
@@ -204,7 +205,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
204205
205206 if cursorChanged {
206207 if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
207- return err
208+ log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor")
208209 }
209210 s.mu.Lock()
210211 if s.obtainCursor == previousCursor {
@@ -87,7 +87,8 @@ func (s *Service) Load(ctx context.Context) error {
87 }87 }
88 state, err := s.deps.Store.LoadState(ctx)88 state, err := s.deps.Store.LoadState(ctx)
89 if err != nil {89 if err != nil {
90- return fmt.Errorf("load certificate store state: %w", err)90+ s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor")
91+ state = out.CertificateStoreState{}
91 }92 }
92 if state.ObtainCursor < 0 {93 if state.ObtainCursor < 0 {
93 state.ObtainCursor = 094 state.ObtainCursor = 0
@@ -204,7 +205,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
204 205
205 if cursorChanged {206 if cursorChanged {
206 if err := s.persistObtainCursor(ctx, nextCursor); err != nil {207 if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
207- return err208+ log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor")
208 }209 }
209 s.mu.Lock()210 s.mu.Lock()
210 if s.obtainCursor == previousCursor {211 if s.obtainCursor == previousCursor {
M internal/usecase/publictls/service_test.go +72 -11
@@ -134,6 +134,7 @@ type mockCertificateStoreState struct {
134134 account *out.ACMEAccount
135135 certs []out.StoredCertificate
136136 state out.CertificateStoreState
137+ loadStateErr error
137138 saveStateErr error
138139 }
139140
@@ -157,6 +158,12 @@ func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
157158 s.state = state
158159 }
159160
161+func (s *mockCertificateStoreState) SetLoadStateError(err error) {
162+ s.mu.Lock()
163+ defer s.mu.Unlock()
164+ s.loadStateErr = err
165+}
166+
160167 func (s *mockCertificateStoreState) SetSaveStateError(err error) {
161168 s.mu.Lock()
162169 defer s.mu.Unlock()
@@ -194,7 +201,12 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o
194201 loadAllCall.Maybe()
195202
196203 loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
197- return state.State(), nil
204+ state.mu.Lock()
205+ defer state.mu.Unlock()
206+ if state.loadStateErr != nil {
207+ return out.CertificateStoreState{}, state.loadStateErr
208+ }
209+ return state.state, nil
198210 })
199211 loadStateCall.Maybe()
200212
@@ -390,7 +402,7 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
390402 assert.Equal(t, "http01-b.example.com", stored[0].ID)
391403 }
392404
393-func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing.T) {
405+func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) {
394406 ctx := context.Background()
395407
396408 routes := &fakeRoutes{
@@ -401,6 +413,8 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing
401413 }
402414 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
403415 store, storeState := newMockCertificateStore(t)
416+ storeState.SetState(out.CertificateStoreState{ObtainCursor: 1})
417+ storeState.SetLoadStateError(fmt.Errorf("state unreadable"))
404418
405419 cfg := Config{
406420 Enabled: true,
@@ -424,20 +438,67 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing
424438 },
425439 })
426440
427- storeState.SetSaveStateError(fmt.Errorf("disk full"))
428441 require.NoError(t, svc.Load(ctx))
429- err := svc.Reconcile(ctx)
430- require.Error(t, err)
431- assert.Contains(t, err.Error(), "save certificate store state")
432- assert.Empty(t, recorder.Orders(), "obtain should not run when cursor persistence fails")
433- assert.Equal(t, out.CertificateStoreState{}, storeState.State())
434-
435- storeState.SetSaveStateError(nil)
436442 require.NoError(t, svc.Reconcile(ctx))
437443
438444 orders := recorder.Orders()
439445 require.Len(t, orders, 1)
440- assert.Equal(t, "http01-a.example.com", orders[0].ID, "failed cursor persistence should not skip the first target in memory")
446+ assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor")
447+}
448+
449+func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) {
450+ ctx := context.Background()
451+
452+ routes := &fakeRoutes{
453+ routes: []domain.Route{
454+ {Domain: "b.example.com"},
455+ {Domain: "a.example.com"},
456+ },
457+ }
458+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
459+ if order.ID == "http01-a.example.com" {
460+ return nil, fmt.Errorf("acme validation failed")
461+ }
462+ return defaultStoredCert(order)
463+ }, nil)
464+ store, storeState := newMockCertificateStore(t)
465+
466+ cfg := Config{
467+ Enabled: true,
468+ Email: "admin@example.com",
469+ Challenge: "http-01",
470+ HTTPPort: 8088,
471+ TLSPort: 8443,
472+ ObtainBatchSize: 1,
473+ }
474+ svc := NewService(cfg, ServiceDeps{
475+ Config: cfg,
476+ Routes: routes,
477+ Issuer: issuer,
478+ Store: store,
479+ Challenges: NewHTTP01Challenges(),
480+ Effective: EffectiveChallenge{
481+ ConfiguredMode: domain.ACMEChallengeHTTP01,
482+ Mode: domain.ACMEChallengeHTTP01,
483+ TokenSource: domain.ACMETokenSourceNone,
484+ Reason: "http-01 challenge selected",
485+ },
486+ })
487+
488+ storeState.SetSaveStateError(fmt.Errorf("disk full"))
489+ require.NoError(t, svc.Load(ctx))
490+ require.NoError(t, svc.Reconcile(ctx))
491+ require.NoError(t, svc.Reconcile(ctx))
492+ assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged")
493+
494+ orders := recorder.Orders()
495+ require.Len(t, orders, 2)
496+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
497+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor")
498+
499+ stored := storeState.All()
500+ require.Len(t, stored, 1)
501+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
441502 }
442503
443504 func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
@@ -134,6 +134,7 @@ type mockCertificateStoreState struct {
134 account *out.ACMEAccount134 account *out.ACMEAccount
135 certs []out.StoredCertificate135 certs []out.StoredCertificate
136 state out.CertificateStoreState136 state out.CertificateStoreState
137+ loadStateErr error
137 saveStateErr error138 saveStateErr error
138 }139 }
139 140
@@ -157,6 +158,12 @@ func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
157 s.state = state158 s.state = state
158 }159 }
159 160
161+func (s *mockCertificateStoreState) SetLoadStateError(err error) {
162+ s.mu.Lock()
163+ defer s.mu.Unlock()
164+ s.loadStateErr = err
165+}
166+
160 func (s *mockCertificateStoreState) SetSaveStateError(err error) {167 func (s *mockCertificateStoreState) SetSaveStateError(err error) {
161 s.mu.Lock()168 s.mu.Lock()
162 defer s.mu.Unlock()169 defer s.mu.Unlock()
@@ -194,7 +201,12 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o
194 loadAllCall.Maybe()201 loadAllCall.Maybe()
195 202
196 loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {203 loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
197- return state.State(), nil204+ state.mu.Lock()
205+ defer state.mu.Unlock()
206+ if state.loadStateErr != nil {
207+ return out.CertificateStoreState{}, state.loadStateErr
208+ }
209+ return state.state, nil
198 })210 })
199 loadStateCall.Maybe()211 loadStateCall.Maybe()
200 212
@@ -390,7 +402,7 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
390 assert.Equal(t, "http01-b.example.com", stored[0].ID)402 assert.Equal(t, "http01-b.example.com", stored[0].ID)
391 }403 }
392 404
393-func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing.T) {405+func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) {
394 ctx := context.Background()406 ctx := context.Background()
395 407
396 routes := &fakeRoutes{408 routes := &fakeRoutes{
@@ -401,6 +413,8 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing
401 }413 }
402 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)414 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
403 store, storeState := newMockCertificateStore(t)415 store, storeState := newMockCertificateStore(t)
416+ storeState.SetState(out.CertificateStoreState{ObtainCursor: 1})
417+ storeState.SetLoadStateError(fmt.Errorf("state unreadable"))
404 418
405 cfg := Config{419 cfg := Config{
406 Enabled: true,420 Enabled: true,
@@ -424,20 +438,67 @@ func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing
424 },438 },
425 })439 })
426 440
427- storeState.SetSaveStateError(fmt.Errorf("disk full"))
428 require.NoError(t, svc.Load(ctx))441 require.NoError(t, svc.Load(ctx))
429- err := svc.Reconcile(ctx)
430- require.Error(t, err)
431- assert.Contains(t, err.Error(), "save certificate store state")
432- assert.Empty(t, recorder.Orders(), "obtain should not run when cursor persistence fails")
433- assert.Equal(t, out.CertificateStoreState{}, storeState.State())
434-
435- storeState.SetSaveStateError(nil)
436 require.NoError(t, svc.Reconcile(ctx))442 require.NoError(t, svc.Reconcile(ctx))
437 443
438 orders := recorder.Orders()444 orders := recorder.Orders()
439 require.Len(t, orders, 1)445 require.Len(t, orders, 1)
440- assert.Equal(t, "http01-a.example.com", orders[0].ID, "failed cursor persistence should not skip the first target in memory")446+ assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor")
447+}
448+
449+func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) {
450+ ctx := context.Background()
451+
452+ routes := &fakeRoutes{
453+ routes: []domain.Route{
454+ {Domain: "b.example.com"},
455+ {Domain: "a.example.com"},
456+ },
457+ }
458+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
459+ if order.ID == "http01-a.example.com" {
460+ return nil, fmt.Errorf("acme validation failed")
461+ }
462+ return defaultStoredCert(order)
463+ }, nil)
464+ store, storeState := newMockCertificateStore(t)
465+
466+ cfg := Config{
467+ Enabled: true,
468+ Email: "admin@example.com",
469+ Challenge: "http-01",
470+ HTTPPort: 8088,
471+ TLSPort: 8443,
472+ ObtainBatchSize: 1,
473+ }
474+ svc := NewService(cfg, ServiceDeps{
475+ Config: cfg,
476+ Routes: routes,
477+ Issuer: issuer,
478+ Store: store,
479+ Challenges: NewHTTP01Challenges(),
480+ Effective: EffectiveChallenge{
481+ ConfiguredMode: domain.ACMEChallengeHTTP01,
482+ Mode: domain.ACMEChallengeHTTP01,
483+ TokenSource: domain.ACMETokenSourceNone,
484+ Reason: "http-01 challenge selected",
485+ },
486+ })
487+
488+ storeState.SetSaveStateError(fmt.Errorf("disk full"))
489+ require.NoError(t, svc.Load(ctx))
490+ require.NoError(t, svc.Reconcile(ctx))
491+ require.NoError(t, svc.Reconcile(ctx))
492+ assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged")
493+
494+ orders := recorder.Orders()
495+ require.Len(t, orders, 2)
496+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
497+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor")
498+
499+ stored := storeState.All()
500+ require.Len(t, stored, 1)
501+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
441 }502 }
442 503
443 func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {504 func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {

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