diff --git a/Makefile b/Makefile index 4540827f..0bce6772 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ build-backend: start-backend: go run ./cmd/plugin-backend.go -port='9001' -config-path='./config' -static-path='./web/dist' +.PHONY: test-backend +test-backend: + go test ./pkg/... -v + .PHONY: build-image build-image: ./scripts/build-image.sh diff --git a/cmd/plugin-backend.go b/cmd/plugin-backend.go index d7dae2fc..82e76f4b 100644 --- a/cmd/plugin-backend.go +++ b/cmd/plugin-backend.go @@ -1,6 +1,8 @@ package main import ( + "context" + "crypto/tls" "flag" "os" "strconv" @@ -21,6 +23,9 @@ var ( logLevelArg = flag.String("log-level", logrus.InfoLevel.String(), "verbosity of logs\noptions: ['panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace']\n'trace' level will log all incoming requests\n(default 'error')") alertmanagerUrlArg = flag.String("alertmanager", "", "alertmanager url to proxy to for acm mode") thanosQuerierUrlArg = flag.String("thanos-querier", "", "thanos querier url to proxy to for acm mode") + tlsMinVersionArg = flag.String("tls-min-version", "", "minimum TLS version\noptions: ['VersionTLS10', 'VersionTLS11', 'VersionTLS12', 'VersionTLS13']\n(default 'VersionTLS12')") + tlsMaxVersionArg = flag.String("tls-max-version", "", "maximum TLS version\noptions: ['VersionTLS10', 'VersionTLS11', 'VersionTLS12', 'VersionTLS13']\n(default is the highest supported by Go)") + tlsCipherSuitesArg = flag.String("tls-cipher-suites", "", "comma-separated list of cipher suites for the server\nvalues are from tls package constants (https://golang.org/pkg/crypto/tls/#pkg-constants)") log = logrus.WithField("module", "main") ) @@ -37,6 +42,9 @@ func main() { logLevel := mergeEnvValue("MONITORING_PLUGIN_LOG_LEVEL", *logLevelArg, logrus.InfoLevel.String()) alertmanagerUrl := mergeEnvValue("MONITORING_PLUGIN_ALERTMANAGER", *alertmanagerUrlArg, "") thanosQuerierUrl := mergeEnvValue("MONITORING_PLUGIN_THANOS_QUERIER", *thanosQuerierUrlArg, "") + tlsMinVersion := mergeEnvValue("TLS_MIN_VERSION", *tlsMinVersionArg, "") + tlsMaxVersion := mergeEnvValue("TLS_MAX_VERSION", *tlsMaxVersionArg, "") + tlsCipherSuites := mergeEnvValue("TLS_CIPHER_SUITES", *tlsCipherSuitesArg, "") featuresList := strings.Fields(strings.Join(strings.Split(strings.ToLower(features), ","), " ")) @@ -54,7 +62,12 @@ func main() { log.Infof("enabled features: %+q\n", featuresList) - server.Start(&server.Config{ + // Parse TLS configuration + tlsMinVer := parseTLSVersion(tlsMinVersion) + tlsMaxVer := parseTLSVersion(tlsMaxVersion) + tlsCiphers := parseCipherSuites(tlsCipherSuites) + + srv, err := server.CreateServer(context.Background(), &server.Config{ Port: port, CertFile: cert, PrivateKeyFile: key, @@ -64,7 +77,18 @@ func main() { PluginConfigPath: pluginConfigPath, AlertmanagerUrl: alertmanagerUrl, ThanosQuerierUrl: thanosQuerierUrl, + TLSMinVersion: tlsMinVer, + TLSMaxVersion: tlsMaxVer, + TLSCipherSuites: tlsCiphers, }) + + if err != nil { + panic(err) + } + + if err = srv.StartHTTPServer(); err != nil { + panic(err) + } } func mergeEnvValue(key string, arg string, defaultValue string) string { @@ -95,3 +119,66 @@ func mergeEnvValueInt(key string, arg int, defaultValue int) int { return defaultValue } + +func getCipherSuitesMap() map[string]uint16 { + result := make(map[string]uint16) + + for _, suite := range tls.CipherSuites() { + result[suite.Name] = suite.ID + } + + return result +} + +func getTLSVersionsMap() map[string]uint16 { + versions := make(map[string]uint16) + + versions["VersionTLS12"] = tls.VersionTLS12 + versions["VersionTLS13"] = tls.VersionTLS13 + + return versions +} + +func parseTLSVersion(version string) uint16 { + if version == "" { + return tls.VersionTLS12 + } + + tlsVersions := getTLSVersionsMap() + + if v, ok := tlsVersions[version]; ok { + return v + } + + log.Warnf("Invalid TLS version %q, using default VersionTLS12", version) + return tls.VersionTLS12 +} + +func parseCipherSuites(ciphers string) []uint16 { + if ciphers == "" { + return nil + } + + cipherMap := getCipherSuitesMap() + + cipherNames := strings.Split(strings.ReplaceAll(ciphers, " ", ""), ",") + var result []uint16 + + for _, name := range cipherNames { + if name == "" { + continue + } + if cipher, ok := cipherMap[name]; ok { + result = append(result, cipher) + } else { + log.Warnf("Unknown cipher suite %q, skipping", name) + } + } + + if len(result) == 0 { + log.Warn("No valid cipher suites provided, using Go defaults") + return nil + } + + return result +} diff --git a/pkg/server.go b/pkg/server.go index dc616aea..653fca84 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -35,6 +35,18 @@ type Config struct { PluginConfigPath string AlertmanagerUrl string ThanosQuerierUrl string + TLSMinVersion uint16 + TLSMaxVersion uint16 + TLSCipherSuites []uint16 +} + +func (c *Config) IsTLSEnabled() bool { + return c.CertFile != "" && c.PrivateKeyFile != "" +} + +type PluginServer struct { + *http.Server + Config *Config } type PluginConfig struct { @@ -61,19 +73,47 @@ func (pluginConfig *PluginConfig) MarshalJSON() ([]byte, error) { }) } -func Start(cfg *Config) { +func CreateServer(ctx context.Context, cfg *Config) (*PluginServer, error) { + httpServer, err := createHTTPServer(ctx, cfg) + if err != nil { + return nil, err + } + + return &PluginServer{ + Config: cfg, + Server: httpServer, + }, nil +} + +func (s *PluginServer) StartHTTPServer() error { + if s.Config.IsTLSEnabled() { + log.Infof("listening for https on %s", s.Server.Addr) + return s.Server.ListenAndServeTLS(s.Config.CertFile, s.Config.PrivateKeyFile) + } + log.Infof("listening for http on %s", s.Server.Addr) + return s.Server.ListenAndServe() +} + +func (s *PluginServer) Shutdown(ctx context.Context) error { + if s.Server != nil { + return s.Server.Shutdown(ctx) + } + return nil +} + +func createHTTPServer(ctx context.Context, cfg *Config) (*http.Server, error) { acmMode := cfg.Features[AcmAlerting] acmLocationsLength := len(cfg.AlertmanagerUrl) + len(cfg.ThanosQuerierUrl) if acmLocationsLength > 0 && !acmMode { - log.Panic("alertmanager and thanos-querier cannot be set without the 'acm-alerting' feature flag") + return nil, fmt.Errorf("alertmanager and thanos-querier cannot be set without the 'acm-alerting' feature flag") } if acmLocationsLength == 0 && acmMode { - log.Panic("alertmanager and thanos-querier must be set to use the 'acm-alerting' feature flag") + return nil, fmt.Errorf("alertmanager and thanos-querier must be set to use the 'acm-alerting' feature flag") } if cfg.Port == int(proxy.AlertmanagerPort) || cfg.Port == int(proxy.ThanosQuerierPort) { - log.Panic(fmt.Printf("Cannot set default port to reserved port %d", cfg.Port)) + return nil, fmt.Errorf("cannot set default port to reserved port %d", cfg.Port) } // Uncomment the following line for local development: @@ -86,12 +126,12 @@ func Start(cfg *Config) { k8sconfig, err := rest.InClusterConfig() if err != nil { - panic(fmt.Errorf("cannot get in cluster config: %w", err)) + return nil, fmt.Errorf("cannot get in cluster config: %w", err) } k8sclient, err = dynamic.NewForConfig(k8sconfig) if err != nil { - panic(fmt.Errorf("error creating dynamicClient: %w", err)) + return nil, fmt.Errorf("error creating dynamicClient: %w", err) } } else { k8sclient = nil @@ -100,15 +140,27 @@ func Start(cfg *Config) { router, pluginConfig := setupRoutes(cfg) router.Use(corsHeaderMiddleware()) - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - tlsEnabled := cfg.CertFile != "" && cfg.PrivateKeyFile != "" + tlsConfig := &tls.Config{} + + tlsEnabled := cfg.IsTLSEnabled() if tlsEnabled { + // Set MinVersion - default to TLS 1.2 if not specified + if cfg.TLSMinVersion != 0 { + tlsConfig.MinVersion = cfg.TLSMinVersion + } else { + tlsConfig.MinVersion = tls.VersionTLS12 + } + + if cfg.TLSMaxVersion != 0 { + tlsConfig.MaxVersion = cfg.TLSMaxVersion + } + + if len(cfg.TLSCipherSuites) > 0 { + tlsConfig.CipherSuites = cfg.TLSCipherSuites + } + // Build and run the controller which reloads the certificate and key // files whenever they change. - ctx := context.Background() - certKeyPair, err := dynamiccertificates.NewDynamicServingContentFromFiles("serving-cert", cfg.CertFile, cfg.PrivateKeyFile) if err != nil { log.WithError(err).Fatal("unable to create TLS controller") @@ -138,6 +190,7 @@ func Start(cfg *Config) { // Notify cert/key file changes to the controller. certKeyPair.AddListener(ctrl) + // Start certificate controllers in background go ctrl.Run(1, ctx.Done()) go certKeyPair.Run(ctx, 1) } @@ -160,18 +213,13 @@ func Start(cfg *Config) { httpServer.Handler = loggedRouter } - if tlsEnabled { - if acmMode { - startProxy(cfg, k8sclient, tlsConfig, timeout, proxy.AlertManagerKind, proxy.AlertmanagerPort) - startProxy(cfg, k8sclient, tlsConfig, timeout, proxy.ThanosQuerierKind, proxy.ThanosQuerierPort) - } - - log.Infof("listening for https on %s", httpServer.Addr) - panic(httpServer.ListenAndServeTLS(cfg.CertFile, cfg.PrivateKeyFile)) - } else { - log.Infof("listening for http on %s", httpServer.Addr) - panic(httpServer.ListenAndServe()) + // Start proxy servers if in ACM mode + if tlsEnabled && acmMode { + startProxy(cfg, k8sclient, tlsConfig, timeout, proxy.AlertManagerKind, proxy.AlertmanagerPort) + startProxy(cfg, k8sclient, tlsConfig, timeout, proxy.ThanosQuerierKind, proxy.ThanosQuerierPort) } + + return httpServer, nil } func setupRoutes(cfg *Config) (*mux.Router, *PluginConfig) { @@ -210,17 +258,32 @@ func setupProxyRoutes(cfg *Config, k8sclient *dynamic.DynamicClient, kind proxy. return router } -func filesHandler(root http.FileSystem) http.Handler { - fileServer := http.FileServer(root) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - filePath := r.URL.Path +type headerPreservingWriter struct { + http.ResponseWriter + wroteHeader bool +} - // disable caching for plugin entry point - if strings.HasPrefix(filePath, "/plugin-entry.js") { +func (w *headerPreservingWriter) WriteHeader(statusCode int) { + if !w.wroteHeader { + if w.Header().Get("Cache-Control") == "" { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + } + if w.Header().Get("Expires") == "" { w.Header().Set("Expires", "0") } + w.wroteHeader = true + } + w.ResponseWriter.WriteHeader(statusCode) +} +func filesHandler(root http.FileSystem) http.Handler { + fileServer := http.FileServer(root) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // disable caching for plugin entry point + if strings.HasPrefix(r.URL.Path, "/plugin-entry.js") { + fileServer.ServeHTTP(&headerPreservingWriter{ResponseWriter: w}, r) + return + } fileServer.ServeHTTP(w, r) }) } diff --git a/pkg/server_test.go b/pkg/server_test.go index af4080a0..4a69cdc9 100644 --- a/pkg/server_test.go +++ b/pkg/server_test.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "context" "crypto/rand" "crypto/rsa" "crypto/tls" @@ -33,6 +34,33 @@ const ( testHostname = "127.0.0.1" ) +// startTestServer is a helper that starts a server for testing and returns +// a cleanup function that should be deferred by the caller. +func startTestServer(t *testing.T, conf *Config) (*PluginServer, func()) { + ctx, cancel := context.WithCancel(context.Background()) + + server, err := CreateServer(ctx, conf) + require.NoError(t, err) + + // Start the server in a goroutine for testing + go func() { + if err := server.StartHTTPServer(); err != nil && err != http.ErrServerClosed { + t.Errorf("Server error: %v", err) + } + }() + + cleanup := func() { + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelShutdown() + server.Shutdown(shutdownCtx) + cancel() + // Give time for the dynamic certificate controller to fully stop + time.Sleep(100 * time.Millisecond) + } + + return server, cleanup +} + func TestServerRunning(t *testing.T) { testPort, err := getFreePort(testHostname) if err != nil { @@ -49,11 +77,10 @@ func TestServerRunning(t *testing.T) { tmpDir := prepareServerAssets(t) defer os.RemoveAll(tmpDir) - go func() { - Start(&Config{ - Port: testPort, - }) - }() + _, cleanup := startTestServer(t, &Config{ + Port: testPort, + }) + defer cleanup() t.Logf("Started test http server: %v", serverURL) @@ -109,8 +136,6 @@ func TestSecureServerRunning(t *testing.T) { if err != nil { t.Fatalf("Failed to create server cert/key files: %v", err) } - defer os.Remove(testServerCertFile) - defer os.Remove(testServerKeyFile) testClientCertFile := tmpDir + "/server-test-client.cert" testClientKeyFile := tmpDir + "/server-test-client.key" @@ -119,8 +144,6 @@ func TestSecureServerRunning(t *testing.T) { if err != nil { t.Fatalf("Failed to create client cert/key files: %v", err) } - defer os.Remove(testClientCertFile) - defer os.Remove(testClientKeyFile) conf := &Config{ CertFile: testServerCertFile, @@ -134,10 +157,9 @@ func TestSecureServerRunning(t *testing.T) { tmpDirAssets := prepareServerAssets(t) defer os.RemoveAll(tmpDirAssets) - go func() { - Start(conf) - }() - t.Logf("Started test http server: %v", serverURL) + _, cleanup := startTestServer(t, conf) + defer cleanup() + t.Logf("Started test https server: %v", serverURL) httpConfig := httpClientConfig{ CertFile: testClientCertFile, @@ -344,6 +366,250 @@ func generateCertificate(t *testing.T, certPath string, keyPath string, host str return nil } +func TestTLSConfigWithCustomSettings(t *testing.T) { + testPort, err := getFreePort(testHostname) + require.NoError(t, err) + t.Logf("Will use free port [%v] on host [%v] for tests", testPort, testHostname) + + tmpDir, err := os.MkdirTemp("", "server-test-tls") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + testServerCertFile := tmpDir + "/server-test-tls.cert" + testServerKeyFile := tmpDir + "/server-test-tls.key" + testServerHostPort := fmt.Sprintf("%v:%v", testHostname, testPort) + err = generateCertificate(t, testServerCertFile, testServerKeyFile, testServerHostPort) + require.NoError(t, err) + + testClientCertFile := tmpDir + "/client-test-tls.cert" + testClientKeyFile := tmpDir + "/client-test-tls.key" + err = generateCertificate(t, testClientCertFile, testClientKeyFile, testHostname) + require.NoError(t, err) + + conf := &Config{ + CertFile: testServerCertFile, + PrivateKeyFile: testServerKeyFile, + Port: testPort, + TLSMinVersion: tls.VersionTLS12, + TLSMaxVersion: tls.VersionTLS13, + TLSCipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384}, + } + + serverURL := fmt.Sprintf("https://%s", testServerHostPort) + + tmpDirAssets := prepareServerAssets(t) + defer os.RemoveAll(tmpDirAssets) + + _, cleanup := startTestServer(t, conf) + defer cleanup() + t.Logf("Started test https server with custom TLS config: %v", serverURL) + + httpConfigTLS13 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + }, + } + httpClientTLS13, err := httpConfigTLS13.buildHTTPClient() + require.NoError(t, err) + + checkHTTPReady(httpClientTLS13, serverURL+"/health") + + if _, err = getRequestResults(t, httpClientTLS13, serverURL+"/health"); err != nil { + t.Fatalf("Failed: could not connect with TLS 1.3: %v", err) + } + + httpConfigTLS12 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, + }, + } + httpClientTLS12, err := httpConfigTLS12.buildHTTPClient() + require.NoError(t, err) + + if _, err = getRequestResults(t, httpClientTLS12, serverURL+"/health"); err != nil { + t.Fatalf("Failed: could not connect with TLS 1.2: %v", err) + } + + httpConfigTLS11 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS10, + MaxVersion: tls.VersionTLS11, + }, + } + httpClientTLS11, err := httpConfigTLS11.buildHTTPClient() + require.NoError(t, err) + + if _, err = getRequestResults(t, httpClientTLS11, serverURL+"/health"); err == nil { + t.Fatalf("Failed: should not have been able to connect with TLS 1.1") + } +} + +func TestTLSConfigWithDefaults(t *testing.T) { + testPort, err := getFreePort(testHostname) + require.NoError(t, err) + t.Logf("Will use free port [%v] on host [%v] for tests", testPort, testHostname) + + tmpDir, err := os.MkdirTemp("", "server-test-tls-defaults") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + testServerCertFile := tmpDir + "/server-defaults.cert" + testServerKeyFile := tmpDir + "/server-defaults.key" + testServerHostPort := fmt.Sprintf("%v:%v", testHostname, testPort) + err = generateCertificate(t, testServerCertFile, testServerKeyFile, testServerHostPort) + require.NoError(t, err) + + testClientCertFile := tmpDir + "/client-defaults.cert" + testClientKeyFile := tmpDir + "/client-defaults.key" + err = generateCertificate(t, testClientCertFile, testClientKeyFile, testHostname) + require.NoError(t, err) + + conf := &Config{ + CertFile: testServerCertFile, + PrivateKeyFile: testServerKeyFile, + Port: testPort, + // No TLS settings - should use Go defaults + } + + serverURL := fmt.Sprintf("https://%s", testServerHostPort) + + tmpDirAssets := prepareServerAssets(t) + defer os.RemoveAll(tmpDirAssets) + + _, cleanup := startTestServer(t, conf) + defer cleanup() + t.Logf("Started test https server with default TLS config: %v", serverURL) + + httpConfigTLS12 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + }, + } + httpClientTLS12, err := httpConfigTLS12.buildHTTPClient() + require.NoError(t, err) + + checkHTTPReady(httpClientTLS12, serverURL+"/health") + + if _, err = getRequestResults(t, httpClientTLS12, serverURL+"/health"); err != nil { + t.Fatalf("Failed: could not connect with TLS 1.2: %v", err) + } + + httpConfigTLS13 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + }, + } + httpClientTLS13, err := httpConfigTLS13.buildHTTPClient() + require.NoError(t, err) + + if _, err = getRequestResults(t, httpClientTLS13, serverURL+"/health"); err != nil { + t.Fatalf("Failed: could not connect with TLS 1.3: %v", err) + } + + httpConfigTLS11 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MaxVersion: tls.VersionTLS11, + }, + } + httpClientTLS11, err := httpConfigTLS11.buildHTTPClient() + require.NoError(t, err) + + if _, err = getRequestResults(t, httpClientTLS11, serverURL+"/health"); err == nil { + t.Fatalf("Failed: should not have been able to connect with TLS 1.1") + } +} + +func TestTLSConfigMinVersionOnly(t *testing.T) { + testPort, err := getFreePort(testHostname) + require.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "server-test-tls-minonly") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + testServerCertFile := tmpDir + "/server-minonly.cert" + testServerKeyFile := tmpDir + "/server-minonly.key" + testServerHostPort := fmt.Sprintf("%v:%v", testHostname, testPort) + err = generateCertificate(t, testServerCertFile, testServerKeyFile, testServerHostPort) + require.NoError(t, err) + + testClientCertFile := tmpDir + "/client-minonly.cert" + testClientKeyFile := tmpDir + "/client-minonly.key" + err = generateCertificate(t, testClientCertFile, testClientKeyFile, testHostname) + require.NoError(t, err) + + conf := &Config{ + CertFile: testServerCertFile, + PrivateKeyFile: testServerKeyFile, + Port: testPort, + TLSMinVersion: tls.VersionTLS13, + // No MaxVersion - should allow TLS 1.3 + } + + serverURL := fmt.Sprintf("https://%s", testServerHostPort) + + tmpDirAssets := prepareServerAssets(t) + defer os.RemoveAll(tmpDirAssets) + + _, cleanup := startTestServer(t, conf) + defer cleanup() + t.Logf("Started test https server with TLS 1.3 minimum: %v", serverURL) + + httpConfigTLS13 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + }, + } + httpClientTLS13, err := httpConfigTLS13.buildHTTPClient() + require.NoError(t, err) + + checkHTTPReady(httpClientTLS13, serverURL+"/health") + + if _, err = getRequestResults(t, httpClientTLS13, serverURL+"/health"); err != nil { + t.Fatalf("Failed: could not connect with TLS 1.3: %v", err) + } + + httpConfigTLS12 := httpClientConfig{ + CertFile: testClientCertFile, + PrivateKeyFile: testClientKeyFile, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS12, + }, + } + httpClientTLS12, err := httpConfigTLS12.buildHTTPClient() + require.NoError(t, err) + + if _, err = getRequestResults(t, httpClientTLS12, serverURL+"/health"); err == nil { + t.Fatalf("Failed: should not have been able to connect with TLS 1.2 when min is TLS 1.3") + } +} + func TestFilesHandler(t *testing.T) { fs := http.Dir("testdata") handler := filesHandler(fs)