v3.3.0 b164fe9

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/require"
17
18	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
19	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
20	"github.com/bnema/gordon/internal/boundaries/out"
21	"github.com/bnema/gordon/internal/domain"
22	"github.com/bnema/gordon/internal/usecase/apptraffic"
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
231// stubAppRoutes is an ACTIVE-derived host source for TLS selector tests.
232type stubAppRoutes struct {
233	hosts []out.AppHost
234}
235
236func (s *stubAppRoutes) AppHosts() []out.AppHost { return s.hosts }
237
238func (s *stubAppRoutes) L4Entries() []apptraffic.RouteEntry { return nil }
239
240func (s *stubAppRoutes) GetExternalRoutes() map[string]string { return nil }
241
242func newRoutesMock(_ *testing.T, domains ...string) *stubAppRoutes {
243	hosts := make([]out.AppHost, 0, len(domains))
244	for _, d := range domains {
245		hosts = append(hosts, out.AppHost{Host: d})
246	}
247	return &stubAppRoutes{hosts: hosts}
248}
249
250func TestCertificateSelector_PublicTLSErrorFallsThroughWithoutLocalPKI(t *testing.T) {
251	publicTLS := inmocks.NewMockPublicTLSService(t)
252	publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
253	selector := &certificateSelector{publicTLS: publicTLS}
254
255	got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
256	require.NoError(t, err)
257	assert.Nil(t, got, "public TLS error with no local PKI returns nil,nil")
258}
259
260// TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
261// nil,nil and no local PKI, the selector returns nil,nil.
262func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T) {
263	publicTLS := inmocks.NewMockPublicTLSService(t)
264	publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
265
266	routes := newRoutesMock(t, "acme-required.example.com")
267	ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
268	require.NoError(t, err)
269	pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, nil, zerowrap.Default())
270	defer pkiSvc.Stop()
271
272	selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc}
273	got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
274	require.NoError(t, err)
275	require.NotNil(t, got, "public TLS error should fall through to local PKI")
276}
277
278func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
279	publicTLS := inmocks.NewMockPublicTLSService(t)
280	publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil)
281	selector := &certificateSelector{publicTLS: publicTLS}
282
283	got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
284	require.NoError(t, err)
285	assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil")
286}
287
288// TestCertificateSelector_NoSource tests that when no source has a matching cert,
289// nil, nil is returned.
290func TestCertificateSelector_NoSource(t *testing.T) {
291	selector := &certificateSelector{}
292
293	got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"})
294	assert.NoError(t, err)
295	assert.Nil(t, got)
296}
297
298// TestMatchingStaticCert tests the matchingStaticCert helper.
299func TestMatchingStaticCert(t *testing.T) {
300	certA := generateTestCert(t, "app.example.com")
301	certB := generateTestCert(t, "api.example.com")
302
303	certs := []tls.Certificate{certA, certB}
304
305	// Exact match
306	got := matchingStaticCert(certs, "app.example.com")
307	require.NotNil(t, got)
308	assert.Equal(t, certA.Certificate[0], got.Certificate[0])
309
310	// Second cert match
311	got = matchingStaticCert(certs, "api.example.com")
312	require.NotNil(t, got)
313	assert.Equal(t, certB.Certificate[0], got.Certificate[0])
314
315	// No match
316	got = matchingStaticCert(certs, "other.example.com")
317	assert.Nil(t, got)
318
319	// Empty server name returns the first configured static certificate.
320	got = matchingStaticCert(certs, "")
321	require.NotNil(t, got)
322	assert.Equal(t, certA.Certificate[0], got.Certificate[0])
323
324	// Empty cert list
325	got = matchingStaticCert(nil, "app.example.com")
326	assert.Nil(t, got)
327}

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