detached 29dcc3b

fix: address CodeRabbit review findings

- Guard leaf cert issuance when intermediate is <1min from expiry
- Remove dead HasPrefix check in onboarding handler
- Add nil guard on singleflight result in GetCertificate
- Omit redundant :443 in HTTPS redirect URLs
- Reorder initPKI to check TLSPort before TLS file validation
- Add lifetime rationale comments to PKI constants
- Wrap ca export file errors with path context
- Deduplicate CLI logger config into shared cliLogConfig
- Generate RouteChecker mock and use it in pki + integration tests
- Convert onboarding handler tests to httptest.NewServer
- Document default port changes in upgrading guide
commit
29dcc3b125521af615eb43ade6554e254876f09d
author
brice · · 2026-03-31 04:50 UTC
parent
2604a62
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +282 -88
M .mockery.yaml +1 -0
@@ -24,6 +24,7 @@ packages:
2424 DomainSecretStore:
2525 RateLimiter:
2626 BackupStorage:
27+ RouteChecker:
2728 github.com/bnema/gordon/internal/boundaries/in:
2829 interfaces:
2930 ContainerService:
@@ -24,6 +24,7 @@ packages:
24 DomainSecretStore:24 DomainSecretStore:
25 RateLimiter:25 RateLimiter:
26 BackupStorage:26 BackupStorage:
27+ RouteChecker:
27 github.com/bnema/gordon/internal/boundaries/in:28 github.com/bnema/gordon/internal/boundaries/in:
28 interfaces:29 interfaces:
29 ContainerService:30 ContainerService:
M docs/upgrading.md +31 -0
@@ -2,6 +2,37 @@
22
33 This guide covers breaking changes and migration steps between major versions.
44
5+## v2.30.0 to v2.31.0
6+
7+### Breaking: Default Port Changes
8+
9+Gordon now defaults to unprivileged ports that work without root or firewall rules:
10+
11+| Setting | Old default | New default |
12+|---------|-------------|-------------|
13+| `server.port` | `80` | `8088` |
14+| `server.registry_port` | `5000` | `5000` (unchanged) |
15+| `server.tls_port` | *(n/a)* | `8443` |
16+
17+**If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly:
18+
19+```toml
20+[server]
21+port = 80
22+```
23+
24+Or set up firewall port forwarding to the new defaults:
25+
26+```bash
27+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
28+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
29+sudo firewall-cmd --reload
30+```
31+
32+### New: Internal Certificate Authority
33+
34+Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details.
35+
536 ## v2.16.0 to v2.30.0
637
738 ### Breaking: Password Authentication Removed
@@ -2,6 +2,37 @@
2 2
3 This guide covers breaking changes and migration steps between major versions.3 This guide covers breaking changes and migration steps between major versions.
4 4
5+## v2.30.0 to v2.31.0
6+
7+### Breaking: Default Port Changes
8+
9+Gordon now defaults to unprivileged ports that work without root or firewall rules:
10+
11+| Setting | Old default | New default |
12+|---------|-------------|-------------|
13+| `server.port` | `80` | `8088` |
14+| `server.registry_port` | `5000` | `5000` (unchanged) |
15+| `server.tls_port` | *(n/a)* | `8443` |
16+
17+**If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly:
18+
19+```toml
20+[server]
21+port = 80
22+```
23+
24+Or set up firewall port forwarding to the new defaults:
25+
26+```bash
27+sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
28+sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443
29+sudo firewall-cmd --reload
30+```
31+
32+### New: Internal Certificate Authority
33+
34+Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details.
35+
5 ## v2.16.0 to v2.30.036 ## v2.16.0 to v2.30.0
6 37
7 ### Breaking: Password Authentication Removed38 ### Breaking: Password Authentication Removed
M internal/adapters/in/cli/ca.go +2 -2
@@ -126,10 +126,10 @@ func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) erro
126126
127127 if outPath != "" {
128128 if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil {
129- return err
129+ return fmt.Errorf("create directory for %s: %w", outPath, err)
130130 }
131131 if err := os.WriteFile(outPath, rootPEM, 0600); err != nil {
132- return err
132+ return fmt.Errorf("write certificate to %s: %w", outPath, err)
133133 }
134134 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
135135 }
@@ -126,10 +126,10 @@ func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) erro
126 126
127 if outPath != "" {127 if outPath != "" {
128 if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil {128 if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil {
129- return err129+ return fmt.Errorf("create directory for %s: %w", outPath, err)
130 }130 }
131 if err := os.WriteFile(outPath, rootPEM, 0600); err != nil {131 if err := os.WriteFile(outPath, rootPEM, 0600); err != nil {
132- return err132+ return fmt.Errorf("write certificate to %s: %w", outPath, err)
133 }133 }
134 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))134 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath)))
135 }135 }
M internal/adapters/in/cli/local.go +1 -5
@@ -67,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
6767 // Config file not found is OK for some operations
6868 }
6969
70- // Create a minimal logger for CLI operations
71- log := zerowrap.New(zerowrap.Config{
72- Level: "warn",
73- Format: "console",
74- })
70+ log := zerowrap.New(cliLogConfig)
7571
7672 // Create config service (without event bus for CLI operations)
7773 configSvc := config.NewService(v, nil)
@@ -67,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
67 // Config file not found is OK for some operations67 // Config file not found is OK for some operations
68 }68 }
69 69
70- // Create a minimal logger for CLI operations70+ log := zerowrap.New(cliLogConfig)
71- log := zerowrap.New(zerowrap.Config{
72- Level: "warn",
73- Format: "console",
74- })
75 71
76 // Create config service (without event bus for CLI operations)72 // Create config service (without event bus for CLI operations)
77 configSvc := config.NewService(v, nil)73 configSvc := config.NewService(v, nil)
M internal/adapters/in/cli/presentation.go +9 -7
@@ -19,14 +19,16 @@ var cliWritef = func(w io.Writer, format string, args ...any) error {
1919 return err
2020 }
2121
22-// cliLogger returns a quiet logger for CLI commands that run outside the
23-// server lifecycle. Level "warn" keeps normal output clean while still
24-// surfacing actionable warnings from adapters.
22+// cliLogConfig is the shared logger configuration for CLI commands that run
23+// outside the server lifecycle. Level "warn" keeps normal output clean while
24+// still surfacing actionable warnings from adapters.
25+var cliLogConfig = zerowrap.Config{
26+ Level: "warn",
27+ Format: "console",
28+}
29+
2530 func cliLogger() zerowrap.Logger {
26- return zerowrap.New(zerowrap.Config{
27- Level: "warn",
28- Format: "console",
29- })
31+ return zerowrap.New(cliLogConfig)
3032 }
3133
3234 func cliRenderTitle(msg string) string {
@@ -19,14 +19,16 @@ var cliWritef = func(w io.Writer, format string, args ...any) error {
19 return err19 return err
20 }20 }
21 21
22-// cliLogger returns a quiet logger for CLI commands that run outside the22+// cliLogConfig is the shared logger configuration for CLI commands that run
23-// server lifecycle. Level "warn" keeps normal output clean while still23+// outside the server lifecycle. Level "warn" keeps normal output clean while
24-// surfacing actionable warnings from adapters.24+// still surfacing actionable warnings from adapters.
25+var cliLogConfig = zerowrap.Config{
26+ Level: "warn",
27+ Format: "console",
28+}
29+
25 func cliLogger() zerowrap.Logger {30 func cliLogger() zerowrap.Logger {
26- return zerowrap.New(zerowrap.Config{31+ return zerowrap.New(cliLogConfig)
27- Level: "warn",
28- Format: "console",
29- })
30 }32 }
31 33
32 func cliRenderTitle(msg string) string {34 func cliRenderTitle(msg string) string {
M internal/adapters/in/http/middleware/cidr.go +6 -1
@@ -103,7 +103,12 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
103103 if !strings.HasPrefix(path, "/") {
104104 path = "/"
105105 }
106- target := fmt.Sprintf("https://%s:%d%s", host, tlsPort, path)
106+ var target string
107+ if tlsPort == 443 {
108+ target = fmt.Sprintf("https://%s%s", host, path)
109+ } else {
110+ target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path)
111+ }
107112
108113 log.Debug().
109114 Str("target", target).
@@ -103,7 +103,12 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
103 if !strings.HasPrefix(path, "/") {103 if !strings.HasPrefix(path, "/") {
104 path = "/"104 path = "/"
105 }105 }
106- target := fmt.Sprintf("https://%s:%d%s", host, tlsPort, path)106+ var target string
107+ if tlsPort == 443 {
108+ target = fmt.Sprintf("https://%s%s", host, path)
109+ } else {
110+ target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path)
111+ }
107 112
108 log.Debug().113 log.Debug().
109 Str("target", target).114 Str("target", target).
M internal/adapters/in/http/onboarding/handler.go +1 -6
@@ -5,7 +5,6 @@ import (
55 "html"
66 "net"
77 "net/http"
8- "strings"
98 )
109
1110 // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.
@@ -50,11 +49,7 @@ func (h *Handler) normalizeHTTPSURL(r *http.Request) string {
5049 if h.tlsPort != 0 && h.tlsPort != 443 {
5150 hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)
5251 }
53- u := fmt.Sprintf("https://%s/", hostname)
54- if !strings.HasPrefix(u, "https://") {
55- return "https://localhost/"
56- }
57- return html.EscapeString(u)
52+ return html.EscapeString(fmt.Sprintf("https://%s/", hostname))
5853 }
5954
6055 // ServeOnboardingPage serves the CA trust onboarding HTML page.
@@ -5,7 +5,6 @@ import (
5 "html"5 "html"
6 "net"6 "net"
7 "net/http"7 "net/http"
8- "strings"
9 )8 )
10 9
11 // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.10 // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.
@@ -50,11 +49,7 @@ func (h *Handler) normalizeHTTPSURL(r *http.Request) string {
50 if h.tlsPort != 0 && h.tlsPort != 443 {49 if h.tlsPort != 0 && h.tlsPort != 443 {
51 hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)50 hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)
52 }51 }
53- u := fmt.Sprintf("https://%s/", hostname)52+ return html.EscapeString(fmt.Sprintf("https://%s/", hostname))
54- if !strings.HasPrefix(u, "https://") {
55- return "https://localhost/"
56- }
57- return html.EscapeString(u)
58 }53 }
59 54
60 // ServeOnboardingPage serves the CA trust onboarding HTML page.55 // ServeOnboardingPage serves the CA trust onboarding HTML page.
M internal/adapters/in/http/onboarding/handler_test.go +51 -31
@@ -1,56 +1,76 @@
11 package onboarding_test
22
33 import (
4+ "io"
45 "net/http"
56 "net/http/httptest"
67 "testing"
78
89 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
911
1012 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
1113 )
1214
13-func TestHandler_CACert(t *testing.T) {
15+func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) {
16+ t.Helper()
1417 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
1518 mobileconfig := []byte("<plist>com.apple.security.root</plist>")
16- h := onboarding.NewHandler(rootPEM, mobileconfig, 443)
1719
18- req := httptest.NewRequest("GET", "/ca.crt", nil)
19- w := httptest.NewRecorder()
20- h.ServeCACert(w, req)
20+ h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort)
21+ mux := http.NewServeMux()
22+ mux.HandleFunc("GET /ca", h.ServeOnboardingPage)
23+ mux.HandleFunc("GET /ca.crt", h.ServeCACert)
24+ mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig)
25+
26+ srv := httptest.NewServer(mux)
27+ t.Cleanup(srv.Close)
28+ return srv, rootPEM, mobileconfig
29+}
30+
31+func TestHandler_CACert(t *testing.T) {
32+ srv, rootPEM, _ := newTestServer(t, 443)
33+
34+ resp, err := http.Get(srv.URL + "/ca.crt")
35+ require.NoError(t, err)
36+ defer resp.Body.Close()
2137
22- assert.Equal(t, http.StatusOK, w.Code)
23- assert.Equal(t, "application/x-x509-ca-cert", w.Header().Get("Content-Type"))
24- assert.Equal(t, rootPEM, w.Body.Bytes())
38+ body, err := io.ReadAll(resp.Body)
39+ require.NoError(t, err)
40+
41+ assert.Equal(t, http.StatusOK, resp.StatusCode)
42+ assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type"))
43+ assert.Equal(t, rootPEM, body)
2544 }
2645
2746 func TestHandler_Mobileconfig(t *testing.T) {
28- rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
29- mobileconfig := []byte("<plist>com.apple.security.root</plist>")
30- h := onboarding.NewHandler(rootPEM, mobileconfig, 443)
47+ srv, _, _ := newTestServer(t, 443)
48+
49+ resp, err := http.Get(srv.URL + "/ca.mobileconfig")
50+ require.NoError(t, err)
51+ defer resp.Body.Close()
3152
32- req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)
33- w := httptest.NewRecorder()
34- h.ServeMobileconfig(w, req)
53+ body, err := io.ReadAll(resp.Body)
54+ require.NoError(t, err)
3555
36- assert.Equal(t, http.StatusOK, w.Code)
37- assert.Equal(t, "application/x-apple-aspen-config", w.Header().Get("Content-Type"))
38- assert.Contains(t, w.Body.String(), "com.apple.security.root")
56+ assert.Equal(t, http.StatusOK, resp.StatusCode)
57+ assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type"))
58+ assert.Contains(t, string(body), "com.apple.security.root")
3959 }
4060
4161 func TestHandler_OnboardingPage(t *testing.T) {
42- rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
43- mobileconfig := []byte("<plist>com.apple.security.root</plist>")
44- h := onboarding.NewHandler(rootPEM, mobileconfig, 443)
45-
46- req := httptest.NewRequest("GET", "/ca", nil)
47- w := httptest.NewRecorder()
48- h.ServeOnboardingPage(w, req)
49-
50- assert.Equal(t, http.StatusOK, w.Code)
51- assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type"))
52- body := w.Body.String()
53- assert.Contains(t, body, "Gordon")
54- assert.Contains(t, body, "/ca.crt")
55- assert.Contains(t, body, "/ca.mobileconfig")
62+ srv, _, _ := newTestServer(t, 443)
63+
64+ resp, err := http.Get(srv.URL + "/ca")
65+ require.NoError(t, err)
66+ defer resp.Body.Close()
67+
68+ body, err := io.ReadAll(resp.Body)
69+ require.NoError(t, err)
70+
71+ assert.Equal(t, http.StatusOK, resp.StatusCode)
72+ assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
73+ assert.Contains(t, string(body), "Gordon")
74+ assert.Contains(t, string(body), "/ca.crt")
75+ assert.Contains(t, string(body), "/ca.mobileconfig")
5676 }
@@ -1,56 +1,76 @@
1 package onboarding_test1 package onboarding_test
2 2
3 import (3 import (
4+ "io"
4 "net/http"5 "net/http"
5 "net/http/httptest"6 "net/http/httptest"
6 "testing"7 "testing"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
9 11
10 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"12 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
11 )13 )
12 14
13-func TestHandler_CACert(t *testing.T) {15+func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) {
16+ t.Helper()
14 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")17 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
15 mobileconfig := []byte("<plist>com.apple.security.root</plist>")18 mobileconfig := []byte("<plist>com.apple.security.root</plist>")
16- h := onboarding.NewHandler(rootPEM, mobileconfig, 443)
17 19
18- req := httptest.NewRequest("GET", "/ca.crt", nil)20+ h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort)
19- w := httptest.NewRecorder()21+ mux := http.NewServeMux()
20- h.ServeCACert(w, req)22+ mux.HandleFunc("GET /ca", h.ServeOnboardingPage)
23+ mux.HandleFunc("GET /ca.crt", h.ServeCACert)
24+ mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig)
25+
26+ srv := httptest.NewServer(mux)
27+ t.Cleanup(srv.Close)
28+ return srv, rootPEM, mobileconfig
29+}
30+
31+func TestHandler_CACert(t *testing.T) {
32+ srv, rootPEM, _ := newTestServer(t, 443)
33+
34+ resp, err := http.Get(srv.URL + "/ca.crt")
35+ require.NoError(t, err)
36+ defer resp.Body.Close()
21 37
22- assert.Equal(t, http.StatusOK, w.Code)38+ body, err := io.ReadAll(resp.Body)
23- assert.Equal(t, "application/x-x509-ca-cert", w.Header().Get("Content-Type"))39+ require.NoError(t, err)
24- assert.Equal(t, rootPEM, w.Body.Bytes())40+
41+ assert.Equal(t, http.StatusOK, resp.StatusCode)
42+ assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type"))
43+ assert.Equal(t, rootPEM, body)
25 }44 }
26 45
27 func TestHandler_Mobileconfig(t *testing.T) {46 func TestHandler_Mobileconfig(t *testing.T) {
28- rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")47+ srv, _, _ := newTestServer(t, 443)
29- mobileconfig := []byte("<plist>com.apple.security.root</plist>")48+
30- h := onboarding.NewHandler(rootPEM, mobileconfig, 443)49+ resp, err := http.Get(srv.URL + "/ca.mobileconfig")
50+ require.NoError(t, err)
51+ defer resp.Body.Close()
31 52
32- req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)53+ body, err := io.ReadAll(resp.Body)
33- w := httptest.NewRecorder()54+ require.NoError(t, err)
34- h.ServeMobileconfig(w, req)
35 55
36- assert.Equal(t, http.StatusOK, w.Code)56+ assert.Equal(t, http.StatusOK, resp.StatusCode)
37- assert.Equal(t, "application/x-apple-aspen-config", w.Header().Get("Content-Type"))57+ assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type"))
38- assert.Contains(t, w.Body.String(), "com.apple.security.root")58+ assert.Contains(t, string(body), "com.apple.security.root")
39 }59 }
40 60
41 func TestHandler_OnboardingPage(t *testing.T) {61 func TestHandler_OnboardingPage(t *testing.T) {
42- rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")62+ srv, _, _ := newTestServer(t, 443)
43- mobileconfig := []byte("<plist>com.apple.security.root</plist>")63+
44- h := onboarding.NewHandler(rootPEM, mobileconfig, 443)64+ resp, err := http.Get(srv.URL + "/ca")
45-65+ require.NoError(t, err)
46- req := httptest.NewRequest("GET", "/ca", nil)66+ defer resp.Body.Close()
47- w := httptest.NewRecorder()67+
48- h.ServeOnboardingPage(w, req)68+ body, err := io.ReadAll(resp.Body)
49-69+ require.NoError(t, err)
50- assert.Equal(t, http.StatusOK, w.Code)70+
51- assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type"))71+ assert.Equal(t, http.StatusOK, resp.StatusCode)
52- body := w.Body.String()72+ assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
53- assert.Contains(t, body, "Gordon")73+ assert.Contains(t, string(body), "Gordon")
54- assert.Contains(t, body, "/ca.crt")74+ assert.Contains(t, string(body), "/ca.crt")
55- assert.Contains(t, body, "/ca.mobileconfig")75+ assert.Contains(t, string(body), "/ca.mobileconfig")
56 }76 }
M internal/adapters/out/pki/ca.go +7 -0
@@ -30,6 +30,10 @@ import (
3030 // Compile-time check: *CA satisfies the CertificateAuthority boundary.
3131 var _ out.CertificateAuthority = (*CA)(nil)
3232
33+// Certificate lifetimes balance security and operational convenience:
34+// - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap.
35+// - Intermediate: short-lived to limit blast radius if compromised; auto-renewed.
36+// - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory.
3337 const (
3438 rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years
3539 intermediateLifetime = 7 * 24 * time.Hour // 7 days
@@ -377,6 +381,9 @@ func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
377381 expiry := now.Add(leafLifetime)
378382 if expiry.After(interCert.NotAfter) {
379383 expiry = interCert.NotAfter.Add(-1 * time.Minute)
384+ if !expiry.After(now) {
385+ return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required")
386+ }
380387 }
381388
382389 template := &x509.Certificate{
@@ -30,6 +30,10 @@ import (
30 // Compile-time check: *CA satisfies the CertificateAuthority boundary.30 // Compile-time check: *CA satisfies the CertificateAuthority boundary.
31 var _ out.CertificateAuthority = (*CA)(nil)31 var _ out.CertificateAuthority = (*CA)(nil)
32 32
33+// Certificate lifetimes balance security and operational convenience:
34+// - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap.
35+// - Intermediate: short-lived to limit blast radius if compromised; auto-renewed.
36+// - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory.
33 const (37 const (
34 rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years38 rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years
35 intermediateLifetime = 7 * 24 * time.Hour // 7 days39 intermediateLifetime = 7 * 24 * time.Hour // 7 days
@@ -377,6 +381,9 @@ func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) {
377 expiry := now.Add(leafLifetime)381 expiry := now.Add(leafLifetime)
378 if expiry.After(interCert.NotAfter) {382 if expiry.After(interCert.NotAfter) {
379 expiry = interCert.NotAfter.Add(-1 * time.Minute)383 expiry = interCert.NotAfter.Add(-1 * time.Minute)
384+ if !expiry.After(now) {
385+ return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required")
386+ }
380 }387 }
381 388
382 template := &x509.Certificate{389 template := &x509.Certificate{
M internal/app/pki_integration_test.go +13 -11
@@ -9,24 +9,26 @@ import (
99 "time"
1010
1111 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
12- outboundary "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
1313 "github.com/bnema/gordon/internal/domain"
1414 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
1515 "github.com/bnema/zerowrap"
1616 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1718 "github.com/stretchr/testify/require"
1819 )
1920
20-// Compile-time check: stubRoutes implements the boundary interface.
21-var _ outboundary.RouteChecker = (*stubRoutes)(nil)
22-
23-type stubRoutes struct {
24- routes []domain.Route
21+func newRoutesMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
22+ m := mocks.NewMockRouteChecker(t)
23+ routes := make([]domain.Route, len(domains))
24+ for i, d := range domains {
25+ routes[i] = domain.Route{Domain: d}
26+ }
27+ m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe()
28+ m.EXPECT().GetExternalRoutes().Return(nil).Maybe()
29+ return m
2530 }
2631
27-func (s *stubRoutes) GetRoutes(_ context.Context) []domain.Route { return s.routes }
28-func (s *stubRoutes) GetExternalRoutes() map[string]string { return nil }
29-
3032 func TestTLSHandshake_OnDemandCert(t *testing.T) {
3133 dir := t.TempDir()
3234 log := zerowrap.Default()
@@ -34,7 +36,7 @@ func TestTLSHandshake_OnDemandCert(t *testing.T) {
3436 ca, err := pkiadapter.NewCA(dir, log)
3537 require.NoError(t, err)
3638
37- routes := &stubRoutes{routes: []domain.Route{{Domain: "test.local"}}}
39+ routes := newRoutesMock(t, "test.local")
3840 ctx, cancel := context.WithCancel(context.Background())
3941 defer cancel()
4042
@@ -87,7 +89,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) {
8789 ca, err := pkiadapter.NewCA(dir, log)
8890 require.NoError(t, err)
8991
90- routes := &stubRoutes{routes: []domain.Route{{Domain: "allowed.local"}}}
92+ routes := newRoutesMock(t, "allowed.local")
9193 ctx, cancel := context.WithCancel(context.Background())
9294 defer cancel()
9395
@@ -9,24 +9,26 @@ import (
9 "time"9 "time"
10 10
11 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"11 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
12- outboundary "github.com/bnema/gordon/internal/boundaries/out"12+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"14 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
15 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
16 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
18 )19 )
19 20
20-// Compile-time check: stubRoutes implements the boundary interface.21+func newRoutesMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
21-var _ outboundary.RouteChecker = (*stubRoutes)(nil)22+ m := mocks.NewMockRouteChecker(t)
22-23+ routes := make([]domain.Route, len(domains))
23-type stubRoutes struct {24+ for i, d := range domains {
24- routes []domain.Route25+ routes[i] = domain.Route{Domain: d}
26+ }
27+ m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe()
28+ m.EXPECT().GetExternalRoutes().Return(nil).Maybe()
29+ return m
25 }30 }
26 31
27-func (s *stubRoutes) GetRoutes(_ context.Context) []domain.Route { return s.routes }
28-func (s *stubRoutes) GetExternalRoutes() map[string]string { return nil }
29-
30 func TestTLSHandshake_OnDemandCert(t *testing.T) {32 func TestTLSHandshake_OnDemandCert(t *testing.T) {
31 dir := t.TempDir()33 dir := t.TempDir()
32 log := zerowrap.Default()34 log := zerowrap.Default()
@@ -34,7 +36,7 @@ func TestTLSHandshake_OnDemandCert(t *testing.T) {
34 ca, err := pkiadapter.NewCA(dir, log)36 ca, err := pkiadapter.NewCA(dir, log)
35 require.NoError(t, err)37 require.NoError(t, err)
36 38
37- routes := &stubRoutes{routes: []domain.Route{{Domain: "test.local"}}}39+ routes := newRoutesMock(t, "test.local")
38 ctx, cancel := context.WithCancel(context.Background())40 ctx, cancel := context.WithCancel(context.Background())
39 defer cancel()41 defer cancel()
40 42
@@ -87,7 +89,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) {
87 ca, err := pkiadapter.NewCA(dir, log)89 ca, err := pkiadapter.NewCA(dir, log)
88 require.NoError(t, err)90 require.NoError(t, err)
89 91
90- routes := &stubRoutes{routes: []domain.Route{{Domain: "allowed.local"}}}92+ routes := newRoutesMock(t, "allowed.local")
91 ctx, cancel := context.WithCancel(context.Background())93 ctx, cancel := context.WithCancel(context.Background())
92 defer cancel()94 defer cancel()
93 95
M internal/app/run.go +4 -4
@@ -417,15 +417,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
417417
418418 // initPKI initialises the internal CA and PKI service when TLS is enabled.
419419 func (si *serviceInit) initPKI() error {
420- if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
421- return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
422- }
423-
424420 if si.cfg.Server.TLSPort == 0 {
425421 si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
426422 return nil
427423 }
428424
425+ if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
426+ return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
427+ }
428+
429429 caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)
430430 if err != nil {
431431 return si.log.WrapErr(err, "failed to initialize internal CA")
@@ -417,15 +417,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
417 417
418 // initPKI initialises the internal CA and PKI service when TLS is enabled.418 // initPKI initialises the internal CA and PKI service when TLS is enabled.
419 func (si *serviceInit) initPKI() error {419 func (si *serviceInit) initPKI() error {
420- if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
421- return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
422- }
423-
424 if si.cfg.Server.TLSPort == 0 {420 if si.cfg.Server.TLSPort == 0 {
425 si.log.Info().Msg("internal CA disabled (server.tls_port=0)")421 si.log.Info().Msg("internal CA disabled (server.tls_port=0)")
426 return nil422 return nil
427 }423 }
428 424
425+ if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") {
426+ return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither")
427+ }
428+
429 caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)429 caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log)
430 if err != nil {430 if err != nil {
431 return si.log.WrapErr(err, "failed to initialize internal CA")431 return si.log.WrapErr(err, "failed to initialize internal CA")
A internal/boundaries/out/mocks/mock_route_checker.go +138 -0
@@ -0,0 +1,138 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockRouteChecker creates a new instance of MockRouteChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockRouteChecker(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockRouteChecker {
20+ mock := &MockRouteChecker{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockRouteChecker is an autogenerated mock type for the RouteChecker type
29+type MockRouteChecker struct {
30+ mock.Mock
31+}
32+
33+type MockRouteChecker_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockRouteChecker) EXPECT() *MockRouteChecker_Expecter {
38+ return &MockRouteChecker_Expecter{mock: &_m.Mock}
39+}
40+
41+// GetExternalRoutes provides a mock function for the type MockRouteChecker
42+func (_mock *MockRouteChecker) GetExternalRoutes() map[string]string {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for GetExternalRoutes")
47+ }
48+
49+ var r0 map[string]string
50+ if returnFunc, ok := ret.Get(0).(func() map[string]string); ok {
51+ r0 = returnFunc()
52+ } else {
53+ if ret.Get(0) != nil {
54+ r0 = ret.Get(0).(map[string]string)
55+ }
56+ }
57+ return r0
58+}
59+
60+// MockRouteChecker_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes'
61+type MockRouteChecker_GetExternalRoutes_Call struct {
62+ *mock.Call
63+}
64+
65+// GetExternalRoutes is a helper method to define mock.On call
66+func (_e *MockRouteChecker_Expecter) GetExternalRoutes() *MockRouteChecker_GetExternalRoutes_Call {
67+ return &MockRouteChecker_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")}
68+}
69+
70+func (_c *MockRouteChecker_GetExternalRoutes_Call) Run(run func()) *MockRouteChecker_GetExternalRoutes_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ run()
73+ })
74+ return _c
75+}
76+
77+func (_c *MockRouteChecker_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockRouteChecker_GetExternalRoutes_Call {
78+ _c.Call.Return(stringToString)
79+ return _c
80+}
81+
82+func (_c *MockRouteChecker_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockRouteChecker_GetExternalRoutes_Call {
83+ _c.Call.Return(run)
84+ return _c
85+}
86+
87+// GetRoutes provides a mock function for the type MockRouteChecker
88+func (_mock *MockRouteChecker) GetRoutes(ctx context.Context) []domain.Route {
89+ ret := _mock.Called(ctx)
90+
91+ if len(ret) == 0 {
92+ panic("no return value specified for GetRoutes")
93+ }
94+
95+ var r0 []domain.Route
96+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok {
97+ r0 = returnFunc(ctx)
98+ } else {
99+ if ret.Get(0) != nil {
100+ r0 = ret.Get(0).([]domain.Route)
101+ }
102+ }
103+ return r0
104+}
105+
106+// MockRouteChecker_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes'
107+type MockRouteChecker_GetRoutes_Call struct {
108+ *mock.Call
109+}
110+
111+// GetRoutes is a helper method to define mock.On call
112+// - ctx context.Context
113+func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call {
114+ return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
115+}
116+
117+func (_c *MockRouteChecker_GetRoutes_Call) Run(run func(ctx context.Context)) *MockRouteChecker_GetRoutes_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ var arg0 context.Context
120+ if args[0] != nil {
121+ arg0 = args[0].(context.Context)
122+ }
123+ run(
124+ arg0,
125+ )
126+ })
127+ return _c
128+}
129+
130+func (_c *MockRouteChecker_GetRoutes_Call) Return(routes []domain.Route) *MockRouteChecker_GetRoutes_Call {
131+ _c.Call.Return(routes)
132+ return _c
133+}
134+
135+func (_c *MockRouteChecker_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockRouteChecker_GetRoutes_Call {
136+ _c.Call.Return(run)
137+ return _c
138+}
@@ -0,0 +1,138 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockRouteChecker creates a new instance of MockRouteChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockRouteChecker(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockRouteChecker {
20+ mock := &MockRouteChecker{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockRouteChecker is an autogenerated mock type for the RouteChecker type
29+type MockRouteChecker struct {
30+ mock.Mock
31+}
32+
33+type MockRouteChecker_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockRouteChecker) EXPECT() *MockRouteChecker_Expecter {
38+ return &MockRouteChecker_Expecter{mock: &_m.Mock}
39+}
40+
41+// GetExternalRoutes provides a mock function for the type MockRouteChecker
42+func (_mock *MockRouteChecker) GetExternalRoutes() map[string]string {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for GetExternalRoutes")
47+ }
48+
49+ var r0 map[string]string
50+ if returnFunc, ok := ret.Get(0).(func() map[string]string); ok {
51+ r0 = returnFunc()
52+ } else {
53+ if ret.Get(0) != nil {
54+ r0 = ret.Get(0).(map[string]string)
55+ }
56+ }
57+ return r0
58+}
59+
60+// MockRouteChecker_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes'
61+type MockRouteChecker_GetExternalRoutes_Call struct {
62+ *mock.Call
63+}
64+
65+// GetExternalRoutes is a helper method to define mock.On call
66+func (_e *MockRouteChecker_Expecter) GetExternalRoutes() *MockRouteChecker_GetExternalRoutes_Call {
67+ return &MockRouteChecker_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")}
68+}
69+
70+func (_c *MockRouteChecker_GetExternalRoutes_Call) Run(run func()) *MockRouteChecker_GetExternalRoutes_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ run()
73+ })
74+ return _c
75+}
76+
77+func (_c *MockRouteChecker_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockRouteChecker_GetExternalRoutes_Call {
78+ _c.Call.Return(stringToString)
79+ return _c
80+}
81+
82+func (_c *MockRouteChecker_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockRouteChecker_GetExternalRoutes_Call {
83+ _c.Call.Return(run)
84+ return _c
85+}
86+
87+// GetRoutes provides a mock function for the type MockRouteChecker
88+func (_mock *MockRouteChecker) GetRoutes(ctx context.Context) []domain.Route {
89+ ret := _mock.Called(ctx)
90+
91+ if len(ret) == 0 {
92+ panic("no return value specified for GetRoutes")
93+ }
94+
95+ var r0 []domain.Route
96+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok {
97+ r0 = returnFunc(ctx)
98+ } else {
99+ if ret.Get(0) != nil {
100+ r0 = ret.Get(0).([]domain.Route)
101+ }
102+ }
103+ return r0
104+}
105+
106+// MockRouteChecker_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes'
107+type MockRouteChecker_GetRoutes_Call struct {
108+ *mock.Call
109+}
110+
111+// GetRoutes is a helper method to define mock.On call
112+// - ctx context.Context
113+func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call {
114+ return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)}
115+}
116+
117+func (_c *MockRouteChecker_GetRoutes_Call) Run(run func(ctx context.Context)) *MockRouteChecker_GetRoutes_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ var arg0 context.Context
120+ if args[0] != nil {
121+ arg0 = args[0].(context.Context)
122+ }
123+ run(
124+ arg0,
125+ )
126+ })
127+ return _c
128+}
129+
130+func (_c *MockRouteChecker_GetRoutes_Call) Return(routes []domain.Route) *MockRouteChecker_GetRoutes_Call {
131+ _c.Call.Return(routes)
132+ return _c
133+}
134+
135+func (_c *MockRouteChecker_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockRouteChecker_GetRoutes_Call {
136+ _c.Call.Return(run)
137+ return _c
138+}
M internal/usecase/pki/service.go +3 -0
@@ -129,6 +129,9 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
129129 if err != nil {
130130 return nil, err
131131 }
132+ if result == nil {
133+ return nil, nil
134+ }
132135 return result.(*tls.Certificate), nil
133136 }
134137
@@ -129,6 +129,9 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
129 if err != nil {129 if err != nil {
130 return nil, err130 return nil, err
131 }131 }
132+ if result == nil {
133+ return nil, nil
134+ }
132 return result.(*tls.Certificate), nil135 return result.(*tls.Certificate), nil
133 }136 }
134 137
M internal/usecase/pki/service_test.go +15 -21
@@ -7,36 +7,34 @@ import (
77
88 "github.com/bnema/zerowrap"
99 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
1011 "github.com/stretchr/testify/require"
1112
1213 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
13- outboundary "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
1415 "github.com/bnema/gordon/internal/domain"
1516 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
1617 )
1718
18-// Compile-time check: stubConfigService implements the boundary interface.
19-var _ outboundary.RouteChecker = (*stubConfigService)(nil)
19+func testLogger() zerowrap.Logger { return zerowrap.Default() }
2020
21-// stubConfigService satisfies out.RouteChecker
22-type stubConfigService struct {
23- routes []domain.Route
24- externalRoutes map[string]string
21+func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
22+ m := mocks.NewMockRouteChecker(t)
23+ routes := make([]domain.Route, len(domains))
24+ for i, d := range domains {
25+ routes[i] = domain.Route{Domain: d}
26+ }
27+ m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe()
28+ m.EXPECT().GetExternalRoutes().Return(nil).Maybe()
29+ return m
2530 }
2631
27-func (s *stubConfigService) GetRoutes(_ context.Context) []domain.Route { return s.routes }
28-func (s *stubConfigService) GetExternalRoutes() map[string]string { return s.externalRoutes }
29-
30-func testLogger() zerowrap.Logger { return zerowrap.Default() }
31-
3232 func TestService_GetCertificate_KnownDomain(t *testing.T) {
3333 dir := t.TempDir()
3434 ca, err := pkiadapter.NewCA(dir, testLogger())
3535 require.NoError(t, err)
3636
37- cfg := &stubConfigService{
38- routes: []domain.Route{{Domain: "app.example.com"}},
39- }
37+ cfg := newRouteCheckerMock(t, "app.example.com")
4038
4139 ctx, cancel := context.WithCancel(context.Background())
4240 defer cancel()
@@ -55,9 +53,7 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) {
5553 ca, err := pkiadapter.NewCA(dir, testLogger())
5654 require.NoError(t, err)
5755
58- cfg := &stubConfigService{
59- routes: []domain.Route{{Domain: "app.example.com"}},
60- }
56+ cfg := newRouteCheckerMock(t, "app.example.com")
6157
6258 ctx, cancel := context.WithCancel(context.Background())
6359 defer cancel()
@@ -76,9 +72,7 @@ func TestService_GetCertificate_Caching(t *testing.T) {
7672 ca, err := pkiadapter.NewCA(dir, testLogger())
7773 require.NoError(t, err)
7874
79- cfg := &stubConfigService{
80- routes: []domain.Route{{Domain: "cached.example.com"}},
81- }
75+ cfg := newRouteCheckerMock(t, "cached.example.com")
8276
8377 ctx, cancel := context.WithCancel(context.Background())
8478 defer cancel()
@@ -7,36 +7,34 @@ import (
7 7
8 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 12
12 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"13 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
13- outboundary "github.com/bnema/gordon/internal/boundaries/out"14+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
14 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
15 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"16 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
16 )17 )
17 18
18-// Compile-time check: stubConfigService implements the boundary interface.19+func testLogger() zerowrap.Logger { return zerowrap.Default() }
19-var _ outboundary.RouteChecker = (*stubConfigService)(nil)
20 20
21-// stubConfigService satisfies out.RouteChecker21+func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker {
22-type stubConfigService struct {22+ m := mocks.NewMockRouteChecker(t)
23- routes []domain.Route23+ routes := make([]domain.Route, len(domains))
24- externalRoutes map[string]string24+ for i, d := range domains {
25+ routes[i] = domain.Route{Domain: d}
26+ }
27+ m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe()
28+ m.EXPECT().GetExternalRoutes().Return(nil).Maybe()
29+ return m
25 }30 }
26 31
27-func (s *stubConfigService) GetRoutes(_ context.Context) []domain.Route { return s.routes }
28-func (s *stubConfigService) GetExternalRoutes() map[string]string { return s.externalRoutes }
29-
30-func testLogger() zerowrap.Logger { return zerowrap.Default() }
31-
32 func TestService_GetCertificate_KnownDomain(t *testing.T) {32 func TestService_GetCertificate_KnownDomain(t *testing.T) {
33 dir := t.TempDir()33 dir := t.TempDir()
34 ca, err := pkiadapter.NewCA(dir, testLogger())34 ca, err := pkiadapter.NewCA(dir, testLogger())
35 require.NoError(t, err)35 require.NoError(t, err)
36 36
37- cfg := &stubConfigService{37+ cfg := newRouteCheckerMock(t, "app.example.com")
38- routes: []domain.Route{{Domain: "app.example.com"}},
39- }
40 38
41 ctx, cancel := context.WithCancel(context.Background())39 ctx, cancel := context.WithCancel(context.Background())
42 defer cancel()40 defer cancel()
@@ -55,9 +53,7 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) {
55 ca, err := pkiadapter.NewCA(dir, testLogger())53 ca, err := pkiadapter.NewCA(dir, testLogger())
56 require.NoError(t, err)54 require.NoError(t, err)
57 55
58- cfg := &stubConfigService{56+ cfg := newRouteCheckerMock(t, "app.example.com")
59- routes: []domain.Route{{Domain: "app.example.com"}},
60- }
61 57
62 ctx, cancel := context.WithCancel(context.Background())58 ctx, cancel := context.WithCancel(context.Background())
63 defer cancel()59 defer cancel()
@@ -76,9 +72,7 @@ func TestService_GetCertificate_Caching(t *testing.T) {
76 ca, err := pkiadapter.NewCA(dir, testLogger())72 ca, err := pkiadapter.NewCA(dir, testLogger())
77 require.NoError(t, err)73 require.NoError(t, err)
78 74
79- cfg := &stubConfigService{75+ cfg := newRouteCheckerMock(t, "cached.example.com")
80- routes: []domain.Route{{Domain: "cached.example.com"}},
81- }
82 76
83 ctx, cancel := context.WithCancel(context.Background())77 ctx, cancel := context.WithCancel(context.Background())
84 defer cancel()78 defer cancel()

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