Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions Documentation/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 `<host>:<port>` format where `<host>` 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 `<host>:<port>` format where `<host>` 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.
Expand Down
6 changes: 2 additions & 4 deletions Documentation/reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions cmd/clair/idle.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
19 changes: 19 additions & 0 deletions cmd/clair/listen.go
Original file line number Diff line number Diff line change
@@ -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)
}
210 changes: 210 additions & 0 deletions cmd/clair/listen_linux.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading