v2.31.0-dev22 8d342c4

public_tls_wiring_test.go RawHistoryFolder

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}

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