github.com/letsencrypt/boulder@v0.20251208.0/cmd/config.go (about)

     1  package cmd
     2  
     3  import (
     4  	"crypto/tls"
     5  	"crypto/x509"
     6  	"encoding/hex"
     7  	"errors"
     8  	"fmt"
     9  	"net"
    10  	"os"
    11  	"strings"
    12  
    13  	"github.com/prometheus/client_golang/prometheus"
    14  	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    15  	"google.golang.org/grpc/resolver"
    16  
    17  	"github.com/letsencrypt/boulder/config"
    18  	"github.com/letsencrypt/boulder/core"
    19  	"github.com/letsencrypt/boulder/identifier"
    20  )
    21  
    22  // PasswordConfig contains a path to a file containing a password.
    23  type PasswordConfig struct {
    24  	PasswordFile string `validate:"required"`
    25  }
    26  
    27  // Pass returns a password, extracted from the PasswordConfig's PasswordFile
    28  func (pc *PasswordConfig) Pass() (string, error) {
    29  	// Make PasswordConfigs optional, for backwards compatibility.
    30  	if pc.PasswordFile == "" {
    31  		return "", nil
    32  	}
    33  	contents, err := os.ReadFile(pc.PasswordFile)
    34  	if err != nil {
    35  		return "", err
    36  	}
    37  	return strings.TrimRight(string(contents), "\n"), nil
    38  }
    39  
    40  // ServiceConfig contains config items that are common to all our services, to
    41  // be embedded in other config structs.
    42  type ServiceConfig struct {
    43  	// DebugAddr is the address to run the /debug handlers on.
    44  	DebugAddr string `validate:"omitempty,hostname_port"`
    45  	GRPC      *GRPCServerConfig
    46  	TLS       TLSConfig
    47  
    48  	// HealthCheckInterval is the duration between deep health checks of the
    49  	// service. Defaults to 5 seconds.
    50  	HealthCheckInterval config.Duration `validate:"-"`
    51  }
    52  
    53  // DBConfig defines how to connect to a database. The connect string is
    54  // stored in a file separate from the config, because it can contain a password,
    55  // which we want to keep out of configs.
    56  type DBConfig struct {
    57  	// A file containing a connect URL for the DB.
    58  	DBConnectFile string `validate:"required"`
    59  
    60  	// MaxOpenConns sets the maximum number of open connections to the
    61  	// database. If MaxIdleConns is greater than 0 and MaxOpenConns is
    62  	// less than MaxIdleConns, then MaxIdleConns will be reduced to
    63  	// match the new MaxOpenConns limit. If n < 0, then there is no
    64  	// limit on the number of open connections.
    65  	MaxOpenConns int `validate:"min=-1"`
    66  
    67  	// MaxIdleConns sets the maximum number of connections in the idle
    68  	// connection pool. If MaxOpenConns is greater than 0 but less than
    69  	// MaxIdleConns, then MaxIdleConns will be reduced to match the
    70  	// MaxOpenConns limit. If n < 0, no idle connections are retained.
    71  	MaxIdleConns int `validate:"min=-1"`
    72  
    73  	// ConnMaxLifetime sets the maximum amount of time a connection may
    74  	// be reused. Expired connections may be closed lazily before reuse.
    75  	// If d < 0, connections are not closed due to a connection's age.
    76  	ConnMaxLifetime config.Duration `validate:"-"`
    77  
    78  	// ConnMaxIdleTime sets the maximum amount of time a connection may
    79  	// be idle. Expired connections may be closed lazily before reuse.
    80  	// If d < 0, connections are not closed due to a connection's idle
    81  	// time.
    82  	ConnMaxIdleTime config.Duration `validate:"-"`
    83  }
    84  
    85  // URL returns the DBConnect URL represented by this DBConfig object, loading it
    86  // from the file on disk. Leading and trailing whitespace is stripped.
    87  func (d *DBConfig) URL() (string, error) {
    88  	url, err := os.ReadFile(d.DBConnectFile)
    89  	return strings.TrimSpace(string(url)), err
    90  }
    91  
    92  // PAConfig specifies how a policy authority should connect to its
    93  // database, what policies it should enforce, and what challenges
    94  // it should offer.
    95  type PAConfig struct {
    96  	DBConfig    `validate:"-"`
    97  	Challenges  map[core.AcmeChallenge]bool        `validate:"omitempty,dive,keys,oneof=http-01 dns-01 tls-alpn-01 dns-account-01,endkeys"`
    98  	Identifiers map[identifier.IdentifierType]bool `validate:"omitempty,dive,keys,oneof=dns ip,endkeys"`
    99  }
   100  
   101  // CheckChallenges checks whether the list of challenges in the PA config
   102  // actually contains valid challenge names
   103  func (pc PAConfig) CheckChallenges() error {
   104  	if len(pc.Challenges) == 0 {
   105  		return errors.New("empty challenges map in the Policy Authority config is not allowed")
   106  	}
   107  	for c := range pc.Challenges {
   108  		if !c.IsValid() {
   109  			return fmt.Errorf("invalid challenge in PA config: %s", c)
   110  		}
   111  	}
   112  	return nil
   113  }
   114  
   115  // CheckIdentifiers checks whether the list of identifiers in the PA config
   116  // actually contains valid identifier type names
   117  func (pc PAConfig) CheckIdentifiers() error {
   118  	for i := range pc.Identifiers {
   119  		if !i.IsValid() {
   120  			return fmt.Errorf("invalid identifier type in PA config: %s", i)
   121  		}
   122  	}
   123  	return nil
   124  }
   125  
   126  // HostnamePolicyConfig specifies a file from which to load a policy regarding
   127  // what hostnames to issue for.
   128  type HostnamePolicyConfig struct {
   129  	HostnamePolicyFile string `validate:"required"`
   130  }
   131  
   132  // TLSConfig represents certificates and a key for authenticated TLS.
   133  type TLSConfig struct {
   134  	CertFile string `validate:"required"`
   135  	KeyFile  string `validate:"required"`
   136  	// The CACertFile file may contain any number of root certificates and will
   137  	// be deduplicated internally.
   138  	CACertFile string `validate:"required"`
   139  }
   140  
   141  // Load reads and parses the certificates and key listed in the TLSConfig, and
   142  // returns a *tls.Config suitable for either client or server use. The
   143  // CACertFile file may contain any number of root certificates and will be
   144  // deduplicated internally. Prometheus metrics for various certificate fields
   145  // will be exported.
   146  func (t *TLSConfig) Load(scope prometheus.Registerer) (*tls.Config, error) {
   147  	if t == nil {
   148  		return nil, fmt.Errorf("nil TLS section in config")
   149  	}
   150  	if t.CertFile == "" {
   151  		return nil, fmt.Errorf("nil CertFile in TLSConfig")
   152  	}
   153  	if t.KeyFile == "" {
   154  		return nil, fmt.Errorf("nil KeyFile in TLSConfig")
   155  	}
   156  	if t.CACertFile == "" {
   157  		return nil, fmt.Errorf("nil CACertFile in TLSConfig")
   158  	}
   159  	caCertBytes, err := os.ReadFile(t.CACertFile)
   160  	if err != nil {
   161  		return nil, fmt.Errorf("reading CA cert from %q: %s", t.CACertFile, err)
   162  	}
   163  	rootCAs := x509.NewCertPool()
   164  	if ok := rootCAs.AppendCertsFromPEM(caCertBytes); !ok {
   165  		return nil, fmt.Errorf("parsing CA certs from %s failed", t.CACertFile)
   166  	}
   167  	cert, err := tls.LoadX509KeyPair(t.CertFile, t.KeyFile)
   168  	if err != nil {
   169  		return nil, fmt.Errorf("loading key pair from %q and %q: %s",
   170  			t.CertFile, t.KeyFile, err)
   171  	}
   172  
   173  	tlsNotBefore := prometheus.NewGaugeVec(
   174  		prometheus.GaugeOpts{
   175  			Name: "tlsconfig_notbefore_seconds",
   176  			Help: "TLS certificate NotBefore field expressed as Unix epoch time",
   177  		},
   178  		[]string{"serial"})
   179  	err = scope.Register(tlsNotBefore)
   180  	if err != nil {
   181  		are := prometheus.AlreadyRegisteredError{}
   182  		if errors.As(err, &are) {
   183  			tlsNotBefore = are.ExistingCollector.(*prometheus.GaugeVec)
   184  		} else {
   185  			return nil, err
   186  		}
   187  	}
   188  
   189  	tlsNotAfter := prometheus.NewGaugeVec(
   190  		prometheus.GaugeOpts{
   191  			Name: "tlsconfig_notafter_seconds",
   192  			Help: "TLS certificate NotAfter field expressed as Unix epoch time",
   193  		},
   194  		[]string{"serial"})
   195  	err = scope.Register(tlsNotAfter)
   196  	if err != nil {
   197  		are := prometheus.AlreadyRegisteredError{}
   198  		if errors.As(err, &are) {
   199  			tlsNotAfter = are.ExistingCollector.(*prometheus.GaugeVec)
   200  		} else {
   201  			return nil, err
   202  		}
   203  	}
   204  
   205  	leaf, err := x509.ParseCertificate(cert.Certificate[0])
   206  	if err != nil {
   207  		return nil, err
   208  	}
   209  
   210  	serial := leaf.SerialNumber.String()
   211  	tlsNotBefore.WithLabelValues(serial).Set(float64(leaf.NotBefore.Unix()))
   212  	tlsNotAfter.WithLabelValues(serial).Set(float64(leaf.NotAfter.Unix()))
   213  
   214  	return &tls.Config{
   215  		RootCAs:      rootCAs,
   216  		ClientCAs:    rootCAs,
   217  		ClientAuth:   tls.RequireAndVerifyClientCert,
   218  		Certificates: []tls.Certificate{cert},
   219  		// Set the only acceptable TLS to v1.3.
   220  		MinVersion: tls.VersionTLS13,
   221  	}, nil
   222  }
   223  
   224  // SyslogConfig defines the config for syslogging.
   225  // 3 means "error", 4 means "warning", 6 is "info" and 7 is "debug".
   226  // Configuring a given level causes all messages at that level and below to
   227  // be logged.
   228  type SyslogConfig struct {
   229  	// When absent or zero, this causes no logs to be emitted on stdout/stderr.
   230  	// Errors and warnings will be emitted on stderr if the configured level
   231  	// allows.
   232  	StdoutLevel int `validate:"min=-1,max=7"`
   233  	// When absent or zero, this defaults to logging all messages of level 6
   234  	// or below. To disable syslog logging entirely, set this to -1.
   235  	SyslogLevel int `validate:"min=-1,max=7"`
   236  }
   237  
   238  // ServiceDomain contains the service and domain name the gRPC or bdns provider
   239  // will use to construct a SRV DNS query to lookup backends.
   240  type ServiceDomain struct {
   241  	// Service is the service name to be used for SRV lookups. For example: if
   242  	// record is 'foo.service.consul', then the Service is 'foo'.
   243  	Service string `validate:"required"`
   244  
   245  	// Domain is the domain name to be used for SRV lookups. For example: if the
   246  	// record is 'foo.service.consul', then the Domain is 'service.consul'.
   247  	Domain string `validate:"required"`
   248  }
   249  
   250  // GRPCClientConfig contains the information necessary to setup a gRPC client
   251  // connection. The following field combinations are allowed:
   252  //
   253  // ServerAddress, DNSAuthority, [Timeout], [HostOverride]
   254  // SRVLookup, DNSAuthority, [Timeout], [HostOverride], [SRVResolver]
   255  // SRVLookups, DNSAuthority, [Timeout], [HostOverride], [SRVResolver]
   256  type GRPCClientConfig struct {
   257  	// DNSAuthority is a single <hostname|IPv4|[IPv6]>:<port> of the DNS server
   258  	// to be used for resolution of gRPC backends. If the address contains a
   259  	// hostname the gRPC client will resolve it via the system DNS. If the
   260  	// address contains a port, the client will use it directly, otherwise port
   261  	// 53 is used.
   262  	DNSAuthority string `validate:"required_with=SRVLookup SRVLookups,omitempty,ip|hostname|hostname_port"`
   263  
   264  	// SRVLookup contains the service and domain name the gRPC client will use
   265  	// to construct a SRV DNS query to lookup backends. For example: if the
   266  	// resource record is 'foo.service.consul', then the 'Service' is 'foo' and
   267  	// the 'Domain' is 'service.consul'. The expected dNSName to be
   268  	// authenticated in the server certificate would be 'foo.service.consul'.
   269  	//
   270  	// Note: The 'proto' field of the SRV record MUST contain 'tcp' and the
   271  	// 'port' field MUST be a valid port. In a Consul configuration file you
   272  	// would specify 'foo.service.consul' as:
   273  	//
   274  	// services {
   275  	//   id      = "some-unique-id-1"
   276  	//   name    = "foo"
   277  	//   address = "10.77.77.77"
   278  	//   port    = 8080
   279  	//   tags    = ["tcp"]
   280  	// }
   281  	// services {
   282  	//   id      = "some-unique-id-2"
   283  	//   name    = "foo"
   284  	//   address = "10.77.77.77"
   285  	//   port    = 8180
   286  	//   tags    = ["tcp"]
   287  	// }
   288  	//
   289  	// If you've added the above to your Consul configuration file (and reloaded
   290  	// Consul) then you should be able to resolve the following dig query:
   291  	//
   292  	// $ dig @10.77.77.10 -t SRV _foo._tcp.service.consul +short
   293  	// 1 1 8080 0a585858.addr.dc1.consul.
   294  	// 1 1 8080 0a4d4d4d.addr.dc1.consul.
   295  	SRVLookup *ServiceDomain `validate:"required_without_all=SRVLookups ServerAddress"`
   296  
   297  	// SRVLookups allows you to pass multiple SRV records to the gRPC client.
   298  	// The gRPC client will resolves each SRV record and use the results to
   299  	// construct a list of backends to connect to. For more details, see the
   300  	// documentation for the SRVLookup field. Note: while you can pass multiple
   301  	// targets to the gRPC client using this field, all of the targets will use
   302  	// the same HostOverride and TLS configuration.
   303  	SRVLookups []*ServiceDomain `validate:"required_without_all=SRVLookup ServerAddress"`
   304  
   305  	// SRVResolver is an optional override to indicate that a specific
   306  	// implementation of the SRV resolver should be used. The default is 'srv'
   307  	// For more details, see the documentation in:
   308  	// grpc/internal/resolver/dns/dns_resolver.go.
   309  	SRVResolver string `validate:"excluded_with=ServerAddress,isdefault|oneof=srv nonce-srv"`
   310  
   311  	// ServerAddress is a single <hostname|IPv4|[IPv6]>:<port> or `:<port>` that
   312  	// the gRPC client will, if necessary, resolve via DNS and then connect to.
   313  	// If the address provided is 'foo.service.consul:8080' then the dNSName to
   314  	// be authenticated in the server certificate would be 'foo.service.consul'.
   315  	//
   316  	// In a Consul configuration file you would specify 'foo.service.consul' as:
   317  	//
   318  	// services {
   319  	//   id      = "some-unique-id-1"
   320  	//   name    = "foo"
   321  	//   address = "10.77.77.77"
   322  	// }
   323  	// services {
   324  	//   id      = "some-unique-id-2"
   325  	//   name    = "foo"
   326  	//   address = "10.88.88.88"
   327  	// }
   328  	//
   329  	// If you've added the above to your Consul configuration file (and reloaded
   330  	// Consul) then you should be able to resolve the following dig query:
   331  	//
   332  	// $ dig A @10.77.77.10 foo.service.consul +short
   333  	// 10.77.77.77
   334  	// 10.88.88.88
   335  	ServerAddress string `validate:"required_without_all=SRVLookup SRVLookups,omitempty,hostname_port"`
   336  
   337  	// HostOverride is an optional override for the dNSName the client will
   338  	// verify in the certificate presented by the server.
   339  	HostOverride string `validate:"omitempty,hostname"`
   340  	Timeout      config.Duration
   341  
   342  	// NoWaitForReady turns off our (current) default of setting grpc.WaitForReady(true).
   343  	// This means if all of a GRPC client's backends are down, it will error immediately.
   344  	// The current default, grpc.WaitForReady(true), means that if all of a GRPC client's
   345  	// backends are down, it will wait until either one becomes available or the RPC
   346  	// times out.
   347  	NoWaitForReady bool
   348  }
   349  
   350  // MakeTargetAndHostOverride constructs the target URI that the gRPC client will
   351  // connect to and the hostname (only for 'ServerAddress' and 'SRVLookup') that
   352  // will be validated during the mTLS handshake. An error is returned if the
   353  // provided configuration is invalid.
   354  func (c *GRPCClientConfig) MakeTargetAndHostOverride() (string, string, error) {
   355  	var hostOverride string
   356  	if c.ServerAddress != "" {
   357  		if c.SRVLookup != nil {
   358  			return "", "", errors.New(
   359  				"both 'serverAddress' and 'SRVLookup' in gRPC client config. Only one should be provided",
   360  			)
   361  		}
   362  		// Lookup backends using DNS A records.
   363  		targetHost, _, err := net.SplitHostPort(c.ServerAddress)
   364  		if err != nil {
   365  			return "", "", err
   366  		}
   367  
   368  		hostOverride = targetHost
   369  		if c.HostOverride != "" {
   370  			hostOverride = c.HostOverride
   371  		}
   372  		return fmt.Sprintf("dns://%s/%s", c.DNSAuthority, c.ServerAddress), hostOverride, nil
   373  
   374  	} else if c.SRVLookup != nil {
   375  		if c.DNSAuthority == "" {
   376  			return "", "", errors.New("field 'dnsAuthority' is required in gRPC client config with SRVLookup")
   377  		}
   378  		scheme, err := c.makeSRVScheme()
   379  		if err != nil {
   380  			return "", "", err
   381  		}
   382  		// Lookup backends using DNS SRV records.
   383  		targetHost := c.SRVLookup.Service + "." + c.SRVLookup.Domain
   384  
   385  		hostOverride = targetHost
   386  		if c.HostOverride != "" {
   387  			hostOverride = c.HostOverride
   388  		}
   389  		return fmt.Sprintf("%s://%s/%s", scheme, c.DNSAuthority, targetHost), hostOverride, nil
   390  
   391  	} else if c.SRVLookups != nil {
   392  		if c.DNSAuthority == "" {
   393  			return "", "", errors.New("field 'dnsAuthority' is required in gRPC client config with SRVLookups")
   394  		}
   395  		scheme, err := c.makeSRVScheme()
   396  		if err != nil {
   397  			return "", "", err
   398  		}
   399  		// Lookup backends using multiple DNS SRV records.
   400  		var targetHosts []string
   401  		for _, s := range c.SRVLookups {
   402  			targetHosts = append(targetHosts, s.Service+"."+s.Domain)
   403  		}
   404  		if c.HostOverride != "" {
   405  			hostOverride = c.HostOverride
   406  		}
   407  		return fmt.Sprintf("%s://%s/%s", scheme, c.DNSAuthority, strings.Join(targetHosts, ",")), hostOverride, nil
   408  
   409  	} else {
   410  		return "", "", errors.New(
   411  			"at least one of 'serverAddress', 'SRVLookup', or 'SRVLookups' required in gRPC client config",
   412  		)
   413  	}
   414  }
   415  
   416  // makeSRVScheme returns the scheme to use for SRV lookups. If the SRVResolver
   417  // field is empty, it returns "srv". Otherwise it checks that the specified
   418  // SRVResolver is registered with the gRPC runtime and returns it.
   419  func (c *GRPCClientConfig) makeSRVScheme() (string, error) {
   420  	if c.SRVResolver == "" {
   421  		return "srv", nil
   422  	}
   423  	rb := resolver.Get(c.SRVResolver)
   424  	if rb == nil {
   425  		return "", fmt.Errorf("resolver %q is not registered", c.SRVResolver)
   426  	}
   427  	return c.SRVResolver, nil
   428  }
   429  
   430  // GRPCServerConfig contains the information needed to start a gRPC server.
   431  type GRPCServerConfig struct {
   432  	Address string `json:"address" validate:"omitempty,hostname_port"`
   433  	// Services is a map of service names to configuration specific to that service.
   434  	// These service names must match the service names advertised by gRPC itself,
   435  	// which are identical to the names set in our gRPC .proto files prefixed by
   436  	// the package names set in those files (e.g. "ca.CertificateAuthority").
   437  	Services map[string]*GRPCServiceConfig `json:"services" validate:"required,dive,required"`
   438  	// MaxConnectionAge specifies how long a connection may live before the server sends a GoAway to the
   439  	// client. Because gRPC connections re-resolve DNS after a connection close,
   440  	// this controls how long it takes before a client learns about changes to its
   441  	// backends.
   442  	// https://pkg.go.dev/google.golang.org/grpc/keepalive#ServerParameters
   443  	MaxConnectionAge config.Duration `validate:"required"`
   444  }
   445  
   446  // GRPCServiceConfig contains the information needed to configure a gRPC service.
   447  type GRPCServiceConfig struct {
   448  	// ClientNames is the list of accepted gRPC client certificate SANs.
   449  	// Connections from clients not in this list will be rejected by the
   450  	// upstream listener, and RPCs from unlisted clients will be denied by the
   451  	// server interceptor.
   452  	ClientNames []string `json:"clientNames" validate:"min=1,dive,hostname,required"`
   453  }
   454  
   455  // OpenTelemetryConfig configures tracing via OpenTelemetry.
   456  // To enable tracing, set a nonzero SampleRatio and configure an Endpoint
   457  type OpenTelemetryConfig struct {
   458  	// Endpoint to connect to with the OTLP protocol over gRPC.
   459  	// It should be of the form "localhost:4317"
   460  	//
   461  	// It always connects over plaintext, and so is only intended to connect
   462  	// to a local OpenTelemetry collector. This should not be used over an
   463  	// insecure network.
   464  	Endpoint string
   465  
   466  	// SampleRatio is the ratio of new traces to head sample.
   467  	// This only affects new traces without a parent with its own sampling
   468  	// decision, and otherwise use the parent's sampling decision.
   469  	//
   470  	// Set to something between 0 and 1, where 1 is sampling all traces.
   471  	// This is primarily meant as a pressure relief if the Endpoint we connect to
   472  	// is being overloaded, and we otherwise handle sampling in the collectors.
   473  	// See otel trace.ParentBased and trace.TraceIDRatioBased for details.
   474  	SampleRatio float64
   475  }
   476  
   477  // OpenTelemetryHTTPConfig configures the otelhttp server tracing.
   478  type OpenTelemetryHTTPConfig struct {
   479  	// TrustIncomingSpans should only be set true if there's a trusted service
   480  	// connecting to Boulder, such as a load balancer that's tracing-aware.
   481  	// If false, the default, incoming traces won't be set as the parent.
   482  	// See otelhttp.WithPublicEndpoint
   483  	TrustIncomingSpans bool
   484  }
   485  
   486  // Options returns the otelhttp options for this configuration. They can be
   487  // passed to otelhttp.NewHandler or Boulder's wrapper, measured_http.New.
   488  func (c *OpenTelemetryHTTPConfig) Options() []otelhttp.Option {
   489  	var options []otelhttp.Option
   490  	if !c.TrustIncomingSpans {
   491  		options = append(options, otelhttp.WithPublicEndpoint())
   492  	}
   493  	return options
   494  }
   495  
   496  // DNSProvider contains the configuration for a DNS provider in the bdns package
   497  // which supports dynamic reloading of its backends.
   498  type DNSProvider struct {
   499  	// DNSAuthority is the single <hostname|IPv4|[IPv6]>:<port> of the DNS
   500  	// server to be used for resolution of DNS backends. If the address contains
   501  	// a hostname it will be resolved via the system DNS. If the port is left
   502  	// unspecified it will default to '53'. If this field is left unspecified
   503  	// the system DNS will be used for resolution of DNS backends.
   504  	DNSAuthority string `validate:"required,ip|hostname|hostname_port"`
   505  
   506  	// SRVLookup contains the service and domain name used to construct a SRV
   507  	// DNS query to lookup DNS backends. 'Domain' is required. 'Service' is
   508  	// optional and will be defaulted to 'dns' if left unspecified.
   509  	//
   510  	// Usage: If the resource record is 'unbound.service.consul', then the
   511  	// 'Service' is 'unbound' and the 'Domain' is 'service.consul'. The expected
   512  	// dNSName to be authenticated in the server certificate would be
   513  	// 'unbound.service.consul'. The 'proto' field of the SRV record MUST
   514  	// contain 'udp' and the 'port' field MUST be a valid port. In a Consul
   515  	// configuration file you would specify 'unbound.service.consul' as:
   516  	//
   517  	// services {
   518  	//   id      = "unbound-1" // Must be unique
   519  	//   name    = "unbound"
   520  	//   address = "10.77.77.77"
   521  	//   port    = 8053
   522  	//   tags    = ["udp"]
   523  	// }
   524  	//
   525  	// services {
   526  	//   id      = "unbound-2" // Must be unique
   527  	//   name    = "unbound"
   528  	//   address = "10.77.77.77"
   529  	//   port    = 8153
   530  	//   tags    = ["udp"]
   531  	// }
   532  	//
   533  	// If you've added the above to your Consul configuration file (and reloaded
   534  	// Consul) then you should be able to resolve the following dig query:
   535  	//
   536  	// $ dig @10.77.77.10 -t SRV _unbound._udp.service.consul +short
   537  	// 1 1 8053 0a4d4d4d.addr.dc1.consul.
   538  	// 1 1 8153 0a4d4d4d.addr.dc1.consul.
   539  	SRVLookup ServiceDomain `validate:"required"`
   540  }
   541  
   542  // HMACKeyConfig specifies a path to a file containing a hexadecimal-encoded
   543  // HMAC key. The key must represent exactly 256 bits (32 bytes) of random data
   544  // to be suitable for use as a 256-bit hashing key (e.g., the output of `openssl
   545  // rand -hex 32`).
   546  type HMACKeyConfig struct {
   547  	KeyFile string `validate:"required"`
   548  }
   549  
   550  // Load reads the HMAC key from the file, decodes it from hexadecimal, ensures
   551  // it represents exactly 256 bits (32 bytes), and returns it as a byte slice.
   552  func (hc *HMACKeyConfig) Load() ([]byte, error) {
   553  	contents, err := os.ReadFile(hc.KeyFile)
   554  	if err != nil {
   555  		return nil, err
   556  	}
   557  
   558  	decoded, err := hex.DecodeString(strings.TrimSpace(string(contents)))
   559  	if err != nil {
   560  		return nil, fmt.Errorf("invalid hexadecimal encoding: %w", err)
   561  	}
   562  
   563  	if len(decoded) != 32 {
   564  		return nil, fmt.Errorf(
   565  			"validating HMAC key, must be exactly 256 bits (32 bytes) after decoding, got %d",
   566  			len(decoded),
   567  		)
   568  	}
   569  	return decoded, nil
   570  }