From f32a4989025d409b9a7fb598fd76f768f5b4460a Mon Sep 17 00:00:00 2001 From: peppi-lotta Date: Mon, 30 Jun 2025 12:14:42 +0000 Subject: [PATCH 1/3] Add Config to FlagConfig struct This allows the Serve-function to be called with out having a config file. This makes the function more easilly callable from other projects. This way configuration is not limited to an existing yaml file but can be specified in the project that is using this package. Signed-off-by: peppi-lotta --- web/handler.go | 18 ++- web/tls_config.go | 77 ++++++++----- web/tls_config_test.go | 255 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 318 insertions(+), 32 deletions(-) diff --git a/web/handler.go b/web/handler.go index 0a2718d5..a1f32a21 100644 --- a/web/handler.go +++ b/web/handler.go @@ -78,6 +78,7 @@ HeadersLoop: type webHandler struct { tlsConfigPath string + config *Config handler http.Handler logger *slog.Logger cache *cache @@ -88,11 +89,18 @@ type webHandler struct { } func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - c, err := getConfig(u.tlsConfigPath) - if err != nil { - u.logger.Error("Unable to parse configuration", "err", err.Error()) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return + var c *Config + var err error + + if u.config == nil { + c, err = getConfig(u.tlsConfigPath) + if err != nil { + u.logger.Error("Unable to parse configuration", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } else { + c = u.config } if u.limiter != nil && !u.limiter.Allow() { diff --git a/web/tls_config.go b/web/tls_config.go index cf8b5718..1a3a92ad 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -75,10 +75,9 @@ type TLSConfig struct { type FlagConfig struct { // WebListenAddresses contains the listen addresses for the HTTP server. WebListenAddresses *[]string - // WebSystemdSocket enables systemd socket activation listeners. - WebSystemdSocket *bool - // WebConfigFile points to the TLS and authentication configuration file. - WebConfigFile *string + WebSystemdSocket *bool + WebConfigFile *string // Optional: path to the TLS config file. Ether this or TLSConfig must be set. + WebConfig *Config // Optional: Configuration. If set, it overrides WebConfigFile. } // checkFlags validates that the flag configuration contains the required @@ -393,14 +392,28 @@ func parseVsockPort(address string) (uint32, error) { // WebConfigFile in the FlagConfig, TLS or basic auth could be enabled. func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog.Logger) error { logger.Info("Listening on", "address", l.Addr().String()) - tlsConfigPath := *flags.WebConfigFile - if tlsConfigPath == "" { - logger.Info("TLS is disabled.", "http2", false, "address", l.Addr().String()) - return server.Serve(l) - } + var c *Config + var err error + + // WebConfig overrides WebConfigFile. If WebConfig field is not set, then WebConfigFile is used. + if flags.WebConfig == nil { + tlsConfigPath := *flags.WebConfigFile + if tlsConfigPath == "" { + logger.Info("TLS is disabled.", "http2", false, "address", l.Addr().String()) + return server.Serve(l) + } - if err := validateUsers(tlsConfigPath); err != nil { - return err + if err := validateUsers(tlsConfigPath); err != nil { + return err + } + + c, err = getConfig(tlsConfigPath) + if err != nil { + return err + } + } else { + // Use the provided config. + c = flags.WebConfig } // Setup basic authentication. @@ -409,11 +422,6 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. handler = server.Handler } - c, err := getConfig(tlsConfigPath) - if err != nil { - return err - } - var limiter *rate.Limiter if c.RateLimiterConfig.Interval != 0 { limiter = rate.NewLimiter(rate.Every(c.RateLimiterConfig.Interval), c.RateLimiterConfig.Burst) @@ -421,11 +429,11 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. } server.Handler = &webHandler{ - tlsConfigPath: tlsConfigPath, - logger: logger, - handler: handler, - cache: newCache(), - limiter: limiter, + config: c, + logger: logger, + handler: handler, + cache: newCache(), + limiter: limiter, } config, err := ConfigToTLSConfig(&c.TLSConfig) @@ -450,12 +458,29 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. // Set the GetConfigForClient method of the HTTPS server so that the config // and certs are reloaded on new connections. server.TLSConfig.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { - config, err := getTLSConfig(tlsConfigPath) - if err != nil { - return nil, err + var tlsConfig *tls.Config + var err error + // Config overrides WebConfigFile. If Config fiels is not set, then WebConfigFile is used. + if flags.WebConfig == nil { + tlsConfigPath := *flags.WebConfigFile + + if err := validateUsers(tlsConfigPath); err != nil { + return nil, err + } + + tlsConfig, err = getTLSConfig(tlsConfigPath) + if err != nil { + return nil, err + } + } else { + // Use the provided config. + tlsConfig, err = ConfigToTLSConfig(&flags.WebConfig.TLSConfig) + if err != nil { + return nil, err + } } - config.NextProtos = tlsNextProtos(server, c.HTTPConfig.HTTP2) - return config, nil + tlsConfig.NextProtos = tlsNextProtos(server, c.HTTPConfig.HTTP2) + return tlsConfig, nil } return server.ServeTLS(l, "", "") } diff --git a/web/tls_config_test.go b/web/tls_config_test.go index b8b3a966..2975dea2 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -87,6 +87,7 @@ type TestInputs struct { Name string Server func() *http.Server YAMLConfigPath string + WebConfig bool ExpectedError *regexp.Regexp UseTLSClient bool ClientMaxTLSVersion uint16 @@ -205,6 +206,10 @@ func TestServerBehaviour(t *testing.T) { YAMLConfigPath: "", ExpectedError: nil, }, + { + Name: `default client`, + ExpectedError: nil, + }, { Name: `empty string YAMLConfigPath and TLS client`, YAMLConfigPath: "", @@ -216,18 +221,38 @@ func TestServerBehaviour(t *testing.T) { YAMLConfigPath: "testdata/web_config_noAuth.good.yml", ExpectedError: ErrorMap["HTTP Request to HTTPS server"], }, + { + Name: `WebConfig: valid tls config and default client`, + YAMLConfigPath: "testdata/web_config_noAuth.good.yml", + WebConfig: true, + ExpectedError: ErrorMap["HTTP Request to HTTPS server"], + }, { Name: `valid tls config yml and tls client`, YAMLConfigPath: "testdata/web_config_noAuth.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client`, + YAMLConfigPath: "testdata/web_config_noAuth.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml (cert and key inline) and tls client`, YAMLConfigPath: "testdata/web_config_noAuth_tlsInline.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config (cert and key inline) and tls client`, + YAMLConfigPath: "testdata/web_config_noAuth_tlsInline.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with TLS 1.1 client`, YAMLConfigPath: "testdata/web_config_noAuth.good.yml", @@ -235,12 +260,27 @@ func TestServerBehaviour(t *testing.T) { ClientMaxTLSVersion: tls.VersionTLS11, ExpectedError: ErrorMap["Incompatible TLS version"], }, + { + Name: `WebConfig: valid tls config with TLS 1.1 client`, + YAMLConfigPath: "testdata/web_config_noAuth.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientMaxTLSVersion: tls.VersionTLS11, + ExpectedError: ErrorMap["Incompatible TLS version"], + }, { Name: `valid tls config yml with all ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_allCiphers.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with all ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_allCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with some ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", @@ -248,6 +288,14 @@ func TestServerBehaviour(t *testing.T) { CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with some ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}, + ExpectedError: nil, + }, { Name: `valid tls config yml with no common cipher`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", @@ -255,6 +303,14 @@ func TestServerBehaviour(t *testing.T) { CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA}, ExpectedError: ErrorMap["Handshake failure"], }, + { + Name: `WebConfig: valid tls config with no common cipher`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA}, + ExpectedError: ErrorMap["Handshake failure"], + }, { Name: `valid tls config yml with multiple client ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", @@ -266,6 +322,18 @@ func TestServerBehaviour(t *testing.T) { ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with multiple client ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + ExpectedError: nil, + }, { Name: `valid tls config yml with multiple client ciphers, client chooses cipher`, YAMLConfigPath: "testdata/web_config_noAuth_someCiphers_noOrder.good.yml", @@ -277,12 +345,31 @@ func TestServerBehaviour(t *testing.T) { ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with multiple client ciphers, client chooses cipher`, + YAMLConfigPath: "testdata/web_config_noAuth_someCiphers_noOrder.good.yml", + WebConfig: true, + UseTLSClient: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + }, + ActualCipher: tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + ExpectedError: nil, + }, { Name: `valid tls config yml with all curves`, YAMLConfigPath: "testdata/web_config_noAuth_allCurves.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with all curves`, + YAMLConfigPath: "testdata/web_config_noAuth_allCurves.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with some curves`, YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", @@ -290,6 +377,14 @@ func TestServerBehaviour(t *testing.T) { CurvePreferences: []tls.CurveID{tls.CurveP521}, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with some curves`, + YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", + WebConfig: true, + UseTLSClient: true, + CurvePreferences: []tls.CurveID{tls.CurveP521}, + ExpectedError: nil, + }, { Name: `valid tls config yml with no common curves`, YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", @@ -297,37 +392,82 @@ func TestServerBehaviour(t *testing.T) { CurvePreferences: []tls.CurveID{tls.CurveP384}, ExpectedError: ErrorMap["Handshake failure"], }, + { + Name: `WebConfig: valid tls config with no common curves`, + YAMLConfigPath: "testdata/web_config_noAuth_someCurves.good.yml", + WebConfig: true, + UseTLSClient: true, + CurvePreferences: []tls.CurveID{tls.CurveP384}, + ExpectedError: ErrorMap["Handshake failure"], + }, { Name: `valid tls config yml with non-http2 ciphers`, YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2.good.yml", UseTLSClient: true, ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config with non-http2 ciphers`, + YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: nil, + }, { Name: `valid tls config yml with non-http2 ciphers but http2 enabled`, YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2Cipher.bad.yml", UseTLSClient: true, ExpectedError: ErrorMap["No HTTP2 cipher"], }, + { + Name: `WebConfig: valid tls config with non-http2 ciphers but http2 enabled`, + YAMLConfigPath: "testdata/web_config_noAuth_noHTTP2Cipher.bad.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: ErrorMap["No HTTP2 cipher"], + }, { Name: `valid headers config`, YAMLConfigPath: "testdata/web_config_headers.good.yml", }, + { + Name: `WebConfig: valid headers config`, + YAMLConfigPath: "testdata/web_config_headers.good.yml", + WebConfig: true, + }, { Name: `invalid X-Content-Type-Options headers config`, YAMLConfigPath: "testdata/web_config_headers_content_type_options.bad.yml", ExpectedError: ErrorMap["Invalid value"], }, + { + Name: `WebConfig: invalid X-Content-Type-Options headers config`, + YAMLConfigPath: "testdata/web_config_headers_content_type_options.bad.yml", + WebConfig: true, + ExpectedError: ErrorMap["Invalid value"], + }, { Name: `invalid X-Frame-Options headers config`, YAMLConfigPath: "testdata/web_config_headers_frame_options.bad.yml", ExpectedError: ErrorMap["Invalid value"], }, + { + Name: `WebConfig: invalid X-Frame-Options headers config`, + YAMLConfigPath: "testdata/web_config_headers_frame_options.bad.yml", + WebConfig: true, + ExpectedError: ErrorMap["Invalid value"], + }, { Name: `HTTP header that can not be overridden`, YAMLConfigPath: "testdata/web_config_headers_extra_header.bad.yml", ExpectedError: ErrorMap["Invalid header"], }, + { + Name: `WebConfig: HTTP header that can not be overridden`, + YAMLConfigPath: "testdata/web_config_headers_extra_header.bad.yml", + WebConfig: true, + ExpectedError: ErrorMap["Invalid header"], + }, { Name: `valid tls config yml and tls client with RequireAnyClientCert (present certificate)`, YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", @@ -335,6 +475,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client with RequireAnyClientCert (present certificate)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml (cert from file, key inline) and tls client with RequireAnyClientCert (present certificate)`, YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", @@ -342,6 +490,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config (cert from file, key inline) and tls client with RequireAnyClientCert (present certificate)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireanyclientcert.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml and tls client with RequireAndVerifyClientCert (present certificate)`, YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.good.yml", @@ -349,6 +505,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client with RequireAndVerifyClientCert (present certificate)`, + YAMLConfigPath: "testdata/tls_config_noAuth.requireandverifyclientcert.good.yml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml and tls client with VerifyPeerCertificate (present good SAN DNS entry)`, YAMLConfigPath: "testdata/web_config_auth_client_san.good.yaml", @@ -356,6 +520,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client2_selfsigned", ExpectedError: nil, }, + { + Name: `WebConfig: valid tls config and tls client with VerifyPeerCertificate (present good SAN DNS entry)`, + YAMLConfigPath: "testdata/web_config_auth_client_san.good.yaml", + WebConfig: true, + UseTLSClient: true, + ClientCertificate: "client2_selfsigned", + ExpectedError: nil, + }, { Name: `valid tls config yml and tls client with VerifyPeerCertificate (present invalid SAN DNS entries)`, YAMLConfigPath: "testdata/web_config_auth_client_san.bad.yaml", @@ -363,6 +535,14 @@ func TestServerBehaviour(t *testing.T) { ClientCertificate: "client2_selfsigned", ExpectedError: ErrorMap["Invalid client cert"], }, + { + Name: "valid rate limiter (no rate limiter set up) that doesn't block", + YAMLConfigPath: "testdata/web_config_rate_limiter_nonblocking.yaml", + WebConfig: true, + UseTLSClient: false, + Requests: 10, + ExpectedError: nil, + }, { Name: "valid rate limiter (no rate limiter set up) that doesn't block", YAMLConfigPath: "testdata/web_config_rate_limiter_nonblocking.yaml", @@ -370,6 +550,14 @@ func TestServerBehaviour(t *testing.T) { Requests: 10, ExpectedError: nil, }, + { + Name: "valid rate limiter with an interval of one second", + YAMLConfigPath: "testdata/web_config_rate_limiter_one_second.yaml", + WebConfig: true, + UseTLSClient: false, + Requests: 10, + ExpectedError: ErrorMap["Too Many Requests"], + }, { Name: "valid rate limiter with an interval of one second", YAMLConfigPath: "testdata/web_config_rate_limiter_one_second.yaml", @@ -493,7 +681,15 @@ func (test *TestInputs) Test(t *testing.T) { flags := FlagConfig{ WebListenAddresses: &([]string{port}), WebSystemdSocket: OfBool(false), - WebConfigFile: &test.YAMLConfigPath, + } + if test.WebConfig { + webConfig, err := getConfig(test.YAMLConfigPath) + if err != nil { + recordConnectionError(fmt.Errorf("Could not parse configuration: %v", err)) + } + flags.WebConfig = webConfig + } else { + flags.WebConfigFile = &test.YAMLConfigPath } err := ListenAndServe(server, &flags, testlogger) recordConnectionError(err) @@ -662,6 +858,12 @@ func TestUsers(t *testing.T) { YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: without basic auth`, + YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", + WebConfig: true, + ExpectedError: ErrorMap["Unauthorized"], + }, { Name: `with correct basic auth`, YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", @@ -669,12 +871,27 @@ func TestUsers(t *testing.T) { Password: "dave123", ExpectedError: nil, }, + { + Name: `WebConfig: with correct basic auth`, + YAMLConfigPath: "testdata/web_config_users_noTLS.good.yml", + WebConfig: true, + Username: "dave", + Password: "dave123", + ExpectedError: nil, + }, { Name: `without basic auth and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", UseTLSClient: true, ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: without basic auth and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + ExpectedError: ErrorMap["Unauthorized"], + }, { Name: `with correct basic auth and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -683,6 +900,15 @@ func TestUsers(t *testing.T) { Password: "dave123", ExpectedError: nil, }, + { + Name: `WebConfig: with correct basic auth and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "dave", + Password: "dave123", + ExpectedError: nil, + }, { Name: `with another correct basic auth and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -691,6 +917,15 @@ func TestUsers(t *testing.T) { Password: "carol123", ExpectedError: nil, }, + { + Name: `WebConfig: with another correct basic auth and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "carol", + Password: "carol123", + ExpectedError: nil, + }, { Name: `with bad password and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -699,6 +934,15 @@ func TestUsers(t *testing.T) { Password: "bad", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: with bad password and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "dave", + Password: "bad", + ExpectedError: ErrorMap["Unauthorized"], + }, { Name: `with bad username and TLS`, YAMLConfigPath: "testdata/web_config_users.good.yml", @@ -707,6 +951,15 @@ func TestUsers(t *testing.T) { Password: "nonexistent", ExpectedError: ErrorMap["Unauthorized"], }, + { + Name: `WebConfig: with bad username and TLS`, + YAMLConfigPath: "testdata/web_config_users.good.yml", + WebConfig: true, + UseTLSClient: true, + Username: "nonexistent", + Password: "nonexistent", + ExpectedError: ErrorMap["Unauthorized"], + }, } for _, testInputs := range testTables { t.Run(testInputs.Name, testInputs.Test) From 74c1102a82a49ae6f2b959a57e08724323d0b738 Mon Sep 17 00:00:00 2001 From: peppi-lotta Date: Fri, 18 Jul 2025 13:54:08 +0000 Subject: [PATCH 2/3] Add validation for WebConfig Signed-off-by: peppi-lotta --- web/handler.go | 16 ++++++++------- web/tls_config.go | 45 +++++++++++++++++++++++++++++++----------- web/tls_config_test.go | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/web/handler.go b/web/handler.go index a1f32a21..e35eda41 100644 --- a/web/handler.go +++ b/web/handler.go @@ -38,14 +38,9 @@ var extraHTTPHeaders = map[string][]string{ "Content-Security-Policy": nil, } -func validateUsers(configPath string) error { - c, err := getConfig(configPath) - if err != nil { - return err - } - +func validateUsers(c *Config) error { for _, p := range c.Users { - _, err = bcrypt.Cost([]byte(p)) + _, err := bcrypt.Cost([]byte(p)) if err != nil { return err } @@ -108,6 +103,13 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + err = ValidateWebConfig(c) + if err != nil { + u.logger.Error("Invalid web configuration", "err", err.Error()) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + // Configure http headers. for k, v := range c.HTTPConfig.Header { w.Header().Set(k, v) diff --git a/web/tls_config.go b/web/tls_config.go index 1a3a92ad..aac8c3bc 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -185,6 +185,11 @@ func getTLSConfig(configPath string) (*tls.Config, error) { if err != nil { return nil, err } + + if err := validateUsers(c); err != nil { + return nil, err + } + return ConfigToTLSConfig(&c.TLSConfig) } @@ -403,10 +408,6 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. return server.Serve(l) } - if err := validateUsers(tlsConfigPath); err != nil { - return err - } - c, err = getConfig(tlsConfigPath) if err != nil { return err @@ -416,6 +417,11 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. c = flags.WebConfig } + err = ValidateWebConfig(c) + if err != nil { + return err + } + // Setup basic authentication. var handler http.Handler = http.DefaultServeMux if server.Handler != nil { @@ -464,15 +470,15 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. if flags.WebConfig == nil { tlsConfigPath := *flags.WebConfigFile - if err := validateUsers(tlsConfigPath); err != nil { - return nil, err - } - tlsConfig, err = getTLSConfig(tlsConfigPath) if err != nil { return nil, err } } else { + err = ValidateWebConfig(flags.WebConfig) + if err != nil { + return nil, err + } // Use the provided config. tlsConfig, err = ConfigToTLSConfig(&flags.WebConfig.TLSConfig) if err != nil { @@ -518,13 +524,13 @@ func Validate(tlsConfigPath string) error { if tlsConfigPath == "" { return nil } - if err := validateUsers(tlsConfigPath); err != nil { - return err - } c, err := getConfig(tlsConfigPath) if err != nil { return err } + if err := validateUsers(c); err != nil { + return err + } _, err = ConfigToTLSConfig(&c.TLSConfig) if err == errNoTLSConfig { return nil @@ -532,6 +538,23 @@ func Validate(tlsConfigPath string) error { return err } +// ValidateWebConfig validates the web configuration, including the TLS config and HTTP headers. +func ValidateWebConfig(config *Config) error { + if config == nil { + return nil + } + if err := validateUsers(config); err != nil { + return err + } + if err := validateHeaderConfig(config.HTTPConfig.Header); err != nil { + return err + } + if _, err := ConfigToTLSConfig(&config.TLSConfig); err != nil && err != errNoTLSConfig { + return err + } + return nil +} + type Cipher uint16 func (c *Cipher) UnmarshalYAML(unmarshal func(any) error) error { diff --git a/web/tls_config_test.go b/web/tls_config_test.go index 2975dea2..88ab95ae 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -27,6 +27,8 @@ import ( "sync" "testing" "time" + + "gopkg.in/yaml.v2" ) // Helpers for literal FlagConfig @@ -106,6 +108,7 @@ func TestYAMLFiles(t *testing.T) { Name: `path to config yml invalid`, YAMLConfigPath: "somefile", ExpectedError: ErrorMap["No such file"], + WebConfig: false, }, { Name: `empty config yml`, @@ -196,6 +199,9 @@ func TestYAMLFiles(t *testing.T) { for _, testInputs := range testTables { t.Run("run/"+testInputs.Name, testInputs.Test) t.Run("validate/"+testInputs.Name, testInputs.TestValidate) + if testInputs.WebConfig { + t.Run("validateWebConfig/"+testInputs.Name, testInputs.TestValidateWebConfig) + } } } @@ -787,6 +793,39 @@ func (test *TestInputs) TestValidate(t *testing.T) { } } +func (test *TestInputs) TestValidateWebConfig(t *testing.T) { + content, err := os.ReadFile(test.YAMLConfigPath) + if err != nil { + t.Fatalf("Could not read configuration file: %v", err) + } + c := &Config{ + TLSConfig: TLSConfig{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + PreferServerCipherSuites: true, + }, + HTTPConfig: HTTPConfig{HTTP2: true}, + } + err = yaml.UnmarshalStrict(content, c) + if err != nil { + t.Fatalf("Could not parse configuration: %v", err) + } + validationErr := ValidateWebConfig(c) + if test.ExpectedError == nil { + if validationErr != nil { + t.Errorf("Expected no error, got error: %v", validationErr) + } + return + } + if validationErr == nil { + t.Errorf("Got no error, expected: %v", test.ExpectedError) + return + } + if !test.ExpectedError.MatchString(validationErr.Error()) { + t.Errorf("Expected error %v, got error: %v", test.ExpectedError, validationErr) + } +} + func (test *TestInputs) isCorrectError(returnedError error) bool { switch { case returnedError == nil && test.ExpectedError == nil: From c1617599cd39a661c116f14862ffdc62b03ae403 Mon Sep 17 00:00:00 2001 From: peppi-lotta Date: Mon, 14 Sep 2026 14:22:12 +0300 Subject: [PATCH 3/3] Add WebConfig defaults and add test to check they are retained Signed-off-by: peppi-lotta --- web/flags_test.go | 19 +++++ web/handler.go | 7 -- web/tls_config.go | 81 +++++++++++++-------- web/tls_config_test.go | 161 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 230 insertions(+), 38 deletions(-) diff --git a/web/flags_test.go b/web/flags_test.go index e72c6d65..34acad0f 100644 --- a/web/flags_test.go +++ b/web/flags_test.go @@ -80,6 +80,25 @@ func TestCheckFlags(t *testing.T) { }, want: ErrNoListeners, }, + { + name: "both web config file and injected web config set", + flags: &FlagConfig{ + WebListenAddresses: &[]string{":9100"}, + WebSystemdSocket: OfBool(false), + WebConfigFile: OfString("testdata/web_config_noAuth.good.yml"), + WebConfig: &Config{}, + }, + want: ErrConflictingFlagsInConfigs, + }, + { + name: "only injected web config set", + flags: &FlagConfig{ + WebListenAddresses: &[]string{":9100"}, + WebSystemdSocket: OfBool(false), + WebConfig: &Config{}, + }, + want: nil, + }, } { t.Run(tc.name, func(t *testing.T) { if err := tc.flags.checkFlags(); !errors.Is(err, tc.want) { diff --git a/web/handler.go b/web/handler.go index e35eda41..3be436ea 100644 --- a/web/handler.go +++ b/web/handler.go @@ -103,13 +103,6 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - err = ValidateWebConfig(c) - if err != nil { - u.logger.Error("Invalid web configuration", "err", err.Error()) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - // Configure http headers. for k, v := range c.HTTPConfig.Header { w.Header().Set(k, v) diff --git a/web/tls_config.go b/web/tls_config.go index aac8c3bc..17f0f03f 100644 --- a/web/tls_config.go +++ b/web/tls_config.go @@ -38,9 +38,10 @@ import ( ) var ( - errNoTLSConfig = errors.New("TLS config is not present") - ErrMissingFlag = errors.New("missing required flag configuration") - ErrNoListeners = errors.New("no web listen address or systemd socket flag specified") + errNoTLSConfig = errors.New("TLS config is not present") + ErrMissingFlag = errors.New("missing required flag configuration") + ErrNoListeners = errors.New("no web listen address or systemd socket flag specified") + ErrConflictingFlagsInConfigs = errors.New("conflicting flag configuration in TLS config. Both a config file path and an injected config cannot be provided at the same time.") ) type Config struct { @@ -75,9 +76,21 @@ type TLSConfig struct { type FlagConfig struct { // WebListenAddresses contains the listen addresses for the HTTP server. WebListenAddresses *[]string - WebSystemdSocket *bool - WebConfigFile *string // Optional: path to the TLS config file. Ether this or TLSConfig must be set. - WebConfig *Config // Optional: Configuration. If set, it overrides WebConfigFile. + // WebSystemdSocket enables systemd socket activation listeners. + WebSystemdSocket *bool + // WebConfigFile is the optional path to the TLS config file. Either this or + // WebConfig must be set. + // + // TLS MinVersion and MaxVersion default to TLS 1.2 and TLS 1.3 when unset. + // HTTP2 is enabled if the HTTPConfig.HTTP2 field is left unset. + WebConfigFile *string + // WebConfig is an optional configuration. Either this or WebConfigFile must be set. + // + // TLS MinVersion and MaxVersion default to TLS 1.2 and TLS 1.3 when unset. + // Other fields are used as provided (in particular HTTP/2 is only enabled + // when HTTPConfig.HTTP2 is set to true, unlike WebConfigFile which enables + // HTTP/2 automatically if left unset). + WebConfig *Config } // checkFlags validates that the flag configuration contains the required @@ -86,9 +99,14 @@ func (c *FlagConfig) checkFlags() error { if c == nil { return ErrMissingFlag } - if c.WebConfigFile == nil { + // Either a config file path or an injected config must be provided. + if c.WebConfigFile == nil && c.WebConfig == nil { return ErrMissingFlag } + // Both a config file path and an injected config cannot be provided at the same time. + if c.WebConfigFile != nil && c.WebConfig != nil { + return ErrConflictingFlagsInConfigs + } // Listen addresses are only optional when systemd socket activation is // actually enabled. Checking that WebSystemdSocket is non-nil is not // enough: kingpinflag.AddFlags always hands out a non-nil pointer, so a @@ -166,9 +184,8 @@ func getConfig(configPath string) (*Config, error) { } c := &Config{ TLSConfig: TLSConfig{ - MinVersion: tls.VersionTLS12, - MaxVersion: tls.VersionTLS13, - PreferServerCipherSuites: true, + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, }, HTTPConfig: HTTPConfig{HTTP2: true}, } @@ -186,7 +203,7 @@ func getTLSConfig(configPath string) (*tls.Config, error) { return nil, err } - if err := validateUsers(c); err != nil { + if err := ValidateWebConfig(c); err != nil { return nil, err } @@ -249,11 +266,21 @@ func ConfigToTLSConfig(c *TLSConfig) (*tls.Config, error) { return nil, err } - // c.PreferServerCipherSuites is deliberately not passed on: the tls.Config - // field of that name has had no effect since Go 1.17. + // Default the TLS versions without mutating the caller's config. An unset + // (zero) MinVersion/MaxVersion means "use the default" rather than crypto/tls' + // own zero-value defaults, so resolve them here into the derived tls.Config. + minVersion := c.MinVersion + if minVersion == 0 { + minVersion = tls.VersionTLS12 + } + maxVersion := c.MaxVersion + if maxVersion == 0 { + maxVersion = tls.VersionTLS13 + } + cfg := &tls.Config{ - MinVersion: (uint16)(c.MinVersion), - MaxVersion: (uint16)(c.MaxVersion), + MinVersion: (uint16)(minVersion), + MaxVersion: (uint16)(maxVersion), } cfg.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { @@ -400,8 +427,9 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. var c *Config var err error - // WebConfig overrides WebConfigFile. If WebConfig field is not set, then WebConfigFile is used. - if flags.WebConfig == nil { + // Determine which configuration to use based on the provided flags. + // checkConfigFlags has ensured exactly one of WebConfigFile / WebConfig is set. + if flags.WebConfigFile != nil { tlsConfigPath := *flags.WebConfigFile if tlsConfigPath == "" { logger.Info("TLS is disabled.", "http2", false, "address", l.Addr().String()) @@ -413,12 +441,10 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. return err } } else { - // Use the provided config. c = flags.WebConfig } - err = ValidateWebConfig(c) - if err != nil { + if err := ValidateWebConfig(c); err != nil { return err } @@ -466,8 +492,10 @@ func Serve(l net.Listener, server *http.Server, flags *FlagConfig, logger *slog. server.TLSConfig.GetConfigForClient = func(*tls.ClientHelloInfo) (*tls.Config, error) { var tlsConfig *tls.Config var err error - // Config overrides WebConfigFile. If Config fiels is not set, then WebConfigFile is used. - if flags.WebConfig == nil { + // Determine which configuration to use based on the provided flags. + // checkFlags has ensured exactly one of + // WebConfigFile / WebConfig is set. + if flags.WebConfigFile != nil { tlsConfigPath := *flags.WebConfigFile tlsConfig, err = getTLSConfig(tlsConfigPath) @@ -528,14 +556,7 @@ func Validate(tlsConfigPath string) error { if err != nil { return err } - if err := validateUsers(c); err != nil { - return err - } - _, err = ConfigToTLSConfig(&c.TLSConfig) - if err == errNoTLSConfig { - return nil - } - return err + return ValidateWebConfig(c) } // ValidateWebConfig validates the web configuration, including the TLS config and HTTP headers. diff --git a/web/tls_config_test.go b/web/tls_config_test.go index 88ab95ae..0503b4ed 100644 --- a/web/tls_config_test.go +++ b/web/tls_config_test.go @@ -28,7 +28,7 @@ import ( "testing" "time" - "gopkg.in/yaml.v2" + "go.yaml.in/yaml/v2" ) // Helpers for literal FlagConfig @@ -658,6 +658,105 @@ func TestConfigReloading(t *testing.T) { } } +// TestWebConfigReloading mirrors TestConfigReloading but for an injected +// FlagConfig.WebConfig instead of a WebConfigFile. There is no file to swap, so +// the "reload" is an in-place mutation of flags.WebConfig: Serve's +// GetConfigForClient callback re-reads it on every new connection. The test +// starts with a blocking client-auth policy (RequireAndVerifyClientCert), which +// rejects a client that sends no certificate, then relaxes it to +// VerifyClientCertIfGiven and confirms a new connection is accepted without a +// restart. +func TestWebConfigReloading(t *testing.T) { + errorChannel := make(chan error, 1) + var once sync.Once + recordConnectionError := func(err error) { + once.Do(func() { + errorChannel <- err + }) + } + defer func() { + if recover() != nil { + recordConnectionError(errors.New("Panic in test function")) + } + }() + + goodYAMLPath := "testdata/web_config_noAuth.good.yml" + webConfigGood, err := getConfig(goodYAMLPath) + if err != nil { + t.Fatal(err) + } + badYAMLPath := "testdata/web_config_noAuth.good.blocking.yml" + webConfigBad, err := getConfig(badYAMLPath) + if err != nil { + t.Fatal(err) + } + + localPort := getPort() + + server := &http.Server{ + Addr: localPort, + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("Hello World!")) + }), + } + defer func() { + server.Close() + }() + + flags := FlagConfig{ + WebListenAddresses: &([]string{localPort}), + WebSystemdSocket: OfBool(false), + WebConfig: webConfigBad, + } + + go func() { + defer func() { + if recover() != nil { + recordConnectionError(errors.New("Panic starting server")) + } + }() + err := Listen(server, &flags, testlogger) + recordConnectionError(err) + }() + + client := getTLSClient("") + + TestClientConnection := func() error { + time.Sleep(250 * time.Millisecond) + r, err := client.Get("https://localhost" + localPort) + if err != nil { + return err + } + body, err := io.ReadAll(r.Body) + if err != nil { + return err + } + if string(body) != "Hello World!" { + return errors.New(string(body)) + } + return nil + } + + err = TestClientConnection() + if err == nil { + recordConnectionError(errors.New("connection accepted but should have failed")) + } else { + flags.WebConfig = webConfigGood + err = TestClientConnection() + if err != nil { + recordConnectionError(errors.New("connection failed but should have been accepted")) + } else { + + recordConnectionError(nil) + } + } + + err = <-errorChannel + if err != nil { + t.Errorf(" *** Failed test: %s *** Returned error: %v", "TestConfigReloading", err) + } +} + func (test *TestInputs) Test(t *testing.T) { errorChannel := make(chan error, 1) var once sync.Once @@ -1049,3 +1148,63 @@ func TestTLSConfigIsEnabled(t *testing.T) { }) } } + +// TestConfigToTLSConfigVersionDefaults verifies that ConfigToTLSConfig resolves +// unset TLS MinVersion/MaxVersion to the defaults (TLS 1.2 / TLS 1.3) in the +// derived tls.Config, that explicitly set versions are preserved, and that the +// provided TLSConfig is never mutated. +func TestConfigToTLSConfigVersionDefaults(t *testing.T) { + for _, tc := range []struct { + name string + provided TLSConfig + expectedMinVersion uint16 + expectedMaxVersion uint16 + }{ + { + name: "unset versions get defaulted", + provided: TLSConfig{ + TLSCertPath: "testdata/server.crt", + TLSKeyPath: "testdata/server.key", + }, + expectedMinVersion: tls.VersionTLS12, + expectedMaxVersion: tls.VersionTLS13, + }, + { + name: "explicit versions are preserved", + provided: TLSConfig{ + TLSCertPath: "testdata/server.crt", + TLSKeyPath: "testdata/server.key", + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + }, + expectedMinVersion: tls.VersionTLS13, + expectedMaxVersion: tls.VersionTLS13, + }, + } { + t.Run(tc.name, func(t *testing.T) { + provided := tc.provided + originalMin := provided.MinVersion + originalMax := provided.MaxVersion + + cfg, err := ConfigToTLSConfig(&provided) + if err != nil { + t.Fatalf("ConfigToTLSConfig returned an error: %v", err) + } + + if cfg.MinVersion != tc.expectedMinVersion { + t.Errorf("derived MinVersion = %d, expected %d", cfg.MinVersion, tc.expectedMinVersion) + } + if cfg.MaxVersion != tc.expectedMaxVersion { + t.Errorf("derived MaxVersion = %d, expected %d", cfg.MaxVersion, tc.expectedMaxVersion) + } + + // The caller's config must not be mutated. + if provided.MinVersion != originalMin { + t.Errorf("provided MinVersion was mutated: got %d, want %d", provided.MinVersion, originalMin) + } + if provided.MaxVersion != originalMax { + t.Errorf("provided MaxVersion was mutated: got %d, want %d", provided.MaxVersion, originalMax) + } + }) + } +}