diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ef438e..46525ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # CHANGELOG +## 6.0.0 + +* `geoipupdate` now supports configuration via environment variables. Any + configuration set this way will override any value from the config file, + but still be overridden by any associated command line option (if any). + The following new environment variables are supported: + + * `GEOIPUPDATE_ACCOUNT_ID` + * `GEOIPUPDATE_ACCOUNT_ID_FILE` + * `GEOIPUPDATE_CONF_FILE` + * `GEOIPUPDATE_DB_DIR` + * `GEOIPUPDATE_EDITION_IDS` + * `GEOIPUPDATE_HOST` + * `GEOIPUPDATE_LICENSE_KEY` + * `GEOIPUPDATE_LICENSE_KEY_FILE` + * `GEOIPUPDATE_LOCK_FILE` + * `GEOIPUPDATE_PARALLELISM` + * `GEOIPUPDATE_PRESERVE_FILE_TIMES` + * `GEOIPUPDATE_PROXY` + * `GEOIPUPDATE_PROXY_USER_PASSWORD` + * `GEOIPUPDATE_RETRY_FOR` + * `GEOIPUPDATE_VERBOSE` + +* Changed the signature of `NewConfig` in `pkg/geoipupdate` to no longer accept + a positional config file path argument, which can now be passed in using the + option from `WithConfigFile` along with the other optional parameters. +* `geoipupdate` and `NewConfig` no longer require a config file to exist. + ## 5.1.1 (2023-05-08) * Based on feedback, the change to use a non-root user in 5.1.0 diff --git a/cmd/geoipupdate/args.go b/cmd/geoipupdate/args.go index d1b3a74c..bfcb0243 100644 --- a/cmd/geoipupdate/args.go +++ b/cmd/geoipupdate/args.go @@ -19,10 +19,15 @@ type Args struct { } func getArgs() *Args { + confFileDefault := vars.DefaultConfigFile + if value, ok := os.LookupEnv("GEOIPUPDATE_CONF_FILE"); ok { + confFileDefault = value + } + configFile := flag.StringP( "config-file", "f", - vars.DefaultConfigFile, + confFileDefault, "Configuration file", ) databaseDirectory := flag.StringP( @@ -49,11 +54,6 @@ func getArgs() *Args { os.Exit(0) } - if *configFile == "" { - log.Printf("You must provide a configuration file.") - printUsage() - } - if *parallelism < 0 { log.Printf("Parallelism must be a positive number") printUsage() diff --git a/cmd/geoipupdate/main.go b/cmd/geoipupdate/main.go index 5f5ebcf5..0a9bf0a5 100644 --- a/cmd/geoipupdate/main.go +++ b/cmd/geoipupdate/main.go @@ -3,7 +3,6 @@ package main import ( "context" - "fmt" "log" "os" @@ -41,14 +40,14 @@ func main() { } config, err := geoipupdate.NewConfig( - args.ConfigFile, + geoipupdate.WithConfigFile(args.ConfigFile), geoipupdate.WithDatabaseDirectory(args.DatabaseDirectory), geoipupdate.WithParallelism(args.Parallelism), geoipupdate.WithVerbose(args.Verbose), geoipupdate.WithOutput(args.Output), ) if err != nil { - fatalLogger(fmt.Sprintf("error loading configuration file %s", args.ConfigFile), err) + fatalLogger("error loading configuration", err) } if config.Verbose { diff --git a/doc/GeoIP.conf.md b/doc/GeoIP.conf.md index 272997a9..6ba9ca57 100644 --- a/doc/GeoIP.conf.md +++ b/doc/GeoIP.conf.md @@ -17,66 +17,80 @@ sensitive. `AccountID` -: Your MaxMind account ID. This was formerly known as `UserId`. +: Your MaxMind account ID. This was formerly known as `UserId`. This can be + overridden at run time by either the `GEOIPUPDATE_ACCOUNT_ID` or the + `GEOIPUPDATE_ACCOUNT_ID_FILE` environment variables. `LicenseKey` -: Your case-sensitive MaxMind license key. +: Your case-sensitive MaxMind license key. This can be overridden at run time + by either the `GEOIPUPDATE_LICENSE_KEY` or `GEOIPUPDATE_LICENSE_KEY_FILE` + environment variables. `EditionIDs` : List of space-separated database edition IDs. Edition IDs may consist of letters, digits, and dashes. For example, `GeoIP2-City` would - download the GeoIP2 City database (`GeoIP2-City`). Note: this was - formerly called `ProductIds`. + download the GeoIP2 City database (`GeoIP2-City`). This can be overridden + at run time by the `GEOIPUPDATE_EDITION_IDS` environment variable. Note: + this was formerly called `ProductIds`. ## Optional settings: `DatabaseDirectory` : The directory to store the database files. If not set, the default is - DATADIR. This can be overridden at run time by the `-d` command line - argument. + DATADIR. This can be overridden at run time by the `GEOIPUPDATE_DB_DIR` + environment variable or the `-d` command line argument. `Host` : The host name of the server to use. The default is `updates.maxmind.com`. + This can be overridden at run time by the `GEOIPUPDATE_HOST` environment + variable. `Proxy` -: The proxy host name or IP address. You may optionally specify - a port number, e.g., `127.0.0.1:8888`. If no port number is specified, - 1080 will be used. +: The proxy host name or IP address. You may optionally specify a port + number, e.g., `127.0.0.1:8888`. If no port number is specified, 1080 + will be used. This can be overridden at run time by the + `GEOIPUPDATE_PROXY` environment variable. `ProxyUserPassword` : The proxy user name and password, separated by a colon. For instance, - `username:password`. + `username:password`. This can be overridden at run time by the + `GEOIPUPDATE_PROXY_USER_PASSWORD` environment variable. `PreserveFileTimes` : Whether to preserve modification times of files downloaded from the - server. This option is either `0` or `1`. The default is `0`. + server. This option is either `0` or `1`. The default is `0`. This + can be overridden at run time by the `GEOIPUPDATE_PRESERVE_FILE_TIMES` + environment variable. `LockFile` : The lock file to use. This ensures only one `geoipupdate` process can run at a time. Note: Once created, this lockfile is not removed from the filesystem. The default is `.geoipupdate.lock` under the - `DatabaseDirectory`. + `DatabaseDirectory`. This can be overridden at run time by the + `GEOIPUPDATE_LOCK_FILE` environment variable. `RetryFor` : The amount of time to retry for when errors during HTTP transactions are encountered. It can be specified as a (possibly fractional) decimal number followed by a unit suffix. Valid time units are `ns`, `us` (or `µs`), `ms`, - `s`, `m`, `h`. The default is `5m` (5 minutes). + `s`, `m`, `h`. The default is `5m` (5 minutes). This can be overridden at + run time by the `GEOIPUPDATE_RETRY_FOR` environment variable. `Parallelism` -: The maximum number of parallel database downloads. The default is - 1, which means that databases will be downloaded sequentially. This can be - overriden at runtime by the `--parallelism` command line argument. +: The maximum number of parallel database downloads. The default is + 1, which means that databases will be downloaded sequentially. This can be + overridden at run time by the `GEOIPUPDATE_PARALLELISM` environment + variable or the `--parallelism` command line argument. ## Deprecated settings: diff --git a/doc/docker.md b/doc/docker.md index 569817eb..3197e271 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -10,16 +10,23 @@ The source code is available on [GitHub](https://github.com/maxmind/geoipupdate) The Docker image is configured by environment variables. The following variables are required: -* `GEOIPUPDATE_ACCOUNT_ID` - Your MaxMind account ID. -* `GEOIPUPDATE_LICENSE_KEY` - Your case-sensitive MaxMind license key. * `GEOIPUPDATE_EDITION_IDS` - List of space-separated database edition IDs. Edition IDs may consist of letters, digits, and dashes. For example, `GeoIP2-City` would download the GeoIP2 City database (`GeoIP2-City`). +One of: + +* `GEOIPUPDATE_ACCOUNT_ID` - Your MaxMind account ID. +* `GEOIPUPDATE_ACCOUNT_ID_FILE` - A file containing your MaxMind account ID. + +One of: + +* `GEOIPUPDATE_LICENSE_KEY` - Your case-sensitive MaxMind license key. +* `GEOIPUPDATE_LICENSE_KEY_FILE` - A file containing your case-sensitive + MaxMind license key. + The following are optional: -* `GEOIPUPDATE_ACCOUNT_ID_FILE` - The path to a file containing your MaxMind account ID. This is intended to be used with Docker secrets (example below). -* `GEOIPUPDATE_LICENSE_KEY_FILE` - The path to a file containing your case-sensitive MaxMind license key. This is intended to be used with Docker secrets (example below). * `GEOIPUPDATE_FREQUENCY` - The number of hours between `geoipupdate` runs. If this is not set or is set to `0`, `geoipupdate` will run once and exit. * `GEOIPUPDATE_HOST` - The host name of the server to use. The default is @@ -34,8 +41,8 @@ The following are optional: default is `0`. * `GEOIPUPDATE_VERBOSE` - Enable verbose mode. Prints out the steps that `geoipupdate` takes. Set to **anything** (e.g., `1`) to enable. -* `GEOIPUPDATE_CONF_FILE` - The path where the configuration file will be - written. The default is `/etc/GeoIP.conf`. +* `GEOIPUPDATE_CONF_FILE` - The path of a configuration file to be used by + `geoipupdate`. * `GEOIPUPDATE_DB_DIR` - The directory where geoipupdate will download the databases. The default is `/usr/share/GeoIP`. diff --git a/doc/geoipupdate.md b/doc/geoipupdate.md index 0cc7f0f2..e39b88db 100644 --- a/doc/geoipupdate.md +++ b/doc/geoipupdate.md @@ -21,12 +21,14 @@ open. `-d`, `--database-directory` : Install databases to a custom directory. This is optional. If provided, it - overrides any `DatabaseDirectory` set in the configuration file. + overrides the `DatabaseDirectory` value from the configuration file and the + `GEOIPUPDATE_DB_DIR` environment variable. `-f`, `--config-file` : The configuration file to use. See `GeoIP.conf` and its documentation for - more information. This is optional. It defaults to CONFFILE. + more information. This is optional. It defaults to the environment variable + `GEOIPUPDATE_CONF_FILE` if it is set, or CONFFILE otherwise. `--parallelism` @@ -47,7 +49,8 @@ open. `-v`, `--verbose` -: Enable verbose mode. Prints out the steps that `geoipupdate` takes. +: Enable verbose mode. Prints out the steps that `geoipupdate` takes. If + provided, it overrides any `GEOIPUPDATE_VERBOSE` environment variable. `-o`, `--output` @@ -72,9 +75,9 @@ runs `geoipupdate` on each Wednesday at noon: # end of crontab -To use with a proxy server, update your `GeoIP.conf` file as specified -in the `GeoIP.conf` man page or set the `http_proxy` environment -variable. +To use with a proxy server, update your `GeoIP.conf` file as specified in +the `GeoIP.conf` man page. Alternatively, set the `GEOIPUPDATE_PROXY` or +`http_proxy` environment variable. # BUGS diff --git a/docker/entry.sh b/docker/entry.sh index 85ea22a0..c2b5ebca 100755 --- a/docker/entry.sh +++ b/docker/entry.sh @@ -14,67 +14,36 @@ term_handler() { trap 'kill ${!}; term_handler' SIGTERM pid=0 -conf_file=/var/lib/geoipupdate/GeoIP.conf database_dir=/usr/share/GeoIP log_dir="/var/lib/geoipupdate" log_file="$log_dir/.healthcheck" flags="--output" frequency=$((GEOIPUPDATE_FREQUENCY * 60 * 60)) -if ! [ -z "$GEOIPUPDATE_CONF_FILE" ]; then - conf_file=$GEOIPUPDATE_CONF_FILE +if [ -z "$GEOIPUPDATE_DB_DIR" ]; then + GEOIPUPDATE_DB_DIR="$database_dir" fi -if ! [ -z "$GEOIPUPDATE_DB_DIR" ]; then - database_dir=$GEOIPUPDATE_DB_DIR -fi - -if [ ! -z "$GEOIPUPDATE_ACCOUNT_ID_FILE" ]; then - GEOIPUPDATE_ACCOUNT_ID=$( cat "$GEOIPUPDATE_ACCOUNT_ID_FILE" ) -fi - -if [ ! -z "$GEOIPUPDATE_LICENSE_KEY_FILE" ]; then - GEOIPUPDATE_LICENSE_KEY=$( cat "$GEOIPUPDATE_LICENSE_KEY_FILE" ) -fi - -if [ -z "$GEOIPUPDATE_ACCOUNT_ID" ] || [ -z "$GEOIPUPDATE_LICENSE_KEY" ] || [ -z "$GEOIPUPDATE_EDITION_IDS" ]; then - echo "ERROR: You must set the environment variables GEOIPUPDATE_ACCOUNT_ID, GEOIPUPDATE_LICENSE_KEY, and GEOIPUPDATE_EDITION_IDS!" +if [ -z "$GEOIPUPDATE_ACCOUNT_ID" ] && [ -z "$GEOIPUPDATE_ACCOUNT_ID_FILE" ]; then + echo "ERROR: You must set the environment variable GEOIPUPDATE_ACCOUNT_ID or GEOIPUPDATE_ACCOUNT_ID_FILE!" exit 1 fi -# Create configuration file -echo "# STATE: Creating configuration file at $conf_file" -cat < "$conf_file" -AccountID $GEOIPUPDATE_ACCOUNT_ID -LicenseKey $GEOIPUPDATE_LICENSE_KEY -EditionIDs $GEOIPUPDATE_EDITION_IDS -EOF - -if [ ! -z "$GEOIPUPDATE_HOST" ]; then - echo "Host $GEOIPUPDATE_HOST" >> "$conf_file" -fi - -if [ ! -z "$GEOIPUPDATE_PROXY" ]; then - echo "Proxy $GEOIPUPDATE_PROXY" >> "$conf_file" -fi - -if [ ! -z "$GEOIPUPDATE_PROXY_USER_PASSWORD" ]; then - echo "ProxyUserPassword $GEOIPUPDATE_PROXY_USER_PASSWORD" >> "$conf_file" -fi - -if [ ! -z "$GEOIPUPDATE_PRESERVE_FILE_TIMES" ]; then - echo "PreserveFileTimes $GEOIPUPDATE_PRESERVE_FILE_TIMES" >> "$conf_file" +if [ -z "$GEOIPUPDATE_LICENSE_KEY" ] && [ -z "$GEOIPUPDATE_LICENSE_KEY_FILE" ]; then + echo "ERROR: You must set the environment variable GEOIPUPDATE_LICENSE_KEY or GEOIPUPDATE_LICENSE_KEY_FILE!" + exit 1 fi -if [ "$GEOIPUPDATE_VERBOSE" ]; then - flags="$flags -v" +if [ -z "$GEOIPUPDATE_EDITION_IDS" ]; then + echo "ERROR: You must set the environment variable GEOIPUPDATE_EDITION_IDS!" + exit 1 fi mkdir -p $log_dir while true; do echo "# STATE: Running geoipupdate" - /usr/bin/geoipupdate -d "$database_dir" -f "$conf_file" $flags 1>$log_file + /usr/bin/geoipupdate $flags 1>$log_file if [ "$frequency" -eq 0 ]; then break fi diff --git a/pkg/geoipupdate/config.go b/pkg/geoipupdate/config.go index 13074d13..1cb18505 100644 --- a/pkg/geoipupdate/config.go +++ b/pkg/geoipupdate/config.go @@ -19,6 +19,9 @@ import ( type Config struct { // AccountID is the account ID. AccountID int + // confFile is the path to any configuration file used when + // potentially populating Config fields. + configFile string // DatabaseDirectory is where database files are going to be // stored. DatabaseDirectory string @@ -39,6 +42,10 @@ type Config struct { Parallelism int // Proxy is host name or IP address of a proxy server. Proxy *url.URL + // proxyURL is the host value of Proxy + proxyURL string + // proxyUserInfo is the userinfo value of Proxy + proxyUserInfo string // RetryFor is the retry timeout for HTTP requests. It defaults // to 5 minutes. RetryFor time.Duration @@ -98,20 +105,24 @@ func WithOutput(val bool) Option { } } -// NewConfig parses the configuration file. -// flagOptions is provided to provide optional flag overrides to the config -// file. -func NewConfig( //nolint: gocyclo // long but breaking it up may be worse - path string, - flagOptions ...Option, -) (*Config, error) { - fh, err := os.Open(filepath.Clean(path)) - if err != nil { - return nil, fmt.Errorf("error opening file: %w", err) +// WithConfigFile returns an Option that sets the configuration +// file to be used. +func WithConfigFile(file string) Option { + return func(c *Config) error { + if file != "" { + c.configFile = filepath.Clean(file) + } + return nil } +} - defer fh.Close() - +// NewConfig creates a new configuration and populates it based on an optional +// config file pointed to by an option set with WithConfigFile, then by various +// environment variables, and then finally by flag overrides provided by +// flagOptions. Values from the later override the former. +func NewConfig( + flagOptions ...Option, +) (*Config, error) { // config defaults config := &Config{ URL: "https://updates.maxmind.com", @@ -120,10 +131,77 @@ func NewConfig( //nolint: gocyclo // long but breaking it up may be worse Parallelism: 1, } + // Potentially populate config.configFilePath. We will rerun this function + // again later to ensure the flag values override env variables. + err := setConfigFromFlags(config, flagOptions...) + if err != nil { + return nil, err + } + + // Override config with values from the config file. + if confFile := config.configFile; confFile != "" { + err = setConfigFromFile(config, confFile) + if err != nil { + return nil, err + } + } + + // Override config with values from environment variables. + err = setConfigFromEnv(config) + if err != nil { + return nil, err + } + + // Override config with values from option flags. + err = setConfigFromFlags(config, flagOptions...) + if err != nil { + return nil, err + } + + // Set config values that depend on other config values. For instance + // proxyURL may have been set by the default config, and proxyUserInfo + // by config file. Both of these values need to be combined to create + // the public Proxy field that is a *url.URL. + + config.Proxy, err = parseProxy(config.proxyURL, config.proxyUserInfo) + if err != nil { + return nil, err + } + + if config.LockFile == "" { + config.LockFile = filepath.Join(config.DatabaseDirectory, ".geoipupdate.lock") + } + + // Validate config values now that all config sources have been considered and + // any value that may need to be created from other values has been set. + + err = validateConfig(config) + if err != nil { + return nil, err + } + + // Reset values that were only needed to communicate information between + // config overrides. + + config.configFile = "" + config.proxyURL = "" + config.proxyUserInfo = "" + + return config, nil +} + +// setConfigFromFile sets Config fields based on the configuration file. +func setConfigFromFile(config *Config, path string) error { + fh, err := os.Open(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("error opening file: %w", err) + } + + defer fh.Close() + scanner := bufio.NewScanner(fh) lineNumber := 0 keysSeen := map[string]struct{}{} - var proxy, proxyUserPassword string for scanner.Scan() { lineNumber++ line := strings.TrimSpace(scanner.Text()) @@ -133,13 +211,13 @@ func NewConfig( //nolint: gocyclo // long but breaking it up may be worse fields := strings.Fields(line) if len(fields) < 2 { - return nil, fmt.Errorf("invalid format on line %d", lineNumber) + return fmt.Errorf("invalid format on line %d", lineNumber) } key := fields[0] value := strings.Join(fields[1:], " ") if _, ok := keysSeen[key]; ok { - return nil, fmt.Errorf("`%s' is in the config multiple times", key) + return fmt.Errorf("`%s' is in the config multiple times", key) } keysSeen[key] = struct{}{} @@ -147,7 +225,7 @@ func NewConfig( //nolint: gocyclo // long but breaking it up may be worse case "AccountID", "UserId": accountID, err := strconv.Atoi(value) if err != nil { - return nil, fmt.Errorf("invalid account ID format: %w", err) + return fmt.Errorf("invalid account ID format") } config.AccountID = accountID keysSeen["AccountID"] = struct{}{} @@ -166,79 +244,170 @@ func NewConfig( //nolint: gocyclo // long but breaking it up may be worse config.LockFile = filepath.Clean(value) case "PreserveFileTimes": if value != "0" && value != "1" { - return nil, errors.New("`PreserveFileTimes' must be 0 or 1") - } - if value == "1" { - config.PreserveFileTimes = true + return errors.New("`PreserveFileTimes' must be 0 or 1") } + config.PreserveFileTimes = value == "1" case "Proxy": - proxy = value + config.proxyURL = value case "ProxyUserPassword": - proxyUserPassword = value + config.proxyUserInfo = value case "Protocol", "SkipHostnameVerification", "SkipPeerVerification": // Deprecated. case "RetryFor": dur, err := time.ParseDuration(value) if err != nil || dur < 0 { - return nil, fmt.Errorf("'%s' is not a valid duration", value) + return fmt.Errorf("'%s' is not a valid duration", value) } config.RetryFor = dur case "Parallelism": parallelism, err := strconv.Atoi(value) if err != nil { - return nil, fmt.Errorf("'%s' is not a valid parallelism value: %w", value, err) + return fmt.Errorf("'%s' is not a valid parallelism value: %w", value, err) } if parallelism <= 0 { - return nil, fmt.Errorf("parallelism should be greater than 0, got '%d'", parallelism) + return fmt.Errorf("parallelism should be greater than 0, got '%d'", parallelism) } config.Parallelism = parallelism default: - return nil, fmt.Errorf("unknown option on line %d", lineNumber) + return fmt.Errorf("unknown option on line %d", lineNumber) } } if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading file: %w", err) + return fmt.Errorf("error reading file: %w", err) } - // Mandatory values. - if _, ok := keysSeen["EditionIDs"]; !ok { - return nil, fmt.Errorf("the `EditionIDs` option is required") + return nil +} + +// setConfigFromEnv sets Config fields based on environment variables. +func setConfigFromEnv(config *Config) error { + if value, ok := os.LookupEnv("GEOIPUPDATE_ACCOUNT_ID"); ok { + var err error + config.AccountID, err = strconv.Atoi(value) + if err != nil { + return fmt.Errorf("invalid account ID format") + } } - if _, ok := keysSeen["AccountID"]; !ok { - return nil, fmt.Errorf("the `AccountID` option is required") + if value := os.Getenv("GEOIPUPDATE_ACCOUNT_ID_FILE"); value != "" { + var err error + + accountID, err := os.ReadFile(filepath.Clean(value)) + if err != nil { + return fmt.Errorf("failed to open GEOIPUPDATE_ACCOUNT_ID_FILE: %w", err) + } + + config.AccountID, err = strconv.Atoi(string(accountID)) + if err != nil { + return fmt.Errorf("invalid account ID format") + } } - if _, ok := keysSeen["LicenseKey"]; !ok { - return nil, fmt.Errorf("the `LicenseKey` option is required") + if value, ok := os.LookupEnv("GEOIPUPDATE_DB_DIR"); ok { + config.DatabaseDirectory = value } - // Overrides. - for _, option := range flagOptions { - if err := option(config); err != nil { - return nil, fmt.Errorf("error applying flag to config: %w", err) + if value, ok := os.LookupEnv("GEOIPUPDATE_EDITION_IDS"); ok { + config.EditionIDs = strings.Fields(value) + } + + if value, ok := os.LookupEnv("GEOIPUPDATE_HOST"); ok { + config.URL = "https://" + value + } + + if value, ok := os.LookupEnv("GEOIPUPDATE_LICENSE_KEY"); ok { + config.LicenseKey = value + } + + if value := os.Getenv("GEOIPUPDATE_LICENSE_KEY_FILE"); value != "" { + var err error + + licenseKey, err := os.ReadFile(filepath.Clean(value)) + if err != nil { + return fmt.Errorf("failed to open GEOIPUPDATE_LICENSE_KEY_FILE: %w", err) } + + config.LicenseKey = string(licenseKey) } - if config.LockFile == "" { - config.LockFile = filepath.Join(config.DatabaseDirectory, ".geoipupdate.lock") + if value, ok := os.LookupEnv("GEOIPUPDATE_LOCK_FILE"); ok { + config.LockFile = value } - config.Proxy, err = parseProxy(proxy, proxyUserPassword) - if err != nil { - return nil, err + if value, ok := os.LookupEnv("GEOIPUPDATE_PARALLELISM"); ok { + parallelism, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("'%s' is not a valid parallelism value: %w", value, err) + } + if parallelism <= 0 { + return fmt.Errorf("parallelism should be greater than 0, got '%d'", parallelism) + } + config.Parallelism = parallelism } + if value, ok := os.LookupEnv("GEOIPUPDATE_PRESERVE_FILE_TIMES"); ok { + if value != "0" && value != "1" { + return errors.New("`PreserveFileTimes' must be 0 or 1") + } + config.PreserveFileTimes = value == "1" + } + + if value, ok := os.LookupEnv("GEOIPUPDATE_PROXY"); ok { + config.proxyURL = value + } + + if value, ok := os.LookupEnv("GEOIPUPDATE_PROXY_USER_PASSWORD"); ok { + config.proxyUserInfo = value + } + + if value, ok := os.LookupEnv("GEOIPUPDATE_RETRY_FOR"); ok { + dur, err := time.ParseDuration(value) + if err != nil || dur < 0 { + return fmt.Errorf("'%s' is not a valid duration", value) + } + config.RetryFor = dur + } + + if value, ok := os.LookupEnv("GEOIPUPDATE_VERBOSE"); ok { + config.Verbose = value != "" + } + + return nil +} + +// setConfigFromFlags sets Config fields based on option flags. +func setConfigFromFlags(config *Config, flagOptions ...Option) error { + for _, option := range flagOptions { + if err := option(config); err != nil { + return fmt.Errorf("error applying flag to config: %w", err) + } + } + return nil +} + +func validateConfig(config *Config) error { // We used to recommend using 999999 / 000000000000 for free downloads // and many people still use this combination. With a real account id // and license key now being required, we want to give those people a // sensible error message. if (config.AccountID == 0 || config.AccountID == 999999) && config.LicenseKey == "000000000000" { - return nil, errors.New("geoipupdate requires a valid AccountID and LicenseKey combination") + return errors.New("geoipupdate requires a valid AccountID and LicenseKey combination") } - return config, nil + if len(config.EditionIDs) == 0 { + return fmt.Errorf("the `EditionIDs` option is required") + } + + if config.AccountID == 0 { + return fmt.Errorf("the `AccountID` option is required") + } + + if config.LicenseKey == "" { + return fmt.Errorf("the `LicenseKey` option is required") + } + + return nil } var schemeRE = regexp.MustCompile(`(?i)\A([a-z][a-z0-9+\-.]*)://`) diff --git a/pkg/geoipupdate/config_test.go b/pkg/geoipupdate/config_test.go index 49d09ca5..6cdb182d 100644 --- a/pkg/geoipupdate/config_test.go +++ b/pkg/geoipupdate/config_test.go @@ -5,6 +5,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "testing" "time" @@ -17,6 +18,7 @@ func TestNewConfig(t *testing.T) { tests := []struct { Description string Input string + Env map[string]string Flags []Option Output *Config Err string @@ -195,6 +197,8 @@ Parallelism 3 User: url.UserPassword("username", "password"), Host: "127.0.0.1:8888", }, + proxyURL: "", + proxyUserInfo: "", PreserveFileTimes: true, URL: "https://updates.example.com", RetryFor: 10 * time.Minute, @@ -227,7 +231,7 @@ UserId 456 Description: "Invalid account ID", Input: `AccountID 1a `, - Err: `invalid account ID format: strconv.Atoi: parsing "1a": invalid syntax`, + Err: `invalid account ID format`, }, { Description: "Invalid PreserveFileTimes", @@ -390,8 +394,7 @@ SkipPeerVerification 1 { Description: "CR line ending does not work", Input: "AccountID 0\rLicenseKey 123\rEditionIDs GeoIP2-City\r", - //nolint: lll - Err: `invalid account ID format: strconv.Atoi: parsing "0 LicenseKey 123 EditionIDs GeoIP2-City": invalid syntax`, + Err: `invalid account ID format`, }, { Description: "Multiple spaces between option and value works", @@ -425,26 +428,412 @@ EditionIDs GeoLite2-City GeoLite2-Country Parallelism: 1, }, }, + { + Description: "Config flags override env vars override config file", + Input: "AccountID\t\t123\nLicenseKey\t\t456\nParallelism\t\t1\n", + Env: map[string]string{ + "GEOIPUPDATE_DB_DIR": "/tmp/db", + "GEOIPUPDATE_EDITION_IDS": "GeoLite2-Country GeoLite2-City", + "GEOIPUPDATE_HOST": "updates.maxmind.com", + "GEOIPUPDATE_LICENSE_KEY": "000000000001", + "GEOIPUPDATE_LOCK_FILE": "/tmp/lock", + "GEOIPUPDATE_PARALLELISM": "2", + "GEOIPUPDATE_PRESERVE_FILE_TIMES": "1", + "GEOIPUPDATE_PROXY": "127.0.0.1:8888", + "GEOIPUPDATE_PROXY_USER_PASSWORD": "username:password", + "GEOIPUPDATE_RETRY_FOR": "1m", + "GEOIPUPDATE_VERBOSE": "1", + }, + Flags: []Option{WithParallelism(3)}, + Output: &Config{ + AccountID: 123, + DatabaseDirectory: "/tmp/db", + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + LicenseKey: "000000000001", + LockFile: "/tmp/lock", + Parallelism: 3, + PreserveFileTimes: true, + Proxy: &url.URL{ + Scheme: "http", + User: url.UserPassword("username", "password"), + Host: "127.0.0.1:8888", + }, + RetryFor: 1 * time.Minute, + URL: "https://updates.maxmind.com", + Verbose: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.Description, func(t *testing.T) { + withEnvVars(t, test.Env, func() { + tempName := filepath.Join(t.TempDir(), "/GeoIP-test.conf") + require.NoError(t, os.WriteFile(tempName, []byte(test.Input), 0o600)) + testFlags := append([]Option{WithConfigFile(tempName)}, test.Flags...) + config, err := NewConfig(testFlags...) + if test.Err == "" { + assert.NoError(t, err, test.Description) + } else { + assert.EqualError(t, err, test.Err, test.Description) + } + assert.Equal(t, test.Output, config, test.Description) + }) + }) } +} - tempFh, err := os.CreateTemp("", "conf-test") - require.NoError(t, err) - tempName := tempFh.Name() - require.NoError(t, tempFh.Close()) - defer func() { - _ = os.Remove(tempName) - }() +func TestSetConfigFromFile(t *testing.T) { + tests := []struct { + Description string + Input string + Expected Config + Err string + }{ + { + Description: "All config file related variables", + Input: `AccountID 1 + DatabaseDirectory /tmp/db + EditionIDs GeoLite2-Country GeoLite2-City + Host updates.maxmind.com + LicenseKey 000000000001 + LockFile /tmp/lock + Parallelism 2 + PreserveFileTimes 1 + Proxy 127.0.0.1:8888 + ProxyUserPassword username:password + RetryFor 1m + `, + Expected: Config{ + AccountID: 1, + DatabaseDirectory: filepath.Clean("/tmp/db"), + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + LicenseKey: "000000000001", + LockFile: filepath.Clean("/tmp/lock"), + Parallelism: 2, + PreserveFileTimes: true, + proxyURL: "127.0.0.1:8888", + proxyUserInfo: "username:password", + RetryFor: 1 * time.Minute, + URL: "https://updates.maxmind.com", + }, + }, + { + Description: "Empty config", + Input: "", + Expected: Config{}, + }, + { + Description: "Invalid account ID", + Input: "AccountID 1a", + Err: `invalid account ID format`, + }, + { + Description: "Invalid PreserveFileTimes", + Input: "PreserveFileTimes 1a", + Err: "`PreserveFileTimes' must be 0 or 1", + }, + { + Description: "RetryFor needs a unit", + Input: "RetryFor 5", + Err: "'5' is not a valid duration", + }, + { + Description: "RetryFor needs to be non-negative", + Input: "RetryFor -5m", + Err: "'-5m' is not a valid duration", + }, + { + Description: "Parallelism should be a number", + Input: "Parallelism a", + Err: "'a' is not a valid parallelism value: strconv.Atoi: parsing \"a\": invalid syntax", + }, + { + Description: "Parallelism should be a positive number", + Input: "Parallelism 0", + Err: "parallelism should be greater than 0, got '0'", + }, + } for _, test := range tests { t.Run(test.Description, func(t *testing.T) { + tempName := filepath.Join(t.TempDir(), "/GeoIP-test.conf") require.NoError(t, os.WriteFile(tempName, []byte(test.Input), 0o600)) - config, err := NewConfig(tempName, test.Flags...) + + var config Config + + err := setConfigFromFile(&config, tempName) + if test.Err == "" { + assert.NoError(t, err, test.Description) + } else { + assert.EqualError(t, err, test.Err, test.Description) + } + assert.Equal(t, test.Expected, config, test.Description) + }) + } +} + +func TestSetConfigFromEnv(t *testing.T) { + tests := []struct { + Description string + AccountIDFileContents string + LicenseKeyFileContents string + Env map[string]string + Expected Config + Err string + }{ + { + Description: "All config related environment variables", + Env: map[string]string{ + "GEOIPUPDATE_ACCOUNT_ID": "1", + "GEOIPUPDATE_ACCOUNT_ID_FILE": "", + "GEOIPUPDATE_DB_DIR": "/tmp/db", + "GEOIPUPDATE_EDITION_IDS": "GeoLite2-Country GeoLite2-City", + "GEOIPUPDATE_HOST": "updates.maxmind.com", + "GEOIPUPDATE_LICENSE_KEY": "000000000001", + "GEOIPUPDATE_LICENSE_KEY_FILE": "", + "GEOIPUPDATE_LOCK_FILE": "/tmp/lock", + "GEOIPUPDATE_PARALLELISM": "2", + "GEOIPUPDATE_PRESERVE_FILE_TIMES": "1", + "GEOIPUPDATE_PROXY": "127.0.0.1:8888", + "GEOIPUPDATE_PROXY_USER_PASSWORD": "username:password", + "GEOIPUPDATE_RETRY_FOR": "1m", + "GEOIPUPDATE_VERBOSE": "1", + }, + Expected: Config{ + AccountID: 1, + DatabaseDirectory: "/tmp/db", + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + LicenseKey: "000000000001", + LockFile: "/tmp/lock", + Parallelism: 2, + PreserveFileTimes: true, + proxyURL: "127.0.0.1:8888", + proxyUserInfo: "username:password", + RetryFor: 1 * time.Minute, + URL: "https://updates.maxmind.com", + Verbose: true, + }, + }, + { + Description: "ACCOUNT_ID_FILE and LICENSE_KEY_FILE override", + AccountIDFileContents: "2", + LicenseKeyFileContents: "000000000002", + Env: map[string]string{ + "GEOIPUPDATE_ACCOUNT_ID": "1", + "GEOIPUPDATE_ACCOUNT_ID_FILE": filepath.Join(t.TempDir(), "accountIDFile"), + "GEOIPUPDATE_DB_DIR": "/tmp/db", + "GEOIPUPDATE_EDITION_IDS": "GeoLite2-Country GeoLite2-City", + "GEOIPUPDATE_HOST": "updates.maxmind.com", + "GEOIPUPDATE_LICENSE_KEY": "000000000001", + "GEOIPUPDATE_LICENSE_KEY_FILE": filepath.Join(t.TempDir(), "licenseKeyFile"), + "GEOIPUPDATE_LOCK_FILE": "/tmp/lock", + "GEOIPUPDATE_PARALLELISM": "2", + "GEOIPUPDATE_PRESERVE_FILE_TIMES": "1", + "GEOIPUPDATE_PROXY": "127.0.0.1:8888", + "GEOIPUPDATE_PROXY_USER_PASSWORD": "username:password", + "GEOIPUPDATE_RETRY_FOR": "1m", + "GEOIPUPDATE_VERBOSE": "1", + }, + Expected: Config{ + AccountID: 2, + DatabaseDirectory: "/tmp/db", + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + LicenseKey: "000000000002", + LockFile: "/tmp/lock", + Parallelism: 2, + PreserveFileTimes: true, + proxyURL: "127.0.0.1:8888", + proxyUserInfo: "username:password", + RetryFor: 1 * time.Minute, + URL: "https://updates.maxmind.com", + Verbose: true, + }, + }, + { + Description: "Empty config", + Env: map[string]string{}, + Expected: Config{}, + }, + { + Description: "Invalid account ID", + Env: map[string]string{ + "GEOIPUPDATE_ACCOUNT_ID": "1a", + }, + Err: `invalid account ID format`, + }, + { + Description: "Invalid PreserveFileTimes", + Env: map[string]string{ + "GEOIPUPDATE_PRESERVE_FILE_TIMES": "1a", + }, + Err: "`PreserveFileTimes' must be 0 or 1", + }, + { + Description: "RetryFor needs a unit", + Env: map[string]string{ + "GEOIPUPDATE_RETRY_FOR": "5", + }, + Err: "'5' is not a valid duration", + }, + { + Description: "RetryFor needs to be non-negative", + Env: map[string]string{ + "GEOIPUPDATE_RETRY_FOR": "-5m", + }, + Err: "'-5m' is not a valid duration", + }, + { + Description: "Parallelism should be a number", + Env: map[string]string{ + "GEOIPUPDATE_PARALLELISM": "a", + }, + Err: "'a' is not a valid parallelism value: strconv.Atoi: parsing \"a\": invalid syntax", + }, + { + Description: "Parallelism should be a positive number", + Env: map[string]string{ + "GEOIPUPDATE_PARALLELISM": "0", + }, + Err: "parallelism should be greater than 0, got '0'", + }, + } + + for _, test := range tests { + t.Run(test.Description, func(t *testing.T) { + accountIDFile := test.Env["GEOIPUPDATE_ACCOUNT_ID_FILE"] + licenseKeyFile := test.Env["GEOIPUPDATE_LICENSE_KEY_FILE"] + + if test.AccountIDFileContents != "" { + require.NoError(t, os.WriteFile(accountIDFile, []byte(test.AccountIDFileContents), 0o600)) + } + + if test.LicenseKeyFileContents != "" { + require.NoError(t, os.WriteFile(licenseKeyFile, []byte(test.LicenseKeyFileContents), 0o600)) + } + + withEnvVars(t, test.Env, func() { + var config Config + + err := setConfigFromEnv(&config) + if test.Err == "" { + assert.NoError(t, err, test.Description) + } else { + assert.EqualError(t, err, test.Err, test.Description) + } + assert.Equal(t, test.Expected, config, test.Description) + }) + }) + } +} + +func TestSetConfigFromFlags(t *testing.T) { + tests := []struct { + Description string + Flags []Option + Expected Config + Err string + }{ + { + Description: "All option flag related config set", + Flags: []Option{ + WithDatabaseDirectory("/tmp/db"), + WithOutput(true), + WithParallelism(2), + WithVerbose(true), + }, + Expected: Config{ + DatabaseDirectory: filepath.Clean("/tmp/db"), + Output: true, + Parallelism: 2, + Verbose: true, + }, + }, + { + Description: "Empty config", + Flags: []Option{}, + Expected: Config{}, + }, + { + Description: "Parallelism should be a positive number", + Flags: []Option{WithParallelism(-1)}, + Err: "error applying flag to config: parallelism can't be negative, got '-1'", + }, + } + + for _, test := range tests { + t.Run(test.Description, func(t *testing.T) { + var config Config + + err := setConfigFromFlags(&config, test.Flags...) + if test.Err == "" { + assert.NoError(t, err, test.Description) + } else { + assert.EqualError(t, err, test.Err, test.Description) + } + assert.Equal(t, test.Expected, config, test.Description) + }) + } +} + +func TestValidateConfig(t *testing.T) { + tests := []struct { + Description string + Config Config + Err string + }{ + { + Description: "Basic config", + Config: Config{ + AccountID: 42, + LicenseKey: "000000000001", + DatabaseDirectory: "/tmp/db", + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + LockFile: "/tmp/lock", + URL: "https://updates.maxmind.com", + RetryFor: 5 * time.Minute, + Parallelism: 1, + }, + Err: "", + }, + { + Description: "EditionIDs required", + Config: Config{}, + Err: "the `EditionIDs` option is required", + }, + { + Description: "AccountID required", + Config: Config{ + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + }, + Err: "the `AccountID` option is required", + }, + { + Description: "LicenseKey required", + Config: Config{ + AccountID: 42, + EditionIDs: []string{"GeoLite2-Country", "GeoLite2-City"}, + }, + Err: "the `LicenseKey` option is required", + }, + { + Description: "Valid AccountID + LicenseKey combination", + Config: Config{ + AccountID: 999999, + LicenseKey: "000000000000", + }, + Err: "geoipupdate requires a valid AccountID and LicenseKey combination", + }, + } + + for _, test := range tests { + t.Run(test.Description, func(t *testing.T) { + err := validateConfig(&test.Config) if test.Err == "" { assert.NoError(t, err, test.Description) } else { assert.EqualError(t, err, test.Err, test.Description) } - assert.Equal(t, test.Output, config, test.Description) }) } } @@ -535,3 +924,25 @@ func TestParseProxy(t *testing.T) { ) } } + +func withEnvVars(t *testing.T, newEnvVars map[string]string, f func()) { + origEnv := os.Environ() + + for key, val := range newEnvVars { + err := os.Setenv(key, val) + require.NoError(t, err) + } + + // Execute the test + f() + + // Clean the environment + os.Clearenv() + + // Reset the original environment variables + for _, pair := range origEnv { + parts := strings.SplitN(pair, "=", 2) + err := os.Setenv(parts[0], parts[1]) + require.NoError(t, err) + } +}