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
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ tlsctl client --file hosts.txt
# Probe supported TLS versions and cipher suites
tlsctl client --tls-versions example.com

# Show what the default handshake negotiated
tlsctl client example.com

# Negotiate ALPN explicitly
tlsctl client --alpn h2,http/1.1 example.com

# Use a custom port
tlsctl client example.com:8443

Expand All @@ -142,6 +148,7 @@ $ tlsctl client badssl.com
Subject: CN=*.badssl.com
Issuer: CN=R13,O=Let's Encrypt,C=US
Validity: 2026-01-20 → 2026-04-20
Handshake: TLS 1.2 / TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
SANs: *.badssl.com, badssl.com

Chain: *.badssl.com → R13 (2 certificates)
Expand Down Expand Up @@ -273,6 +280,29 @@ In human output, insecure cipher suites are highlighted in red and tagged with `
In non-human outputs (`json`, `yaml`, `csv-full`, and `text`), cipher suites are split into
`secure_cipher_suites` and `insecure_cipher_suites`.

### Showing negotiated handshake details

The human-readable client output always shows the negotiated TLS version, cipher suite, and,
when present, ALPN for the default connection. If you want ALPN to be negotiated, advertise
protocols with `--alpn`:

```
$ tlsctl client --alpn h2,http/1.1 github.com
github.com (secure, expires in 84 days) ✓
Subject: CN=github.com
Issuer: CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB
Validity: 2026-03-06 → 2026-06-03
Handshake: TLS 1.3 / TLS_AES_128_GCM_SHA256 / h2
SANs: github.com, www.github.com

Chain: github.com → Sectigo Public Server Authentication CA DV E36 → Sectigo Public Server Authentication Root E46 (3 certificates)
```

Structured client outputs include the same data under `negotiated_tls` by default.

`--alpn` accepts a comma-separated list such as `h2,http/1.1` and forwards it to the TLS client
handshake as the advertised ALPN protocol list.

### Verbose text output

Use `-o text` for the full certificate details:
Expand Down Expand Up @@ -327,6 +357,11 @@ $ tlsctl client -o json badssl.com

```json
{
"negotiated_tls": {
"tls_version": "TLS 1.3",
"cipher_suite": "TLS_AES_128_GCM_SHA256",
"alpn": "h2"
},
"certificates": [
{
"type": "leaf",
Expand Down Expand Up @@ -375,6 +410,11 @@ $ tlsctl client -o json --format-version 2 github.com missing.example.com
"status": "success",
"tls_status": "secure",
"result": {
"negotiated_tls": {
"tls_version": "TLS 1.3",
"cipher_suite": "TLS_AES_128_GCM_SHA256",
"alpn": "h2"
},
"certificates": [
{
"type": "leaf",
Expand Down Expand Up @@ -447,16 +487,16 @@ $ tlsctl client -o csv badssl.com
```

```csv
target,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names
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"
target,tls_version,cipher_suite,alpn,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names
badssl.com:443,TLS 1.2,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,,*.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,,,,,,,
target,status,tls_status,error,tls_version,cipher_suite,alpn,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names
github.com:443,success,secure,,TLS 1.3,TLS_AES_128_GCM_SHA256,h2,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.
Expand Down Expand Up @@ -707,6 +747,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul
"format-version": 2,
"proxy": "http://proxy:8080",
"tls-versions": true,
"alpn": "h2,http/1.1",
"connect-timeout": "3s",
"handshake-timeout": "6s",
"revocation-soft-fail": false
Expand All @@ -718,7 +759,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`, `connect-timeout`, `handshake-timeout`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. The `client` section also supports `format-version`, `proxy`, `file`, `tls-versions`, `servername`, and `starttls` for client-specific behavior. 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`, `connect-timeout`, `handshake-timeout`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. The `client` section also supports `format-version`, `proxy`, `file`, `tls-versions`, `alpn`, `servername`, and `starttls` for client-specific behavior. 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
7 changes: 7 additions & 0 deletions cmd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func newClientCmd(rt *Runtime) *cobra.Command {
var proxyURL string
var inputFile string
var tlsVersions bool
var alpnProtocols string
var serverName string
var startTLS string
var rf revocationFlags
Expand Down Expand Up @@ -105,6 +106,10 @@ func newClientCmd(rt *Runtime) *cobra.Command {
if startTLS != "" && !tlsquery.ValidStartTLSProtocol(startTLS) {
return fmt.Errorf("invalid --starttls protocol %q: must be one of %s", startTLS, tlsquery.StartTLSProtocolList())
}
parsedALPNProtocols, err := tlsquery.ParseALPNProtocols(alpnProtocols)
if err != nil {
return fmt.Errorf("invalid --alpn value: %w", err)
}

targets, err := collectTargets(args, inputFile, startTLS)
if err != nil {
Expand All @@ -115,6 +120,7 @@ func newClientCmd(rt *Runtime) *cobra.Command {
CACertFile: caCertFile,
Proxy: proxyURL,
TLSVersions: tlsVersions,
ALPNProtocols: parsedALPNProtocols,
ServerName: serverName,
StartTLS: startTLS,
ConnectTimeout: cf.connectTimeout,
Expand Down Expand Up @@ -172,6 +178,7 @@ func newClientCmd(rt *Runtime) *cobra.Command {
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)")
cmd.Flags().BoolVar(&tlsVersions, "tls-versions", false, "Probe and display supported TLS versions")
cmd.Flags().StringVar(&alpnProtocols, "alpn", "", "Comma-separated ALPN protocols to advertise in the TLS handshake (for example: h2,http/1.1)")
cmd.Flags().StringVar(&serverName, "servername", "", "Override the SNI server name sent in the TLS handshake")
cmd.Flags().StringVar(&startTLS, "starttls", "", "Use STARTTLS for the given protocol: "+tlsquery.StartTLSProtocolList())
addRevocationFlags(cmd, &rf)
Expand Down
45 changes: 45 additions & 0 deletions cmd/client_targets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

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

func TestCollectTargets(t *testing.T) {
Expand Down Expand Up @@ -162,3 +163,47 @@ func TestValidateConnectionTimeouts(t *testing.T) {
})
}
}

func TestParseALPNProtocols(t *testing.T) {
tests := []struct {
name string
input string
want []string
wantErr bool
}{
{name: "empty", input: "", want: nil},
{name: "single", input: "h2", want: []string{"h2"}},
{name: "multiple", input: "h2,http/1.1", want: []string{"h2", "http/1.1"}},
{name: "trim spaces", input: " h2, http/1.1 ", want: []string{"h2", "http/1.1"}},
{name: "empty entry", input: "h2,,http/1.1", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tlsquery.ParseALPNProtocols(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
}
}

func TestNewClientCmdALPNFlag(t *testing.T) {
cmd := newClientCmd(defaultRuntime)
flag := cmd.Flags().Lookup("alpn")
if flag == nil {
t.Fatal("expected --alpn flag to be registered")
}
if flag.DefValue != "" {
t.Fatalf("expected --alpn default to be empty, got %q", flag.DefValue)
}
}
26 changes: 25 additions & 1 deletion cmd/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ func testChains() []*tlsquery.ChainInfo {
InputName: "a.example.com:443",
InputLabel: "target",
Verified: true,
NegotiatedTLS: &tlsquery.HandshakeInfo{
TLSVersion: "TLS 1.3",
CipherSuite: "TLS_AES_128_GCM_SHA256",
ALPN: "h2",
},
Certificates: []tlsquery.CertInfo{
{
Type: "leaf",
Expand All @@ -34,6 +39,10 @@ func testChains() []*tlsquery.ChainInfo {
InputName: "b.example.com:443",
InputLabel: "target",
Verified: true,
NegotiatedTLS: &tlsquery.HandshakeInfo{
TLSVersion: "TLS 1.2",
CipherSuite: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
},
Certificates: []tlsquery.CertInfo{
{
Type: "leaf",
Expand Down Expand Up @@ -260,12 +269,18 @@ func TestRenderTargetResults_MultiCSVLegacy(t *testing.T) {
if len(rows) != 2 {
t.Fatalf("expected header plus one successful row, got %d rows", len(rows))
}
if rows[0][0] != "target" || rows[0][1] != "common_name" {
if rows[0][0] != "target" || rows[0][1] != "tls_version" {
t.Fatalf("unexpected legacy CSV headers: %v", rows[0][:2])
}
if rows[0][1] != "tls_version" || rows[0][2] != "cipher_suite" || rows[0][3] != "alpn" || rows[0][4] != "common_name" {
t.Fatalf("unexpected legacy CSV negotiated tls headers: %v", rows[0][:5])
}
if rows[1][0] != "a.example.com:443" {
t.Fatalf("unexpected successful CSV row: %v", rows[1])
}
if rows[1][1] != "TLS 1.3" || rows[1][2] != "TLS_AES_128_GCM_SHA256" || rows[1][3] != "h2" {
t.Fatalf("unexpected negotiated tls values in successful CSV row: %v", rows[1][:4])
}
}

func TestRenderTargetResults_SingleJSONBatchV2(t *testing.T) {
Expand Down Expand Up @@ -320,10 +335,19 @@ func TestRenderTargetResults_MultiCSVBatchV2(t *testing.T) {
if rows[0][0] != "target" || rows[0][1] != "status" || rows[0][2] != "tls_status" || rows[0][3] != "error" {
t.Fatalf("unexpected CSV v2 headers: %v", rows[0][:4])
}
if rows[0][4] != "tls_version" || rows[0][5] != "cipher_suite" || rows[0][6] != "alpn" {
t.Fatalf("unexpected CSV v2 negotiated tls headers: %v", rows[0][:7])
}
if rows[1][0] != "a.example.com:443" || rows[1][1] != "success" || rows[1][2] != "secure" || rows[1][3] != "" {
t.Fatalf("unexpected success row: %v", rows[1][:4])
}
if rows[1][4] != "TLS 1.3" || rows[1][5] != "TLS_AES_128_GCM_SHA256" || rows[1][6] != "h2" {
t.Fatalf("unexpected negotiated tls values in success row: %v", rows[1][:7])
}
if rows[2][0] != "missing.example.com:443" || rows[2][1] != "failure" || rows[2][2] != "" || rows[2][3] != "connection failed" {
t.Fatalf("unexpected failed row: %v", rows[2][:4])
}
if rows[2][4] != "" || rows[2][5] != "" || rows[2][6] != "" {
t.Fatalf("expected empty negotiated tls values in failed row: %v", rows[2][:7])
}
}
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type ClientSettings struct {
Proxy *string `json:"proxy,omitempty"`
File *string `json:"file,omitempty"`
TLSVersions *bool `json:"tls-versions,omitempty"`
ALPN *string `json:"alpn,omitempty"`
ServerName *string `json:"servername,omitempty"`
StartTLS *string `json:"starttls,omitempty"`
ConnectTimeout *Duration `json:"connect-timeout,omitempty"`
Expand Down Expand Up @@ -155,6 +156,9 @@ func (s *Settings) validate() error {
if err := validateStartTLS(s.Client.StartTLS); err != nil {
return fmt.Errorf("client.starttls: %w", err)
}
if err := validateALPN(s.Client.ALPN); err != nil {
return fmt.Errorf("client.alpn: %w", err)
}
if err := validatePositiveDuration(s.Client.ConnectTimeout); err != nil {
return fmt.Errorf("client.connect-timeout: %w", err)
}
Expand Down Expand Up @@ -199,6 +203,14 @@ func validateStartTLS(v *string) error {
return fmt.Errorf("must be one of %s", tlsquery.StartTLSProtocolList())
}

func validateALPN(v *string) error {
if v == nil {
return nil
}
_, err := tlsquery.ParseALPNProtocols(*v)
return err
}

func validateFormatVersion(v *int) error {
if v == nil {
return nil
Expand Down Expand Up @@ -295,6 +307,9 @@ func addClientFlags(vals map[string]string, c *ClientSettings) {
if c.TLSVersions != nil {
vals["tls-versions"] = boolStr(*c.TLSVersions)
}
if c.ALPN != nil {
vals["alpn"] = *c.ALPN
}
if c.ServerName != nil {
vals["servername"] = *c.ServerName
}
Expand Down
22 changes: 22 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ func TestLoad_InvalidStartTLS(t *testing.T) {
}
}

func TestLoad_InvalidALPN(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
if err := os.WriteFile(path, []byte(`{"client": {"alpn": "h2,,http/1.1"}}`), 0644); err != nil {
t.Fatalf("failed to write file: %v", err)
}

_, err := Load(path, false)
if err == nil {
t.Fatal("expected error for invalid alpn")
}
}

func TestLoad_InvalidFormatVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
Expand Down Expand Up @@ -165,6 +178,7 @@ func TestLoad_ValidConfig(t *testing.T) {
"format-version": 2,
"proxy": "http://proxy:8080",
"tls-versions": true,
"alpn": "h2,http/1.1",
"connect-timeout": "4s",
"handshake-timeout": "9s",
"revocation": "ocsp",
Expand Down Expand Up @@ -216,6 +230,9 @@ func TestLoad_ValidConfig(t *testing.T) {
if s.Client.TLSVersions == nil || !*s.Client.TLSVersions {
t.Error("expected client.tls-versions = true")
}
if s.Client.ALPN == nil || *s.Client.ALPN != "h2,http/1.1" {
t.Error("expected client.alpn = h2,http/1.1")
}
if s.Client.ConnectTimeout == nil || s.Client.ConnectTimeout.Duration != 4*time.Second {
t.Error("expected client.connect-timeout = 4s")
}
Expand All @@ -242,13 +259,15 @@ func TestFlagValues_Client(t *testing.T) {
expiry := 21
output := "json"
formatVersion := 2
alpn := "h2,http/1.1"
connectTimeout := Duration{Duration: 4 * time.Second}
handshakeTimeout := Duration{Duration: 9 * time.Second}
s := &Settings{
Client: ClientSettings{
ExpiryWarning: &expiry,
Output: &output,
FormatVersion: &formatVersion,
ALPN: &alpn,
ConnectTimeout: &connectTimeout,
HandshakeTimeout: &handshakeTimeout,
},
Expand All @@ -264,6 +283,9 @@ func TestFlagValues_Client(t *testing.T) {
if vals["format-version"] != "2" {
t.Errorf("expected format-version=2, got %s", vals["format-version"])
}
if vals["alpn"] != "h2,http/1.1" {
t.Errorf("expected alpn=h2,http/1.1, got %s", vals["alpn"])
}
if vals["connect-timeout"] != "4s" {
t.Errorf("expected connect-timeout=4s, got %s", vals["connect-timeout"])
}
Expand Down
Loading
Loading