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.EntryPoints = map[string]traffic.EntryPointConfig{
164 traffic.DefaultEdgeEntryPointName: {Address: ":443", Protocol: domain.EntryPointProtocolSmartTCP},
165 }
166
167 require.NoError(t, si.initPKI())
168 require.NotNil(t, si.svc.pkiSvc)
169 si.svc.pkiSvc.Stop()
170}
171
172func cfgServiceForPKITest(t *testing.T) *config.Service {
173 t.Helper()
174 v := viper.New()
175 svc := config.NewService(v, nil)
176 require.NoError(t, svc.Load(context.Background()))
177 return svc
178}
179
180// generateTestCert creates a self-signed certificate for the given hostnames.
181func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
182 t.Helper()
183
184 privKey, err := rsa.GenerateKey(rand.Reader, 2048)
185 require.NoError(t, err)
186
187 template := &x509.Certificate{
188 SerialNumber: big.NewInt(1),
189 NotBefore: time.Now().Add(-1 * time.Hour),
190 NotAfter: time.Now().Add(24 * time.Hour),
191 IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
192 }
193 for _, host := range hosts {
194 if ip := net.ParseIP(host); ip != nil {
195 template.IPAddresses = append(template.IPAddresses, ip)
196 } else {
197 template.DNSNames = append(template.DNSNames, host)
198 }
199 }
200
201 certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey)
202 require.NoError(t, err)
203
204 return tls.Certificate{
205 Certificate: [][]byte{certDER},
206 PrivateKey: privKey,
207 }
208}
209
210// TestCertificateSelector_StaticCertWins tests that a static certificate matching
211// the server name is returned before public TLS.
212func TestCertificateSelector_StaticCertWins(t *testing.T) {
213 staticCert := generateTestCert(t, "static.example.com")
214 selector := &certificateSelector{
215 staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
216 publicTLS: inmocks.NewMockPublicTLSService(t),
217 }
218
219 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})
220 require.NoError(t, err)
221 require.NotNil(t, got)
222 assert.Equal(t, staticCert.Certificate[0], got.Certificate[0],
223 "static cert should be returned, not public TLS")
224}
225
226func TestCertificateSelector_PublicTLSErrorFallsThroughWithoutLocalPKI(t *testing.T) {
227 publicTLS := inmocks.NewMockPublicTLSService(t)
228 publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
229 selector := &certificateSelector{publicTLS: publicTLS}
230
231 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
232 require.NoError(t, err)
233 assert.Nil(t, got, "public TLS error with no local PKI returns nil,nil")
234}
235
236// TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
237// nil,nil and no local PKI, the selector returns nil,nil.
238func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T) {
239 publicTLS := inmocks.NewMockPublicTLSService(t)
240 publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
241
242 routes := outmocks.NewMockRouteChecker(t)
243 routes.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "acme-required.example.com"}}).Maybe()
244 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
245 ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
246 require.NoError(t, err)
247 pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default())
248 defer pkiSvc.Stop()
249
250 selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}
251 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
252 require.NoError(t, err)
253 require.NotNil(t, got, "public TLS error should fall through to local PKI")
254}
255
256func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
257 publicTLS := inmocks.NewMockPublicTLSService(t)
258 publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil)
259 selector := &certificateSelector{publicTLS: publicTLS}
260
261 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
262 require.NoError(t, err)
263 assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil")
264}
265
266// TestCertificateSelector_NoSource tests that when no source has a matching cert,
267// nil, nil is returned.
268func TestCertificateSelector_NoSource(t *testing.T) {
269 selector := &certificateSelector{}
270
271 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"})
272 assert.NoError(t, err)
273 assert.Nil(t, got)
274}
275
276// TestMatchingStaticCert tests the matchingStaticCert helper.
277func TestMatchingStaticCert(t *testing.T) {
278 certA := generateTestCert(t, "app.example.com")
279 certB := generateTestCert(t, "api.example.com")
280
281 certs := []tls.Certificate{certA, certB}
282
283 // Exact match
284 got := matchingStaticCert(certs, "app.example.com")
285 require.NotNil(t, got)
286 assert.Equal(t, certA.Certificate[0], got.Certificate[0])
287
288 // Second cert match
289 got = matchingStaticCert(certs, "api.example.com")
290 require.NotNil(t, got)
291 assert.Equal(t, certB.Certificate[0], got.Certificate[0])
292
293 // No match
294 got = matchingStaticCert(certs, "other.example.com")
295 assert.Nil(t, got)
296
297 // Empty server name returns the first configured static certificate.
298 got = matchingStaticCert(certs, "")
299 require.NotNil(t, got)
300 assert.Equal(t, certA.Certificate[0], got.Certificate[0])
301
302 // Empty cert list
303 got = matchingStaticCert(nil, "app.example.com")
304 assert.Nil(t, got)
305}