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
61 changes: 57 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,46 @@ $ tlsctl client -o json badssl.com
}
```

With the default `--format-version 1`, single-target and multi-target output both preserve the legacy schema. For multiple targets, structured stdout contains only successful results and per-target runtime failures are still reported on stderr.

Use `--format-version 2` if you want the same envelope for both single-target and multi-target client output:

```bash
$ tlsctl client -o json --format-version 2 github.com missing.example.com
```

```json
{
"status": "partial_success",
"summary": {
"total": 2,
"succeeded": 1,
"failed": 1
},
"results": [
{
"target": "github.com:443",
"status": "success",
"tls_status": "secure",
"result": {
"certificates": [
{
"type": "leaf",
"common_name": "github.com"
}
],
"verified": true
}
},
{
"target": "missing.example.com:443",
"status": "failure",
"error": "connection failed: dial tcp: lookup missing.example.com: no such host"
}
]
}
```

When `--tls-versions` is enabled, each `tls_versions` entry includes:

```json
Expand Down Expand Up @@ -394,9 +434,13 @@ certificates:
verified: true
```

With the default `--format-version 1`, YAML also preserves the legacy behavior for both single-target and multi-target runs. Use `--format-version 2` if you want the stable `status` / `summary` / `results` envelope for both single-target and multi-target client output.

In version 2 output, `status` describes whether the query itself succeeded, while `tls_status` describes the TLS health of successful results (`secure`, `expiring`, `insecure`, or `revocation_error`).

### CSV output

Use `-o csv` for a concise, spreadsheet-friendly summary. Each input produces one row based on the leaf certificate:
Use `-o csv` for a concise, spreadsheet-friendly summary. Single-target output produces one row based on the leaf certificate:

```bash
$ tlsctl client -o csv badssl.com
Expand All @@ -407,6 +451,14 @@ target,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alt
badssl.com:443,*.badssl.com,"CN=R13,O=Let's Encrypt,C=US",2026-01-20T20:02:51Z,2026-04-20T20:02:50Z,90,b4:5a:53:24:32:d9:8f:62:b6:ea:f1:47:32:06:10:f1:...,"*.badssl.com; badssl.com"
```

Use `--format-version 2` with `csv` or `csv-full` if you want failed targets included inline with `status` and `error` columns:

```csv
target,status,tls_status,error,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names
github.com:443,success,secure,,github.com,"CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB",2026-03-06T00:00:00Z,2026-06-03T23:59:59Z,89,ab:cd:ef:...,"github.com; www.github.com"
missing.example.com:443,failure,,connection failed: dial tcp: lookup missing.example.com: no such host,,,,,,,
```

Use `-o csv-full` if you want the row-per-certificate export with the wider field set.

### Raw PEM output
Expand Down Expand Up @@ -537,8 +589,8 @@ tlsctl client -o json example.com | jq -r '.certificates[] | "\(.type): \(.seria
# Count the number of SANs on the leaf certificate
tlsctl client -o json example.com | jq '.certificates[] | select(.type == "leaf") | .subject_alternative_names | length'

# Check multiple hosts and report their expiry dates
tlsctl client -o json google.com github.com | jq -r '.[] | .certificates[] | select(.type == "leaf") | "\(.common_name) expires \(.not_after)"'
# Check multiple hosts and report their expiry dates with the stable v2 envelope
tlsctl client -o json --format-version 2 google.com github.com | jq -r '.results[] | select(.status == "success") | .result.certificates[] | select(.type == "leaf") | "\(.common_name) expires \(.not_after)"'
```

## Output formats
Expand Down Expand Up @@ -633,6 +685,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul
"client": {
"quiet": true,
"expiry-warning": 21,
"format-version": 2,
"proxy": "http://proxy:8080",
"tls-versions": true,
"revocation-soft-fail": false
Expand All @@ -644,7 +697,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul
}
```

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.
The `global` section applies to all subcommands and supports: `no-color`, `quiet`, `expiry-warning`, `output`, `cacert`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. The `client` section also supports `format-version` for versioned `json`, `yaml`, `csv`, and `csv-full` output. 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
75 changes: 70 additions & 5 deletions cmd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func validateRevocationMode(mode string) error {

func newClientCmd(rt *Runtime) *cobra.Command {
var outputFormat string
var formatVersion int
var caCertFile string
var proxyURL string
var inputFile string
Expand Down Expand Up @@ -73,6 +74,9 @@ func newClientCmd(rt *Runtime) *cobra.Command {
if err := validateRevocationMode(rf.mode); err != nil {
return err
}
if err := validateOutputFormatVersion(output.Format(outputFormat), formatVersion); err != nil {
return err
}

if startTLS != "" && !tlsquery.ValidStartTLSProtocol(startTLS) {
return fmt.Errorf("invalid --starttls protocol %q: must be one of %s", startTLS, tlsquery.StartTLSProtocolList())
Expand All @@ -95,6 +99,7 @@ func newClientCmd(rt *Runtime) *cobra.Command {
renderOpts := output.Options{
Now: func() time.Time { return now },
ExpiryWarningDays: expiryWarningDays,
FormatVersion: formatVersion,
}

var revocationFn func(*tlsquery.ChainInfo)
Expand All @@ -105,26 +110,29 @@ func newClientCmd(rt *Runtime) *cobra.Command {
}

results := queryTargets(targets, opts, revocationFn)
var chains []*tlsquery.ChainInfo
var runtimeErrors []error
for _, result := range results {
if result.err != nil {
runtimeErrors = append(runtimeErrors, fmt.Errorf("%s: %w", result.endpoint, result.err))
continue
}
updateExitCodeForChain(rt.ExitTracker, result.chain, now, expiryWarningDays)
chains = append(chains, result.chain)
}

renderedRuntimeErrors := false
if !quiet {
if err := renderChains(rt.Stdout, output.Format(outputFormat), chains, renderOpts); err != nil {
var err error
renderedRuntimeErrors, err = renderTargetResults(rt.Stdout, output.Format(outputFormat), results, renderOpts)
if err != nil {
return err
}
}

if len(runtimeErrors) > 0 {
for _, err := range runtimeErrors {
fmt.Fprintln(rt.Stderr, err)
if quiet || !renderedRuntimeErrors {
for _, err := range runtimeErrors {
fmt.Fprintln(rt.Stderr, err)
}
}
rt.ExitTracker.Set(ExitRuntimeError)
}
Expand All @@ -133,6 +141,7 @@ func newClientCmd(rt *Runtime) *cobra.Command {
}

cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: human (default), json, yaml, csv, csv-full, text (verbose), raw (PEM)")
cmd.Flags().IntVar(&formatVersion, "format-version", 1, "Structured output format version for client json, yaml, csv, or csv-full output")
cmd.Flags().StringVar(&caCertFile, "cacert", "", "Path to CA certificate file (PEM format)")
cmd.Flags().StringVarP(&proxyURL, "proxy", "x", "", "Proxy URL (e.g. http://proxy:8080). Falls back to HTTPS_PROXY/HTTP_PROXY env vars if not set")
cmd.Flags().StringVar(&inputFile, "file", "", "Read endpoints from file (one per line, '-' for stdin)")
Expand All @@ -145,6 +154,21 @@ func newClientCmd(rt *Runtime) *cobra.Command {
return cmd
}

func validateOutputFormatVersion(format output.Format, version int) error {
if version < 1 || version > 2 {
return fmt.Errorf("--format-version must be 1 or 2")
}
if version == 1 {
return nil
}
switch format {
case output.FormatJSON, output.FormatYAML, output.FormatCSV, output.FormatCSVFull:
return nil
default:
return fmt.Errorf("--format-version 2 is only supported with --output json, yaml, csv, or csv-full")
}
}

func runRevocationCheck(chain *tlsquery.ChainInfo, mode string, timeout time.Duration, softFail bool) {
if len(chain.Certificates) == 0 {
return
Expand Down Expand Up @@ -344,6 +368,47 @@ func renderChains(w io.Writer, format output.Format, chains []*tlsquery.ChainInf
return nil
}

func renderTargetResults(w io.Writer, format output.Format, results []targetResult, opts output.Options) (bool, error) {
if len(results) == 0 {
return false, nil
}

renderer, err := output.New(format)
if err != nil {
return false, err
}

if opts.FormatVersionOrDefault() >= 2 {
if batchRenderer, ok := renderer.(output.BatchRenderer); ok {
return true, batchRenderer.RenderBatch(w, toOutputTargetResults(results), opts)
}
}

var chains []*tlsquery.ChainInfo
for _, result := range results {
if result.err == nil {
chains = append(chains, result.chain)
}
}

return false, renderChains(w, format, chains, opts)
}

func toOutputTargetResults(results []targetResult) []output.TargetResult {
batch := make([]output.TargetResult, len(results))
for i, result := range results {
batch[i] = output.TargetResult{
Target: result.endpoint,
}
if result.err != nil {
batch[i].Error = result.err.Error()
continue
}
batch[i].Result = result.chain
}
return batch
}

func init() {
rootCmd.AddCommand(newClientCmd(defaultRuntime))
}
34 changes: 34 additions & 0 deletions cmd/client_targets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"reflect"
"testing"

"github.com/catay/tlsctl/internal/output"
)

func TestCollectTargets(t *testing.T) {
Expand Down Expand Up @@ -80,3 +82,35 @@ example.org:8443 # inline comment
})
}
}

func TestValidateOutputFormatVersion(t *testing.T) {
tests := []struct {
name string
format string
version int
wantErr bool
}{
{name: "default version", format: "", version: 1},
{name: "json v2", format: "json", version: 2},
{name: "yaml v2", format: "yaml", version: 2},
{name: "invalid version", format: "json", version: 3, wantErr: true},
{name: "csv v2", format: "csv", version: 2},
{name: "csv full v2", format: "csv-full", version: 2},
{name: "human v2 unsupported", format: "", version: 2, wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateOutputFormatVersion(output.Format(tt.format), tt.version)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
Loading
Loading