From 3252bd4c4efff0f654336265aac0f22e8e26a337 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 30 Jan 2026 17:00:09 -0600 Subject: [PATCH 1/4] config: add top-level server configuration objects Signed-off-by: Hank Donnay --- config/api.go | 102 ++++++++++++++++++++++++++++++++++++++++ config/config.go | 20 ++++---- config/defaults.go | 21 ++++++++- config/introspection.go | 63 +++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 config/api.go diff --git a/config/api.go b/config/api.go new file mode 100644 index 0000000000..eb53ee7b19 --- /dev/null +++ b/config/api.go @@ -0,0 +1,102 @@ +package config + +import ( + "slices" + "time" +) + +// API holds configuration for the Clair API services. +type API struct { + // V1 is the configuration for the HTTP v1 API. + V1 APIv1 `yaml:"v1,omitempty" json:"v1,omitempty"` +} + +func (a *API) validate(_ Mode) ([]Warning, error) { + // TODO(hank) When there's an "UpdaterMode," don't bother with validating + // the API configurations. + + enabled := slices.ContainsFunc([]*bool{}, func(e *bool) bool { + return e != nil && *e + }) + // With multiple versions, the highest one should be the default, probably. + if !enabled { + a.V1.Enabled = &[]bool{true}[0] // TODO(go1.26) Use the "new(true)" syntax. + } + + return nil, nil +} + +// APIv1 holds configuration values for the HTTP v1 API. +type APIv1 struct { + // Enabled configures enabling the API server at all. + // The set of API endpoints served by any one process depends on the mode + // the process is started in. + // + // If unset, defaults to "true". + Enabled *bool `yaml:"enabled" json:"enabled"` + + // Network configures the network type to be used for serving API requests. + // + // If unset, [DefaultAPIv1Network] will be used. + // See also: [net.Dial]. + Network string `yaml:"network" json:"network"` + + // Address configures the address to listen on for serving API requests. + // The format depends on the "network" member. + // + // If unset, [DefaultAPIv1Address] will be used. + // See also: [net.Dial]. + Address string `yaml:"address" json:"address"` + + // IdleTimeout configures whether the Clair process should exit after not + // handling any requests for a specified non-zero duration. + IdleTimeout Duration `yaml:"idle_timeout" json:"idle_timeout"` + + // TLS configures HTTPS support. + // + // Note that any non-trivial deployment means the certificate provided here + // will need to be for the name the load balancer used to connect to a given + // Clair instance. + TLS *TLS `yaml:"tls,omitempty" json:"tls,omitempty"` +} + +func (a *APIv1) validate(_ Mode) ([]Warning, error) { + if a.Enabled == nil || !*a.Enabled { + return nil, nil + } + if a.Network == "" { + a.Network = DefaultAPIv1Network + } + if a.Address == "" { + a.Address = DefaultAPIv1Address + } + + return a.lint() +} + +func (a *APIv1) lint() (ws []Warning, err error) { + if a.Network == "" { + ws = append(ws, Warning{ + path: ".network", + msg: `listen network not provided, default will be used`, + }) + } + if a.Address == "" { + ws = append(ws, Warning{ + path: ".address", + msg: `listen address not provided, default will be used`, + }) + } + + switch dur := time.Duration(a.IdleTimeout); { + case dur == 0: // OK, disabled. + case dur < (2 * time.Minute): + ws = append(ws, Warning{ + path: ".idle_timeout", + msg: `idle timeout seems short, may cause frequent startups`, + }) + default: // OK, reasonably long. + } + + return ws, nil +} diff --git a/config/config.go b/config/config.go index 02638cb747..b10bff4e5e 100644 --- a/config/config.go +++ b/config/config.go @@ -31,15 +31,17 @@ type Config struct { // exposes Clair's metrics and health endpoints. IntrospectionAddr string `yaml:"introspection_addr" json:"introspection_addr"` // Set the logging level. - LogLevel LogLevel `yaml:"log_level" json:"log_level"` - Indexer Indexer `yaml:"indexer,omitempty" json:"indexer,omitempty"` - Matcher Matcher `yaml:"matcher,omitempty" json:"matcher,omitempty"` - Matchers Matchers `yaml:"matchers,omitempty" json:"matchers,omitempty"` - Updaters Updaters `yaml:"updaters,omitempty" json:"updaters,omitempty"` - Notifier Notifier `yaml:"notifier,omitempty" json:"notifier,omitempty"` - Auth Auth `yaml:"auth,omitempty" json:"auth,omitempty"` - Trace Trace `yaml:"trace,omitempty" json:"trace,omitempty"` - Metrics Metrics `yaml:"metrics,omitempty" json:"metrics,omitempty"` + LogLevel LogLevel `yaml:"log_level" json:"log_level"` + Indexer Indexer `yaml:"indexer,omitempty" json:"indexer,omitempty"` + Matcher Matcher `yaml:"matcher,omitempty" json:"matcher,omitempty"` + Matchers Matchers `yaml:"matchers,omitempty" json:"matchers,omitempty"` + Updaters Updaters `yaml:"updaters,omitempty" json:"updaters,omitempty"` + Notifier Notifier `yaml:"notifier,omitempty" json:"notifier,omitempty"` + Auth Auth `yaml:"auth,omitempty" json:"auth,omitempty"` + Trace Trace `yaml:"trace,omitempty" json:"trace,omitempty"` + Metrics Metrics `yaml:"metrics,omitempty" json:"metrics,omitempty"` + API API `yaml:"api,omitempty" json:"api,omitempty"` + Introspection Introspection `yaml:"introspection,omitempty" json:"introspection,omitempty"` } func (c *Config) validate(mode Mode) ([]Warning, error) { diff --git a/config/defaults.go b/config/defaults.go index 5a23387378..32639c4a6e 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -4,8 +4,20 @@ import "time" // These are defaults, used in the documented spots. const ( - // DefaultAddress is used if an "http_listen_addr" is not provided in the config. - DefaultAddress = ":6060" + // DefaultAPIv1Network is used if a network for the v1 API is not provided + // in the config. + DefaultAPIv1Network = "tcp" + // DefaultAPIv1Address is used if an address for the v1 API is not provided + // in the config. + DefaultAPIv1Address = ":6060" + + // DefaultIntrospectionNetwork is used if a network for the Introspection + // server is not provided in the config. + DefaultIntrospectionNetwork = "tcp" + // DefaultIntrospectionAddress is used if an address for the Introspection + // server is not provided in the config. + DefaultIntrospectionAddress = ":8089" + // DefaultScanLockRetry is the default retry period for attempting locks // during the indexing process. Its name is a historical accident. DefaultScanLockRetry = 1 @@ -23,3 +35,8 @@ const ( // outstanding notifications at this rate. DefaultNotifierDeliveryInterval = 1 * time.Hour ) + +// DefaultAddress is the previous name of [DefaultAPIv1Address]. +// +// Deprecated: Refer to [DefaultAPIv1Address] directly. +const DefaultAddress = DefaultAPIv1Address diff --git a/config/introspection.go b/config/introspection.go index 69c0f6c700..727b26f77b 100644 --- a/config/introspection.go +++ b/config/introspection.go @@ -2,6 +2,69 @@ package config import "fmt" +// Introspection is the configuration for Clair's introspection and debugging +// endpoints. +type Introspection struct { + // Enabled configures enabling the Introspection server at all. + // + // If unset, defaults to "true". + Enabled *bool `yaml:"enabled" json:"enabled"` + + // Required configures Clair to exit with an error if the Introspection + // server fails to start. + // + // Defaults to "false". + Required bool `yaml:"required" json:"required"` + + // Network configures the network type to be used for serving Introspection + // requests. + // + // If unset, [DefaultIntrospectionNetwork] will be used. + // See also: [net.Dial]. + Network string `yaml:"network" json:"network"` + + // Address configures the address to listen on for serving Introspection + // requests. The format depends on the "network" member. + // + // If unset, [DefaultIntrospectionAddress] will be used. + // See also: [net.Dial]. + Address string `yaml:"address" json:"address"` +} + +func (i *Introspection) validate(_ Mode) ([]Warning, error) { + switch { + case i.Enabled == nil: + i.Enabled = &[]bool{true}[0] // TODO(go1.26) Use the "new(true)" syntax. + case !*i.Enabled: + return nil, nil + } + if i.Network == "" { + i.Network = DefaultIntrospectionNetwork + } + if i.Address == "" { + i.Address = DefaultIntrospectionAddress + } + + return i.lint() +} + +func (i *Introspection) lint() (ws []Warning, err error) { + if i.Network == "" { + ws = append(ws, Warning{ + path: ".network", + msg: `listen network not provided, default will be used`, + }) + } + if i.Address == "" { + ws = append(ws, Warning{ + path: ".address", + msg: `listen address not provided, default will be used`, + }) + } + + return ws, nil +} + // Trace specifies how to configure Clair's tracing support. // // The "Name" key must match the provider to use. From 4846d976dff8778242b0e5bde5616cfb99de90da Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 30 Jan 2026 19:01:00 -0600 Subject: [PATCH 2/4] config: documentation updates Signed-off-by: Hank Donnay --- config/config.go | 18 ++++++++++-------- config/tls.go | 12 ++++++------ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/config/config.go b/config/config.go index b10bff4e5e..36d02461cd 100644 --- a/config/config.go +++ b/config/config.go @@ -12,23 +12,25 @@ type Config struct { // TLS configures HTTPS support. // // Note that any non-trivial deployment means the certificate provided here - // will need to be for the name the load balancer uses to connect to a given - // Clair instance. + // will need to be for the name used by the load balancer for a given Clair + // instance. // - // This is not used for outgoing requests; setting the SSL_CERT_DIR - // environment variable is the recommended way to do that. The release - // container has `/var/run/certs` added to the list already. + // Deprecated: Use the [API] configuration hierarchy. TLS *TLS `yaml:"tls,omitempty" json:"tls,omitempty"` // Sets which mode the clair instance will run. Mode Mode `yaml:"-" json:"-"` // A string in : format where can be an empty string. // - // exposes Clair node's functionality to the network. - // see /openapi/v1 for api spec. + // Exposes Clair node's functionality to the network. + // See /openapi/v1 for the API spec. + // + // Deprecated: Use the [API] configuration hierarchy. HTTPListenAddr string `yaml:"http_listen_addr" json:"http_listen_addr"` // A string in : format where can be an empty string. // - // exposes Clair's metrics and health endpoints. + // Exposes Clair's metrics and health endpoints. + // + // Deprecated: Use the [Introspection] configuration hierarchy. IntrospectionAddr string `yaml:"introspection_addr" json:"introspection_addr"` // Set the logging level. LogLevel LogLevel `yaml:"log_level" json:"log_level"` diff --git a/config/tls.go b/config/tls.go index 5d4812cbb1..e0b9a79741 100644 --- a/config/tls.go +++ b/config/tls.go @@ -15,13 +15,13 @@ import ( // // Using the environment variables "SSL_CERT_DIR" or "SSL_CERT_FILE" or // modifying the system's trust store are the ways to modify root CAs for all -// outgoing TLS connections. +// outgoing TLS connections. The Clair release containers have `/var/run/certs` +// added to the list already. type TLS struct { // The filesystem path where a root CA can be read. // - // This can also be controlled by the SSL_CERT_FILE and SSL_CERT_DIR - // environment variables, or adding the relevant certs to the system trust - // store. + // Deprecated: Use the "SSL_CERT_FILE" or "SSL_CERT_DIR" environment + // variables, or add the relevant certs to the system trust store. RootCA string `yaml:"root_ca" json:"root_ca"` // The filesystem path where a TLS certificate can be read. Cert string `yaml:"cert" json:"cert"` @@ -29,9 +29,9 @@ type TLS struct { Key string `yaml:"key" json:"key"` } -// Config returns a tls.Config modified according to the TLS struct. +// Config returns a [tls.Config] modified according to the TLS struct. // -// If the *TLS is nil, a default tls.Config is returned. +// If the receiver is nil, a default [tls.Config] is returned. func (t *TLS) Config() (*tls.Config, error) { var cfg tls.Config if t == nil { From 9ab91df4a819f9f6465e77773ea066ab97ec4749 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 30 Jan 2026 19:01:29 -0600 Subject: [PATCH 3/4] config: add tls tests Signed-off-by: Hank Donnay --- config/tls_test.go | 108 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 config/tls_test.go diff --git a/config/tls_test.go b/config/tls_test.go new file mode 100644 index 0000000000..58e67c97fa --- /dev/null +++ b/config/tls_test.go @@ -0,0 +1,108 @@ +package config + +import ( + "bytes" + "crypto/tls" + "net" + "os/exec" + "path/filepath" + "testing" +) + +func TestTLS(t *testing.T) { + dir := t.TempDir() + t.Setenv("SSL_CERT_FILE", filepath.Join(dir, `cert.pem`)) + + out, err := exec.Command(`go`, `env`, `GOROOT`).CombinedOutput() + if err != nil { + t.Logf("output:\n%s", string(out)) + t.Fatal(err) + } + goroot := string(bytes.TrimSpace(out)) + cmd := exec.Command(`go`, `run`, + filepath.Join(goroot, "/src/crypto/tls/generate_cert.go"), + "--rsa-bits=2048", + "--host=127.0.0.1,::1,example.com", + "--ca", + "--start-date=Jan 1 00:00:00 1970", + "--duration=1000000h", + ) + cmd.Dir = dir + var errBuf bytes.Buffer + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + t.Logf("stderr:\n%s", errBuf.String()) + t.Fatal(err) + } + + tlscfg := TLS{ + Cert: filepath.Join(dir, `cert.pem`), + Key: filepath.Join(dir, `key.pem`), + } + tlscfg.RootCA = tlscfg.Cert + cfg, err := tlscfg.Config() + if err != nil { + t.Fatal(err) + } + + checkTLSVersions(t, cfg) +} + +func checkTLSVersions(t *testing.T, cfg *tls.Config) { + t.Helper() + l, err := net.Listen("tcp", "[::1]:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + addr := l.Addr() + l = tls.NewListener(l, cfg) + done, gone := make(chan struct{}), make(chan struct{}) + go func() { + defer close(gone) + for { + select { + case <-done: + return + default: + } + c, err := l.Accept() + if err != nil { + t.Error(err) + return + } + t.Log("connected") + tc := c.(*tls.Conn) + if err := tc.Handshake(); err != nil { + t.Log(err) + continue + } + st := tc.ConnectionState() + t.Logf("version: %v", st.Version) + c.Close() + } + }() + + for _, tc := range []struct { + Version uint16 + FailOK bool + }{ + {tls.VersionTLS10, true}, + {tls.VersionTLS11, true}, + {tls.VersionTLS12, false}, + {tls.VersionTLS13, false}, + } { + cfg := cfg.Clone() + cfg.Certificates = nil + cfg.MaxVersion = tc.Version + _, err := tls.Dial(addr.Network(), addr.String(), cfg) + if err != nil { + t.Logf("%v: %v", tc.Version, err) + if !tc.FailOK { + t.Fail() + } + } + } + close(done) + <-gone +} From e85ae56cc4c777d3e35e5ab8ae4134fd53dd32a8 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 30 Jan 2026 19:02:50 -0600 Subject: [PATCH 4/4] config: update lints for listen configuration Signed-off-by: Hank Donnay --- config/config.go | 21 ++++++++++----------- config/lint_test.go | 6 ++++-- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/config/config.go b/config/config.go index 36d02461cd..3d9775f221 100644 --- a/config/config.go +++ b/config/config.go @@ -47,9 +47,6 @@ type Config struct { } func (c *Config) validate(mode Mode) ([]Warning, error) { - if c.HTTPListenAddr == "" { - c.HTTPListenAddr = DefaultAddress - } if c.Matcher.DisableUpdaters { c.Updaters.Sets = []string{} } @@ -59,23 +56,25 @@ func (c *Config) validate(mode Mode) ([]Warning, error) { default: return nil, fmt.Errorf("unknown mode: %q", mode) } - if _, _, err := net.SplitHostPort(c.HTTPListenAddr); err != nil { - return nil, err + if c.HTTPListenAddr != "" { + if _, _, err := net.SplitHostPort(c.HTTPListenAddr); err != nil { + return nil, err + } } return c.lint() } func (c *Config) lint() (ws []Warning, err error) { - if c.HTTPListenAddr == "" { + if c.HTTPListenAddr != "" { ws = append(ws, Warning{ - path: ".http_listen_addr", - msg: `http listen address not provided, default will be used`, + path: ".http_listen_addr", + inner: fmt.Errorf(`configuration via $.api.v1 is preferred: %w`, ErrDeprecated), }) } - if c.IntrospectionAddr == "" { + if c.IntrospectionAddr != "" { ws = append(ws, Warning{ - path: ".introspection_addr", - msg: `introspection address not provided, default will be used`, + path: ".introspection_addr", + inner: fmt.Errorf(`configuration via $.introspection is preferred: %w`, ErrDeprecated), }) } return ws, nil diff --git a/config/lint_test.go b/config/lint_test.go index 4c270de5a9..e5e3c1234c 100644 --- a/config/lint_test.go +++ b/config/lint_test.go @@ -27,8 +27,6 @@ func ExampleLint() { } // Output: // error: - // warning: http listen address not provided, default will be used (at $.http_listen_addr) - // warning: introspection address not provided, default will be used (at $.introspection_addr) // warning: connection string is empty and no relevant environment variables found (at $.indexer.connstring) // warning: connection string is empty and no relevant environment variables found (at $.matcher.connstring) // warning: updater period is very aggressive: most sources are updated daily (at $.matcher.period) @@ -36,4 +34,8 @@ func ExampleLint() { // warning: connection string is empty and no relevant environment variables found (at $.notifier.connstring) // warning: interval is very fast: may result in increased workload (at $.notifier.poll_interval) // warning: interval is very fast: may result in increased workload (at $.notifier.delivery_interval) + // warning: listen network not provided, default will be used (at $.api.v1.network) + // warning: listen address not provided, default will be used (at $.api.v1.address) + // warning: listen network not provided, default will be used (at $.introspection.network) + // warning: listen address not provided, default will be used (at $.introspection.address) }