diff --git a/proxy/ja4_integration_test.go b/proxy/ja4_integration_test.go
@@ -0,0 +1,138 @@
+//go:build integration
+
+package proxy
+
+import (
+ "bufio"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/djnnvx/mic/fingerprint"
+)
+
+// expectedJA4 maps fingerprint profile names to their known JA4 strings.
+// Values come from fingerprint.Table (measured empirically via cmd/probe against
+// tlsinfo.me). chrome-120-pq is absent: it emits the same JA4 as chrome-120
+// because JA4 does not distinguish the X25519MLKEM768 key share.
+var expectedJA4 = map[string]string{
+ "chrome-120": "t13d1516h2_8daaf6152771_02713d6af862",
+ "firefox-120": "t13d1715h2_5b57614c22b0_5c2c66f702b0",
+ "safari-16": "t13d2014h2_a09f3c656075_14788d8d241b",
+ "edge-106": "t13d1516h2_8daaf6152771_e5627efa2ab1",
+}
+
+// TestClientFront_JA4 verifies that each fingerprint profile produces the
+// expected JA4 on outbound connections in client-front (HTTP CONNECT) mode.
+//
+// test client → TCP CONNECT → proxy (HttpsHandler) → uTLS(profile) → captureJA4Server
+func TestClientFront_JA4(t *testing.T) {
+ for name, want := range expectedJA4 {
+ name, want := name, want
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ captureAddr, capturePool, ja4s := captureJA4Server(t)
+
+ fp, err := fingerprint.ByName(name)
+ if err != nil {
+ t.Fatalf("fingerprint.ByName(%q): %v", name, err)
+ }
+
+ proxyAddr := serveHandler(t, &Proxy{CAPool: capturePool, Fingerprint: fp}, HttpsHandler)
+
+ conn, err := net.DialTimeout("tcp", proxyAddr, testTimeout)
+ if err != nil {
+ t.Fatalf("dial proxy: %v", err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(testTimeout)) //nolint:errcheck
+
+ fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", captureAddr, captureAddr)
+ if status, err := readHTTPStatus(bufio.NewReader(conn)); err != nil || status != 200 {
+ t.Fatalf("CONNECT: status=%d err=%v", status, err)
+ }
+
+ // Send a plain HTTP request through the uTLS tunnel the proxy established.
+ fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", captureAddr)
+ io.Copy(io.Discard, conn) //nolint:errcheck
+
+ if got := receiveJA4(t, ja4s); got != want {
+ t.Errorf("JA4 mismatch for %s:\n got %s\n want %s", name, got, want)
+ }
+ })
+ }
+}
+
+// TestServerFront_JA4 verifies that each fingerprint profile produces the
+// expected JA4 on outbound connections in server-front (TLS termination) mode.
+//
+// test client → TLS → proxy (ServerFrontHandler) → uTLS(profile) → captureJA4Server
+func TestServerFront_JA4(t *testing.T) {
+ for name, want := range expectedJA4 {
+ name, want := name, want
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ captureAddr, capturePool, ja4s := captureJA4Server(t)
+ proxyCertFile, proxyKeyFile, proxyPool := writeTempCertKey(t)
+
+ fp, err := fingerprint.ByName(name)
+ if err != nil {
+ t.Fatalf("fingerprint.ByName(%q): %v", name, err)
+ }
+
+ p := &Proxy{BackendAddr: captureAddr, CAPool: capturePool, Fingerprint: fp}
+ proxyAddr := serveHandler(t, p, ServerFrontHandler(proxyCertFile, proxyKeyFile))
+
+ tlsConn, err := tls.DialWithDialer(
+ &net.Dialer{Timeout: testTimeout},
+ "tcp", proxyAddr,
+ &tls.Config{RootCAs: proxyPool, ServerName: "localhost"},
+ )
+ if err != nil {
+ t.Fatalf("tls.Dial to proxy: %v", err)
+ }
+ defer tlsConn.Close()
+ tlsConn.SetDeadline(time.Now().Add(testTimeout)) //nolint:errcheck
+
+ fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+ io.Copy(io.Discard, tlsConn) //nolint:errcheck
+
+ if got := receiveJA4(t, ja4s); got != want {
+ t.Errorf("JA4 mismatch for %s:\n got %s\n want %s", name, got, want)
+ }
+ })
+ }
+}
+
+func readHTTPStatus(r *bufio.Reader) (int, error) {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return 0, err
+ }
+ var proto string
+ var status int
+ fmt.Sscanf(line, "%s %d", &proto, &status)
+ for {
+ h, err := r.ReadString('\n')
+ if err != nil || h == "\r\n" {
+ break
+ }
+ }
+ return status, nil
+}
+
+func receiveJA4(t *testing.T, ja4s <-chan string) string {
+ t.Helper()
+ select {
+ case ja4 := <-ja4s:
+ return ja4
+ case <-time.After(testTimeout):
+ t.Fatal("timed out waiting for captured JA4")
+ return ""
+ }
+}
diff --git a/proxy/ja4capture_test.go b/proxy/ja4capture_test.go
@@ -0,0 +1,100 @@
+//go:build integration
+
+package proxy
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
+ "io"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/djnnvx/mic/fingerprint"
+)
+
+// teeConn tees all bytes read from it into an io.Writer, letting us capture
+// the raw ClientHello while tls.Server reads from the connection normally.
+type teeConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (tc *teeConn) Read(b []byte) (int, error) { return tc.r.Read(b) }
+
+// captureJA4Server starts a TLS listener that captures the JA4 fingerprint
+// from each incoming connection's ClientHello and sends it on the returned channel.
+// The server also serves a minimal HTTP/1.1 200 response so callers can complete
+// their request without stalling.
+//
+// The returned addr is "localhost:PORT" (not "127.0.0.1:PORT") so that
+// dialTarget uses a hostname SNI rather than an IP address. This matches the
+// "d" (domain) indicator in the stored JA4 fingerprint table, which was
+// measured against tlsinfo.me — also a hostname.
+func captureJA4Server(t *testing.T) (addr string, certPool *x509.CertPool, ja4s <-chan string) {
+ t.Helper()
+
+ pool, tlsCert := generateSelfSignedCert(t)
+ tlsCfg := &tls.Config{Certificates: []tls.Certificate{tlsCert}}
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("captureJA4Server: listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ _, port, _ := net.SplitHostPort(ln.Addr().String())
+
+ ch := make(chan string, 16)
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go serveCapture(conn, tlsCfg, ch)
+ }
+ }()
+
+ return "localhost:" + port, pool, ch
+}
+
+func serveCapture(conn net.Conn, cfg *tls.Config, ch chan<- string) {
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(10 * time.Second)) //nolint:errcheck
+
+ var buf bytes.Buffer
+ tc := &teeConn{Conn: conn, r: io.TeeReader(conn, &buf)}
+
+ srv := tls.Server(tc, cfg)
+ if err := srv.Handshake(); err != nil {
+ if ja4, err2 := captureToJA4(buf.Bytes()); err2 == nil {
+ ch <- ja4
+ }
+ return
+ }
+
+ if ja4, err := captureToJA4(buf.Bytes()); err == nil {
+ ch <- ja4
+ }
+
+ reader := bufio.NewReader(srv)
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil || line == "\r\n" {
+ break
+ }
+ }
+ fmt.Fprintf(srv, "HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nOK\n")
+}
+
+func captureToJA4(raw []byte) (string, error) {
+ ch, err := fingerprint.ParseClientHello(raw)
+ if err != nil {
+ return "", err
+ }
+ return fingerprint.ComputeJA4(ch), nil
+}
| |