1package app
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/rsa"
7 "crypto/tls"
8 "crypto/x509"
9 "math/big"
10 "net"
11 "testing"
12 "time"
13
14 "github.com/spf13/viper"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18
19 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
20 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
21 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
22 "github.com/bnema/gordon/internal/domain"
23 config "github.com/bnema/gordon/internal/usecase/config"
24 pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
25 "github.com/bnema/gordon/internal/usecase/publictls"
26 traffic "github.com/bnema/gordon/internal/usecase/traffic"
27 "github.com/bnema/zerowrap"
28)
29
30func TestPublicTLSReadinessAllowsDNS01WithoutEdgePort(t *testing.T) {
31 cfg := Config{}
32 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
33
34 require.NoError(t, validatePublicTLSReadiness(cfg))
35 assert.Equal(t, 0, effectivePublicTLSPort(cfg))
36}
37
38func TestPublicTLSReadinessRejectsAutoResolvedHTTP01WithoutBoundExternalPort80(t *testing.T) {
39 cfg := Config{}
40 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeAuto)
41 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
42 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
43 }
44 effective := publictls.EffectiveChallenge{
45 ConfiguredMode: domain.ACMEChallengeAuto,
46 Mode: domain.ACMEChallengeHTTP01,
47 TokenSource: domain.ACMETokenSourceNone,
48 Reason: "auto selected http-01 (no cloudflare token)",
49 }
50
51 err := validateEffectivePublicTLSReadiness(cfg, effective)
52 require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
53}
54
55func TestEffectivePublicTLSPortPrefersTLSCapableEntrypointOverLegacyTLSPort(t *testing.T) {
56 cfg := Config{}
57 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeCloudflareDNS01)
58 cfg.Server.TLSPort = 8443
59 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
60 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
61 }
62
63 assert.Equal(t, 443, effectivePublicTLSPort(cfg))
64}
65
66func TestEffectivePublicTLSPortPrefersDefaultEdgeWithMultipleTLSCapableEntrypoints(t *testing.T) {
67 cfg := Config{}
68 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
69 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
70 "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
71 }
72
73 for range 20 {
74 assert.Equal(t, 443, effectivePublicTLSPort(cfg))
75 }
76}
77
78func TestEffectivePublicTLSPortUsesSoleNonEdgeTLSCapableEntrypoint(t *testing.T) {
79 cfg := Config{}
80 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
81 "public": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
82 "ssh": {Address: ":22", Protocol: domain.EntryPointProtocolTCP},
83 }
84
85 assert.Equal(t, 8443, effectivePublicTLSPort(cfg))
86}
87
88func TestEffectiveProxyPortsIgnoreLegacyServerPortsWithoutEntrypoints(t *testing.T) {
89 cfg := Config{}
90 cfg.Server.Port = 8080
91 cfg.Server.TLSPort = 8443
92
93 assert.Equal(t, 0, effectiveProxyHTTPPort(cfg))
94 assert.Equal(t, 0, effectiveProxyTLSPort(cfg))
95}
96
97func TestEffectivePublicTLSPortReturnsZeroWhenAmbiguousWithoutEdge(t *testing.T) {
98 cfg := Config{}
99 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
100 "public-a": {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
101 "public-b": {Address: ":8443", Protocol: domain.EntryPointProtocolTLSMux},
102 }
103
104 for range 20 {
105 assert.Equal(t, 0, effectivePublicTLSPort(cfg))
106 }
107}
108
109func TestPublicTLSReadinessRequiresHTTP01ActuallyBoundExternalPort80(t *testing.T) {
110 t.Run("rejects http entrypoint when smart tcp suppresses standalone http", func(t *testing.T) {
111 cfg := Config{}
112 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
113 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
114 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
115 "web": {Address: ":80", Protocol: domain.EntryPointProtocolHTTP},
116 }
117
118 err := validatePublicTLSReadiness(cfg)
119 require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
120 })
121
122 t.Run("allows smart tcp entrypoint on port 80", func(t *testing.T) {
123 cfg := Config{}
124 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
125 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
126 traffic.DefaultEdgeEntryPointName: {Address: ":80", Protocol: domain.EntryPointProtocolSmartTCP},
127 }
128
129 require.NoError(t, validatePublicTLSReadiness(cfg))
130 assert.Equal(t, 80, effectiveHTTP01Port(cfg))
131 })
132
133 t.Run("rejects legacy standalone http port 80 without explicit entrypoint", func(t *testing.T) {
134 cfg := Config{}
135 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
136 cfg.Server.Port = 80
137
138 err := validatePublicTLSReadiness(cfg)
139 require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
140 assert.Equal(t, 0, effectiveHTTP01Port(cfg))
141 })
142
143 t.Run("rejects legacy port 80 when smart tcp suppresses standalone http", func(t *testing.T) {
144 cfg := Config{}
145 cfg.TLS.ACME.Challenge = string(domain.ACMEChallengeHTTP01)
146 cfg.Server.Port = 80
147 cfg.EntryPoints = map[string]traffic.EntryPointConfig{
148 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
149 }
150
151 err := validatePublicTLSReadiness(cfg)
152 require.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
153 })
154}
155
156func TestPKIInitializesForSmartTCPEvenWithoutLegacyTLSPort(t *testing.T) {
157 si := &serviceInit{
158 ctx: context.Background(),
159 log: zerowrap.Default(),
160 svc: &services{configSvc: cfgServiceForPKITest(t)},
161 }
162 si.cfg.Server.DataDir = t.TempDir()
163 si.cfg.Server.GordonDomain = "gordon.example.com"
164 si.cfg.EntryPoints = map[string]traffic.EntryPointConfig{
165 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
166 }
167
168 require.NoError(t, si.initPKI())
169 require.NotNil(t, si.svc.pkiSvc)
170 defer si.svc.pkiSvc.Stop()
171
172 cert, err := si.svc.pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "gordon.example.com"})
173 require.NoError(t, err)
174 require.NotNil(t, cert)
175}
176
177func cfgServiceForPKITest(t *testing.T) *config.Service {
178 t.Helper()
179 v := viper.New()
180 svc := config.NewService(v, nil)
181 require.NoError(t, svc.Load(context.Background()))
182 return svc
183}
184
185// generateTestCert creates a self-signed certificate for the given hostnames.
186func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
187 t.Helper()
188
189 privKey, err := rsa.GenerateKey(rand.Reader, 2048)
190 require.NoError(t, err)
191
192 template := &x509.Certificate{
193 SerialNumber: big.NewInt(1),
194 NotBefore: time.Now().Add(-1 * time.Hour),
195 NotAfter: time.Now().Add(24 * time.Hour),
196 IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
197 }
198 for _, host := range hosts {
199 if ip := net.ParseIP(host); ip != nil {
200 template.IPAddresses = append(template.IPAddresses, ip)
201 } else {
202 template.DNSNames = append(template.DNSNames, host)
203 }
204 }
205
206 certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
207 require.NoError(t, err)
208
209 return tls.Certificate{
210 Certificate: [][]byte{certDER},
211 PrivateKey: privKey,
212 }
213}
214
215// TestCertificateSelector_StaticCertWins tests that a static certificate matching
216// the server name is returned before public TLS.
217func TestCertificateSelector_StaticCertWins(t *testing.T) {
218 staticCert := generateTestCert(t, "static.example.com")
219 selector := &certificateSelector{
220 staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
221 publicTLS: inmocks.NewMockPublicTLSService(t),
222 }
223
224 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})
225 require.NoError(t, err)
226 require.NotNil(t, got)
227 assert.Equal(t, staticCert.Certificate[0], got.Certificate[0],
228 "static cert should be returned, not public TLS")
229}
230
231func TestCertificateSelector_PublicTLSErrorFallsThroughWithoutLocalPKI(t *testing.T) {
232 publicTLS := inmocks.NewMockPublicTLSService(t)
233 publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
234 selector := &certificateSelector{publicTLS: publicTLS}
235
236 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
237 require.NoError(t, err)
238 assert.Nil(t, got, "public TLS error with no local PKI returns nil,nil")
239}
240
241// TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
242// nil,nil and no local PKI, the selector returns nil,nil.
243func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T) {
244 publicTLS := inmocks.NewMockPublicTLSService(t)
245 publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
246
247 routes := outmocks.NewMockRouteChecker(t)
248 routes.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "acme-required.example.com"}}).Maybe()
249 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
250 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
251 require.NoError(t, err)
252 pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, nil, zerowrap.Default())
253 defer pkiSvc.Stop()
254
255 selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}
256 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
257 require.NoError(t, err)
258 require.NotNil(t, got, "public TLS error should fall through to local PKI")
259}
260
261func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
262 publicTLS := inmocks.NewMockPublicTLSService(t)
263 publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil)
264 selector := &certificateSelector{publicTLS: publicTLS}
265
266 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
267 require.NoError(t, err)
268 assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil")
269}
270
271// TestCertificateSelector_NoSource tests that when no source has a matching cert,
272// nil, nil is returned.
273func TestCertificateSelector_NoSource(t *testing.T) {
274 selector := &certificateSelector{}
275
276 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"})
277 assert.NoError(t, err)
278 assert.Nil(t, got)
279}
280
281// TestMatchingStaticCert tests the matchingStaticCert helper.
282func TestMatchingStaticCert(t *testing.T) {
283 certA := generateTestCert(t, "app.example.com")
284 certB := generateTestCert(t, "api.example.com")
285
286 certs := []tls.Certificate{certA, certB}
287
288 // Exact match
289 got := matchingStaticCert(certs, "app.example.com")
290 require.NotNil(t, got)
291 assert.Equal(t, certA.Certificate[0], got.Certificate[0])
292
293 // Second cert match
294 got = matchingStaticCert(certs, "api.example.com")
295 require.NotNil(t, got)
296 assert.Equal(t, certB.Certificate[0], got.Certificate[0])
297
298 // No match
299 got = matchingStaticCert(certs, "other.example.com")
300 assert.Nil(t, got)
301
302 // Empty server name returns the first configured static certificate.
303 got = matchingStaticCert(certs, "")
304 require.NotNil(t, got)
305 assert.Equal(t, certA.Certificate[0], got.Certificate[0])
306
307 // Empty cert list
308 got = matchingStaticCert(nil, "app.example.com")
309 assert.Nil(t, got)
310}