diff --git a/cmd/csaf_checker/config.go b/cmd/csaf_checker/config.go index ad28bc7..f76d360 100644 --- a/cmd/csaf_checker/config.go +++ b/cmd/csaf_checker/config.go @@ -11,19 +11,16 @@ package main import ( "crypto/tls" "errors" - "fmt" - "log" "net/http" - "os" - "github.com/BurntSushi/toml" - "github.com/csaf-poc/csaf_distribution/v2/util" - "github.com/jessevdk/go-flags" - "github.com/mitchellh/go-homedir" + "github.com/csaf-poc/csaf_distribution/v2/internal/options" ) +const defaultPreset = "mandatory" + type config struct { - Output string `short:"o" long:"output" description:"File name of the generated report" value-name:"REPORT-FILE" toml:"output"` + Output string `short:"o" long:"output" description:"File name of the generated report" value-name:"REPORT-FILE" toml:"output"` + //lint:ignore SA5008 We are using choice twice: json, html. Format string `short:"f" long:"format" choice:"json" choice:"html" description:"Format of report" default:"json" toml:"format"` Insecure bool `long:"insecure" description:"Do not check TLS certificates from provider" toml:"insecure"` ClientCert *string `long:"client-cert" description:"TLS client certificate file (PEM encoded data)" value-name:"CERT-FILE" toml:"client_cert"` @@ -38,80 +35,38 @@ type config struct { RemoteValidatorCache string `long:"validatorcache" description:"FILE to cache remote validations" value-name:"FILE" toml:"validator_cache"` RemoteValidatorPresets []string `long:"validatorpreset" description:"One or more presets to validate remotely" default:"mandatory" toml:"validator_preset"` - Config *string `short:"c" long:"config" description:"Path to config TOML file" value-name:"TOML-FILE" toml:"-"` + Config string `short:"c" long:"config" description:"Path to config TOML file" value-name:"TOML-FILE" toml:"-"` clientCerts []tls.Certificate } -// parseArgsConfig parse the command arguments and loads configuration -// from a configuration file. -func parseArgsConfig() ([]string, *config, error) { - cfg := &config{ - RemoteValidatorPresets: []string{"mandatory"}, - } - - parser := flags.NewParser(cfg, flags.Default) - parser.Usage = "[OPTIONS] domain..." - args, err := parser.Parse() - if err != nil { - return nil, nil, err - } - - if cfg.Version { - fmt.Println(util.SemVersion) - os.Exit(0) - } - - if cfg.Config != nil { - path, err := homedir.Expand(*cfg.Config) - if err != nil { - return nil, nil, err - } - if err := cfg.load(path); err != nil { - return nil, nil, err - } - } else if path := findConfigFile(); path != "" { - if err := cfg.load(path); err != nil { - return nil, nil, err - } - } - - return args, cfg, nil -} - -// configPaths are the potential file locations of the the config file. +// configPaths are the potential file locations of the config file. var configPaths = []string{ "~/.config/csaf/checker.toml", "~/.csaf_checker.toml", "csaf_checker.toml", } -// findConfigFile looks for a file in the pre-defined paths in "configPaths". -// The returned value will be the name of file if found, otherwise an empty string. -func findConfigFile() string { - for _, f := range configPaths { - name, err := homedir.Expand(f) - if err != nil { - log.Printf("warn: %v\n", err) - continue - } - if _, err := os.Stat(name); err == nil { - return name - } +// parseArgsConfig parse the command arguments and loads configuration +// from a configuration file. +func parseArgsConfig() ([]string, *config, error) { + p := options.Parser[config]{ + DefaultConfigLocations: configPaths, + ConfigLocation: func(cfg *config) string { + return cfg.Config + }, + Usage: "[OPTIONS] domain...", + SetDefaults: func(cfg *config) { + cfg.RemoteValidatorPresets = []string{defaultPreset} + }, + // Re-establish default values if not set. + EnsureDefaults: func(cfg *config) { + if cfg.RemoteValidatorPresets == nil { + cfg.RemoteValidatorPresets = []string{defaultPreset} + } + }, } - return "" -} - -// load loads a configuration from file. -func (cfg *config) load(path string) error { - md, err := toml.DecodeFile(path, &cfg) - if err != nil { - return err - } - if undecoded := md.Undecoded(); len(undecoded) != 0 { - return fmt.Errorf("could not parse %q from %q", undecoded, path) - } - return nil + return p.Parse() } // protectedAccess returns true if we have client certificates or @@ -139,13 +94,3 @@ func (cfg *config) prepare() error { } return nil } - -// errCheck checks if err is not nil and terminates the program if so. -func errCheck(err error) { - if err != nil { - if flags.WroteHelp(err) { - os.Exit(0) - } - log.Fatalf("error: %v\n", err) - } -} diff --git a/cmd/csaf_checker/main.go b/cmd/csaf_checker/main.go index 4ebd5f7..b360e6c 100644 --- a/cmd/csaf_checker/main.go +++ b/cmd/csaf_checker/main.go @@ -10,84 +10,11 @@ package main import ( - "bufio" - _ "embed" // Used for embedding. - "encoding/json" - "html/template" - "io" "log" - "os" + + "github.com/csaf-poc/csaf_distribution/v2/internal/options" ) -//go:embed tmpl/report.html -var reportHTML string - -// writeJSON writes the JSON encoding of the given report to the given stream. -// It returns nil, otherwise an error. -func writeJSON(report *Report, w io.WriteCloser) error { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - err := enc.Encode(report) - if e := w.Close(); err != nil { - err = e - } - return err -} - -// writeHTML writes the given report to the given writer, it uses the template -// in the "reportHTML" variable. It returns nil, otherwise an error. -func writeHTML(report *Report, w io.WriteCloser) error { - tmpl, err := template.New("Report HTML").Parse(reportHTML) - if err != nil { - w.Close() - return err - } - buf := bufio.NewWriter(w) - - if err := tmpl.Execute(buf, report); err != nil { - w.Close() - return err - } - - err = buf.Flush() - if e := w.Close(); err == nil { - err = e - } - return err -} - -type nopCloser struct{ io.Writer } - -func (nc *nopCloser) Close() error { return nil } - -// writeReport defines where to write the report according to the "output" flag option. -// It calls also the "writeJSON" or "writeHTML" function according to the "format" flag option. -func writeReport(report *Report, cfg *config) error { - - var w io.WriteCloser - - if cfg.Output == "" { - w = &nopCloser{os.Stdout} - } else { - f, err := os.Create(cfg.Output) - if err != nil { - return err - } - w = f - } - - var writer func(*Report, io.WriteCloser) error - - switch cfg.Format { - case "json": - writer = writeJSON - default: - writer = writeHTML - } - - return writer(report, w) -} - // run uses a processor to check all the given domains or direct urls // and generates a report. func run(cfg *config, domains []string) (*Report, error) { @@ -101,8 +28,8 @@ func run(cfg *config, domains []string) (*Report, error) { func main() { domains, cfg, err := parseArgsConfig() - - errCheck(cfg.prepare()) + options.ErrorCheck(err) + options.ErrorCheck(cfg.prepare()) if len(domains) == 0 { log.Println("No domain or direct url given.") @@ -110,7 +37,7 @@ func main() { } report, err := run(cfg, domains) - errCheck(err) + options.ErrorCheck(err) - errCheck(writeReport(report, cfg)) + options.ErrorCheck(report.write(cfg.Format, cfg.Output)) } diff --git a/cmd/csaf_checker/report.go b/cmd/csaf_checker/report.go index 07c8d9c..1653728 100644 --- a/cmd/csaf_checker/report.go +++ b/cmd/csaf_checker/report.go @@ -9,7 +9,13 @@ package main import ( + "bufio" + _ "embed" // Used for embedding. + "encoding/json" "fmt" + "html/template" + "io" + "os" "time" "github.com/csaf-poc/csaf_distribution/v2/csaf" @@ -104,3 +110,72 @@ func (r *Requirement) message(typ MessageType, texts ...string) { r.Messages = append(r.Messages, Message{Type: typ, Text: text}) } } + +// writeJSON writes the JSON encoding of the given report to the given stream. +// It returns nil, otherwise an error. +func (r *Report) writeJSON(w io.WriteCloser) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + err := enc.Encode(r) + if e := w.Close(); err != nil { + err = e + } + return err +} + +//go:embed tmpl/report.html +var reportHTML string + +// writeHTML writes the given report to the given writer, it uses the template +// in the "reportHTML" variable. It returns nil, otherwise an error. +func (r *Report) writeHTML(w io.WriteCloser) error { + tmpl, err := template.New("Report HTML").Parse(reportHTML) + if err != nil { + w.Close() + return err + } + buf := bufio.NewWriter(w) + + if err := tmpl.Execute(buf, r); err != nil { + w.Close() + return err + } + + err = buf.Flush() + if e := w.Close(); err == nil { + err = e + } + return err +} + +type nopCloser struct{ io.Writer } + +func (nc *nopCloser) Close() error { return nil } + +// write defines where to write the report according to the "output" flag option. +// It calls also the "writeJSON" or "writeHTML" function according to the "format" flag option. +func (r *Report) write(format, output string) error { + + var w io.WriteCloser + + if output == "" { + w = &nopCloser{os.Stdout} + } else { + f, err := os.Create(output) + if err != nil { + return err + } + w = f + } + + var writer func(*Report, io.WriteCloser) error + + switch format { + case "json": + writer = (*Report).writeJSON + default: + writer = (*Report).writeHTML + } + + return writer(r, w) +} diff --git a/cmd/csaf_downloader/config.go b/cmd/csaf_downloader/config.go index 3688da1..45afcdc 100644 --- a/cmd/csaf_downloader/config.go +++ b/cmd/csaf_downloader/config.go @@ -9,15 +9,9 @@ package main import ( - "fmt" - "log" "net/http" - "os" - "github.com/BurntSushi/toml" - "github.com/csaf-poc/csaf_distribution/v2/util" - "github.com/jessevdk/go-flags" - "github.com/mitchellh/go-homedir" + "github.com/csaf-poc/csaf_distribution/v2/internal/options" ) const ( @@ -43,106 +37,36 @@ type config struct { Config string `short:"c" long:"config" description:"Path to config TOML file" value-name:"TOML-FILE" toml:"-"` } -// configPaths are the potential file locations of the the config file. +// configPaths are the potential file locations of the config file. var configPaths = []string{ "~/.config/csaf/downloader.toml", "~/.csaf_downloader.toml", "csaf_downloader.toml", } -// newConfig returns a new configuration. -func newConfig() *config { - return &config{ - Worker: defaultWorker, - RemoteValidatorPresets: []string{defaultPreset}, - } -} - // parseArgsConfig parses the command line and if need a config file. func parseArgsConfig() ([]string, *config, error) { - - // Parse the command line first. - cmdLineCfg := newConfig() - parser := flags.NewParser(cmdLineCfg, flags.Default) - parser.Usage = "[OPTIONS] domain..." - args, err := parser.Parse() - if err != nil { - return nil, nil, err + p := options.Parser[config]{ + DefaultConfigLocations: configPaths, + ConfigLocation: func(cfg *config) string { + return cfg.Config + }, + Usage: "[OPTIONS] domain...", + SetDefaults: func(cfg *config) { + cfg.Worker = defaultWorker + cfg.RemoteValidatorPresets = []string{defaultPreset} + }, + // Re-establish default values if not set. + EnsureDefaults: func(cfg *config) { + if cfg.Worker == 0 { + cfg.Worker = defaultWorker + } + if cfg.RemoteValidatorPresets == nil { + cfg.RemoteValidatorPresets = []string{defaultPreset} + } + }, } - - // Directly quit if the version flag was set. - if cmdLineCfg.Version { - fmt.Println(util.SemVersion) - os.Exit(0) - } - - var path string - // Do we have a config file explicitly given by command line? - if cmdLineCfg.Config != "" { - path = cmdLineCfg.Config - } else { - path = findConfigFile() - } - // No config file -> We are good. - if path == "" { - return args, cmdLineCfg, nil - } - - if path, err = homedir.Expand(path); err != nil { - return nil, nil, err - } - - // Load the config file - fileCfg := &config{} - if err := fileCfg.load(path); err != nil { - return nil, nil, err - } - - // Parse command line a second time to overwrite the - // loaded config at places where explicitly command line - // options where given. - args, err = flags.NewParser(fileCfg, flags.Default).Parse() - if err != nil { - return nil, nil, err - } - - // Re-establish default values. - if fileCfg.Worker == 0 { - fileCfg.Worker = defaultWorker - } - if fileCfg.RemoteValidatorPresets == nil { - fileCfg.RemoteValidatorPresets = []string{defaultPreset} - } - - return args, fileCfg, nil -} - -// load loads a configuration from file. -func (cfg *config) load(path string) error { - md, err := toml.DecodeFile(path, &cfg) - if err != nil { - return err - } - if undecoded := md.Undecoded(); len(undecoded) != 0 { - return fmt.Errorf("could not parse %q from %q", undecoded, path) - } - return nil -} - -// findConfigFile looks for a file in the pre-defined paths in "configPath". -// The returned value will be the name of file if found, otherwise an empty string. -func findConfigFile() string { - for _, f := range configPaths { - name, err := homedir.Expand(f) - if err != nil { - log.Printf("warn: %v\n", err) - continue - } - if _, err := os.Stat(name); err == nil { - return name - } - } - return "" + return p.Parse() } // prepare prepares internal state of a loaded configuration. @@ -150,14 +74,3 @@ func (cfg *config) prepare() error { // TODO: Implement me! return nil } - -// errCheck checks if err is not nil and terminates -// the program if so. -func errCheck(err error) { - if err != nil { - if flags.WroteHelp(err) { - os.Exit(0) - } - log.Fatalf("error: %v\n", err) - } -} diff --git a/cmd/csaf_downloader/main.go b/cmd/csaf_downloader/main.go index 1b405b7..33d3b32 100644 --- a/cmd/csaf_downloader/main.go +++ b/cmd/csaf_downloader/main.go @@ -14,6 +14,8 @@ import ( "log" "os" "os/signal" + + "github.com/csaf-poc/csaf_distribution/v2/internal/options" ) func run(cfg *config, domains []string) error { @@ -34,13 +36,13 @@ func run(cfg *config, domains []string) error { func main() { domains, cfg, err := parseArgsConfig() - errCheck(err) - errCheck(cfg.prepare()) + options.ErrorCheck(err) + options.ErrorCheck(cfg.prepare()) if len(domains) == 0 { log.Println("No domains given.") return } - errCheck(run(cfg, domains)) + options.ErrorCheck(run(cfg, domains)) } diff --git a/internal/options/options.go b/internal/options/options.go new file mode 100644 index 0000000..b124a74 --- /dev/null +++ b/internal/options/options.go @@ -0,0 +1,147 @@ +// This file is Free Software under the MIT License +// without warranty, see README.md and LICENSES/MIT.txt for details. +// +// SPDX-License-Identifier: MIT +// +// SPDX-FileCopyrightText: 2022 German Federal Office for Information Security (BSI) +// Software-Engineering: 2022 Intevation GmbH + +// Package options helpers to handle command line options and config files. +package options + +import ( + "fmt" + "log" + "os" + + "github.com/BurntSushi/toml" + "github.com/jessevdk/go-flags" + "github.com/mitchellh/go-homedir" + + "github.com/csaf-poc/csaf_distribution/v2/util" +) + +// Parser helps parsing command line arguments and loading +// stored configurations from file. +type Parser[C any] struct { + // DefaultConfigLocations are the locations where to + // look for config files if no explicit config was given. + DefaultConfigLocations []string + // Usage is the usage prefix for written help. + Usage string + + // SetDefaults pre-inits a configuration. + SetDefaults func(*C) + // EnsureDefaults ensures that default values are set + // if they are not configured. + EnsureDefaults func(*C) + // HasVersion checks if there was a version request. + HasVersion func(*C) bool + // ConfigLocation extracts the name of the configuration file. + ConfigLocation func(*C) string +} + +// Parse parses the command line for options. +// If a config file was specified it is loaded. +// Returns the arguments and the configuration. +func (p *Parser[C]) Parse() ([]string, *C, error) { + + var cmdLineOpts C + if p.SetDefaults != nil { + p.SetDefaults(&cmdLineOpts) + } + // Parse the command line first. + parser := flags.NewParser(&cmdLineOpts, flags.Default) + if p.Usage != "" { + parser.Usage = p.Usage + } + args, err := parser.Parse() + if err != nil { + if flags.WroteHelp(err) { + os.Exit(0) + } + return nil, nil, err + } + + // Directly quit if the version flag was set. + if p.HasVersion != nil && p.HasVersion(&cmdLineOpts) { + fmt.Println(util.SemVersion) + os.Exit(0) + } + + var path string + // Do we have a config file explicitly given by command line? + if p.ConfigLocation != nil { + path = p.ConfigLocation(&cmdLineOpts) + } else { + path = findConfigFile(p.DefaultConfigLocations) + } + + // No config file -> We are good. + if path == "" { + return args, &cmdLineOpts, nil + } + + if path, err = homedir.Expand(path); err != nil { + return nil, nil, err + } + + // Load the config file + var fileOpts C + if err := loadTOML(&fileOpts, path); err != nil { + return nil, nil, err + } + + // Parse command line a second time to overwrite the + // loaded config at places where explicitly command line + // options where given. + args, err = flags.NewParser(&fileOpts, flags.Default).Parse() + if err != nil { + if flags.WroteHelp(err) { + os.Exit(0) + } + return nil, nil, err + } + + if p.EnsureDefaults != nil { + p.EnsureDefaults(&fileOpts) + } + + return args, &fileOpts, nil +} + +// findConfigFile looks for a file in the pre-defined paths in a list of given locations. +// The returned value will be the name of file if found, otherwise an empty string. +func findConfigFile(locations []string) string { + for _, f := range locations { + name, err := homedir.Expand(f) + if err != nil { + log.Printf("warn: %v\n", err) + continue + } + if _, err := os.Stat(name); err == nil { + return name + } + } + return "" +} + +// loadTOML loads a configuration from file. +func loadTOML(cfg any, path string) error { + md, err := toml.DecodeFile(path, cfg) + if err != nil { + return err + } + if undecoded := md.Undecoded(); len(undecoded) != 0 { + return fmt.Errorf("could not parse %q from %q", undecoded, path) + } + return nil +} + +// ErrorCheck checks if err is not nil and terminates +// the program if so. +func ErrorCheck(err error) { + if err != nil { + log.Fatalf("error: %v\n", err) + } +}