diff --git a/AGENTS.md b/AGENTS.md index 29cc984..a3145c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 78af73e..3306ff7 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b765886..9a9e3b9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ``` diff --git a/internal/tlsinspect/tlsinspect.go b/internal/tlsinspect/tlsinspect.go index 0bf69f4..c73061d 100644 --- a/internal/tlsinspect/tlsinspect.go +++ b/internal/tlsinspect/tlsinspect.go @@ -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" ) @@ -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 { @@ -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. diff --git a/internal/tlsinspect/tlsinspect_test.go b/internal/tlsinspect/tlsinspect_test.go index 1477cd1..21d4a5d 100644 --- a/internal/tlsinspect/tlsinspect_test.go +++ b/internal/tlsinspect/tlsinspect_test.go @@ -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) { @@ -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 { @@ -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 @@ -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()