From 3252bd4c4efff0f654336265aac0f22e8e26a337 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 30 Jan 2026 17:00:09 -0600 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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) } From 83fc190ea4db599e045722e0191e44ce9eab669c Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Mon, 2 Feb 2026 11:13:25 -0600 Subject: [PATCH 5/9] wip: use prerelease config module Signed-off-by: Hank Donnay Change-Id: Ifa138b1cb5ed3adb88b47a2b8a8530126a6a6964 JJ: See-Also: CLAIRDEV-NNNN JJ: Closes: #NNNN --- cmd/clair/main.go | 4 ++++ go.mod | 2 ++ 2 files changed, 6 insertions(+) diff --git a/cmd/clair/main.go b/cmd/clair/main.go index b7e024a7c9..0d94883dc6 100644 --- a/cmd/clair/main.go +++ b/cmd/clair/main.go @@ -107,6 +107,8 @@ func main() { slog.InfoContext(ctx, "registered signal handler") go func() { <-sig.Done() + notify(msgStopping, + msgStatus, fmt.Sprintf("received signal (%v)", context.Cause(sig))) stop() slog.InfoContext(ctx, "unregistered signal handler") }() @@ -116,6 +118,8 @@ func main() { srvs.Go(serveAPI(srvctx, &conf)) slog.InfoContext(ctx, "ready", "version", cmd.Version) + notify(msgReady, + msgStatus, fmt.Sprintf("version: %s", cmd.Version)) if err := srvs.Wait(); err != nil { slog.ErrorContext(ctx, "fatal error", "reason", err) fail = true diff --git a/go.mod b/go.mod index 407642e97a..8d57a23e28 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/quay/clair/config => ./config + require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect From 29d9e2228cacbc1d260818ffc1419f0a308d894e Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Tue, 31 Mar 2026 13:12:44 -0500 Subject: [PATCH 6/9] docs: update documentation for configuration changes Signed-off-by: Hank Donnay Change-Id: Id8fbc5fa619881707bb81a2a7cd387246a6a6964 --- Documentation/reference/config.md | 60 ++++++++++++++++++++++++++----- Documentation/reference_test.go | 6 ++-- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Documentation/reference/config.md b/Documentation/reference/config.md index bbb7eaca14..18a4908f77 100644 --- a/Documentation/reference/config.md +++ b/Documentation/reference/config.md @@ -78,8 +78,11 @@ Please see the [go module documentation][godoc_config] for additional documentat [godoc_config]: https://pkg.go.dev/github.com/quay/clair/config ``` -http_listen_addr: "" -introspection_addr: "" +api: + v1: + enabled: true +introspection: + enabled: true log_level: "" tls: {} indexer: @@ -158,18 +161,59 @@ more information. # `$.metrics.otlp.http.client_tls.root_ca` # `$.metrics.otlp.grpc.client_tls` # `$.metrics.otlp.grpc.client_tls.root_ca` +# `$.api.v1.tls` +# `$.api.v1.tls.root_ca` +# `$.http_listen_addr` +# `$.introspection_addr` --> -### `$.http_listen_addr` -A string in `:` format where `` can be an empty string. +### `$.api` + +Configuration for the Clair API. + +#### `$.api.v1` +Configuration for the v1 Clair API. -This configures where the HTTP API is exposed. See `/openapi/v1` for the API spec. -### `$.introspection_addr` -A string in `:` format where `` can be an empty string. +##### `$.api.v1.enabled` +Whether the v1 HTTP API is enabled. Defaults to `true`. + +##### `$.api.v1.network` +The network (suitable for [`net.Dial`](https://pkg.go.dev/net#Dial)) that the v1 +HTTP API should be exposed on. Defaults to `"tcp"`. + +##### `$.api.v1.address` +The address (suitable for [`net.Dial`](https://pkg.go.dev/net#Dial)) that the v1 +HTTP API should be exposed on. + +##### `$.api.v1.idle_timeout` +If configured, have the process exit if the v1 HTTP API has not served a request +for the specified duration. + +##### `$.api.v1.tls.cert` +The TLS certificate to be used. Must be a full-chain certificate, as in nginx. + +##### `$.api.v1.tls.key` +A key file for the TLS certificate. Encryption is not supported on the key. + +### `$.introspection` +Configuration for Clair's metrics and health endpoints. + +#### `$.introspection.enabled` +Whether the introspection HTTP server is enabled. Defaults to `true`. + +#### `$.introspection.required` +Whether the introspection HTTP server should terminate the process if unable to +start. Defaults to `false`. + +#### `$.introspection.network` +The network (suitable for [`net.Dial`](https://pkg.go.dev/net#Dial)) that the +introspection HTTP server should be exposed on. Defaults to `"tcp"`. -This configures where Clair's metrics and health endpoints are exposed. +#### `$.introspection.address` +The address (suitable for [`net.Dial`](https://pkg.go.dev/net#Dial)) that the +introspection HTTP server should be exposed on. ### `$.log_level` Set the logging level. diff --git a/Documentation/reference_test.go b/Documentation/reference_test.go index 4297451763..6d2c9066b9 100644 --- a/Documentation/reference_test.go +++ b/Documentation/reference_test.go @@ -33,7 +33,7 @@ func TestConfigReference(t *testing.T) { t.Error(err) } var want []string - if err := walk(&want, "$", reflect.TypeOf(config.Config{})); err != nil { + if err := walk(&want, "$", reflect.TypeFor[config.Config]()); err != nil { t.Error(err) } sort.Strings(want) @@ -43,11 +43,9 @@ func TestConfigReference(t *testing.T) { } } -type walkFunc func(interface{}) ([]string, error) - func walk(ws *[]string, path string, t reflect.Type) error { // Dereference the pointer, if this is a pointer. - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } if t.Kind() == reflect.Struct { From a949155d25a5d2bf344a0ecdd5b256ecf9c5a3f3 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Fri, 30 Jan 2026 14:36:36 -0600 Subject: [PATCH 7/9] clair: add support for systemd file passing This will allow for Clair instances to start on-demand. Signed-off-by: Hank Donnay Change-Id: I72c1ad6e7a99444cbb0a92d35ae26df16a6a6964 JJ: See-Also: CLAIRDEV-NNNN JJ: Closes: #NNNN Signed-off-by: Hank Donnay --- cmd/clair/listen.go | 19 ++++ cmd/clair/listen_linux.go | 210 ++++++++++++++++++++++++++++++++++++++ cmd/clair/listen_other.go | 20 ++++ cmd/clair/main.go | 22 +++- introspection/server.go | 13 --- 5 files changed, 267 insertions(+), 17 deletions(-) create mode 100644 cmd/clair/listen.go create mode 100644 cmd/clair/listen_linux.go create mode 100644 cmd/clair/listen_other.go diff --git a/cmd/clair/listen.go b/cmd/clair/listen.go new file mode 100644 index 0000000000..1b3587743e --- /dev/null +++ b/cmd/clair/listen.go @@ -0,0 +1,19 @@ +package main + +import ( + "cmp" + + "github.com/quay/clair/config" +) + +// GetIntrospectionAddress returns the address in the configuration. +func getIntrospectionAddress(cfg *config.Config) string { + icfg := &cfg.Introspection + return cmp.Or(icfg.Address, cfg.IntrospectionAddr) +} + +// GetAPIv1Address returns the address in the configuration. +func getAPIv1Address(cfg *config.Config) string { + apicfg := &cfg.API.V1 + return cmp.Or(apicfg.Address, cfg.HTTPListenAddr) +} diff --git a/cmd/clair/listen_linux.go b/cmd/clair/listen_linux.go new file mode 100644 index 0000000000..60bd50752e --- /dev/null +++ b/cmd/clair/listen_linux.go @@ -0,0 +1,210 @@ +package main + +import ( + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net" + "os" + "strconv" + "strings" + "sync" + + "github.com/quay/clair/config" + "golang.org/x/sys/unix" +) + +// ListenAPI returns a listener to serve the API on. +// +// # Linux +// +// The Linux implementation checks if the process has been passed a file +// descriptor to use by systemd. If there is only 1, it will be used to serve +// the API service. If there's more than one, the descriptor with the associated +// name "api" will be used. +func listenAPI(ctx context.Context, cfg *config.Config) (net.Listener, error) { + const ( + fdName = `api` + msg = `unable to use passed files` + ) + fds, err := getFDs() + switch { + case err != nil: + slog.WarnContext(ctx, msg, "reason", err) + case len(fds) == 0: + case len(fds) == 1: + f := os.NewFile(fds[0].FD, fds[0].Name) + defer f.Close() + return net.FileListener(f) + default: + for _, fd := range fds { + if fd.Name == fdName { + f := os.NewFile(fd.FD, fd.Name) + defer f.Close() + return net.FileListener(f) + } + } + slog.WarnContext(ctx, msg, + "reason", fmt.Sprintf("none with name %q", fdName)) + } + + return net.Listen(cfg.API.V1.Network, getAPIv1Address(cfg)) +} + +// ListenIntrospection returns a listener to serve the Introspection endpoints +// on. +// +// # Linux +// +// The Linux implementation checks if the process has been passed a file +// descriptor to use by systemd. If there's more than one, the descriptor with +// the associated name "introspection" will be used. +func listenIntrospection(ctx context.Context, cfg *config.Config) (net.Listener, error) { + const ( + fdName = `introspection` + msg = `unable to use passed files` + ) + fds, err := getFDs() + switch { + case err != nil: + slog.WarnContext(ctx, msg, "reason", err) + case len(fds) == 0: + default: + for _, fd := range fds { + if fd.Name == fdName { + f := os.NewFile(fd.FD, fd.Name) + defer f.Close() + return net.FileListener(f) + } + } + slog.WarnContext(ctx, msg, + "reason", fmt.Sprintf("none with name %q", fdName)) + } + + return net.Listen(cfg.Introspection.Network, getIntrospectionAddress(cfg)) +} + +// GetFDs implements [sd_listen_fds(3)]. +// +// [sd_listen_fds(3)]: https://www.freedesktop.org/software/systemd/man/latest/sd_listen_fds.html +var getFDs = sync.OnceValues(func() ([]passedFD, error) { + const ( + fdsStart = 3 + errMsg = "failed to parse environment variable %q: %w" + + pidKey = `LISTEN_PID` + pidfdKey = `LISTEN_PIDFDID` + countKey = `LISTEN_FDS` + namesKey = `LISTEN_FDNAMES` + ) + errNoVar := errors.New("no environment variable") + tryParse := func(key string) (uint64, error) { + s, ok := os.LookupEnv(key) + if !ok { + return 0, errNoVar + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, fmt.Errorf(errMsg, key, err) + } + return n, nil + } + // Always unset the environment variables. This is equivalent to the + // "unset_environment" argument to sd_listen_fds(3). + // + // SAFETY: These keys are only read in this function, which is only run + // once. + defer func() { + os.Unsetenv(pidKey) + os.Unsetenv(pidfdKey) + os.Unsetenv(countKey) + os.Unsetenv(namesKey) + }() + + // Check that the current process is the target of passed fds. + tgtPid, err := tryParse(pidKey) + switch err { + case nil: + case errNoVar: + return nil, nil + default: + return nil, err + } + pid := os.Getpid() + if tgtPid != uint64(pid) { + return nil, nil + } + + // If a new enough kernel+systemd to also pass the pidfd ID, check that: + var kernelOK, varOK bool + fd, err := unix.PidfdOpen(pid, 0) + switch { + case err == nil: + kernelOK = true + case errors.Is(err, unix.ENOSYS): // Old kernel + default: + return nil, fmt.Errorf(`unexpected error: %w`, err) + } + tgtPidfdid, err := tryParse(pidfdKey) + switch err { + case nil: + varOK = true + case errNoVar: + default: + return nil, err + } + if kernelOK && varOK { + buf := new(unix.Statfs_t) + if err := unix.Fstatfs(fd, buf); err != nil { + return nil, fmt.Errorf(`unexpected %q error: %w`, "fstatfs", err) + } + if buf.Type != unix.PID_FS_MAGIC { + return nil, fmt.Errorf(`unexpected error: incorrect magic on pidfd`) + } + + stat := new(unix.Stat_t) + if err := unix.Fstat(fd, stat); err != nil { + return nil, fmt.Errorf(`unexpected %q error: %w`, "fstat", err) + } + if tgtPidfdid != stat.Ino { + return nil, nil + } + } + + // Get the count of passed fds. + ct, err := tryParse(countKey) + switch err { + case nil: + case errNoVar: + return nil, fmt.Errorf("parsing %q: %w", countKey, err) + default: + return nil, err + } + // Get the associated names or use a default. + ns := make([]string, int(ct)) + if s, ok := os.LookupEnv(namesKey); ok { + ns = strings.Split(s, ":") + // This strict length check is the libsystemd behavior. + if len(ns) != int(ct) { + return nil, fmt.Errorf(errMsg, namesKey, err) + } + } + + // Build and return the list of fds. + fds := make([]passedFD, ct) + for i, n := range ns { + fds[i] = passedFD{ + Name: cmp.Or(n, `unknown`), + FD: uintptr(i + fdsStart), + } + } + return fds, nil +}) + +// PassedFD is a file descriptor passed by systemd and the associated name. +type passedFD struct { + Name string + FD uintptr +} diff --git a/cmd/clair/listen_other.go b/cmd/clair/listen_other.go new file mode 100644 index 0000000000..dd97920ad2 --- /dev/null +++ b/cmd/clair/listen_other.go @@ -0,0 +1,20 @@ +//go:build !linux + +package main + +import ( + "context" + "net" + + "github.com/quay/clair/config" +) + +// ListenAPI returns a listener to serve the API on. +func listenAPI(_ context.Context, cfg *config.Config) (net.Listener, error) { + return net.Listen(cfg.API.V1.Network, getAPIv1Address(cfg)) +} + +// ListenIntrospection returns a listener to serve the Introspection services on. +func listenIntrospection(_ context.Context, cfg *config.Config) (net.Listener, error) { + return net.Listen(cfg.Introspection.Network, getIntrospectionAddress(cfg)) +} diff --git a/cmd/clair/main.go b/cmd/clair/main.go index 0d94883dc6..3ba755c24e 100644 --- a/cmd/clair/main.go +++ b/cmd/clair/main.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "crypto/tls" "errors" @@ -127,6 +128,13 @@ func main() { } func serveAPI(ctx context.Context, cfg *config.Config) func() error { + apicfg := &cfg.API.V1 + if !*apicfg.Enabled { + return func() error { + slog.InfoContext(ctx, "http transport disabled") + return nil + } + } return func() error { slog.InfoContext(ctx, "launching http transport") srvs, err := initialize.Services(ctx, cfg) @@ -142,12 +150,12 @@ func serveAPI(ctx context.Context, cfg *config.Config) func() error { if err != nil { return fmt.Errorf("http transport configuration failed: %w", err) } - l, err := net.Listen("tcp", cfg.HTTPListenAddr) + l, err := listenAPI(ctx, cfg) if err != nil { return fmt.Errorf("http transport configuration failed: %w", err) } - if cfg.TLS != nil { - cfg, err := cfg.TLS.Config() + if tlscfg := cmp.Or(apicfg.TLS, cfg.TLS); tlscfg != nil { + cfg, err := tlscfg.Config() if err != nil { return fmt.Errorf("tls configuration failed: %w", err) } @@ -183,10 +191,16 @@ func serveIntrospection(ctx context.Context, cfg *config.Config) func() error { "reason", err) return nil } + l, err := listenIntrospection(ctx, cfg) + if err != nil { + slog.WarnContext(ctx, "introspection server configuration failed; continuing anyway", + "reason", err) + return nil + } var eg errgroup.Group eg.Go(func() error { - if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + if err := srv.Serve(l); !errors.Is(err, http.ErrServerClosed) { slog.WarnContext(ctx, "introspection server failed to launch; continuing anyway", "reason", err) } diff --git a/introspection/server.go b/introspection/server.go index 48b0c0d6bb..fb02271c6d 100644 --- a/introspection/server.go +++ b/introspection/server.go @@ -55,9 +55,6 @@ const ( ReadyEndpoint = "/readyz" ) -// DefaultIntrospectionAddr is the default address if not provided in the configuration. -const DefaultIntrospectionAddr = ":8089" - // Server provides an HTTP server exposing Clair metrics and debugging information. type Server struct { // configuration provided when starting Clair @@ -74,19 +71,9 @@ type Server struct { // New constructs a [*Server], which has an embedded [*http.Server]. func New(ctx context.Context, conf *config.Config, health func() bool) (*Server, error) { var err error - var addr string - if conf.IntrospectionAddr == "" { - addr = DefaultIntrospectionAddr - slog.InfoContext(ctx, "no introspection address provided; using default", - "address", addr) - } else { - addr = conf.IntrospectionAddr - } - i := &Server{ conf: conf, Server: &http.Server{ - Addr: addr, BaseContext: func(_ net.Listener) context.Context { return ctx }, }, ServeMux: http.NewServeMux(), From 6f8461391acadca070b0977b352b4ea61ea06feb Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Mon, 2 Feb 2026 11:07:47 -0600 Subject: [PATCH 8/9] clair: add idle timeout This will allow for Clair instances to spin down when they stop receiving requests for some period. This is particularly useful when combined with a systemd socket unit. Signed-off-by: Hank Donnay --- cmd/clair/idle.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/clair/main.go | 9 ++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 cmd/clair/idle.go diff --git a/cmd/clair/idle.go b/cmd/clair/idle.go new file mode 100644 index 0000000000..441ee9d4ef --- /dev/null +++ b/cmd/clair/idle.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "net" + "net/http" + "sync/atomic" + "time" +) + +// IdleMontior holds idle tracking machinery. +type idleMonitor struct { + timer *time.Timer + timeout time.Duration + ct atomic.Uint32 +} + +// NewIdleMonitor starts a goroutine to call "f" if the duration "timeout" +// passes with no active HTTP connections. +// +// The goroutine will also exit if the passed context is canceled. +func newIdleMonitor(ctx context.Context, timeout time.Duration, f context.CancelCauseFunc) idleMonitor { + timer := time.NewTimer(timeout) + go func() { + select { + case <-ctx.Done(): + case <-timer.C: + f(nil) // TODO(hank) Add a specific "idle timeout" condition? + } + }() + + return idleMonitor{ + timer: timer, + timeout: timeout, + } +} + +// ServerHook is a function suitable for use as [http.Server.ConnState]. +// +// This hook watches connection state changes, starting an idle timer when there +// are no open connections. The timer will be stopped if new connections arrive +// during the timeout period. +func (m *idleMonitor) ServerHook(_ net.Conn, state http.ConnState) { + switch state { + case http.StateNew: + if m.ct.Add(1) == 1 { + m.timer.Stop() + } + case http.StateClosed, http.StateHijacked: + if m.ct.Add(^uint32(0)) == 0 { + m.timer.Reset(m.timeout) + } + } +} diff --git a/cmd/clair/main.go b/cmd/clair/main.go index 3ba755c24e..1b4a13150e 100644 --- a/cmd/clair/main.go +++ b/cmd/clair/main.go @@ -115,8 +115,9 @@ func main() { }() srvs, srvctx := errgroup.WithContext(sig) + srvctx, teardown := context.WithCancelCause(srvctx) srvs.Go(serveIntrospection(srvctx, &conf)) - srvs.Go(serveAPI(srvctx, &conf)) + srvs.Go(serveAPI(srvctx, &conf, teardown)) slog.InfoContext(ctx, "ready", "version", cmd.Version) notify(msgReady, @@ -127,7 +128,7 @@ func main() { } } -func serveAPI(ctx context.Context, cfg *config.Config) func() error { +func serveAPI(ctx context.Context, cfg *config.Config, teardown context.CancelCauseFunc) func() error { apicfg := &cfg.API.V1 if !*apicfg.Enabled { return func() error { @@ -146,6 +147,10 @@ func serveAPI(ctx context.Context, cfg *config.Config) func() error { return context.WithoutCancel(ctx) }, } + if t := time.Duration(apicfg.IdleTimeout); t != 0 { + idle := newIdleMonitor(ctx, t, teardown) + srv.ConnState = idle.ServerHook + } srv.Handler, err = httptransport.New(ctx, cfg, srvs.Indexer, srvs.Matcher, srvs.Notifier) if err != nil { return fmt.Errorf("http transport configuration failed: %w", err) From 327b04df412e0384ff58d5f0f7c4076ff0322cb1 Mon Sep 17 00:00:00 2001 From: Hank Donnay Date: Mon, 2 Feb 2026 11:11:34 -0600 Subject: [PATCH 9/9] clair: add supervisor notification mechanism This allows Clair to inform a process supervisor about its state. Currently, only systemd is supported and only "ready" and "stopping" messages are used. Signed-off-by: Hank Donnay --- cmd/clair/notify.go | 13 +++++ cmd/clair/notify_linux.go | 106 ++++++++++++++++++++++++++++++++++++++ cmd/clair/notify_other.go | 8 +++ 3 files changed, 127 insertions(+) create mode 100644 cmd/clair/notify.go create mode 100644 cmd/clair/notify_linux.go create mode 100644 cmd/clair/notify_other.go diff --git a/cmd/clair/notify.go b/cmd/clair/notify.go new file mode 100644 index 0000000000..5c8e6b60e7 --- /dev/null +++ b/cmd/clair/notify.go @@ -0,0 +1,13 @@ +package main + +type notifyMsg uint + +const ( + _ notifyMsg = iota + msgReady + msgReloading + msgStopping + msgStatus + msgSocketAPI + msgSocketIntrospection +) diff --git a/cmd/clair/notify_linux.go b/cmd/clair/notify_linux.go new file mode 100644 index 0000000000..f32e7f21c6 --- /dev/null +++ b/cmd/clair/notify_linux.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "net" + "os" + "strings" + + "golang.org/x/sys/unix" +) + +// Notify sends information back to a supervisor process. +// +// # Linux +// +// The supervisor is assumed to be systemd, and this function implements the +// sd_notify(3) protocol. Some messages require an additional argument. +func notify(args ...any) error { + const key = `NOTIFY_SOCKET` + + sockpath, ok := os.LookupEnv(key) + if !ok { + return nil + } + sock, err := net.ResolveUnixAddr("unix", sockpath) + if err != nil { + return err + } + conn, err := net.DialUnix("unix", nil, sock) + if err != nil { + return err + } + defer conn.Close() + rc, err := conn.SyscallConn() + if err != nil { + return err + } + var ctlErr error + err = rc.Control(func(fd uintptr) { + const szTgt = 8 * 1026 * 1026 // 8 MiB + + _, ctlErr = unix.FcntlInt(fd, unix.F_SETFD, unix.FD_CLOEXEC) + if ctlErr != nil { + return + } + + var sz int + sz, ctlErr = unix.GetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUF) + if ctlErr != nil { + return + } + if sz < szTgt { + ctlErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUF, szTgt) + if ctlErr != nil { + return + } + } + }) + if err := errors.Join(err, ctlErr); err != nil { + return err + } + + // build message + var buf bytes.Buffer + var oob []byte + for i := 0; i < len(args); i++ { + var fdname string + msg := args[i].(notifyMsg) + switch msg { + case msgReady: + buf.WriteString("READY=1\n") + case msgReloading: + buf.WriteString("RELOADING=1\n") + case msgStopping: + buf.WriteString("STOPPING=1\n") + case msgStatus: + buf.WriteString("STATUS=") + i++ + s := args[i].(string) + buf.WriteString(strings.TrimSpace(s)) + buf.WriteByte('\n') + case msgSocketAPI: + fdname = "api" + case msgSocketIntrospection: + fdname = "introspection" + default: + panic(fmt.Sprintf("programmer error: unknown msg kind: %v", msg)) + } + if fdname != "" { + if oob != nil { + panic("programmer error: sending multiple file descriptors") + } + buf.WriteString("FDSTORE=1\n") + buf.WriteString("FDNAME=") + buf.WriteString(fdname) + buf.WriteByte('\n') + i++ + oob = unix.UnixRights(args[i].(int)) + } + } + + _, _, err = conn.WriteMsgUnix(buf.Bytes(), oob, sock) + return err +} diff --git a/cmd/clair/notify_other.go b/cmd/clair/notify_other.go new file mode 100644 index 0000000000..b4b31b8274 --- /dev/null +++ b/cmd/clair/notify_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package main + +// Notify sends information back to a supervisor process. +func notify(_ ...any) error { + return nil +}