diff --git a/README.md b/README.md index 94105e9..df8ddc4 100644 --- a/README.md +++ b/README.md @@ -577,8 +577,9 @@ tlsctl client --no-color example.com | tee cert.log `tlsctl` supports a `settings.json` configuration file for defining default values per subcommand. Configuration values are applied in the following order of precedence: 1. **CLI arguments** (highest priority) -2. **Values from `settings.json`** -3. **Built-in defaults** (lowest priority) +2. **Subcommand-specific values from `settings.json`** +3. **Global values from `settings.json`** +4. **Built-in defaults** (lowest priority) ### File location @@ -604,26 +605,29 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul { "global": { "no-color": false, - "quiet": false + "quiet": false, + "expiry-warning": 30, + "output": "json", + "cacert": "/etc/ssl/certs/ca.pem", + "revocation": "ocsp", + "revocation-timeout": "10s", + "revocation-soft-fail": true }, "client": { + "quiet": true, "expiry-warning": 21, - "output": "json", "proxy": "http://proxy:8080", "tls-versions": true, - "revocation": "ocsp", - "revocation-timeout": "10s", "revocation-soft-fail": false }, "pem": { "expiry-warning": 7, - "output": "yaml", - "cacert": "/etc/ssl/certs/ca.pem" + "output": "yaml" } } ``` -The `global` section applies to all subcommands. Each subcommand section (`client`, `pem`) supports the same keys as the corresponding CLI flags. Only set the values you want to override — omitted keys use built-in defaults. +The `global` section applies to all subcommands and supports: `no-color`, `quiet`, `expiry-warning`, `output`, `cacert`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. Each subcommand section (`client`, `pem`) can override any global value with subcommand-specific settings. Only set the values you want to override — omitted keys inherit from `global` or use built-in defaults. Invalid JSON, unknown keys, and invalid values (e.g., out-of-range `expiry-warning`) produce clear error messages. diff --git a/internal/config/config.go b/internal/config/config.go index 9656d79..4e521bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,12 +38,20 @@ func (d Duration) MarshalJSON() ([]byte, error) { // GlobalSettings holds settings that apply to all subcommands. type GlobalSettings struct { - NoColor *bool `json:"no-color,omitempty"` - Quiet *bool `json:"quiet,omitempty"` + NoColor *bool `json:"no-color,omitempty"` + Quiet *bool `json:"quiet,omitempty"` + ExpiryWarning *int `json:"expiry-warning,omitempty"` + Output *string `json:"output,omitempty"` + CACert *string `json:"cacert,omitempty"` + Revocation *string `json:"revocation,omitempty"` + RevocationTimeout *Duration `json:"revocation-timeout,omitempty"` + RevocationSoftFail *bool `json:"revocation-soft-fail,omitempty"` } // ClientSettings holds settings for the client subcommand. type ClientSettings struct { + NoColor *bool `json:"no-color,omitempty"` + Quiet *bool `json:"quiet,omitempty"` ExpiryWarning *int `json:"expiry-warning,omitempty"` Output *string `json:"output,omitempty"` CACert *string `json:"cacert,omitempty"` @@ -59,6 +67,8 @@ type ClientSettings struct { // PemSettings holds settings for the pem subcommand. type PemSettings struct { + NoColor *bool `json:"no-color,omitempty"` + Quiet *bool `json:"quiet,omitempty"` ExpiryWarning *int `json:"expiry-warning,omitempty"` Output *string `json:"output,omitempty"` CACert *string `json:"cacert,omitempty"` @@ -110,6 +120,12 @@ func Load(path string, explicit bool) (*Settings, error) { } func (s *Settings) validate() error { + if err := validateExpiryWarning(s.Global.ExpiryWarning); err != nil { + return fmt.Errorf("global.expiry-warning: %w", err) + } + if err := validateRevocationMode(s.Global.Revocation); err != nil { + return fmt.Errorf("global.revocation: %w", err) + } if err := validateExpiryWarning(s.Client.ExpiryWarning); err != nil { return fmt.Errorf("client.expiry-warning: %w", err) } @@ -175,6 +191,24 @@ func (s *Settings) FlagValues(subcommand string) map[string]string { if s.Global.Quiet != nil { vals["quiet"] = boolStr(*s.Global.Quiet) } + if s.Global.ExpiryWarning != nil { + vals["expiry-warning"] = fmt.Sprintf("%d", *s.Global.ExpiryWarning) + } + if s.Global.Output != nil { + vals["output"] = *s.Global.Output + } + if s.Global.CACert != nil { + vals["cacert"] = *s.Global.CACert + } + if s.Global.Revocation != nil { + vals["revocation"] = *s.Global.Revocation + } + if s.Global.RevocationTimeout != nil { + vals["revocation-timeout"] = s.Global.RevocationTimeout.String() + } + if s.Global.RevocationSoftFail != nil { + vals["revocation-soft-fail"] = boolStr(*s.Global.RevocationSoftFail) + } // Apply subcommand-specific settings (overrides global if same key). switch subcommand { @@ -188,6 +222,12 @@ func (s *Settings) FlagValues(subcommand string) map[string]string { } func addClientFlags(vals map[string]string, c *ClientSettings) { + if c.NoColor != nil { + vals["no-color"] = boolStr(*c.NoColor) + } + if c.Quiet != nil { + vals["quiet"] = boolStr(*c.Quiet) + } if c.ExpiryWarning != nil { vals["expiry-warning"] = fmt.Sprintf("%d", *c.ExpiryWarning) } @@ -224,6 +264,12 @@ func addClientFlags(vals map[string]string, c *ClientSettings) { } func addPemFlags(vals map[string]string, p *PemSettings) { + if p.NoColor != nil { + vals["no-color"] = boolStr(*p.NoColor) + } + if p.Quiet != nil { + vals["quiet"] = boolStr(*p.Quiet) + } if p.ExpiryWarning != nil { vals["expiry-warning"] = fmt.Sprintf("%d", *p.ExpiryWarning) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d848e5c..dd7d773 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -257,6 +257,167 @@ func TestLoad_EmptyFile(t *testing.T) { } } +func TestFlagValues_GlobalOverriddenBySubcommand(t *testing.T) { + globalExpiry := 30 + clientExpiry := 21 + globalOutput := "yaml" + clientOutput := "json" + globalCACert := "/global/ca.pem" + + s := &Settings{ + Global: GlobalSettings{ + ExpiryWarning: &globalExpiry, + Output: &globalOutput, + CACert: &globalCACert, + }, + Client: ClientSettings{ + ExpiryWarning: &clientExpiry, + Output: &clientOutput, + }, + } + + vals := s.FlagValues("client") + if vals["expiry-warning"] != "21" { + t.Errorf("expected client override expiry-warning=21, got %s", vals["expiry-warning"]) + } + if vals["output"] != "json" { + t.Errorf("expected client override output=json, got %s", vals["output"]) + } + if vals["cacert"] != "/global/ca.pem" { + t.Errorf("expected global cacert, got %s", vals["cacert"]) + } +} + +func TestFlagValues_GlobalAppliedWhenSubcommandUnset(t *testing.T) { + globalExpiry := 30 + globalOutput := "yaml" + globalRevocation := "ocsp" + + s := &Settings{ + Global: GlobalSettings{ + ExpiryWarning: &globalExpiry, + Output: &globalOutput, + Revocation: &globalRevocation, + }, + } + + vals := s.FlagValues("pem") + if vals["expiry-warning"] != "30" { + t.Errorf("expected global expiry-warning=30, got %s", vals["expiry-warning"]) + } + if vals["output"] != "yaml" { + t.Errorf("expected global output=yaml, got %s", vals["output"]) + } + if vals["revocation"] != "ocsp" { + t.Errorf("expected global revocation=ocsp, got %s", vals["revocation"]) + } +} + +func TestFlagValues_SubcommandOverridesGlobalNoColorQuiet(t *testing.T) { + globalNoColor := true + globalQuiet := true + clientNoColor := false + pemQuiet := false + + s := &Settings{ + Global: GlobalSettings{ + NoColor: &globalNoColor, + Quiet: &globalQuiet, + }, + Client: ClientSettings{ + NoColor: &clientNoColor, + }, + Pem: PemSettings{ + Quiet: &pemQuiet, + }, + } + + clientVals := s.FlagValues("client") + if clientVals["no-color"] != "false" { + t.Errorf("expected client override no-color=false, got %s", clientVals["no-color"]) + } + if clientVals["quiet"] != "true" { + t.Errorf("expected global quiet=true for client, got %s", clientVals["quiet"]) + } + + pemVals := s.FlagValues("pem") + if pemVals["no-color"] != "true" { + t.Errorf("expected global no-color=true for pem, got %s", pemVals["no-color"]) + } + if pemVals["quiet"] != "false" { + t.Errorf("expected pem override quiet=false, got %s", pemVals["quiet"]) + } +} + +func TestLoad_InvalidGlobalExpiryWarning(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if err := os.WriteFile(path, []byte(`{"global": {"expiry-warning": 0}}`), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + _, err := Load(path, false) + if err == nil { + t.Fatal("expected validation error for global.expiry-warning") + } +} + +func TestLoad_InvalidGlobalRevocation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if err := os.WriteFile(path, []byte(`{"global": {"revocation": "invalid"}}`), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + _, err := Load(path, false) + if err == nil { + t.Fatal("expected validation error for global.revocation") + } +} + +func TestLoad_ValidGlobalConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if err := os.WriteFile(path, []byte(`{ + "global": { + "no-color": true, + "quiet": false, + "expiry-warning": 45, + "output": "json", + "cacert": "/etc/ssl/ca.pem", + "revocation": "crl", + "revocation-timeout": "8s", + "revocation-soft-fail": false + } + }`), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + s, err := Load(path, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if s.Global.ExpiryWarning == nil || *s.Global.ExpiryWarning != 45 { + t.Error("expected global.expiry-warning = 45") + } + if s.Global.Output == nil || *s.Global.Output != "json" { + t.Error("expected global.output = json") + } + if s.Global.CACert == nil || *s.Global.CACert != "/etc/ssl/ca.pem" { + t.Error("expected global.cacert = /etc/ssl/ca.pem") + } + if s.Global.Revocation == nil || *s.Global.Revocation != "crl" { + t.Error("expected global.revocation = crl") + } + if s.Global.RevocationTimeout == nil || s.Global.RevocationTimeout.Duration != 8*time.Second { + t.Error("expected global.revocation-timeout = 8s") + } + if s.Global.RevocationSoftFail == nil || *s.Global.RevocationSoftFail { + t.Error("expected global.revocation-soft-fail = false") + } +} + func TestDuration_UnmarshalJSON(t *testing.T) { tests := []struct { name string