From cafca619f38b68083319da69824591fb92ffad74 Mon Sep 17 00:00:00 2001 From: Gaukas Wang Date: Wed, 5 Jun 2024 09:50:07 -0600 Subject: [PATCH] update: minor improvements Signed-off-by: Gaukas Wang --- README.md | 140 ++++++++---------- .../testdata/QUIC_IETF_Firefox_126_0-RTT.bin | Bin 0 -> 1357 bytes modcaddy/README.md | 73 +++++++++ modcaddy/app/caddyfile.go | 40 +++-- modcaddy/app/reservoir.go | 50 ++++--- modcaddy/handler/handler.go | 120 ++++++--------- modcaddy/listener/listener.go | 69 --------- quic_client_initial_test.go | 6 +- quic_clienthello.go | 5 +- quic_common_test.go | 7 + quic_fingerprint.go | 66 ++++++++- quic_frame_test.go | 3 + quic_header_test.go | 8 + tls_fingerprint.go | 44 ++++-- 14 files changed, 345 insertions(+), 286 deletions(-) create mode 100644 internal/testdata/QUIC_IETF_Firefox_126_0-RTT.bin create mode 100644 modcaddy/README.md diff --git a/README.md b/README.md index d0e12c1..53e9930 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,32 @@ -# clienthellod +# `clienthellod`: TLS ClientHello/QUIC Initial Packet reflection service ![Go Build Status](https://github.com/gaukas/clienthellod/actions/workflows/go.yml/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/gaukas/clienthellod)](https://goreportcard.com/report/github.com/gaukas/clienthellod) -[![DeepSource](https://app.deepsource.com/gh/gaukas/clienthellod.svg/?label=active+issues&show_trend=true&token=GugDSBnYAxAF25QNpfyAO5d2)](https://app.deepsource.com/gh/gaukas/clienthellod/) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fgaukas%2Fclienthellod.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fgaukas%2Fclienthellod?ref=badge_shield&issueType=license) +[![Go Doc](https://pkg.go.dev/badge/github.com/refraction-networking/water.svg)](https://pkg.go.dev/github.com/refraction-networking/water) -ClientHello Parser/Resolver as a Service from [tlsfingerprint.io](https://tlsfingerprint.io). +`clienthellod`, read as "client-hello-D", is a TLS ClientHello/QUIC Initial Packet reflection service. It can be used to parses TLS ClientHello messages and QUIC Initial Packets into human-readable and highly programmable formats such as JSON. -## What does it do - -`clienthellod`, read as "client hello DEE", is a service that parses and resolves the ClientHello message sent by the client to the server. It is a part of the TLS fingerprintability research project which spans [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io](https://quic.tlsfingerprint.io). It parses the ClientHello messages sent by TLS clients and QUIC Client Initial Packets sent by QUIC clients and display the parsed information in a human-readable format with high programmability. +Is is a part of the TLS fingerprintability research project which spans [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io](https://quic.tlsfingerprint.io). It parses the ClientHello messages sent by TLS clients and QUIC Client Initial Packets sent by QUIC clients and display the parsed information in a human-readable format with high programmability. See [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io](https://quic.tlsfingerprint.io) for more details about the project. -## How to use +## Quick Start + +`clienthellod` comes as a Go library, which can be used to parse both TLS and QUIC protocols. + +### TLS/QUIC Fingerprinter + +```go + tlsFingerprinter := clienthellod.NewTLSFingerprinter() +``` -`clienthellod` is provided as a Go library in the root directory of this repository. +```go + quicFingerprinter := clienthellod.NewQUICFingerprinter() +``` -### Quick Start +### TLS ClientHello -#### TLS ClientHello +#### From a `net.Conn` ```go tcpLis, err := net.Listen("tcp", ":443") @@ -30,7 +38,7 @@ See [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io]( } defer conn.Close() - ch, err := clienthellod.ReadClientHello(conn) // saves ClientHello + ch, err := clienthellod.ReadClientHello(conn) // reads ClientHello from the connection if err != nil { panic(err) } @@ -46,11 +54,24 @@ See [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io]( } fmt.Println(string(jsonB)) - fmt.Println("ClientHello ID: " + ch.FingerprintID(false)) // prints ClientHello's original fingerprint ID, as TLS extension IDs in their provided order - fmt.Println("ClientHello NormID: " + ch.FingerprintID(true)) // prints ClientHello's normalized fingerprint ID, as TLS extension IDs in a sorted order + fmt.Println("ClientHello ID: " + ch.HexID) // prints ClientHello's original fingerprint ID calculated using observed TLS extension order + fmt.Println("ClientHello NormID: " + ch.NormHexID) // prints ClientHello's normalized fingerprint ID calculated using sorted TLS extension list ``` -#### QUIC Client Initial Packet +#### From raw `[]byte` + +```go + ch, err := clienthellod.UnmarshalClientHello(raw) + if err != nil { + panic(err) + } + + // err := ch.ParseClientHello() // no need to call again, UnmarshalClientHello automatically calls ParseClientHello +``` + +### QUIC Initial Packets (Client-sourced) + +#### Single packet ```go udpConn, err := net.ListenUDP("udp", ":443") @@ -62,7 +83,7 @@ See [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io]( panic(err) } - cip, err := clienthellod.ParseQUICCIP(buf[:n]) // reads in and parses QUIC Client Initial Packet + ci, err := clienthellod.UnmarshalQUICClientInitialPacket(buf[:n]) // decodes QUIC Client Initial Packet if err != nil { panic(err) } @@ -75,79 +96,42 @@ See [tlsfingerprint.io](https://tlsfingerprint.io) and [quic.tlsfingerprint.io]( fmt.Println(string(jsonB)) // including fingerprint IDs of: ClientInitialPacket, QUIC Header, QUIC ClientHello, QUIC Transport Parameters' combination ``` -#### Use with Caddy - -`clienthellod` is also provided as a Caddy plugin, `modcaddy`, which can be used to capture ClientHello messages and QUIC Client Initial Packets. See Section [modcaddy](#modcaddy) for more details. - -## modcaddy - -`modcaddy` is a Caddy plugin that provides: -- An caddy `app` that can be used to temporarily store captured ClientHello messages and QUIC Client Initial Packets. -- A caddy `handler` that can be used to serve the ClientHello messages and QUIC Client Initial Packets to the client sending the request. -- A caddy `listener` that can be used to capture ClientHello messages and QUIC Client Initial Packets. +#### Multiple packets -You will need to use [xcaddy](https://github.com/caddyserver/xcaddy) to rebuild Caddy with `modcaddy` included. +Implementations including Chrome/Chromium sends oversized Client Hello which does not fit into one single QUIC packet, in which case multiple QUIC Initial Packets are sent. -It is worth noting that some web browsers may not choose to switch to QUIC protocol in localhost environment, which may result in the QUIC Client Initial Packet not being sent and therefore not being captured/analyzed. - -### Build - -```bash -xcaddy build --with github.com/gaukas/clienthellod/modcaddy -``` - -#### When build locally with changes +```go + gci := GatherClientInitials() // Each GatherClientInitials reassembles one QUIC Client Initial Packets stream. Use a QUIC Fingerprinter for multiple potential senders, which automatically demultiplexes the packets based on the source address. + + udpConn, err := net.ListenUDP("udp", ":443") + defer udpConn.Close() -```bash -xcaddy build --with github.com/gaukas/clienthellod/modcaddy --with github.com/gaukas/clienthellod/=./ -``` + for { + buf := make([]byte, 65535) + n, addr, err := udpConn.ReadFromUDP(buf) + if err != nil { + panic(err) + } -### Caddyfile + if addr != knownSenderAddr { + continue + } -A sample Caddyfile is provided below. + ci, err := clienthellod.UnmarshalQUICClientInitialPacket(buf[:n]) // decodes QUIC Client Initial Packet + if err != nil { + panic(err) + } -```Caddyfile -{ - # debug # for debugging purpose - # https_port 443 # currently, QUIC listener works only on port 443, otherwise you need to make changes to the code - order clienthellod before file_server # make sure it hits handler before file_server - clienthellod { # app (reservoir) - validfor 120s 30s # params: validFor [cleanEvery] # increased for QUIC - } - servers { - listener_wrappers { - clienthellod { # listener - tcp # listens for TCP and saves TLS ClientHello - udp # listens for UDP and saves QUIC Client Initial Packet - } - tls + err = gci.AddPacket(ci) + if err != nil { + panic(err) } - # protocols h3 } -} +``` -1.mydomain.com { - # tls internal - clienthellod { # handler - # quic # mutually exclusive with tls - tls # listener_wrappers.clienthellod.tcp must be set - } - file_server { - root /var/www/html - } -} +### Use with Caddy -2.mydomain.com { - # tls internal - clienthellod { # handler - quic # listener_wrappers.clienthellod.udp must be set - # tls # mutually exclusive with quic - } - file_server { - root /var/www/html - } -} -``` +We also provide clienthellod as a Caddy Module in `modcaddy`, which you can use with Caddy to capture ClientHello messages and QUIC Client Initial Packets. See [modcaddy](https://github.com/gaukas/clienthellod/tree/master/modcaddy) for more details. ## License diff --git a/internal/testdata/QUIC_IETF_Firefox_126_0-RTT.bin b/internal/testdata/QUIC_IETF_Firefox_126_0-RTT.bin new file mode 100644 index 0000000000000000000000000000000000000000..da4d8d7cd5f92a916615b15cf3c431a30528b089 GIT binary patch literal 1357 zcmd_o`8yK~0KoB~92vP{gfV+8CCo&5F~_!K#>?wSuGf(x@08GJk(_ho$d!=Uh#^x< z=6GzGGr2~NM7(m0k?^Xhx8L7C;r;##pYLk`03aX+AbxnfW1?OAUC4xTVD199D;{*O zImPa2{uBzGR~gKN+fP#oDQ+&P56fcjgZ6;nKmE*_8V%P87ORr0x$fXhL#8v^an>`ideY+4XGcVE$TD7s3_r=+zenmh zj!ostJPCUgJn#0C(i70^nx=*FUC?d4ZyMJ8^ue&VbCM`zd3+9UiVw3zQckM5mVYfZ zcrzhXBjpU;%6+~9sYTz8GZ&MFDDW19#9{US+Q^%^iI#RPNjDB>C ziB_6DD!+6QvGJAgz#Yl@Mw8x4Wm98|R@w>%8KGp&-G5L=G39pPYxVd|DC?W=NH1T=WB)9eDcm+SV#a~@5DT#s^amEu7r0H+HG?6 z<~i9jN+Hq7BQRTKXeq$NYv)lrWJu{pISV*gGCQ2ygi&mYmISxZ7(#jQSi3YdXK@xG z!in79stXob9CDVEJK#T25%IHk631zq6eUM%3g{7nfWQsKk_G6T@5=n;R=ci`Ro3v| zo!59%sFT-+u)Sp6yPB<_QwG=pdFI=|SLw~dPNu9(Vb<*q{=C?WinVKk zB|FKEOcgaVGP$-p$|-nsA!$O~vcaFE>Vw0Uj%$0>w(ZMW(fm4yL}k|K5~u4oGm60U z=bJoU`GOkQA-%;DF%YfQAc6Z@RY#@#5s@8E8;gkHLZGZK8CN%+9L75&@5YZ~##!0d38xdyeOLt#R6VOyk#G{$?$Y6!&?*LpU;wJ5;pZ-Vy}`jV5P z`d-m`35j!@*qXyrC#&h#?$IJ zT|Ed}K}ZZ5T3&%fzsEoTm=wb-wYrL4uD9U%QurQB8P$v^I-%-wSctyyvXI$5FmT6H z&BLNjaU<|s=73?|{kv6-4nLHb(p=%$CUPNX-0^Ql{4$8)joS3*$wsP=@7xL~W{?C- z6{=RLxEUE}##6nOi@Wg@@&^@3ka%)mE5GQWQKJ1+{&`vA!En=@jS)x1y>=2E)wvEf znpG-*ZzQz1r?`p!tkcclvE}u4Ysgaum&sg5{ccl(OCP)OOr&IqSr?5FS#aP*3(b`( z7<+Ri2H>o&A*~f+C|;uPEXjev-rL(b1^Ki5Dn0KPzF3m)LnqGh{bf6tnV`5()h%5b zjMh${7(PNu%zuR-HFvs<=C?VpmqRziD4zmPTxLprt@kp$h>S_{m`M> l>NXd(j7Ft0g)#ZQ8i1EB=UmIjID6qMq0k)qFOUC4{{leVB;Wu5 literal 0 HcmV?d00001 diff --git a/modcaddy/README.md b/modcaddy/README.md new file mode 100644 index 0000000..2236153 --- /dev/null +++ b/modcaddy/README.md @@ -0,0 +1,73 @@ +# `clienthellod/modcaddy`: clienthellod as a Caddy module + + +`clienthellod` is also provided as a Caddy plugin, `modcaddy`, which can be used to capture ClientHello messages and QUIC Client Initial Packets. See Section [modcaddy](#modcaddy) for more details. + +`modcaddy` contains a Caddy plugin that provides: +- An caddy `app` that can be used to temporarily store captured ClientHello messages and QUIC Client Initial Packets. +- A caddy `handler` that can be used to serve the ClientHello messages and QUIC Client Initial Packets to the client sending the request. +- A caddy `listener` that can be used to capture ClientHello messages and QUIC Client Initial Packets. + +You will need to use [xcaddy](https://github.com/caddyserver/xcaddy) to rebuild Caddy with `modcaddy` included. + +It is worth noting that some web browsers may not choose to switch to QUIC protocol in localhost environment, which may result in the QUIC Client Initial Packet not being sent and therefore not being captured/analyzed. + +### Build + +```bash +xcaddy build --with github.com/gaukas/clienthellod/modcaddy +``` + +#### When build locally with changes + +```bash +xcaddy build --with github.com/gaukas/clienthellod/modcaddy --with github.com/gaukas/clienthellod/=./ +``` + +### Caddyfile + +A sample Caddyfile is provided below. + +```Caddyfile +{ + # debug # for debugging purpose + # https_port 443 # currently, QUIC listener works only on port 443, otherwise you need to make changes to the code + order clienthellod before file_server # make sure it hits handler before file_server + clienthellod { # app (reservoir) + tls_ttl 10s + quic_ttl 60s + } + servers { + listener_wrappers { + clienthellod { # listener + tcp # listens for TCP and saves TLS ClientHello + udp # listens for UDP and saves QUIC Client Initial Packet + } + tls + } + # protocols h3 + } +} + +1.mydomain.com { + # tls internal + clienthellod { # handler + # quic # mutually exclusive with tls + tls # listener_wrappers.clienthellod.tcp must be set + } + file_server { + root /var/www/html + } +} + +2.mydomain.com { + # tls internal + clienthellod { # handler + quic # listener_wrappers.clienthellod.udp must be set + # tls # mutually exclusive with quic + } + file_server { + root /var/www/html + } +} +``` \ No newline at end of file diff --git a/modcaddy/app/caddyfile.go b/modcaddy/app/caddyfile.go index a3e39ea..f6dc597 100644 --- a/modcaddy/app/caddyfile.go +++ b/modcaddy/app/caddyfile.go @@ -23,16 +23,16 @@ as the first argument (validfor). */ func parseCaddyfile(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { app := &Reservoir{ - ValidFor: caddy.Duration(DEFAULT_RESERVOIR_ENTRY_VALID_FOR), - // CleanInterval: caddy.Duration(DEFAULT_RESERVOIR_CLEANING_INTERVAL), + TlsTTL: caddy.Duration(DEFAULT_TLS_FP_TTL), + QuicTTL: caddy.Duration(DEFAULT_QUIC_FP_TTL), } for d.Next() { for d.NextBlock(0) { switch d.Val() { // skipcq: CRT-A0014 - case "validfor": - if app.ValidFor != caddy.Duration(DEFAULT_RESERVOIR_ENTRY_VALID_FOR) { - return nil, d.Err("only one valid is allowed") + case "tls_ttl": // Time-to-Live for each entry + if app.TlsTTL != caddy.Duration(DEFAULT_TLS_FP_TTL) { + return nil, d.Err("only one tls_ttl is allowed") } args := d.RemainingArgs() if len(args) == 0 { @@ -42,18 +42,26 @@ func parseCaddyfile(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) if err != nil { return nil, d.Errf("invalid duration: %v", err) } - app.ValidFor = caddy.Duration(duration) - // app.CleanInterval = caddy.Duration(duration) + app.TlsTTL = caddy.Duration(duration) - // second argument is deprecated (clean interval) - // if len(args) == 2 { - // duration, err := caddy.ParseDuration(args[1]) - // if err != nil { - // return nil, d.Errf("invalid duration: %v", err) - // } - // app.CleanInterval = caddy.Duration(duration) - // } - if len(args) > 2 { + if len(args) > 1 { + return nil, d.Err("too many arguments") + } + case "quic_ttl": // Time-to-Live for each entry + if app.QuicTTL != caddy.Duration(DEFAULT_QUIC_FP_TTL) { + return nil, d.Err("only one quic_ttl is allowed") + } + args := d.RemainingArgs() + if len(args) == 0 { + return nil, d.ArgErr() + } + duration, err := caddy.ParseDuration(args[0]) + if err != nil { + return nil, d.Errf("invalid duration: %v", err) + } + app.QuicTTL = caddy.Duration(duration) + + if len(args) > 1 { return nil, d.Err("too many arguments") } } diff --git a/modcaddy/app/reservoir.go b/modcaddy/app/reservoir.go index 34a4249..96e4fb4 100644 --- a/modcaddy/app/reservoir.go +++ b/modcaddy/app/reservoir.go @@ -13,27 +13,34 @@ import ( const ( CaddyAppID = "clienthellod" - DEFAULT_RESERVOIR_ENTRY_VALID_FOR = 10 * time.Second - DEFAULT_RESERVOIR_CLEANING_INTERVAL = 10 * time.Second + DEFAULT_TLS_FP_TTL = clienthellod.DEFAULT_TLSFINGERPRINT_EXPIRY // TODO: select a reasonable value + DEFAULT_QUIC_FP_TTL = clienthellod.DEFAULT_QUICFINGERPRINT_EXPIRY // TODO: select a reasonable value ) func init() { caddy.RegisterModule(Reservoir{}) } -// Reservoir implements caddy.App. +// Reservoir implements [caddy.App] and [caddy.Provisioner]. // It is used to store the ClientHello extracted from the incoming TLS // by ListenerWrapper for later use by the Handler when ServeHTTP is called. type Reservoir struct { - ValidFor caddy.Duration `json:"valid_for,omitempty"` + // TlsTTL (Time-to-Live) is the duration for which each TLS fingerprint + // is valid. The entry will remain in the reservoir for at most this + // duration. + // + // There are scenarios an entry gets removed sooner than this duration, including + // when a TLS ClientHello is successfully served by the handler. + TlsTTL caddy.Duration `json:"tls_ttl,omitempty"` - // CleanInterval is the interval at which the reservoir is cleaned - // of expired entries. + // QuicTTL (Time-to-Live) is the duration for which each QUIC fingerprint + // is valid. The entry will remain in the reservoir for at most this + // duration. // - // Deprecated: this field is no longer used. Each entry is cleaned on - // its own schedule, based on its expiry time. Setting ValidFor is - // sufficient. - CleanInterval caddy.Duration `json:"clean_interval,omitempty"` + // Given the fact that some implementations would prefer reusing the previously established + // QUIC connection instead of establishing a new one everytime, it is recommended to set + // a longer TTL for QUIC. + QuicTTL caddy.Duration `json:"quic_ttl,omitempty"` tlsFingerprinter *clienthellod.TLSFingerprinter quicFingerprinter *clienthellod.QUICFingerprinter @@ -49,8 +56,8 @@ func (Reservoir) CaddyModule() caddy.ModuleInfo { // skipcq: GO-W1029 ID: CaddyAppID, New: func() caddy.Module { reservoir := &Reservoir{ - ValidFor: caddy.Duration(DEFAULT_RESERVOIR_ENTRY_VALID_FOR), - // CleanInterval: caddy.Duration(DEFAULT_RESERVOIR_CLEANING_INTERVAL), + TlsTTL: caddy.Duration(DEFAULT_TLS_FP_TTL), + QuicTTL: caddy.Duration(DEFAULT_QUIC_FP_TTL), } return reservoir @@ -72,9 +79,9 @@ func (r *Reservoir) QUICFingerprinter() *clienthellod.QUICFingerprinter { // ski func (r *Reservoir) NewQUICVisitor(ip, fullKey string) { // skipcq: GO-W1029 r.mapLastQUICVisitorPerIP.Store(ip, fullKey) - // delete it after validfor if not updated + // delete it after TTL if not updated go func() { - <-time.After(time.Duration(r.ValidFor)) + <-time.After(time.Duration(r.QuicTTL)) r.mapLastQUICVisitorPerIP.CompareAndDelete(ip, fullKey) }() } @@ -91,14 +98,10 @@ func (r *Reservoir) GetLastQUICVisitor(ip string) (string, bool) { // skipcq: GO // Start implements Start() of caddy.App. func (r *Reservoir) Start() error { // skipcq: GO-W1029 - if r.ValidFor <= 0 { - return errors.New("validfor must be a positive duration") + if r.QuicTTL <= 0 || r.TlsTTL <= 0 { + return errors.New("ttl must be a positive duration") } - // if r.CleanInterval <= 0 { - // return errors.New("clean_interval must be a positive duration") - // } - r.logger.Info("clienthellod reservoir is started") return nil @@ -113,11 +116,12 @@ func (r *Reservoir) Stop() error { // skipcq: GO-W1029 // Provision implements Provision() of caddy.Provisioner. func (r *Reservoir) Provision(ctx caddy.Context) error { // skipcq: GO-W1029 - r.logger = ctx.Logger(r) - r.tlsFingerprinter = clienthellod.NewTLSFingerprinterWithTimeout(time.Duration(r.ValidFor)) - r.quicFingerprinter = clienthellod.NewQUICFingerprinterWithTimeout(time.Duration(r.ValidFor)) + r.tlsFingerprinter = clienthellod.NewTLSFingerprinterWithTimeout(time.Duration(r.TlsTTL)) + r.quicFingerprinter = clienthellod.NewQUICFingerprinterWithTimeout(time.Duration(r.QuicTTL)) r.mapLastQUICVisitorPerIP = new(sync.Map) + r.logger = ctx.Logger(r) + r.logger.Info("clienthellod reservoir is provisioned") return nil } diff --git a/modcaddy/handler/handler.go b/modcaddy/handler/handler.go index e764fe2..7727f4b 100644 --- a/modcaddy/handler/handler.go +++ b/modcaddy/handler/handler.go @@ -72,42 +72,35 @@ func (h *Handler) Provision(ctx caddy.Context) error { // skipcq: GO-W1029 return nil } +// ServeHTTP func (h *Handler) ServeHTTP(wr http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error { // skipcq: GO-W1029 h.logger.Debug("Sering HTTP to " + req.RemoteAddr + " on Protocol " + req.Proto) - if h.TLS && req.ProtoMajor <= 2 { // HTTP/1.0, HTTP/1.1, H2 - return h.serveHTTP12(wr, req, next) // TLS ClientHello capture enabled, serve ClientHello - } else if h.QUIC { - if req.ProtoMajor == 3 { // QUIC - return h.serveQUIC(wr, req, next) - } else { - h.logger.Debug("Serving QUIC Fingerprint over TLS") - return h.serveQUICFingerprintOverTLS(wr, req, next) - } + if h.TLS && req.ProtoMajor <= 2 { // When TLS is enabled and for HTTP/1.0 or HTTP/1.1 or H2 served over TLS + return h.serveTLS(wr, req, next) + } else if h.QUIC { // When QUIC is enabled + // if req.ProtoMajor == 3 { // QUIC + // return h.serveQUIC(wr, req, next) + // } else { + // h.logger.Debug("Serving QUIC Fingerprint over TLS") + // return h.serveQUICFingerprintOverTLS(wr, req, next) + // } + return h.serveQUIC(wr, req, next) } return next.ServeHTTP(wr, req) } -// serveHTTP12 handles HTTP/1.0, HTTP/1.1, H2 requests by looking up the +// serveTLS handles HTTP/1.0, HTTP/1.1, H2 requests by looking up the // ClientHello from the reservoir and writing it to the response. -func (h *Handler) serveHTTP12(wr http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error { // skipcq: GO-W1029 +func (h *Handler) serveTLS(wr http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error { // skipcq: GO-W1029 // get the client hello from the reservoir - ch := h.reservoir.TLSFingerprinter().Lookup(req.RemoteAddr) + ch := h.reservoir.TLSFingerprinter().Pop(req.RemoteAddr) if ch == nil { h.logger.Debug(fmt.Sprintf("Can't extract TLS ClientHello sent by %s, maybe not TLS connection?", req.RemoteAddr)) return next.ServeHTTP(wr, req) } - h.logger.Debug(fmt.Sprintf("Extracted TLS ClientHello for %s", req.RemoteAddr)) - - // err := ch.ParseClientHello() - // if err != nil { - // h.logger.Error("failed to parse client hello", zap.Error(err)) - // return next.ServeHTTP(wr, req) - // } + // h.logger.Debug(fmt.Sprintf("Extracted TLS ClientHello for %s", req.RemoteAddr)) - h.logger.Debug("ClientHello ID: " + ch.HexID) - h.logger.Debug("ClientHello NormID: " + ch.NormHexID) - h.logger.Debug("User-Agent: " + req.UserAgent()) ch.UserAgent = req.UserAgent() // dump JSON @@ -124,10 +117,11 @@ func (h *Handler) serveHTTP12(wr http.ResponseWriter, req *http.Request, next ca } // write JSON to response - h.logger.Debug("ClientHello: " + string(b)) wr.Header().Set("Content-Type", "application/json") - wr.Header().Set("Connection", "close") - wr.Header().Set("Alt-Svc", "clear") // to invalidate QUIC + if req.ProtoMajor == 1 { + wr.Header().Set("Connection", "close") // HTTP/1 only. Forbidden in HTTP/2, HTTP/3 + } + wr.Header().Set("Alt-Svc", "clear") // to prevent web broswers switching to QUIC _, err = wr.Write(b) if err != nil { h.logger.Error("failed to write response", zap.Error(err)) @@ -139,14 +133,35 @@ func (h *Handler) serveHTTP12(wr http.ResponseWriter, req *http.Request, next ca // serveQUIC handles QUIC requests by looking up the ClientHello from the // reservoir and writing it to the response. func (h *Handler) serveQUIC(wr http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error { // skipcq: GO-W1029 + var from string + + if req.ProtoMajor == 3 { + from = req.RemoteAddr + } else { + // Get IP part of the RemoteAddr + ip, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + h.logger.Error(fmt.Sprintf("Can't split IP from %s: %v", req.RemoteAddr, err)) + return next.ServeHTTP(wr, req) + } + + // Get the last QUIC visitor + var ok bool + from, ok = h.reservoir.GetLastQUICVisitor(ip) + if !ok { + h.logger.Debug(fmt.Sprintf("Can't find last QUIC visitor for %s", ip)) + return next.ServeHTTP(wr, req) + } + } + // get the client hello from the reservoir - qfp, err := h.reservoir.QUICFingerprinter().LookupAwait(req.RemoteAddr) + qfp, err := h.reservoir.QUICFingerprinter().PeekAwait(from) if err != nil { h.logger.Error(fmt.Sprintf("Can't extract QUIC fingerprint sent by %s: %v", req.RemoteAddr, err)) return next.ServeHTTP(wr, req) } - h.logger.Debug(fmt.Sprintf("Extracted QUIC fingerprint for %s", req.RemoteAddr)) + // h.logger.Debug(fmt.Sprintf("Extracted QUIC fingerprint for %s", req.RemoteAddr)) // Get IP part of the RemoteAddr ip, _, err := net.SplitHostPort(req.RemoteAddr) @@ -172,56 +187,9 @@ func (h *Handler) serveQUIC(wr http.ResponseWriter, req *http.Request, next cadd // write JSON to response wr.Header().Set("Content-Type", "application/json") - wr.Header().Set("Connection", "close") - _, err = wr.Write(b) - if err != nil { - h.logger.Error("failed to write response", zap.Error(err)) - return next.ServeHTTP(wr, req) - } - return nil -} - -func (h *Handler) serveQUICFingerprintOverTLS(wr http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error { // skipcq: GO-W1029 - // Get IP part of the RemoteAddr - ip, _, err := net.SplitHostPort(req.RemoteAddr) - if err != nil { - h.logger.Error(fmt.Sprintf("Can't extract IP from %s: %v", req.RemoteAddr, err)) - return next.ServeHTTP(wr, req) + if req.ProtoMajor == 1 { + wr.Header().Set("Connection", "close") // HTTP/1 only. Forbidden in HTTP/2, HTTP/3 } - - // Get the last QUIC visitor - fullKey, ok := h.reservoir.GetLastQUICVisitor(ip) - if !ok { - h.logger.Debug(fmt.Sprintf("Can't find last QUIC visitor for %s", ip)) - return next.ServeHTTP(wr, req) - } - - // Get the client hello from the reservoir - // get the client hello from the reservoir - qfp, err := h.reservoir.QUICFingerprinter().LookupAwait(fullKey) - if err != nil { - h.logger.Error(fmt.Sprintf("Can't extract QUIC fingerprint sent by %s: %v", ip, err)) - return next.ServeHTTP(wr, req) - } - - h.logger.Debug(fmt.Sprintf("Extracted QUIC fingerprint for %s", fullKey)) - // qfp.UserAgent = req.UserAgent() // Should have been updated - - // dump JSON - var b []byte - if req.URL.Query().Get("beautify") == "true" { - b, err = json.MarshalIndent(qfp, "", " ") - } else { - b, err = json.Marshal(qfp) - } - if err != nil { - h.logger.Error("failed to marshal QUIC fingerprint into JSON", zap.Error(err)) - return next.ServeHTTP(wr, req) - } - - // write JSON to response - wr.Header().Set("Content-Type", "application/json") - wr.Header().Set("Connection", "close") _, err = wr.Write(b) if err != nil { h.logger.Error("failed to write response", zap.Error(err)) diff --git a/modcaddy/listener/listener.go b/modcaddy/listener/listener.go index 471e4fe..0f93623 100644 --- a/modcaddy/listener/listener.go +++ b/modcaddy/listener/listener.go @@ -88,75 +88,6 @@ func (lw *ListenerWrapper) Provision(ctx caddy.Context) error { // skipcq: GO-W1 return nil } -// func (lw *ListenerWrapper) udpLoop() { // skipcq: GO-W1029 -// for { -// var buf [2048]byte -// n, ipAddr, err := lw.udpListener.ReadFromIP(buf[:]) -// if err != nil { -// lw.logger.Error("UDP read error", zap.Error(err)) -// if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { -// return // return when listener is closed -// } -// continue -// } -// // lw.logger.Debug("Received UDP packet from " + ipAddr.String()) - -// // Parse UDP Packet -// udpPkt, err := utils.ParseUDPPacket(buf[:n]) -// if err != nil { -// lw.logger.Error("Failed to parse UDP packet", zap.Error(err)) -// continue -// } -// if udpPkt.DstPort != 443 { -// continue -// } -// udpAddr := &net.UDPAddr{IP: ipAddr.IP, Port: int(udpPkt.SrcPort)} -// // lw.logger.Debug("Parsed UDP packet from " + udpAddr.String()) - -// cip, err := clienthellod.ParseQUICCIP(udpPkt.Payload) -// if err != nil { -// lw.logger.Debug("Failed to parse QUIC CIP: ", zap.Error(err)) -// continue -// } -// // lw.logger.Debug("Depositing QClientHello from " + ipAddr.String()) -// lw.reservoir.DepositQUICCIP(udpAddr.String(), cip) -// } -// } - -// func (lw *ListenerWrapper) udp6Loop() { // skipcq: GO-W1029 -// for { -// var buf [2048]byte -// n, ipAddr, err := lw.udp6Listener.ReadFromIP(buf[:]) -// if err != nil { -// lw.logger.Error("UDP read error", zap.Error(err)) -// if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) { -// return // return when listener is closed -// } -// continue -// } -// // lw.logger.Debug("Received UDP packet from " + ipAddr.String()) - -// // Parse UDP Packet -// udpPkt, err := utils.ParseUDPPacket(buf[:n]) -// if err != nil { -// lw.logger.Error("Failed to parse UDP packet", zap.Error(err)) -// continue -// } -// if udpPkt.DstPort != 443 { -// continue -// } -// udpAddr := &net.UDPAddr{IP: ipAddr.IP, Port: int(udpPkt.SrcPort)} -// // lw.logger.Debug("Parsed UDP packet from " + udpAddr.String()) - -// cip, err := clienthellod.ParseQUICCIP(udpPkt.Payload) -// if err != nil { -// continue -// } -// // lw.logger.Debug("Depositing QClientHello from " + ipAddr.String()) -// lw.reservoir.DepositQUICCIP(udpAddr.String(), cip) -// } -// } - func (lw *ListenerWrapper) WrapListener(l net.Listener) net.Listener { // skipcq: GO-W1029 lw.logger.Info("Wrapping listener " + l.Addr().String() + "on network " + l.Addr().Network() + "...") diff --git a/quic_client_initial_test.go b/quic_client_initial_test.go index ec4c179..c40c937 100644 --- a/quic_client_initial_test.go +++ b/quic_client_initial_test.go @@ -16,6 +16,9 @@ var mapGatheredClientInitials = map[string][][]byte{ "Firefox126": { quicIETFData_Firefox126, }, + "Firefox126_0-RTT": { + quicIETFData_Firefox126_0_RTT, + }, } func TestGatherClientInitials(t *testing.T) { @@ -62,7 +65,6 @@ func TestGatheredClientInitialsGC(t *testing.T) { for gcCnt < 5 { select { case <-gcOk: - t.Logf("GatheredClientInitials is GCed after %d GC cycles", gcCnt) return default: runtime.GC() @@ -70,5 +72,5 @@ func TestGatheredClientInitialsGC(t *testing.T) { } } - t.Fatalf("GatheredClientInitials is not GCed") + t.Fatalf("GatheredClientInitials is not GCed within 5 cycles") } diff --git a/quic_clienthello.go b/quic_clienthello.go index 1649bc4..169d3c9 100644 --- a/quic_clienthello.go +++ b/quic_clienthello.go @@ -14,8 +14,8 @@ func ParseQUICClientHello(p []byte) (*QUICClientHello, error) { // patch TLS record header to make it a valid TLS record record := make([]byte, 5+len(p)) record[0] = 0x16 // TLS handshake - record[1] = 0x00 // Dummy TLS version MSB - record[2] = 0x00 // Dummy TLS version LSB + record[1] = 0x00 // Dummy TLS version MSB - 00 + record[2] = 0x00 // Dummy TLS version LSB - 00 record[3] = byte(len(p) >> 8) record[4] = byte(len(p)) copy(record[5:], p) @@ -42,6 +42,7 @@ func ParseQUICClientHello(p []byte) (*QUICClientHello, error) { return &QUICClientHello{ClientHello: *ch}, nil } +// Raw returns the raw bytes of the QUIC ClientHello. func (qch *QUICClientHello) Raw() []byte { return qch.ClientHello.Raw()[5:] // strip TLS record header which is added by ParseQUICClientHello } diff --git a/quic_common_test.go b/quic_common_test.go index 87be33d..433764a 100644 --- a/quic_common_test.go +++ b/quic_common_test.go @@ -98,6 +98,8 @@ var ( //go:embed internal/testdata/QUIC_IETF_Firefox_126.bin quicIETFData_Firefox126 []byte + //go:embed internal/testdata/QUIC_IETF_Firefox_126_0-RTT.bin + quicIETFData_Firefox126_0_RTT []byte ) var mapTestDecodeQUICHeaderAndFrames = map[string]struct { @@ -121,6 +123,11 @@ var mapTestDecodeQUICHeaderAndFrames = map[string]struct { headerTruth: quicHeaderTruth_Firefox126, framesTruth: quicFramesTruth_Firefox126, }, + "Firefox126_with_0-RTT": { + data: quicIETFData_Firefox126_0_RTT, + headerTruth: quicHeaderTruth_Firefox126_0_RTT, + framesTruth: quicFramesTruth_Firefox126_0_RTT, + }, } func TestDecodeQUICHeaderAndFrames(t *testing.T) { diff --git a/quic_fingerprint.go b/quic_fingerprint.go index 481ef25..61aa227 100644 --- a/quic_fingerprint.go +++ b/quic_fingerprint.go @@ -51,7 +51,7 @@ func GenerateQUICFingerprint(gci *GatheredClientInitials) (*QUICFingerprint, err return qfp, nil } -const DEFAULT_QUICFINGERPRINT_EXPIRY = 10 * time.Second +const DEFAULT_QUICFINGERPRINT_EXPIRY = 60 * time.Second // QUICFingerprinter can be used to fingerprint QUIC connections. type QUICFingerprinter struct { @@ -176,9 +176,9 @@ func (qfp *QUICFingerprinter) HandleIPConn(ipc *net.IPConn) error { } } -// Lookup looks up a QUICFingerprint for a given key. -func (qfp *QUICFingerprinter) Lookup(from string) *QUICFingerprint { - gci, ok := qfp.mapGatheringClientInitials.Load(from) // when using LoadAndDelete, some implementations "wasting" QUIC connections will fail +// Peek looks up a QUICFingerprint for a given key. +func (qfp *QUICFingerprinter) Peek(from string) *QUICFingerprint { + gci, ok := qfp.mapGatheringClientInitials.Load(from) if !ok { return nil } @@ -200,9 +200,61 @@ func (qfp *QUICFingerprinter) Lookup(from string) *QUICFingerprint { return qf } -// LookupAwait looks up a QUICFingerprint for a given key, waiting for the gathering to complete. -func (qfp *QUICFingerprinter) LookupAwait(from string) (*QUICFingerprint, error) { - gci, ok := qfp.mapGatheringClientInitials.Load(from) // when using LoadAndDelete, some implementations "wasting" QUIC connections will fail +// PeekAwait looks up a QUICFingerprint for a given key. +// It will wait for the gathering to complete if the key exists but the +// gathering is not yet complete, e.g., when CRYPTO frames spread across +// multiple initial packets and some but not all of them are received. +func (qfp *QUICFingerprinter) PeekAwait(from string) (*QUICFingerprint, error) { + gci, ok := qfp.mapGatheringClientInitials.Load(from) + if !ok { + return nil, errors.New("GatheredClientInitials not found for the given key") + } + + gatheredCI, ok := gci.(*GatheredClientInitials) + if !ok { + return nil, errors.New("GatheredClientInitials loaded from sync.Map failed type assertion") + } + + qf, err := GenerateQUICFingerprint(gatheredCI) + if err != nil { + return nil, err + } + + return qf, nil +} + +// Pop looks up a QUICFingerprint for a given key and deletes it from +// the fingerprinter if found. +func (qfp *QUICFingerprinter) Pop(from string) *QUICFingerprint { + gci, ok := qfp.mapGatheringClientInitials.LoadAndDelete(from) + if !ok { + return nil + } + + gatheredCI, ok := gci.(*GatheredClientInitials) + if !ok { + return nil + } + + if !gatheredCI.Completed() { + return nil // gathering incomplete + } + + qf, err := GenerateQUICFingerprint(gatheredCI) + if err != nil { + return nil + } + + return qf +} + +// PopAwait looks up a QUICFingerprint for a given key and deletes it from +// the fingerprinter if found. +// It will wait for the gathering to complete if the key exists but the +// gathering is not yet complete, e.g., when CRYPTO frames spread across +// multiple initial packets and some but not all of them are received. +func (qfp *QUICFingerprinter) PopAwait(from string) (*QUICFingerprint, error) { + gci, ok := qfp.mapGatheringClientInitials.LoadAndDelete(from) if !ok { return nil, errors.New("GatheredClientInitials not found for the given key") } diff --git a/quic_frame_test.go b/quic_frame_test.go index 83ba4bd..9fe15e1 100644 --- a/quic_frame_test.go +++ b/quic_frame_test.go @@ -378,6 +378,9 @@ var ( quicFramesTruth_Firefox126 = QUICFrames{ &CRYPTO{Offset: 0, Length: 633}, } + quicFramesTruth_Firefox126_0_RTT = QUICFrames{ + &CRYPTO{Offset: 0, Length: 594}, + } ) func testQUICFramesEqualsTruth(t *testing.T, frames, truths QUICFrames) { diff --git a/quic_header_test.go b/quic_header_test.go index fb0d428..d4cf7a4 100644 --- a/quic_header_test.go +++ b/quic_header_test.go @@ -35,6 +35,14 @@ var ( HasToken: false, } + quicHeaderTruth_Firefox126_0_RTT = &QUICHeader{ + Version: []byte{0x00, 0x00, 0x00, 0x01}, + DCIDLength: 9, + SCIDLength: 3, + PacketNumber: []byte{0x00}, + + HasToken: true, + } ) func testQUICHeaderEqualsTruth(t *testing.T, header, truth *QUICHeader) { diff --git a/tls_fingerprint.go b/tls_fingerprint.go index bc7b4bf..6b99631 100644 --- a/tls_fingerprint.go +++ b/tls_fingerprint.go @@ -11,7 +11,7 @@ import ( "github.com/gaukas/clienthellod/internal/utils" ) -const DEFAULT_TLSFINGERPRINT_EXPIRY = 10 * time.Second +const DEFAULT_TLSFINGERPRINT_EXPIRY = 5 * time.Second // TLSFingerprinter can be used to fingerprint TLS connections. type TLSFingerprinter struct { @@ -55,14 +55,15 @@ func (tfp *TLSFingerprinter) HandleMessage(from string, p []byte) error { } tfp.mapClientHellos.Store(from, ch) - go func() { - if tfp.timeout == time.Duration(0) { + go func(timeoutOverride time.Duration, key string, oldCh *ClientHello) { + if timeoutOverride == time.Duration(0) { <-time.After(DEFAULT_TLSFINGERPRINT_EXPIRY) } else { - <-time.After(tfp.timeout) + <-time.After(timeoutOverride) } - tfp.mapClientHellos.Delete(from) - }() + // tfp.mapClientHellos.Delete(key) + tfp.mapClientHellos.CompareAndDelete(key, oldCh) + }(tfp.timeout, from, ch) return nil } @@ -83,20 +84,37 @@ func (tfp *TLSFingerprinter) HandleTCPConn(conn net.Conn) (rewindConn net.Conn, } tfp.mapClientHellos.Store(conn.RemoteAddr().String(), ch) - go func() { - if tfp.timeout == time.Duration(0) { + go func(timeoutOverride time.Duration, key string, oldCh *ClientHello) { + if timeoutOverride == time.Duration(0) { <-time.After(DEFAULT_TLSFINGERPRINT_EXPIRY) } else { - <-time.After(tfp.timeout) + <-time.After(timeoutOverride) } - tfp.mapClientHellos.Delete(conn.RemoteAddr().String()) - }() + // tfp.mapClientHellos.Delete(key) + tfp.mapClientHellos.CompareAndDelete(key, oldCh) + }(tfp.timeout, conn.RemoteAddr().String(), ch) return utils.RewindConn(conn, ch.Raw()) } -// Lookup looks up a ClientHello. -func (tfp *TLSFingerprinter) Lookup(from string) *ClientHello { +// Peek looks up a ClientHello for a given key. +func (tfp *TLSFingerprinter) Peek(from string) *ClientHello { + ch, ok := tfp.mapClientHellos.Load(from) + if !ok { + return nil + } + + clientHello, ok := ch.(*ClientHello) + if !ok { + return nil + } + + return clientHello +} + +// Pop looks up a ClientHello for a given key and deletes it from the +// fingerprinter if found. +func (tfp *TLSFingerprinter) Pop(from string) *ClientHello { ch, ok := tfp.mapClientHellos.LoadAndDelete(from) if !ok { return nil