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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ prettier -w .
- `--grpc` now automatically tries gRPC reflection when no local schema is supplied.
- Plaintext loopback gRPC servers are supported via `h2c` for both calls and discovery.
- `--inspect-dns` resolves the URL hostname without making an HTTP request, showing common DNS record types, resolver backend, duration, and per-record TTLs from direct UDP or DoH responses.
- `--inspect-tls --http 3` performs QUIC/TLS inspection with `h3` ALPN instead of the TCP TLS path.
- `--tls` remains a compatibility alias for setting the minimum TLS version; prefer `--min-tls` in new docs/examples, and use `--max-tls` to cap negotiation or combine min/max for an exact TLS version.
- WebSocket terminal sessions use the interactive prompt by default and can be controlled with `--ws-interactive auto|on|off`; output-file/clipboard/retry flags are rejected because the WebSocket path streams through the message loop instead of the normal response pipeline.
- Metadata-only commands (`--help`, `--version`, `--buildinfo`) perform best-effort config parsing for presentation settings, but config errors and background auto-updates cannot block them.
Expand Down
5 changes: 5 additions & 0 deletions docs/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,17 @@ Expiry is color-coded: red if expired or less than 7 days remaining, yellow if l

HTTP-only flags (e.g. `--data`, `--timing`, `--grpc`) are ignored with a warning when used with `--inspect-tls`.

When combined with `--http 3`, TLS inspection uses a QUIC handshake and offers `h3` ALPN instead of dialing TCP.

```sh
# Check certificate chain
fetch --inspect-tls example.com

# Inspect certificates even if invalid
fetch --inspect-tls --insecure expired.badssl.com

# Inspect the HTTP/3 QUIC/TLS path
fetch --inspect-tls --http 3 example.com
```

### Configuration File
Expand Down
3 changes: 2 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,10 +407,11 @@ fetch --min-tls 1.2 --max-tls 1.2 example.com

### `--inspect-tls`

Inspect the TLS certificate chain by performing a TLS handshake only (no HTTP request is made). Displays the TLS version, cipher suite, ALPN protocol, full certificate chain with expiry status, Subject Alternative Names (SANs), and OCSP staple status. Requires an HTTPS URL. HTTP-only flags (e.g. `--data`, `--timing`, `--grpc`) are ignored with a warning.
Inspect the TLS certificate chain by performing a TLS handshake only (no HTTP request is made). Displays the TLS version, cipher suite, ALPN protocol, full certificate chain with expiry status, Subject Alternative Names (SANs), and OCSP staple status. Requires an HTTPS URL. With `--http 3`, inspection uses a QUIC handshake and offers `h3` ALPN. HTTP-only flags (e.g. `--data`, `--timing`, `--grpc`) are ignored with a warning.

```sh
fetch --inspect-tls example.com
fetch --inspect-tls --http 3 example.com
fetch --inspect-tls --insecure expired.badssl.com
```

Expand Down
59 changes: 57 additions & 2 deletions internal/tlsinspect/tlsinspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"github.com/ryanfowler/fetch/internal/core"
"github.com/ryanfowler/fetch/internal/resolver"

"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"golang.org/x/crypto/ocsp"
)

Expand Down Expand Up @@ -64,6 +66,17 @@ func Inspect(ctx context.Context, p *core.Printer, cfg *Config) int {
defer cancel()
}

if cfg.HTTP == core.HTTP3 {
cs, err := inspectQUIC(ctx, res, addr, tlsConfig)
if err != nil {
writeTLSError(p, err)
return 1
}
render(p, cs)
p.Flush()
return 0
}

// Dial and handshake using context for cancellation support.
rawConn, err := res.DialContext(ctx, "tcp", addr)
if err != nil {
Expand All @@ -83,11 +96,53 @@ func Inspect(ctx context.Context, p *core.Printer, cfg *Config) int {
return 0
}

func inspectQUIC(ctx context.Context, res *resolver.Resolver, addr string, tlsConfig *tls.Config) (*tls.ConnectionState, error) {
endpoint, err := res.ResolveAddress(ctx, "udp", addr)
if err != nil {
return nil, err
}

port, err := net.LookupPort("udp", endpoint.Port)
if err != nil {
return nil, err
}

for _, ip := range endpoint.Addrs {
udpAddr := &net.UDPAddr{IP: ip.IP, Port: port}
var lc net.ListenConfig
packetConn, dialErr := lc.ListenPacket(ctx, "udp", ":0")
if dialErr != nil {
err = dialErr
continue
}

conn, dialErr := quic.Dial(ctx, packetConn, udpAddr, tlsConfig, nil)
if dialErr != nil {
packetConn.Close()
err = dialErr
continue
}

state := conn.ConnectionState().TLS
conn.CloseWithError(0, "")
packetConn.Close()
return &state, nil
}
if err == nil {
err = errors.New("no addresses found")
}
return nil, err
}

func alpnProtocols(httpVersion core.HTTPVersion) []string {
if httpVersion == core.HTTP1 {
switch httpVersion {
case core.HTTP1:
return []string{"http/1.1"}
case core.HTTP3:
return []string{http3.NextProtoH3}
default:
return []string{"h2", "http/1.1"}
}
return []string{"h2", "http/1.1"}
}

// writeTLSError writes a TLS connection error, suggesting --insecure for cert errors.
Expand Down
127 changes: 126 additions & 1 deletion internal/tlsinspect/tlsinspect_test.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
package tlsinspect

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"math/big"
"net"
"net/url"
"strings"
"testing"
"time"

"github.com/ryanfowler/fetch/internal/core"

"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)

func newTestPrinter() *core.Printer {
return core.NewHandle(core.ColorOff).Stderr()
return core.TestPrinter(false)
}

func TestCertDisplayName(t *testing.T) {
Expand Down Expand Up @@ -99,6 +107,7 @@ func TestALPNProtocols(t *testing.T) {
{name: "default offers HTTP/2 and HTTP/1.1", httpVersion: core.HTTPDefault, want: []string{"h2", "http/1.1"}},
{name: "HTTP/2 offers HTTP/2 and HTTP/1.1", httpVersion: core.HTTP2, want: []string{"h2", "http/1.1"}},
{name: "HTTP/1 offers only HTTP/1.1", httpVersion: core.HTTP1, want: []string{"http/1.1"}},
{name: "HTTP/3 offers only HTTP/3", httpVersion: core.HTTP3, want: []string{http3.NextProtoH3}},
}

for _, tt := range tests {
Expand All @@ -116,6 +125,66 @@ func TestALPNProtocols(t *testing.T) {
}
}

func TestInspectHTTP3UsesQUICAndH3ALPN(t *testing.T) {
caCert, caKey := generateTestCACert(t)
serverCert, serverKey := generateTestCert(t, caCert, caKey, "quic-server")

ln, err := quic.ListenAddr("127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{{
Certificate: [][]byte{serverCert.Raw},
PrivateKey: serverKey,
Leaf: serverCert,
}},
NextProtos: []string{http3.NextProtoH3},
}, nil)
if err != nil {
t.Fatalf("quic.ListenAddr() error = %v", err)
}
t.Cleanup(func() { ln.Close() })

acceptErr := make(chan error, 1)
go func() {
conn, err := ln.Accept(context.Background())
if err != nil {
acceptErr <- err
return
}
acceptErr <- conn.CloseWithError(0, "")
}()

u, err := url.Parse("https://" + ln.Addr().String())
if err != nil {
t.Fatalf("url.Parse() error = %v", err)
}
p := newTestPrinter()
code := Inspect(context.Background(), p, &Config{
CACerts: []*x509.Certificate{caCert},
HTTP: core.HTTP3,
Timeout: 5 * time.Second,
URL: u,
})
if code != 0 {
t.Fatalf("Inspect() exit code = %d, output:\n%s", code, string(p.Bytes()))
}

select {
case err := <-acceptErr:
if err != nil && !errors.Is(err, net.ErrClosed) {
t.Fatalf("server accept error = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("server did not accept QUIC connection")
}

out := string(p.Bytes())
if !strings.Contains(out, "ALPN: h3") {
t.Fatalf("expected h3 ALPN in output, got:\n%s", out)
}
if !strings.Contains(out, "quic-server") {
t.Fatalf("expected certificate chain in output, got:\n%s", out)
}
}

func TestCertExpiryInfo(t *testing.T) {
fixedNow := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
origNow := tlsInspectNow
Expand Down Expand Up @@ -288,6 +357,62 @@ func TestRenderSANs(t *testing.T) {
})
}

func generateTestCACert(t *testing.T) (*x509.Certificate, *rsa.PrivateKey) {
t.Helper()

key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey() error = %v", err)
}

template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test CA"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate() error = %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("x509.ParseCertificate() error = %v", err)
}
return cert, key
}

func generateTestCert(t *testing.T, caCert *x509.Certificate, caKey *rsa.PrivateKey, name string) (*x509.Certificate, *rsa.PrivateKey) {
t.Helper()

key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey() error = %v", err)
}

template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey)
if err != nil {
t.Fatalf("x509.CreateCertificate() error = %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("x509.ParseCertificate() error = %v", err)
}
return cert, key
}

func TestRender(t *testing.T) {
t.Run("nil ConnectionState", func(t *testing.T) {
p := newTestPrinter()
Expand Down
Loading