Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
50 changes: 48 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
161 changes: 161 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading