Files
brianmcgee 1950b9f470 {cmd, pkg}: enforce a maximum ValidFor
Without an upper bound, a client or malicious peer could set `ValidFor`
to an absurdly large duration (e.g. 290 years), which is functionally
equivalent to no expiry.

Add `config.MaxValidFor` (default 30 days) and enforce it at all three ingress points: HTTP handler, CLI, and gossip import filter.
2026-04-10 15:33:47 +01:00

246 lines
8.0 KiB
Go

// Package config provides structures and functions for loading app config.
package config
import (
"errors"
"fmt"
"time"
"git.clan.lol/clan/data-mesher/pkg/model"
"git.clan.lol/clan/data-mesher/pkg/systemd"
"github.com/mitchellh/mapstructure"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
const (
// DefaultMaxFileSize is the default maximum file size (10 MiB).
DefaultMaxFileSize = 10 * 1024 * 1024
// DefaultSweepInterval is how often the background sweeper walks the signature
// store removing files whose ValidFor has elapsed.
DefaultSweepInterval = 60 * time.Second
// DefaultClockSkewTolerance is the slack the gossip ingress filter applies
// when rejecting already-expired remote signatures. A remote sig is rejected
// only if its effective expiry is more than this far in the past relative to
// the local clock.
DefaultClockSkewTolerance = 2 * time.Minute
// DefaultMaxValidFor is the upper bound on per-file TTLs. Any ValidFor duration
// exceeding this value is rejected at the HTTP, CLI, and gossip boundaries.
DefaultMaxValidFor = 30 * 24 * time.Hour // 30 days
)
// ErrInvalidConfig represents an error indicating that the provided configuration is invalid.
var ErrInvalidConfig = errors.New("invalid config")
// Config represents the application's configuration, containing settings for state directory, host, network, HTTP,
// cluster, and DNS.
type Config struct {
HTTP HTTP `mapstructure:"http"` // HTTP-related config such as ports and addresses
Cluster Cluster `mapstructure:"cluster"` // clustering related configuration
MaxFileSize int64 `mapstructure:"max_file_size"` // maximum allowed file size in bytes
MaxValidFor time.Duration `mapstructure:"max_valid_for"` // upper bound on per-file TTLs
StateDirectory string `mapstructure:"state_directory"` // directory in which to store any state
PluginDirectories []string `mapstructure:"plugin_directories"` // plugin subdirs per network
// SweepInterval controls how often the background sweeper walks the
// signature store removing files whose ValidFor has elapsed. Zero falls
// back to DefaultSweepInterval.
SweepInterval time.Duration `mapstructure:"sweep_interval"`
// ClockSkewTolerance is the slack applied by the gossip ingress filter
// when rejecting already-expired remote signatures. Zero falls back to
// DefaultClockSkewTolerance.
ClockSkewTolerance time.Duration `mapstructure:"clock_skew_tolerance"`
Network Network `mapstructure:"network"`
ExtraNetworks ExtraNetworks `mapstructure:"extra_networks"`
Now model.Clock `mapstructure:"-"`
SignatureAuth *SignatureAuth `mapstructure:"-"`
NetworksByID map[string]*Network `mapstructure:"-"`
}
// Validate checks the configuration fields for required values and validates associated components.
// Returns an error if invalid.
func (c *Config) Validate() error {
var err error
if c.StateDirectory == "" {
return fmt.Errorf("%w: state directory is required", ErrInvalidConfig)
}
// apply default max file size if not set
if c.MaxFileSize == 0 {
c.MaxFileSize = DefaultMaxFileSize
}
if c.MaxFileSize <= 0 {
return fmt.Errorf("%w: max_file_size must be positive", ErrInvalidConfig)
}
// apply default max valid for if not set
if c.MaxValidFor == 0 {
c.MaxValidFor = DefaultMaxValidFor
}
if c.MaxValidFor <= 0 {
return fmt.Errorf("%w: max_valid_for must be positive", ErrInvalidConfig)
}
// apply defaults for TTL-related settings
if c.SweepInterval == 0 {
c.SweepInterval = DefaultSweepInterval
}
if c.SweepInterval < 0 {
return fmt.Errorf("%w: sweep_interval must be non-negative", ErrInvalidConfig)
}
if c.ClockSkewTolerance == 0 {
c.ClockSkewTolerance = DefaultClockSkewTolerance
}
if c.ClockSkewTolerance < 0 {
return fmt.Errorf("%w: clock_skew_tolerance must be non-negative", ErrInvalidConfig)
}
if err = c.HTTP.Validate(); err != nil {
return err
}
if err = c.Network.Validate(); err != nil {
return err
}
// set the default network name
c.Network.Name = DefaultNetworkName
if err = c.Cluster.Validate(c.Network.ID); err != nil {
return err
}
if err = c.ExtraNetworks.Validate(); err != nil {
return err
}
// index networks by ID
c.NetworksByID = map[string]*Network{
c.Network.ID.String(): &c.Network,
}
for _, network := range c.ExtraNetworks {
c.NetworksByID[network.ID.String()] = &network
}
// construct a signature filter to help with accepting/rejecting signatures via the API or from other peers
c.SignatureAuth = NewSignatureAuth(c)
// set the clock to be used throughout the system
// this can be overridden for testing
c.Now = model.Now
return nil
}
// SetFlags initializes and attaches a set of configuration flags to the provided FlagSet.
func SetFlags(fs *pflag.FlagSet) {
fs.String("log_level", "info", "Log level (warn, info, debug)")
fs.Int64("max_file_size", DefaultMaxFileSize, "Maximum allowed file size in bytes")
fs.String("state_dir", "/var/lib/data-mesher", "Directory to store state")
fs.Duration("sweep_interval", DefaultSweepInterval,
"How often to walk the signature store removing files whose TTL has elapsed")
fs.Duration("clock_skew_tolerance", DefaultClockSkewTolerance,
"Tolerance applied when rejecting already-expired remote signatures during gossip")
fs.Uint16("cluster.port", 7946, "Port for cluster connections")
fs.StringSlice("cluster.interfaces", nil, "Network interfaces for cluster connections (empty = all)")
fs.StringSlice("cluster.bootstrap_peers", []string{}, "Bootstrap peer multiaddrs")
fs.String("cluster.push_pull_interval", "30s", "Push/pull interval")
fs.String("cluster.identity_key", "", "Path to libp2p identity key file")
fs.String(
"cluster.identity_cert",
"", "Path to identity certificate file (identity key signed by network key)",
)
fs.String("http.port", "7331", "Port to listen on for HTTP requests")
fs.StringSlice("http.interfaces", []string{"lo"}, "Interfaces to listen on for HTTP requests")
}
// NewViper initializes and returns a new viper instance configured for the application with environment and
// file settings.
func NewViper() (*viper.Viper, error) {
v := viper.New()
// set config type to TOML
v.SetConfigType("toml")
// bind env vars using Config as a template, allowing nested mappings
BindEnvVars(v, "DATA_MESHER", &Config{})
// bind systemd ENV variables to top-level config keys
if err := bindSystemdVars(v); err != nil {
return nil, err
}
// set config file name to dm.toml
v.SetConfigName("dm")
// look in the current working directory first
v.AddConfigPath(".")
// look in the systemd config directory next, falling back to /etc/data-mesher if not
v.AddConfigPath(systemd.ConfigDirectory("/etc/data-mesher"))
return v, nil
}
func bindSystemdVars(v *viper.Viper) error {
if err := v.BindEnv("state_directory", "STATE_DIRECTORY"); err != nil {
return fmt.Errorf("failed to bind systemd env var: %w", err)
}
return nil
}
// FromViper extracts configuration from a Viper instance, unmarshals it into a Config struct, and validates it.
func FromViper(v *viper.Viper) (*Config, error) {
cfg := &Config{}
// add some custom decoders
decoderOpts := viper.DecodeHook(
mapstructure.ComposeDecodeHookFunc(
mapstructure.TextUnmarshallerHookFunc(), // standard decoder
stringToPublicKeyHookFunc(), // string to crypto.PublicKey
stringToPublicKeyListFunc(), // string list to []*crypto.PublicKey
stringToPrivateKeyHookFunc(), // string to *crypto.PrivateKey
stringToCertificateHookFunc(), // string to *crypto.Certificate
stringToAddrInfoSliceHookFunc(), // string list to []peer.AddrInfo
mapstructure.StringToTimeDurationHookFunc(), // string to time
mapstructure.StringToSliceHookFunc(","), // handle lists
),
)
// unmarshal into config instance
if err := v.Unmarshal(cfg, decoderOpts); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// validate the config
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("failed to validate config: %w", err)
}
return cfg, nil
}