mic.git


mic

~ proxy for JA4-TLS fingerprinting evasion
git clone https://github.com/djnnvx/mic.git
log | files | refs | archive | README | LICENSE

commit d4b8c2a53b31537c017cd85085334a87d6061829
parent 96d1caed0f9a85f50a81e21b7802cfe5db513afc
Author: djnn <[email protected]>
Date: Wed, 18 Mar 2026 18:29:02 +0900

fix(git): conflicts

Diffstat:
A.github/workflows/ci.yml | 31+++++++++++++++++++++++++++++++
A.goreleaser.yaml | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 147+++++++++++++++++++++++++++++--------------------------------------------------
Acmd/client.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcmd/root.go | 84++++++-------------------------------------------------------------------------
Acmd/server.go | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcmd/types.go | 11-----------
Dconfig/config.go | 63---------------------------------------------------------------
Dconfig/config_test.go | 94-------------------------------------------------------------------------------
Mfingerprint/fingerprint.go | 11+----------
Afingerprint/ja4.go | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dfingerprint/tcp.go | 11-----------
Mfingerprint/tls.go | 29++++++++++++++++-------------
Mfingerprint/tls_test.go | 65++++++++++++++++++++++++++++++++---------------------------------
Mgo.mod | 1-
Mgo.sum | 2--
Dmic.example.toml | 33---------------------------------
Mproxy/ca.go | 19++++++++-----------
Mproxy/https.go | 18++++--------------
Mproxy/integration_test.go | 37++++++++++---------------------------
Aproxy/ja4_integration_test.go | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aproxy/ja4capture_test.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproxy/proxy.go | 17+++++------------
Mproxy/proxy_test.go | 12+++---------
Mproxy/serverfront.go | 1-
Mscripts/cleanup.sh | 40++++++----------------------------------
Mscripts/setup.sh | 60++++++++++++++++++++----------------------------------------
Dtest/client/main.go | 78------------------------------------------------------------------------------
Dtest/server/cert.pem | 19-------------------
Dtest/server/key.pem | 28----------------------------
Dtest/server/main.go | 26--------------------------

31 files changed, 960 insertions(+), 742 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+  push:
+    branches: ["**"]
+  pull_request:
+
+jobs:
+  unit:
+    name: Unit tests
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v5
+        with:
+          go-version: "1.25"
+          cache: true
+      - name: Run unit tests
+        run: go test ./...
+
+  integration:
+    name: Integration tests (JA4 fingerprint verification)
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-go@v5
+        with:
+          go-version: "1.25"
+          cache: true
+      - name: Run integration tests
+        run: go test -tags integration -v -count=1 ./proxy/... ./fingerprint/...
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
@@ -0,0 +1,56 @@
+# .goreleaser.yaml
+version: 2
+
+project_name: mic
+
+before:
+  hooks:
+    - go mod tidy
+
+builds:
+  - env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+      - darwin
+      - windows
+      - freebsd
+    goarch:
+      - amd64
+      - arm64
+      - 386
+
+    binary: mic
+    ldflags:
+      - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
+    flags:
+      - -trimpath # Important for reproducible builds and security
+
+archives:
+  - name_template: >-
+      {{ .ProjectName }}_
+      {{- .Version }}_
+      {{- if eq .Os "darwin" }}macOS
+      {{- else if eq .Os "linux" }}Linux
+      {{- else if eq .Os "windows" }}Windows
+      {{- else }}{{ .Os }}{{ end }}_
+      {{- if eq .Arch "amd64" }}x86_64
+      {{- else if eq .Arch "386" }}i386
+      {{- else }}{{ .Arch }}{{ end }}
+    format_overrides:
+      - goos: windows
+        format: zip
+
+checksum:
+  name_template: 'checksums.txt'
+
+snapshot:
+  # Used when running with --snapshot
+  version_template: "{{ incpatch .Version }}-next"
+
+changelog:
+  sort: asc
+  filters:
+    exclude:
+      - '^docs:'
+      - '^test:'
diff --git a/README.md b/README.md
@@ -1,13 +1,13 @@
 # mic
 
 mic (mina-is-cute) is a modular Go proxy for controlling outbound TLS fingerprints.
-It lets you specify a JA4-TLS hash and the proxy will use the corresponding `uTLS`
-preset when connecting to upstream servers, making your traffic look like a specific
-browser to any fingerprinting system.
+It lets you pick a browser fingerprint profile and the proxy will use the corresponding
+`uTLS` preset when connecting to upstream servers, making your traffic look like a
+specific browser to any fingerprinting system.
 
 Two modes are supported:
 
-- **client-front** — HTTP CONNECT proxy with MitM TLS interception. The proxy
+- **client-front** — HTTP CONNECT proxy with optional MitM TLS interception. The proxy
   generates a local CA, issues per-host leaf certs on the fly, terminates TLS from
   the client, and re-dials the target with the configured uTLS fingerprint. Standard
   tools (curl, browsers) work after importing the CA once.
@@ -50,7 +50,7 @@ sequenceDiagram
     end
 ```
 
-In both modes the target sees a TLS handshake that matches the configured JA4 hash,
+In both modes the target sees a TLS handshake that matches the configured fingerprint,
 not the default Go TLS fingerprint.
 
 ---
@@ -61,134 +61,93 @@ not the default Go TLS fingerprint.
 go build -o mic .
 ```
 
-Or with the helper script (builds, creates `mic.toml`, starts the proxy, and prints
-CA trust instructions):
-
-```bash
-scripts/setup.sh
-```
-
----
-
-## Configuration
-
-Copy the example config and edit it:
-
-```bash
-cp mic.example.toml mic.toml
-```
-
-```toml
-mode = "client-front"   # or "server-front"
-
-[listen]
-addr = ":8080"
-
-[backend]
-addr = "127.0.0.1:443"   # server-front only
-
-[fingerprint]
-[fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_b0da82dd1658"   # Chrome 120
-
-# server-front only: certificate mic presents to clients
-[fingerprint.tls.termination]
-cert = "/path/to/cert.pem"
-key  = "/path/to/key.pem"
-
-[ca]
-cert = ""   # optional: custom CA for verifying upstream targets
-
-# client-front only: local MitM CA.
-# mic creates these files on first run and reuses them across restarts.
-# Import ca.pem into curl / your browser / the system trust store.
-[ca.intercept]
-cert = "ca.pem"
-key  = "ca-key.pem"
-```
-
-### Available JA4 fingerprints
-
-Hashes are measured by `cmd/probe` against tlsinfo.me and reflect what each utls
-preset actually emits. Re-run the probe after upgrading the utls dependency.
-
-| JA4 hash | Preset |
-|---|---|
-| `t13d1516h2_8daaf6152771_02713d6af862` | Chrome 120 (`HelloChrome_120`) |
-| `t13d1715h2_5b57614c22b0_5c2c66f702b0` | Firefox 120 (`HelloFirefox_120`) |
-| `t13d2014h2_a09f3c656075_14788d8d241b` | Safari 16.0 (`HelloSafari_16_0`) |
-| `t13d1516h2_8daaf6152771_e5627efa2ab1` | Edge 106 (`HelloEdge_106`) |
-
-> `HelloChrome_120_PQ` (post-quantum) produces the same JA4 as Chrome 120 because
-> the X25519MLKEM768 key share is not distinguished by JA4. It is not separately
-> selectable via config hash.
-
 ---
 
 ## Running
 
+### client-front
+
 ```bash
-./mic --config mic.toml
+mic client --listen :8080 --fingerprint chrome-120 \
+           --intercept-cert ca.pem --intercept-key ca-key.pem
 ```
 
-### client-front
+`--intercept-cert` and `--intercept-key` enable MitM interception. mic generates the
+CA files on first run and reuses them. Import the cert once, then all traffic through
+the proxy is transparently intercepted and re-dialled with the configured fingerprint.
 
-Set `mode = "client-front"` and configure `[ca.intercept]` with paths for the local
-CA cert and key. mic will create them on first run.
-
-**Trust the CA once** (pick whichever applies):
+**Trust the CA** (pick whichever applies):
 
 ```bash
-# curl — pass --cacert on every invocation, or set CURL_CA_BUNDLE
+# curl — pass on every call, or set CURL_CA_BUNDLE
 curl --cacert ca.pem -x http://localhost:8080 https://tlsinfo.me/json
 
-# Debian / Ubuntu / Kali — add to system store
-sudo cp ca.pem /usr/local/share/ca-certificates/mic-ca.crt
-sudo update-ca-certificates
+# Debian / Ubuntu / Kali
+sudo cp ca.pem /usr/local/share/ca-certificates/mic-ca.crt && sudo update-ca-certificates
 
 # macOS
 sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.pem
 ```
 
-After trusting the CA, standard tools work without extra flags:
+After trusting, standard tools work without extra flags:
 
 ```bash
 curl -x http://localhost:8080 https://tlsinfo.me/json
-# check the "ja4" field — it should match your configured fingerprint
+# check the "ja4" field — it should match the fingerprint you configured
 ```
 
+Omit `--intercept-*` to run as a plain CONNECT proxy (no MitM, raw tunnel only).
+
 ### server-front
 
-Generate a self-signed certificate for the proxy to present to clients:
+Generate a cert for the proxy to present to clients:
 
 ```bash
 go run $(go env GOROOT)/src/crypto/tls/generate_cert.go --host="localhost,127.0.0.1"
 # produces cert.pem and key.pem
 ```
 
-Set `mode = "server-front"`, point `fingerprint.tls.termination.cert/key` at those
-files, and set `backend.addr` to your upstream. Then connect directly to the proxy
-address with a TLS client.
-
-### Stop
-
 ```bash
-scripts/cleanup.sh
+mic server --listen :8080 --backend 10.0.0.1:443 \
+           --cert cert.pem --key key.pem \
+           --fingerprint chrome-120
 ```
 
+`--backend`, `--cert`, and `--key` are required. `--fingerprint` is optional; without
+it the proxy falls back to a randomised uTLS preset.
+
+### Available fingerprint profiles
+
+Hashes are measured by `cmd/probe` against tlsinfo.me and reflect what each utls
+preset actually emits. Re-run the probe after upgrading the utls dependency.
+
+| Profile | JA4 hash |
+|---|---|
+| `chrome-120` | `t13d1516h2_8daaf6152771_02713d6af862` |
+| `chrome-120-pq` | same as `chrome-120`¹ |
+| `firefox-120` | `t13d1715h2_5b57614c22b0_5c2c66f702b0` |
+| `safari-16` | `t13d2014h2_a09f3c656075_14788d8d241b` |
+| `edge-106` | `t13d1516h2_8daaf6152771_e5627efa2ab1` |
+
+> ¹ `chrome-120-pq` (post-quantum) produces the same JA4 because JA4 does not
+> distinguish the X25519MLKEM768 key share. Use it when you specifically need the PQ
+> key exchange behaviour regardless of fingerprint visibility.
+
 ---
 
 ## Testing
 
-Unit tests:
-
 ```bash
+# unit tests
 go test ./...
+
+# integration tests — spin up in-process TLS servers, no network required
+go test -tags integration -v ./proxy/... ./fingerprint/...
 ```
 
-Integration tests (spin up in-process TLS servers, no external dependencies):
+The integration suite includes JA4 fingerprint verification tests: for each profile,
+both `TestClientFront_JA4` and `TestServerFront_JA4` capture the raw ClientHello sent
+by the proxy's uTLS engine and assert the computed JA4 hash matches the expected value.
+This is the offline equivalent of running `cmd/probe` against tlsinfo.me.
 
-```bash
-scripts/run_integration_tests.sh
-# or: go test -tags integration -v ./proxy/...
-```
+CI runs both suites on every push via `.github/workflows/ci.yml`.
diff --git a/cmd/client.go b/cmd/client.go
@@ -0,0 +1,70 @@
+package cmd
+
+import (
+	"crypto/x509"
+	"log"
+	"os"
+
+	"github.com/djnnvx/mic/fingerprint"
+	"github.com/djnnvx/mic/proxy"
+	"github.com/spf13/cobra"
+)
+
+func newClientCmd() *cobra.Command {
+	var (
+		listen        string
+		fpName        string
+		caPath        string
+		interceptCert string
+		interceptKey  string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "client",
+		Short: "HTTP CONNECT proxy with optional MitM interception",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			p := &proxy.Proxy{ListenAddr: listen}
+
+			if caPath != "" {
+				caCert, err := os.ReadFile(caPath)
+				if err != nil {
+					log.Fatalf("[!] Failed to read CA certificate: %v", err)
+				}
+				pool := x509.NewCertPool()
+				pool.AppendCertsFromPEM(caCert)
+				p.CAPool = pool
+			}
+
+			if fpName != "" {
+				fp, err := fingerprint.ByName(fpName)
+				if err != nil {
+					log.Fatalf("[!] Failed to load fingerprint: %v", err)
+				}
+				p.Fingerprint = fp
+				log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
+			}
+
+			if interceptCert != "" || interceptKey != "" {
+				ca, err := proxy.LoadOrGenerateCA(interceptCert, interceptKey)
+				if err != nil {
+					log.Fatalf("[!] Failed to load/generate intercept CA: %v", err)
+				}
+				p.LocalCA = ca
+				log.Printf("[+] MitM CA ready — import %s, then: curl --cacert %s -x http://localhost%s https://<target>",
+					interceptCert, interceptCert, listen)
+			}
+
+			log.Printf("[+] Mode: client (HTTP CONNECT proxy on %s)", listen)
+			p.RegisterHandler(proxy.HttpsHandler)
+			return p.Run()
+		},
+	}
+
+	cmd.Flags().StringVarP(&listen, "listen", "l", ":8080", "listen address")
+	cmd.Flags().StringVarP(&fpName, "fingerprint", "f", "", "TLS fingerprint profile name (e.g. chrome-120)")
+	cmd.Flags().StringVar(&caPath, "ca", "", "path to upstream CA certificate")
+	cmd.Flags().StringVar(&interceptCert, "intercept-cert", "", "MitM CA cert path (enables interception)")
+	cmd.Flags().StringVar(&interceptKey, "intercept-key", "", "MitM CA key path (enables interception)")
+
+	return cmd
+}
diff --git a/cmd/root.go b/cmd/root.go
@@ -1,14 +1,9 @@
 package cmd
 
 import (
-	"crypto/x509"
 	"fmt"
-	"log"
 	"os"
 
-	"github.com/djnnvx/mic/config"
-	"github.com/djnnvx/mic/fingerprint"
-	"github.com/djnnvx/mic/proxy"
 	"github.com/spf13/cobra"
 )
 
@@ -16,7 +11,6 @@ var SKID_ASCII_ART = `
 
 
 
-
         ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣤⣤⣤⣤⣤⣤⣄⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
     ⠀              ⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠛⠻⠿⢿⣿⣿⣿⣿⣿⣶⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
                ⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⣿⣿⣿⣿⣿⣿⣶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
@@ -35,7 +29,7 @@ var SKID_ASCII_ART = `
         ⠸⣿⣿⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀
         ⠀⢹⣿⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀
         ⠀⠀⠹⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀
-        ⠀⠀⠀⠙⣿⣿⣿⣿⣿⣶⣤⣀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿mina is cute⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀
+        ⠀⠀⠀⠙⣿⣿⣿⣿⣿⣶⣤⣀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿mina is cute⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀
         ⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿<3:)⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀
         ⠀⠀⠀⠀⠀⠀⠉⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀
         ⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀
@@ -46,86 +40,20 @@ var SKID_ASCII_ART = `
 
 `
 
-func getParser(opts *MicCliOptions) *cobra.Command {
-	var rootCmd = &cobra.Command{
+func newRootCmd() *cobra.Command {
+	rootCmd := &cobra.Command{
 		Use:   "mic",
 		Short: "modular proxy for network-fingerprinting evasion",
 		Long:  "mic (mina-is-cute) is a HTTPS proxy to evade networking fingerprinting detection",
-		Run: func(cmd *cobra.Command, args []string) {
-			cfg, err := config.Load(opts.ConfigPath)
-			if err != nil {
-				log.Fatalf("[!] Failed to load config: %v", err)
-			}
-
-			p := &proxy.Proxy{
-				ListenAddr:  cfg.Listen.Addr,
-				BackendAddr: cfg.Backend.Addr,
-			}
-
-			// Load optional custom CA pool.
-			if cfg.CA.Cert != "" {
-				caCert, err := os.ReadFile(cfg.CA.Cert)
-				if err != nil {
-					log.Fatalf("[!] Failed to read CA certificate: %v", err)
-				}
-				pool := x509.NewCertPool()
-				pool.AppendCertsFromPEM(caCert)
-				p.CAPool = pool
-			}
-
-			// Load TLS fingerprint if a JA4 hash is configured.
-			if cfg.Fingerprint.TLS.JA4 != "" {
-				fp, err := fingerprint.NewTLS(cfg.Fingerprint.TLS.JA4)
-				if err != nil {
-					log.Fatalf("[!] Failed to load fingerprint: %v", err)
-				}
-				p.Fingerprint = fp
-				log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
-			}
-
-			switch cfg.Mode {
-			case "client-front":
-				log.Printf("[+] Mode: client-front (HTTP CONNECT proxy)")
-				if cfg.CA.Intercept.Cert != "" || cfg.CA.Intercept.Key != "" {
-					ca, err := proxy.LoadOrGenerateCA(cfg.CA.Intercept.Cert, cfg.CA.Intercept.Key)
-					if err != nil {
-						log.Fatalf("[!] Failed to load/generate intercept CA: %v", err)
-					}
-					p.LocalCA = ca
-					log.Printf("[+] MitM CA ready — import %s, then: curl --cacert %s -x http://localhost%s https://<target>",
-						cfg.CA.Intercept.Cert, cfg.CA.Intercept.Cert, cfg.Listen.Addr)
-				}
-				p.RegisterHandler(proxy.HttpsHandler)
-			case "server-front":
-				log.Printf("[+] Mode: server-front (TLS termination → backend %s)", cfg.Backend.Addr)
-				certFile := cfg.Fingerprint.TLS.Termination.Cert
-				keyFile := cfg.Fingerprint.TLS.Termination.Key
-				if certFile == "" || keyFile == "" {
-					log.Fatalf("[!] server-front mode requires fingerprint.tls.termination.cert and .key")
-				}
-				p.RegisterHandler(proxy.ServerFrontHandler(certFile, keyFile))
-			default:
-				log.Fatalf("[!] Unknown mode %q; expected \"client-front\" or \"server-front\"", cfg.Mode)
-			}
-
-			if err := p.Run(); err != nil {
-				log.Fatalf("[!] Proxy error: %v", err)
-			}
-		},
 	}
-
-	defaults := GetDefaultOptions()
-	rootCmd.Flags().StringVarP(&opts.ConfigPath, "config", "c", defaults.ConfigPath, "path to TOML config file")
-
+	rootCmd.AddCommand(newClientCmd())
+	rootCmd.AddCommand(newServerCmd())
 	return rootCmd
 }
 
 func Execute() {
 	fmt.Println(SKID_ASCII_ART)
-
-	opts := GetDefaultOptions()
-	cliParser := getParser(&opts)
-	if err := cliParser.Execute(); err != nil {
+	if err := newRootCmd().Execute(); err != nil {
 		fmt.Printf("[!] Error: %s\n", err.Error())
 		os.Exit(1)
 	}
diff --git a/cmd/server.go b/cmd/server.go
@@ -0,0 +1,72 @@
+package cmd
+
+import (
+	"crypto/x509"
+	"log"
+	"os"
+
+	"github.com/djnnvx/mic/fingerprint"
+	"github.com/djnnvx/mic/proxy"
+	"github.com/spf13/cobra"
+)
+
+func newServerCmd() *cobra.Command {
+	var (
+		listen  string
+		backend string
+		fpName  string
+		caPath  string
+		cert    string
+		key     string
+	)
+
+	cmd := &cobra.Command{
+		Use:   "server",
+		Short: "TLS termination proxy with upstream fingerprinting",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if backend == "" {
+				log.Fatal("[!] --backend is required for server mode")
+			}
+			if cert == "" || key == "" {
+				log.Fatal("[!] --cert and --key are required for server mode")
+			}
+
+			p := &proxy.Proxy{
+				ListenAddr:  listen,
+				BackendAddr: backend,
+			}
+
+			if caPath != "" {
+				caCert, err := os.ReadFile(caPath)
+				if err != nil {
+					log.Fatalf("[!] Failed to read CA certificate: %v", err)
+				}
+				pool := x509.NewCertPool()
+				pool.AppendCertsFromPEM(caCert)
+				p.CAPool = pool
+			}
+
+			if fpName != "" {
+				fp, err := fingerprint.ByName(fpName)
+				if err != nil {
+					log.Fatalf("[!] Failed to load fingerprint: %v", err)
+				}
+				p.Fingerprint = fp
+				log.Printf("[+] Using TLS fingerprint: %s", fp.Name())
+			}
+
+			log.Printf("[+] Mode: server (TLS termination → backend %s)", backend)
+			p.RegisterHandler(proxy.ServerFrontHandler(cert, key))
+			return p.Run()
+		},
+	}
+
+	cmd.Flags().StringVarP(&listen, "listen", "l", ":8080", "listen address")
+	cmd.Flags().StringVarP(&backend, "backend", "b", "", "backend host:port (required)")
+	cmd.Flags().StringVarP(&fpName, "fingerprint", "f", "", "TLS fingerprint profile name (e.g. chrome-120)")
+	cmd.Flags().StringVar(&caPath, "ca", "", "path to upstream CA certificate")
+	cmd.Flags().StringVar(&cert, "cert", "", "TLS certificate for incoming termination (required)")
+	cmd.Flags().StringVar(&key, "key", "", "TLS key for incoming termination (required)")
+
+	return cmd
+}
diff --git a/cmd/types.go b/cmd/types.go
@@ -1,11 +0,0 @@
-package cmd
-
-type MicCliOptions struct {
-	ConfigPath string
-}
-
-func GetDefaultOptions() MicCliOptions {
-	return MicCliOptions{
-		ConfigPath: "mic.toml",
-	}
-}
diff --git a/config/config.go b/config/config.go
@@ -1,63 +0,0 @@
-package config
-
-import (
-	"fmt"
-	"os"
-
-	"github.com/pelletier/go-toml/v2"
-)
-
-type Config struct {
-	Mode        string  `toml:"mode"`
-	Listen      Listen  `toml:"listen"`
-	Backend     Backend `toml:"backend"`
-	Fingerprint FpConf  `toml:"fingerprint"`
-	CA          CAConf  `toml:"ca"`
-}
-
-type Listen struct {
-	Addr string `toml:"addr"`
-}
-
-type Backend struct {
-	Addr string `toml:"addr"`
-}
-
-type CAConf struct {
-	Cert      string          `toml:"cert"`      // upstream CA cert for verifying targets
-	Intercept InterceptCAConf `toml:"intercept"` // client-front: local MitM CA
-}
-
-type InterceptCAConf struct {
-	Cert string `toml:"cert"` // path to local CA cert (created if absent)
-	Key  string `toml:"key"`  // path to local CA key  (created if absent)
-}
-
-type FpConf struct {
-	TLS TLSConf `toml:"tls"`
-}
-
-type TLSConf struct {
-	JA4         string         `toml:"ja4"`
-	Termination TLSTermination `toml:"termination"`
-}
-
-type TLSTermination struct {
-	Cert string `toml:"cert"`
-	Key  string `toml:"key"`
-}
-
-// Load reads and parses a TOML config file from path.
-func Load(path string) (*Config, error) {
-	data, err := os.ReadFile(path)
-	if err != nil {
-		return nil, fmt.Errorf("reading config file %q: %w", path, err)
-	}
-
-	var cfg Config
-	if err := toml.Unmarshal(data, &cfg); err != nil {
-		return nil, fmt.Errorf("parsing config file %q: %w", path, err)
-	}
-
-	return &cfg, nil
-}
diff --git a/config/config_test.go b/config/config_test.go
@@ -1,94 +0,0 @@
-package config_test
-
-import (
-	"os"
-	"testing"
-
-	"github.com/djnnvx/mic/config"
-)
-
-const validTOML = `
-mode = "client-front"
-
-[listen]
-addr = ":8080"
-
-[backend]
-addr = "127.0.0.1:443"
-
-[fingerprint]
-[fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_b0da82dd1658"
-
-[fingerprint.tls.termination]
-cert = "/path/to/cert.pem"
-key  = "/path/to/key.pem"
-
-[ca]
-cert = "/path/to/ca.pem"
-`
-
-func writeTempFile(t *testing.T, content string) string {
-	t.Helper()
-	f, err := os.CreateTemp("", "mic-config-*.toml")
-	if err != nil {
-		t.Fatalf("creating temp file: %v", err)
-	}
-	defer f.Close()
-	if _, err := f.WriteString(content); err != nil {
-		t.Fatalf("writing temp file: %v", err)
-	}
-	return f.Name()
-}
-
-func TestLoad_Valid(t *testing.T) {
-	path := writeTempFile(t, validTOML)
-	defer os.Remove(path)
-
-	cfg, err := config.Load(path)
-	if err != nil {
-		t.Fatalf("Load returned error: %v", err)
-	}
-
-	if cfg.Mode != "client-front" {
-		t.Errorf("Mode = %q; want %q", cfg.Mode, "client-front")
-	}
-	if cfg.Listen.Addr != ":8080" {
-		t.Errorf("Listen.Addr = %q; want %q", cfg.Listen.Addr, ":8080")
-	}
-	if cfg.Backend.Addr != "127.0.0.1:443" {
-		t.Errorf("Backend.Addr = %q; want %q", cfg.Backend.Addr, "127.0.0.1:443")
-	}
-	if cfg.Fingerprint.TLS.JA4 != "t13d1516h2_8daaf6152771_b0da82dd1658" {
-		t.Errorf("Fingerprint.TLS.JA4 = %q; want %q",
-			cfg.Fingerprint.TLS.JA4, "t13d1516h2_8daaf6152771_b0da82dd1658")
-	}
-	if cfg.Fingerprint.TLS.Termination.Cert != "/path/to/cert.pem" {
-		t.Errorf("Termination.Cert = %q; want %q",
-			cfg.Fingerprint.TLS.Termination.Cert, "/path/to/cert.pem")
-	}
-	if cfg.Fingerprint.TLS.Termination.Key != "/path/to/key.pem" {
-		t.Errorf("Termination.Key = %q; want %q",
-			cfg.Fingerprint.TLS.Termination.Key, "/path/to/key.pem")
-	}
-	if cfg.CA.Cert != "/path/to/ca.pem" {
-		t.Errorf("CA.Cert = %q; want %q", cfg.CA.Cert, "/path/to/ca.pem")
-	}
-}
-
-func TestLoad_MissingFile(t *testing.T) {
-	_, err := config.Load("/nonexistent/path/to/config.toml")
-	if err == nil {
-		t.Fatal("expected error for missing file, got nil")
-	}
-}
-
-func TestLoad_InvalidTOML(t *testing.T) {
-	path := writeTempFile(t, "this is not valid = toml [[[")
-	defer os.Remove(path)
-
-	_, err := config.Load(path)
-	if err == nil {
-		t.Fatal("expected error for invalid TOML, got nil")
-	}
-}
diff --git a/fingerprint/fingerprint.go b/fingerprint/fingerprint.go
@@ -1,10 +1,9 @@
 // Package fingerprint provides interfaces and implementations for controlling
-// outbound connection fingerprints (TLS ClientHello, TCP socket options, etc.).
+// outbound TLS connection fingerprints.
 package fingerprint
 
 import utls "github.com/bogdanfinn/utls"
 
-// Applier is the base interface for all fingerprint types.
 type Applier interface {
 	Name() string
 }
@@ -14,11 +13,3 @@ type TLSApplier interface {
 	Applier
 	ClientHelloID() utls.ClientHelloID
 }
-
-// TCPApplier will control TCP socket options (window size, TTL, MSS, etc.).
-// Left for a future implementation.
-//
-// type TCPApplier interface {
-//     Applier
-//     Apply(fd uintptr) error
-// }
diff --git a/fingerprint/ja4.go b/fingerprint/ja4.go
@@ -0,0 +1,330 @@
+package fingerprint
+
+import (
+	"crypto/sha256"
+	"encoding/binary"
+	"fmt"
+	"net"
+	"sort"
+	"strings"
+)
+
+// ClientHelloFields holds the parsed TLS ClientHello data used to compute JA4.
+type ClientHelloFields struct {
+	LegacyVersion     uint16
+	CipherSuites      []uint16 // non-GREASE, wire order
+	Extensions        []uint16 // non-GREASE extension types, wire order
+	SNIHost           string
+	SNIIsIP           bool
+	SupportedVersions []uint16 // from ext 0x002b, non-GREASE
+	ALPNValues        []string // from ext 0x0010
+	SigAlgs           []uint16 // from ext 0x000d, wire order
+}
+
+// isGREASE reports whether v is a GREASE value (RFC 8701).
+// The pattern: high byte == low byte, low nibble of each == 0xA.
+func isGREASE(v uint16) bool {
+	return v&0x0f == 0x0a && v>>8 == v&0xff
+}
+
+// ParseClientHello parses the first TLS handshake record from raw.
+// raw must start with a handshake record header (content type 0x16).
+func ParseClientHello(raw []byte) (*ClientHelloFields, error) {
+	if len(raw) < 5 {
+		return nil, fmt.Errorf("ja4: record too short (%d bytes)", len(raw))
+	}
+	if raw[0] != 0x16 {
+		return nil, fmt.Errorf("ja4: not a handshake record (type=0x%02x)", raw[0])
+	}
+	recLen := int(binary.BigEndian.Uint16(raw[3:5]))
+	if len(raw) < 5+recLen {
+		return nil, fmt.Errorf("ja4: record body truncated (need %d, have %d)", 5+recLen, len(raw))
+	}
+	hs := raw[5 : 5+recLen]
+
+	if len(hs) < 4 {
+		return nil, fmt.Errorf("ja4: handshake header too short")
+	}
+	if hs[0] != 0x01 {
+		return nil, fmt.Errorf("ja4: not a ClientHello (handshake type=0x%02x)", hs[0])
+	}
+	// Handshake length is 3 bytes, unlike most other TLS length fields.
+	chLen := int(hs[1])<<16 | int(hs[2])<<8 | int(hs[3])
+	if len(hs) < 4+chLen {
+		return nil, fmt.Errorf("ja4: ClientHello body truncated")
+	}
+	r := hs[4 : 4+chLen]
+
+	fields := &ClientHelloFields{}
+
+	if len(r) < 2 {
+		return nil, fmt.Errorf("ja4: too short for legacy version")
+	}
+	fields.LegacyVersion = binary.BigEndian.Uint16(r[:2])
+	r = r[2:]
+
+	if len(r) < 32 {
+		return nil, fmt.Errorf("ja4: too short for random")
+	}
+	r = r[32:]
+
+	if len(r) < 1 {
+		return nil, fmt.Errorf("ja4: too short for session ID length")
+	}
+	sidLen := int(r[0])
+	r = r[1:]
+	if len(r) < sidLen {
+		return nil, fmt.Errorf("ja4: too short for session ID")
+	}
+	r = r[sidLen:]
+
+	if len(r) < 2 {
+		return nil, fmt.Errorf("ja4: too short for cipher suites length")
+	}
+	csLen := int(binary.BigEndian.Uint16(r[:2]))
+	r = r[2:]
+	if len(r) < csLen || csLen%2 != 0 {
+		return nil, fmt.Errorf("ja4: cipher suites truncated or odd length")
+	}
+	for i := 0; i < csLen; i += 2 {
+		cs := binary.BigEndian.Uint16(r[i : i+2])
+		if !isGREASE(cs) {
+			fields.CipherSuites = append(fields.CipherSuites, cs)
+		}
+	}
+	r = r[csLen:]
+
+	if len(r) < 1 {
+		return nil, fmt.Errorf("ja4: too short for compression methods length")
+	}
+	cmLen := int(r[0])
+	r = r[1:]
+	if len(r) < cmLen {
+		return nil, fmt.Errorf("ja4: too short for compression methods")
+	}
+	r = r[cmLen:]
+
+	// Extensions are absent in very old TLS — not an error.
+	if len(r) < 2 {
+		return fields, nil
+	}
+	extsLen := int(binary.BigEndian.Uint16(r[:2]))
+	r = r[2:]
+	if len(r) < extsLen {
+		return nil, fmt.Errorf("ja4: extensions block truncated")
+	}
+	exts := r[:extsLen]
+
+	for len(exts) >= 4 {
+		extType := binary.BigEndian.Uint16(exts[:2])
+		extLen := int(binary.BigEndian.Uint16(exts[2:4]))
+		exts = exts[4:]
+		if len(exts) < extLen {
+			return nil, fmt.Errorf("ja4: extension 0x%04x data truncated", extType)
+		}
+		extData := exts[:extLen]
+		exts = exts[extLen:]
+
+		if isGREASE(extType) {
+			continue
+		}
+		fields.Extensions = append(fields.Extensions, extType)
+
+		switch extType {
+		case 0x0000:
+			parseSNI(extData, fields)
+		case 0x0010:
+			parseALPN(extData, fields)
+		case 0x002b:
+			parseSupportedVersions(extData, fields)
+		case 0x000d:
+			parseSigAlgs(extData, fields)
+		}
+	}
+
+	return fields, nil
+}
+
+func parseSNI(data []byte, f *ClientHelloFields) {
+	if len(data) < 2 {
+		return
+	}
+	listLen := int(binary.BigEndian.Uint16(data[:2]))
+	data = data[2:]
+	if len(data) < listLen {
+		return
+	}
+	data = data[:listLen]
+	for len(data) >= 3 {
+		nameType := data[0]
+		nameLen := int(binary.BigEndian.Uint16(data[1:3]))
+		data = data[3:]
+		if len(data) < nameLen {
+			break
+		}
+		name := string(data[:nameLen])
+		data = data[nameLen:]
+		if nameType == 0 { // host_name (the only defined type)
+			f.SNIHost = name
+			f.SNIIsIP = net.ParseIP(name) != nil
+			return
+		}
+	}
+}
+
+func parseALPN(data []byte, f *ClientHelloFields) {
+	if len(data) < 2 {
+		return
+	}
+	listLen := int(binary.BigEndian.Uint16(data[:2]))
+	data = data[2:]
+	if len(data) < listLen {
+		return
+	}
+	data = data[:listLen]
+	for len(data) >= 1 {
+		pLen := int(data[0])
+		data = data[1:]
+		if len(data) < pLen {
+			break
+		}
+		f.ALPNValues = append(f.ALPNValues, string(data[:pLen]))
+		data = data[pLen:]
+	}
+}
+
+func parseSupportedVersions(data []byte, f *ClientHelloFields) {
+	// ClientHello supported_versions uses a 1-byte list length, unlike most extensions.
+	if len(data) < 1 {
+		return
+	}
+	listLen := int(data[0])
+	data = data[1:]
+	if len(data) < listLen || listLen%2 != 0 {
+		return
+	}
+	for i := 0; i < listLen; i += 2 {
+		v := binary.BigEndian.Uint16(data[i : i+2])
+		if !isGREASE(v) {
+			f.SupportedVersions = append(f.SupportedVersions, v)
+		}
+	}
+}
+
+func parseSigAlgs(data []byte, f *ClientHelloFields) {
+	if len(data) < 2 {
+		return
+	}
+	listLen := int(binary.BigEndian.Uint16(data[:2]))
+	data = data[2:]
+	if len(data) < listLen || listLen%2 != 0 {
+		return
+	}
+	for i := 0; i < listLen; i += 2 {
+		f.SigAlgs = append(f.SigAlgs, binary.BigEndian.Uint16(data[i:i+2]))
+	}
+}
+
+func ComputeJA4(ch *ClientHelloFields) string {
+	return buildJA4a(ch) + "_" + buildJA4b(ch.CipherSuites) + "_" + buildJA4c(ch.Extensions, ch.SigAlgs)
+}
+
+func buildJA4a(ch *ClientHelloFields) string {
+	tlsVer := tlsVersionStr(ch.LegacyVersion)
+	if len(ch.SupportedVersions) > 0 {
+		max := uint16(0)
+		for _, v := range ch.SupportedVersions {
+			if v > max {
+				max = v
+			}
+		}
+		tlsVer = tlsVersionStr(max)
+	}
+
+	sniChar := "n"
+	if ch.SNIHost != "" {
+		if ch.SNIIsIP {
+			sniChar = "i"
+		} else {
+			sniChar = "d"
+		}
+	}
+
+	nc := min99(len(ch.CipherSuites))
+	ne := min99(len(ch.Extensions))
+
+	alpn := "00"
+	if len(ch.ALPNValues) > 0 {
+		v := ch.ALPNValues[0]
+		switch {
+		case len(v) >= 2:
+			alpn = v[:2]
+		case len(v) == 1:
+			alpn = v + "0"
+		}
+	}
+
+	return fmt.Sprintf("t%s%s%02d%02d%s", tlsVer, sniChar, nc, ne, alpn)
+}
+
+// min99 caps n at 99, the maximum that fits in the two-digit JA4_a counter.
+func min99(n int) int {
+	if n > 99 {
+		return 99
+	}
+	return n
+}
+
+func tlsVersionStr(v uint16) string {
+	switch v {
+	case 0x0304:
+		return "13"
+	case 0x0303:
+		return "12"
+	case 0x0302:
+		return "11"
+	case 0x0301:
+		return "10"
+	default:
+		return "00"
+	}
+}
+
+func buildJA4b(ciphers []uint16) string {
+	sorted := make([]uint16, len(ciphers))
+	copy(sorted, ciphers)
+	sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
+
+	parts := make([]string, len(sorted))
+	for i, c := range sorted {
+		parts[i] = fmt.Sprintf("%04x", c)
+	}
+	h := sha256.Sum256([]byte(strings.Join(parts, ",")))
+	return fmt.Sprintf("%x", h)[:12]
+}
+
+// buildJA4c hashes sorted extension types (excluding SNI and ALPN, which vary
+// per-connection) then signature algorithms in wire order. Sorting is what
+// distinguishes JA4 from the raw JA4_r variant.
+func buildJA4c(exts []uint16, sigAlgs []uint16) string {
+	var filtered []uint16
+	for _, e := range exts {
+		if e != 0x0000 && e != 0x0010 {
+			filtered = append(filtered, e)
+		}
+	}
+	sort.Slice(filtered, func(i, j int) bool { return filtered[i] < filtered[j] })
+
+	extParts := make([]string, len(filtered))
+	for i, e := range filtered {
+		extParts[i] = fmt.Sprintf("%04x", e)
+	}
+
+	sigParts := make([]string, len(sigAlgs))
+	for i, s := range sigAlgs {
+		sigParts[i] = fmt.Sprintf("%04x", s)
+	}
+
+	h := sha256.Sum256([]byte(strings.Join(extParts, ",") + "_" + strings.Join(sigParts, ",")))
+	return fmt.Sprintf("%x", h)[:12]
+}
diff --git a/fingerprint/tcp.go b/fingerprint/tcp.go
@@ -1,11 +0,0 @@
-package fingerprint
-
-// TCPApplier is reserved for future TCP-level fingerprinting support
-// (TCP window size, TTL, MSS, timestamps, etc.).
-//
-// Future interface outline:
-//
-//	type TCPApplier interface {
-//	    Applier
-//	    Apply(fd uintptr) error
-//	}
diff --git a/fingerprint/tls.go b/fingerprint/tls.go
@@ -6,6 +6,15 @@ import (
 	utls "github.com/bogdanfinn/utls"
 )
 
+// NameTable maps human-readable profile names to utls ClientHelloID presets.
+var NameTable = map[string]utls.ClientHelloID{
+	"chrome-120":    utls.HelloChrome_120,
+	"chrome-120-pq": utls.HelloChrome_120_PQ,
+	"firefox-120":   utls.HelloFirefox_120,
+	"safari-16":     utls.HelloSafari_16_0,
+	"edge-106":      utls.HelloEdge_106,
+}
+
 // Table maps known JA4-TLS fingerprint hashes to utls ClientHelloID presets.
 //
 // Hashes are measured empirically against tlsinfo.me using cmd/probe — they
@@ -24,34 +33,28 @@ var Table = map[string]utls.ClientHelloID{
 	"t13d1516h2_8daaf6152771_e5627efa2ab1": utls.HelloEdge_106,
 }
 
-// Lookup returns the utls.ClientHelloID for the given JA4 hash.
 func Lookup(ja4 string) (utls.ClientHelloID, bool) {
 	id, ok := Table[ja4]
 	return id, ok
 }
 
-// TLSFingerprint implements TLSApplier for a specific JA4 hash.
 type TLSFingerprint struct {
-	ja4 string
-	id  utls.ClientHelloID
+	name string
+	id   utls.ClientHelloID
 }
 
-// NewTLS creates a TLSFingerprint for the given JA4 hash.
-// Returns an error if the hash is not in the built-in table.
-func NewTLS(ja4 string) (*TLSFingerprint, error) {
-	id, ok := Lookup(ja4)
+func ByName(name string) (*TLSFingerprint, error) {
+	id, ok := NameTable[name]
 	if !ok {
-		return nil, fmt.Errorf("unknown JA4-TLS fingerprint %q", ja4)
+		return nil, fmt.Errorf("unknown TLS fingerprint profile %q", name)
 	}
-	return &TLSFingerprint{ja4: ja4, id: id}, nil
+	return &TLSFingerprint{name: name, id: id}, nil
 }
 
-// Name returns a human-readable identifier including the JA4 hash.
 func (f *TLSFingerprint) Name() string {
-	return "tls:" + f.ja4
+	return f.name
 }
 
-// ClientHelloID returns the utls preset for this fingerprint.
 func (f *TLSFingerprint) ClientHelloID() utls.ClientHelloID {
 	return f.id
 }
diff --git a/fingerprint/tls_test.go b/fingerprint/tls_test.go
@@ -1,62 +1,61 @@
 package fingerprint_test
 
 import (
-	"strings"
 	"testing"
 
 	utls "github.com/bogdanfinn/utls"
 	"github.com/djnnvx/mic/fingerprint"
 )
 
-const knownJA4 = "t13d1516h2_8daaf6152771_02713d6af862"
-
-func TestLookup_Known(t *testing.T) {
-	id, ok := fingerprint.Lookup(knownJA4)
-	if !ok {
-		t.Fatalf("Lookup(%q) returned false; expected true", knownJA4)
+func TestByName_Happy(t *testing.T) {
+	fp, err := fingerprint.ByName("chrome-120")
+	if err != nil {
+		t.Fatalf("ByName(%q) returned error: %v", "chrome-120", err)
 	}
-	if id.Str() != utls.HelloChrome_131.Str() {
-		t.Errorf("Lookup(%q) = %v; want HelloChrome_131", knownJA4, id)
+	if fp == nil {
+		t.Fatal("ByName returned nil fingerprint")
 	}
 }
 
-func TestLookup_Unknown(t *testing.T) {
-	_, ok := fingerprint.Lookup("unknown_hash_that_does_not_exist")
-	if ok {
-		t.Fatal("Lookup with unknown hash returned true; expected false")
+func TestByName_Error(t *testing.T) {
+	_, err := fingerprint.ByName("not-a-real-profile")
+	if err == nil {
+		t.Fatal("ByName with unknown name returned nil error; expected error")
 	}
 }
 
-func TestNewTLS_Happy(t *testing.T) {
-	fp, err := fingerprint.NewTLS(knownJA4)
+func TestName_ReturnsProfile(t *testing.T) {
+	fp, err := fingerprint.ByName("chrome-120")
 	if err != nil {
-		t.Fatalf("NewTLS(%q) returned error: %v", knownJA4, err)
+		t.Fatalf("ByName: %v", err)
 	}
-	if fp == nil {
-		t.Fatal("NewTLS returned nil fingerprint")
+	if fp.Name() != "chrome-120" {
+		t.Errorf("Name() = %q; want %q", fp.Name(), "chrome-120")
 	}
 }
 
-func TestNewTLS_Error(t *testing.T) {
-	_, err := fingerprint.NewTLS("not_a_real_ja4_hash")
-	if err == nil {
-		t.Fatal("NewTLS with unknown hash returned nil error; expected error")
+func TestLookup_Known(t *testing.T) {
+	id, ok := fingerprint.Lookup("t13d1516h2_8daaf6152771_02713d6af862")
+	if !ok {
+		t.Fatal("Lookup returned false for known Chrome 120 JA4 hash")
+	}
+	if id.Str() != utls.HelloChrome_131.Str() {
+		t.Errorf("Lookup returned %v; want HelloChrome_131", id)
 	}
 }
 
-func TestName_ContainsJA4(t *testing.T) {
-	fp, err := fingerprint.NewTLS(knownJA4)
-	if err != nil {
-		t.Fatalf("NewTLS: %v", err)
-	}
-	name := fp.Name()
-	if !strings.Contains(name, knownJA4) {
-		t.Errorf("Name() = %q; does not contain JA4 hash %q", name, knownJA4)
+func TestLookup_Unknown(t *testing.T) {
+	_, ok := fingerprint.Lookup("unknown_hash_that_does_not_exist")
+	if ok {
+		t.Fatal("Lookup with unknown hash returned true; expected false")
 	}
 }
 
-func TestTable_MinEntries(t *testing.T) {
-	if len(fingerprint.Table) < 5 {
-		t.Errorf("Table has %d entries; want at least 5", len(fingerprint.Table))
+func TestNameTable_HasExpectedProfiles(t *testing.T) {
+	expected := []string{"chrome-120", "chrome-120-pq", "firefox-120", "safari-16", "edge-106"}
+	for _, name := range expected {
+		if _, ok := fingerprint.NameTable[name]; !ok {
+			t.Errorf("NameTable missing profile %q", name)
+		}
 	}
 }
diff --git a/go.mod b/go.mod
@@ -4,7 +4,6 @@ go 1.24.4
 
 require (
 	github.com/bogdanfinn/utls v1.7.7-barnius
-	github.com/pelletier/go-toml/v2 v2.2.4
 	github.com/spf13/cobra v1.9.1
 	golang.org/x/net v0.38.0
 )
diff --git a/go.sum b/go.sum
@@ -7,8 +7,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
 github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
diff --git a/mic.example.toml b/mic.example.toml
@@ -1,33 +0,0 @@
-mode = "client-front"   # "client-front" | "server-front"
-
-[listen]
-addr = ":8080"
-
-[backend]
-# Used in server-front mode; client-front uses the CONNECT target directly.
-addr = "127.0.0.1:443"
-
-[fingerprint]
-[fingerprint.tls]
-ja4 = "t13d1516h2_8daaf6152771_02713d6af862"   # Chrome 120 (utls.HelloChrome_120)
-
-# server-front only: certificate for terminating incoming TLS
-[fingerprint.tls.termination]
-cert = "/path/to/cert.pem"
-key  = "/path/to/key.pem"
-
-[ca]
-cert = ""   # optional custom CA for upstream verification
-
-# client-front only: MitM interception CA.
-# mic will create the files on first run, then reuse them across restarts.
-# Import ca.pem into curl / your browser / system trust store.
-[ca.intercept]
-cert = "ca.pem"
-key  = "ca-key.pem"
-
-# Future:
-# [fingerprint.tcp]
-# window_size = 65535
-# ttl = 64
-# mss = 1460
diff --git a/proxy/ca.go b/proxy/ca.go
@@ -21,13 +21,12 @@ import (
 type LocalCA struct {
 	cert    *x509.Certificate
 	key     *ecdsa.PrivateKey
-	derCert []byte // raw DER of CA cert, appended as chain in issued certs
+	derCert []byte // raw DER, appended as chain in issued certs
 
 	mu    sync.Mutex
 	cache map[string]tls.Certificate
 }
 
-// GenerateCA creates a fresh in-memory self-signed CA.
 func GenerateCA() (*LocalCA, error) {
 	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
 	if err != nil {
@@ -37,8 +36,8 @@ func GenerateCA() (*LocalCA, error) {
 }
 
 // LoadOrGenerateCA loads the CA from certPath + keyPath. If the files do not
-// exist, it generates a new CA and writes it to those paths. Pass empty strings
-// to get an ephemeral in-memory CA.
+// exist it generates a new CA and writes it to those paths. Pass empty strings
+// for an ephemeral in-memory CA.
 func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
 	if certPath == "" || keyPath == "" {
 		return GenerateCA()
@@ -52,7 +51,6 @@ func LoadOrGenerateCA(certPath, keyPath string) (*LocalCA, error) {
 		return nil, fmt.Errorf("loading CA: %w", err)
 	}
 
-	// Files don't exist — generate a new CA and persist it.
 	ca, err = GenerateCA()
 	if err != nil {
 		return nil, err
@@ -114,7 +112,6 @@ func buildCA(key *ecdsa.PrivateKey) (*LocalCA, error) {
 	}, nil
 }
 
-// Save writes the CA certificate and private key to PEM files.
 func (ca *LocalCA) Save(certPath, keyPath string) error {
 	cf, err := os.Create(certPath)
 	if err != nil {
@@ -137,13 +134,14 @@ func (ca *LocalCA) Save(certPath, keyPath string) error {
 	return pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
 }
 
-// CertPEM returns the CA certificate as a PEM block, ready to be imported
-// into a trust store (e.g. curl --cacert, system store).
+// CertPEM returns the CA certificate as PEM, suitable for curl --cacert or
+// importing into a system trust store.
 func (ca *LocalCA) CertPEM() []byte {
 	return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.derCert})
 }
 
-// issueCert generates (and caches) a leaf certificate for host, signed by the CA.
+// issueCert returns a leaf certificate for host signed by the CA, issuing and
+// caching a new one on first call.
 func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
 	ca.mu.Lock()
 	defer ca.mu.Unlock()
@@ -179,8 +177,7 @@ func (ca *LocalCA) issueCert(host string) (tls.Certificate, error) {
 		return tls.Certificate{}, err
 	}
 	cert := tls.Certificate{
-		// Include the CA cert so clients that load the CA can verify the chain.
-		Certificate: [][]byte{der, ca.derCert},
+		Certificate: [][]byte{der, ca.derCert}, // CA appended so clients can verify the chain
 		PrivateKey:  key,
 	}
 	ca.cache[host] = cert
diff --git a/proxy/https.go b/proxy/https.go
@@ -12,7 +12,6 @@ import (
 func HttpsHandler(clientConn net.Conn, p *Proxy) {
 	defer clientConn.Close()
 
-	// Read the first line of the request to check for the CONNECT method.
 	reader := bufio.NewReader(clientConn)
 	req, err := http.ReadRequest(reader)
 	if err != nil {
@@ -20,14 +19,11 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
 		return
 	}
 
-	// An HTTPS proxy relies on the HTTP CONNECT method.
 	if req.Method != http.MethodConnect {
 		log.Printf("Received non-CONNECT request: %s %s", req.Method, req.Host)
 		return
 	}
 
-	// The Host field contains the destination host and port.
-	// Default to :443 if no port is specified.
 	host := req.URL.Host
 	if !strings.Contains(host, ":") {
 		host = host + ":443"
@@ -43,9 +39,6 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
 
 	clientConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n"))
 
-	// MitM path: terminate TLS from the client using a per-host certificate
-	// issued by the local CA, then pipe decrypted traffic over the already-
-	// established uTLS connection to the target.
 	if p.LocalCA != nil {
 		serverName := host
 		if idx := strings.LastIndex(host, ":"); idx != -1 {
@@ -58,10 +51,10 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
 			return
 		}
 
-		// Mirror the ALPN negotiated with the target so the client uses the
-		// same application protocol (e.g. h2 vs http/1.1). Without this,
-		// Chrome-fingerprint uTLS often negotiates h2 with the target while
-		// the client sends HTTP/1.1, causing an immediate protocol error.
+		// Mirror the ALPN negotiated with the target so the client uses the same
+		// application protocol. Without this, Chrome-fingerprint uTLS often
+		// negotiates h2 with the target while the client sends HTTP/1.1, causing
+		// an immediate protocol error.
 		nextProtos := []string{"http/1.1"}
 		if proto := targetConn.ConnectionState().NegotiatedProtocol; proto != "" {
 			nextProtos = []string{proto}
@@ -78,12 +71,9 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
 		defer tlsClient.Close()
 
 		log.Printf("MitM tunnel established for %s (proto: %s)", serverName, tlsClient.ConnectionState().NegotiatedProtocol)
-
-		// Both sides are now on the same protocol — pipe plaintext between them.
 		pipe(tlsClient, tlsClient, targetConn)
 		return
 	}
 
-	// No LocalCA: pipe raw bytes (client must not speak TLS after CONNECT).
 	pipe(clientConn, reader, targetConn)
 }
diff --git a/proxy/integration_test.go b/proxy/integration_test.go
@@ -19,13 +19,8 @@ import (
 	"github.com/djnnvx/mic/fingerprint"
 )
 
-const (
-	chrome120JA4 = "t13d1516h2_8daaf6152771_b0da82dd1658"
-	testTimeout  = 10 * time.Second
-)
+const testTimeout = 10 * time.Second
 
-// serveHandler starts a TCP listener and dispatches accepted connections to h.
-// The listener is closed when the test ends.
 func serveHandler(t *testing.T, p *Proxy, h Handler) string {
 	t.Helper()
 	ln, err := net.Listen("tcp", "127.0.0.1:0")
@@ -47,15 +42,11 @@ func serveHandler(t *testing.T, p *Proxy, h Handler) string {
 	return ln.Addr().String()
 }
 
-// writeTempCertKey generates a self-signed cert/key, writes PEM files to disk,
-// and returns the file paths plus a CA pool that trusts the cert.
-// Files are removed when the test ends.
 func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPool) {
 	t.Helper()
 
 	pool, tlsCert := generateSelfSignedCert(t)
 
-	// Certificate PEM.
 	cf, err := os.CreateTemp("", "mic-cert-*.pem")
 	if err != nil {
 		t.Fatalf("creating cert temp file: %v", err)
@@ -64,7 +55,7 @@ func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPo
 	cf.Close()
 	certFile = cf.Name()
 
-	// Private key PEM (PKCS8 — accepted by tls.LoadX509KeyPair as "PRIVATE KEY").
+	// PKCS8 format is required: tls.LoadX509KeyPair accepts "PRIVATE KEY" blocks.
 	keyBytes, err := x509.MarshalPKCS8PrivateKey(tlsCert.PrivateKey)
 	if err != nil {
 		t.Fatalf("marshaling private key: %v", err)
@@ -89,7 +80,6 @@ func writeTempCertKey(t *testing.T) (certFile, keyFile string, pool *x509.CertPo
 //
 //	client → TCP CONNECT → proxy → uTLS(Chrome120) → httptest.TLSServer
 func TestClientFront_Integration(t *testing.T) {
-	// Start a target HTTPS server.
 	ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Connection", "close")
 		w.WriteHeader(http.StatusOK)
@@ -97,18 +87,16 @@ func TestClientFront_Integration(t *testing.T) {
 	}))
 	defer ts.Close()
 
-	// Trust the test server's certificate.
 	caPool := ts.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
 
-	fp, err := fingerprint.NewTLS(chrome120JA4)
+	fp, err := fingerprint.ByName("chrome-120")
 	if err != nil {
-		t.Fatalf("fingerprint.NewTLS: %v", err)
+		t.Fatalf("fingerprint.ByName: %v", err)
 	}
 
 	p := &Proxy{CAPool: caPool, Fingerprint: fp}
 	proxyAddr := serveHandler(t, p, HttpsHandler)
 
-	// Connect to the proxy over plain TCP.
 	conn, err := net.DialTimeout("tcp", proxyAddr, testTimeout)
 	if err != nil {
 		t.Fatalf("dial proxy: %v", err)
@@ -116,7 +104,6 @@ func TestClientFront_Integration(t *testing.T) {
 	defer conn.Close()
 	conn.SetDeadline(time.Now().Add(testTimeout))
 
-	// Send HTTP CONNECT to open a tunnel to the target.
 	targetAddr := ts.Listener.Addr().String()
 	fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", targetAddr, targetAddr)
 
@@ -129,8 +116,8 @@ func TestClientFront_Integration(t *testing.T) {
 		t.Fatalf("CONNECT status: %d; want 200", resp.StatusCode)
 	}
 
-	// After CONNECT the proxy has already done uTLS to the target.
-	// We send raw HTTP — the proxy wraps it in TLS on our behalf.
+	// The proxy has already done uTLS to the target; raw bytes we send here are
+	// wrapped in that TLS connection on our behalf.
 	fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", targetAddr)
 
 	resp, err = http.ReadResponse(reader, nil)
@@ -150,7 +137,6 @@ func TestClientFront_Integration(t *testing.T) {
 //
 //	client → TLS → proxy (terminates TLS) → uTLS(Chrome120) → httptest.TLSServer
 func TestServerFront_Integration(t *testing.T) {
-	// Start the backend HTTPS server (what the proxy connects to upstream).
 	backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Connection", "close")
 		w.WriteHeader(http.StatusOK)
@@ -159,13 +145,11 @@ func TestServerFront_Integration(t *testing.T) {
 	defer backend.Close()
 
 	backendCAPool := backend.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
-
-	// Generate a cert/key pair the proxy will use to terminate incoming client TLS.
 	certFile, keyFile, proxyCertPool := writeTempCertKey(t)
 
-	fp, err := fingerprint.NewTLS(chrome120JA4)
+	fp, err := fingerprint.ByName("chrome-120")
 	if err != nil {
-		t.Fatalf("fingerprint.NewTLS: %v", err)
+		t.Fatalf("fingerprint.ByName: %v", err)
 	}
 
 	p := &Proxy{
@@ -175,7 +159,6 @@ func TestServerFront_Integration(t *testing.T) {
 	}
 	proxyAddr := serveHandler(t, p, ServerFrontHandler(certFile, keyFile))
 
-	// Connect to the proxy with TLS, trusting the proxy's generated cert.
 	tlsConn, err := tls.DialWithDialer(
 		&net.Dialer{Timeout: testTimeout},
 		"tcp", proxyAddr,
@@ -187,8 +170,8 @@ func TestServerFront_Integration(t *testing.T) {
 	defer tlsConn.Close()
 	tlsConn.SetDeadline(time.Now().Add(testTimeout))
 
-	// Send plain HTTP — the proxy decrypts from us and re-encrypts with the
-	// configured fingerprint before forwarding to the backend.
+	// The proxy decrypts our TLS and re-encrypts with the configured fingerprint
+	// before forwarding to the backend.
 	fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
 
 	reader := bufio.NewReader(tlsConn)
diff --git a/proxy/ja4_integration_test.go b/proxy/ja4_integration_test.go
@@ -0,0 +1,137 @@
+//go:build integration
+
+package proxy
+
+import (
+	"bufio"
+	"crypto/tls"
+	"fmt"
+	"io"
+	"net"
+	"testing"
+	"time"
+
+	"github.com/djnnvx/mic/fingerprint"
+)
+
+// Values 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
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -22,17 +22,14 @@ type Proxy struct {
 	handlers []Handler
 }
 
-// Handler is a function that processes an incoming client connection.
 type Handler func(conn net.Conn, p *Proxy)
 
-// RegisterHandler adds a handler to the proxy.
 func (p *Proxy) RegisterHandler(h Handler) {
 	p.handlers = append(p.handlers, h)
 }
 
-// dialTarget dials host (host:port), performs a uTLS handshake using p.Fingerprint,
-// and returns the ready-to-use connection. Falls back to HelloRandomized if no
-// fingerprint is configured.
+// dialTarget dials host (host:port) and returns a uTLS connection after a
+// successful handshake. Falls back to HelloRandomized when no fingerprint is set.
 func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
 	tcpConn, err := net.Dial("tcp", host)
 	if err != nil {
@@ -49,9 +46,7 @@ func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
 		serverName = host[:idx]
 	}
 
-	cfg := &utls.Config{
-		ServerName: serverName,
-	}
+	cfg := &utls.Config{ServerName: serverName}
 	if p.CAPool != nil {
 		cfg.RootCAs = p.CAPool
 	}
@@ -64,9 +59,8 @@ func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
 	return uconn, nil
 }
 
-// pipe copies data bidirectionally between client (reading from r) and target,
-// blocking until both directions are done. When either direction finishes it
-// closes that side's connection so the other goroutine unblocks and exits.
+// pipe copies bidirectionally between client (reading from r) and target.
+// When either direction finishes it closes that side so the other goroutine unblocks.
 func pipe(client io.ReadWriteCloser, r io.Reader, target io.ReadWriteCloser) {
 	var wg sync.WaitGroup
 	wg.Add(2)
@@ -86,7 +80,6 @@ func pipe(client io.ReadWriteCloser, r io.Reader, target io.ReadWriteCloser) {
 	wg.Wait()
 }
 
-// Run starts the proxy server and dispatches incoming connections to registered handlers.
 func (p *Proxy) Run() error {
 	ln, err := net.Listen("tcp", p.ListenAddr)
 	if err != nil {
diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go
@@ -16,7 +16,6 @@ import (
 	"github.com/djnnvx/mic/fingerprint"
 )
 
-// generateSelfSignedCert creates an in-memory self-signed cert/pool for testing.
 func generateSelfSignedCert(t *testing.T) (*x509.CertPool, tls.Certificate) {
 	t.Helper()
 
@@ -58,7 +57,6 @@ func generateSelfSignedCert(t *testing.T) (*x509.CertPool, tls.Certificate) {
 func TestDialTarget_HandshakeSucceeds(t *testing.T) {
 	pool, tlsCert := generateSelfSignedCert(t)
 
-	// Start an in-process TLS server on a random port.
 	ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
 		Certificates: []tls.Certificate{tlsCert},
 	})
@@ -67,7 +65,6 @@ func TestDialTarget_HandshakeSucceeds(t *testing.T) {
 	}
 	defer ln.Close()
 
-	// Accept and drain one connection in the background.
 	go func() {
 		conn, err := ln.Accept()
 		if err != nil {
@@ -77,9 +74,9 @@ func TestDialTarget_HandshakeSucceeds(t *testing.T) {
 		io.Copy(io.Discard, conn)
 	}()
 
-	fp, err := fingerprint.NewTLS("t13d1516h2_8daaf6152771_02713d6af862")
+	fp, err := fingerprint.ByName("chrome-120")
 	if err != nil {
-		t.Fatalf("fingerprint.NewTLS: %v", err)
+		t.Fatalf("fingerprint.ByName: %v", err)
 	}
 
 	p := &Proxy{
@@ -94,7 +91,6 @@ func TestDialTarget_HandshakeSucceeds(t *testing.T) {
 	}
 	defer conn.Close()
 
-	// Verify connection is usable for writes.
 	if _, err := conn.Write([]byte("hello\n")); err != nil {
 		t.Fatalf("writing to connection: %v", err)
 	}
@@ -120,7 +116,6 @@ func TestDialTarget_FallbackToRandomized(t *testing.T) {
 		io.Copy(io.Discard, conn)
 	}()
 
-	// No fingerprint set — should fall back to HelloRandomized.
 	p := &Proxy{
 		ListenAddr: "127.0.0.1:0",
 		CAPool:     pool,
@@ -128,8 +123,7 @@ func TestDialTarget_FallbackToRandomized(t *testing.T) {
 
 	conn, err := p.dialTarget(ln.Addr().String())
 	if err != nil {
-		// HelloRandomized may select post-quantum curves not supported by stdlib TLS servers.
-		// Skip rather than fail — the code path is exercised regardless.
+		// HelloRandomized may select post-quantum curves unsupported by stdlib TLS.
 		t.Skipf("dialTarget with randomized preset: %v", err)
 	}
 	conn.Close()
diff --git a/proxy/serverfront.go b/proxy/serverfront.go
@@ -30,7 +30,6 @@ func ServerFrontHandler(certFile, keyFile string) Handler {
 		}
 		defer tlsConn.Close()
 
-		// Dial the backend with the configured uTLS fingerprint.
 		targetConn, err := p.dialTarget(p.BackendAddr)
 		if err != nil {
 			log.Printf("ServerFront: failed to connect to backend %s: %v", p.BackendAddr, err)
diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh
@@ -1,45 +1,17 @@
 #!/usr/bin/env bash
-# cleanup.sh — stop the proxy and remove build artifacts.
 set -euo pipefail
 
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 ROOT_DIR="$(dirname "$SCRIPT_DIR")"
 BINARY="$ROOT_DIR/mic"
-PID_FILE="$ROOT_DIR/.mic.pid"
 
-# ── stop proxy via PID file ────────────────────────────────────────────────────
-if [ -f "$PID_FILE" ]; then
-    PID="$(cat "$PID_FILE")"
-    if kill -0 "$PID" 2>/dev/null; then
-        echo "[+] Stopping mic (PID $PID)..."
-        kill "$PID"
-        # Wait briefly for clean shutdown.
-        for i in $(seq 1 10); do
-            kill -0 "$PID" 2>/dev/null || break
-            sleep 0.2
-        done
-        if kill -0 "$PID" 2>/dev/null; then
-            echo "[!] Process still alive after 2 s — sending SIGKILL."
-            kill -9 "$PID" || true
-        fi
-    else
-        echo "[~] PID $PID is no longer running."
-    fi
-    rm -f "$PID_FILE"
-else
-    echo "[~] No PID file found."
-fi
-
-# ── catch any stray mic processes ─────────────────────────────────────────────
-if pgrep -x mic >/dev/null 2>&1; then
-    echo "[+] Killing remaining mic processes..."
-    pkill -x mic || true
-fi
-
-# ── remove binary ──────────────────────────────────────────────────────────────
 if [ -f "$BINARY" ]; then
-    echo "[+] Removing binary $BINARY..."
+    echo "[+] Removing binary..."
     rm "$BINARY"
 fi
 
-echo "[+] Cleanup complete."
+echo "[+] Done."
+echo ""
+echo "[i] CA files (ca.pem, ca-key.pem) are left in place."
+echo "    Remove them manually if you no longer need them, and untrust the cert"
+echo "    from your system store if you added it."
diff --git a/scripts/setup.sh b/scripts/setup.sh
@@ -1,51 +1,31 @@
 #!/usr/bin/env bash
-# setup.sh — build the mic binary, create a starter config, and start the proxy.
 set -euo pipefail
 
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 ROOT_DIR="$(dirname "$SCRIPT_DIR")"
 BINARY="$ROOT_DIR/mic"
-CONFIG="$ROOT_DIR/mic.toml"
-EXAMPLE="$ROOT_DIR/mic.example.toml"
-PID_FILE="$ROOT_DIR/.mic.pid"
+CA_CERT="$ROOT_DIR/ca.pem"
+CA_KEY="$ROOT_DIR/ca-key.pem"
 
-# ── build ──────────────────────────────────────────────────────────────────────
 echo "[+] Building mic..."
 (cd "$ROOT_DIR" && go build -o mic .)
-echo "    Binary: $BINARY"
-
-# ── config ─────────────────────────────────────────────────────────────────────
-if [ ! -f "$CONFIG" ]; then
-    echo "[+] Creating mic.toml from mic.example.toml..."
-    cp "$EXAMPLE" "$CONFIG"
-    echo "    Edit $CONFIG before starting (set mode, fingerprint.tls.ja4, etc.)"
-else
-    echo "[~] mic.toml already exists — leaving it untouched."
-fi
 
-# ── start proxy ────────────────────────────────────────────────────────────────
-if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
-    echo "[~] mic is already running (PID $(cat "$PID_FILE"))."
-else
-    echo "[+] Starting mic in the background..."
-    "$BINARY" --config "$CONFIG" > "$ROOT_DIR/mic.log" 2>&1 &
-    echo $! > "$PID_FILE"
-    echo "    PID $(cat "$PID_FILE") — log: $ROOT_DIR/mic.log"
-    echo "    Stop with:  scripts/cleanup.sh"
-fi
+echo ""
+echo "[i] mic will run in client-front mode on :8080 with chrome-120 fingerprint."
+echo "    MitM CA will be written to $CA_CERT on first run."
+echo "    Trust it once, then use mic as a transparent HTTPS proxy:"
+echo ""
+echo "      curl --cacert $CA_CERT -x http://localhost:8080 https://tlsinfo.me/json"
+echo ""
+echo "    macOS system trust:"
+echo "      sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $CA_CERT"
+echo ""
+echo "    Debian/Ubuntu/Kali system trust:"
+echo "      sudo cp $CA_CERT /usr/local/share/ca-certificates/mic-ca.crt && sudo update-ca-certificates"
+echo ""
 
-# ── client-front: MitM CA hint ─────────────────────────────────────────────────
-# Wait briefly for mic to write the CA files on first run.
-CA_CERT="$ROOT_DIR/ca.pem"
-sleep 0.5
-if [ -f "$CA_CERT" ]; then
-    echo ""
-    echo "[i] MitM CA certificate found: $CA_CERT"
-    echo "    Trust it once, then use mic as an HTTPS proxy:"
-    echo ""
-    echo "    curl --cacert $CA_CERT -x http://localhost:8080 https://tlsinfo.me/json"
-    echo ""
-    echo "    To add it to the system trust store (Debian/Ubuntu/Kali):"
-    echo "      sudo cp $CA_CERT /usr/local/share/ca-certificates/mic-ca.crt"
-    echo "      sudo update-ca-certificates"
-fi
+exec "$BINARY" client \
+    --listen :8080 \
+    --fingerprint chrome-120 \
+    --intercept-cert "$CA_CERT" \
+    --intercept-key "$CA_KEY"
diff --git a/test/client/main.go b/test/client/main.go
@@ -1,78 +0,0 @@
-package main
-
-import (
-	"bufio"
-	"log"
-	"net"
-	"net/http"
-	"strings"
-
-	utls "github.com/bogdanfinn/utls"
-)
-
-func main() {
-	proxyAddr := "127.0.0.1:8080"
-	targetAddr := "127.0.0.1:8443"
-
-	// 1. Establish a TCP connection to the proxy.
-	log.Printf("Connecting to proxy at %s", proxyAddr)
-	proxyConn, err := net.Dial("tcp", proxyAddr)
-	if err != nil {
-		log.Fatalf("Failed to connect to proxy: %v", err)
-	}
-	defer proxyConn.Close()
-
-	// 2. Manually send the HTTP CONNECT request to the proxy.
-	connectReq, _ := http.NewRequest(http.MethodConnect, "https://"+targetAddr, nil)
-	if err := connectReq.Write(proxyConn); err != nil {
-		log.Fatalf("Failed to send CONNECT request to proxy: %v", err)
-	}
-
-	// 3. Read the proxy's response to the CONNECT request.
-	reader := bufio.NewReader(proxyConn)
-	resp, err := http.ReadResponse(reader, connectReq)
-	if err != nil {
-		log.Fatalf("Failed to read proxy response: %v", err)
-	}
-	if resp.StatusCode != http.StatusOK {
-		log.Fatalf("Proxy returned non-200 status: %s", resp.Status)
-	}
-	log.Println("Proxy connection established.")
-
-	// 4. Create a uTLS client from the proxied connection.
-	// We'll use the Chrome 116 fingerprint for the client.
-	utlsConfig := &utls.Config{ServerName: strings.Split(targetAddr, ":")[0]}
-	uclient := utls.UClient(proxyConn, utlsConfig, utls.HelloRandomized, false, false, false)
-
-	// 5. Perform the TLS handshake and get the handshake state.
-	if err := uclient.Handshake(); err != nil {
-		log.Fatalf("uTLS handshake failed: %v", err)
-	}
-
-	// 6. Extract and print key details from the server's certificate,
-	// which are used to measure the server's JA4 fingerprint.
-	log.Println("\n--- Server Fingerprint Measurement (JA4-like) ---")
-	connState := uclient.ConnectionState()
-	if len(connState.PeerCertificates) > 0 {
-		cert := connState.PeerCertificates[0]
-		log.Printf("Server Certificate Subject: %s", cert.Subject.CommonName)
-		log.Printf("Server Certificate Issuer: %s", cert.Issuer.CommonName)
-		log.Printf("Server Certificate Validity: %s to %s", cert.NotBefore, cert.NotAfter)
-	}
-
-	// 7. This section demonstrates how to use the tunneled connection to send a request.
-	// The client will use the uTLS connection to send a simple HTTP GET request to the test server.
-	log.Println("\n--- Sending Request to Test Server ---")
-	req, _ := http.NewRequest(http.MethodGet, "https://"+targetAddr+"/", nil)
-	if err := req.Write(uclient); err != nil {
-		log.Fatalf("Failed to write request: %v", err)
-	}
-
-	// Read and print the response from the test server.
-	resp, err = http.ReadResponse(bufio.NewReader(uclient), req)
-	if err != nil {
-		log.Fatalf("Failed to read response from server: %v", err)
-	}
-	defer resp.Body.Close()
-	log.Printf("Server Response Status: %s", resp.Status)
-}
diff --git a/test/server/cert.pem b/test/server/cert.pem
@@ -1,19 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIC/zCCAeegAwIBAgIQTs3QM4jgeBYOOKp+ZekqpjANBgkqhkiG9w0BAQsFADAS
-MRAwDgYDVQQKEwdBY21lIENvMB4XDTI1MDgyMDEyNTYwMloXDTI2MDgyMDEyNTYw
-MlowEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
-AQoCggEBAO2JDkMuo4bun8ZSXR3OsInpZpCXsJrcZ82SjItKyOdRcEDqNIsITmYO
-PmffVGHeTupCmONW0exZvh1BVS3HNHY80LG0usmYvR/vM0nIodUYByPiUO2xOh83
-8RpiqTZ7edp/+DiykK3pbceDzSfBs0FJi/03v/RTJxr+26xVNxnKMpTkigj8rDNA
-lKw8XMm0m7RK6CpPxP77D614hs/lDqKTo9qT9EL8tqeMfkA2+M5drsw9eAJs0ENw
-1UzS83G1EfCJE51KL93YYMFjhIh4PCf9B854gkSBSXZ0/8W51YmLLLRnCpQnxLN5
-Rb+lIbpYbnwe7LcN/N1mNkGvgvBYDKECAwEAAaNRME8wDgYDVR0PAQH/BAQDAgWg
-MBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwGgYDVR0RBBMwEYIJ
-bG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCL8Ltm60bEKmNseEKF
-NLyotxv6AgF+63+WeQwEXrVFckfKb+DA8qcsFkv5/UigZPC+kQ8R90HgWtObeldK
-dJmob9g4IlPRcyY0ygURIsIdzYVeEluofn4mXjoNai4+ubaRvuVvq/IeSMV3QLxx
-8nbh5xuruxWBVuW78siwYcNwd4TTkXBd6BG1zziQMHQo24esLMJWZANte48dg2+E
-64Y0mA9pINVB4tTdgGuy6DlFt22019FGydeLIPqSvSJNAQtTXEhgphIKuusOhk/J
-C1tnexaN+NS3sYjxvbCeOZ41/UTzOzx1CZviaWWF1x5FRmwyYz1bdWejmBd9faaM
-MUvT
------END CERTIFICATE-----
diff --git a/test/server/key.pem b/test/server/key.pem
@@ -1,28 +0,0 @@
------BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDtiQ5DLqOG7p/G
-Ul0dzrCJ6WaQl7Ca3GfNkoyLSsjnUXBA6jSLCE5mDj5n31Rh3k7qQpjjVtHsWb4d
-QVUtxzR2PNCxtLrJmL0f7zNJyKHVGAcj4lDtsTofN/EaYqk2e3naf/g4spCt6W3H
-g80nwbNBSYv9N7/0Uyca/tusVTcZyjKU5IoI/KwzQJSsPFzJtJu0SugqT8T++w+t
-eIbP5Q6ik6Pak/RC/LanjH5ANvjOXa7MPXgCbNBDcNVM0vNxtRHwiROdSi/d2GDB
-Y4SIeDwn/QfOeIJEgUl2dP/FudWJiyy0ZwqUJ8SzeUW/pSG6WG58Huy3DfzdZjZB
-r4LwWAyhAgMBAAECggEAE5XGj6xOxP3ApFd++fz9+idwBeGb9bwEJKNxFyMPzl+d
-A9BlWKyGQLMeWu4p3o/rFDTXHX3qsE3BKr3ySf38ycSMTu3gRzL8YdLh+OY9m0mJ
-45MlEEdM/yBbrsza6tt8QC0A+5vHHHyWSQPWOVfAce4G4gmXHowLBpZkHAd14MuH
-qXuIrVZfXa59dHrqZdsyD1v08NEqEmicEqn33Tct2iLIbA2HOuPD3VzWfdQQcxk+
-2scIMlE50OnmaGBlDKnAkX7ddoYH+zAFlJVlqu8yp5MCp6tbug65fNxGc+vpFHZE
-rbTICT2Rp7OAE89CcXxVg189DWPyUC4TViG06LCdKQKBgQDtx/cEFwmYFlLA5X5C
-rP0q8pMkiCikHiaeGm1Ou6NdD33iUr3UqNwG3jzRWpwp7GrPr9sW5uKtqDv2JDfZ
-FEh6dVD9vWN7a0/iqBUgJhA0n4+Ao+iYmPIwnu5KO4qm9MPhtX6oeQKiaqU42QMr
-26fBLUxyrW78ZMR2avy7jndIuwKBgQD/vEVLKGakas2AQRF/qxc4Kefr9gDJprIj
-wscbApVYv7iIJbbGkpOCRWcRJoVrQzKCSoPHz7CZGJhpe6CMyzQPuAnghT9xvcjo
-AKS3yb1JmFfRqmteA9PBtYCtdSaznSDpcM5NA2ZpzLmHyeDmxOGAp+jzXZ5Va2ew
-hFj/7iboUwKBgDNcWhLcFwpu5/kamSc1zomUVAw0DPNGP/kupCW5ihPz4LEeRjrF
-LqGdJ23hMJItqvSpe516ycPQ7wK/2ABfzTgGVzQ30GE8UMkYnVeQYTe8r3aN8RPl
-FYtDCR2nts9RV25Nkco6OUNz3bRly7WRgK7NZhHvPGXwLi+V9Hle0I05AoGBALFh
-VCriVfd7ARkRiOKluFBDeNMI6dj0n6EAS/wnGZHaQVNl3VX0ew4jt26E6ovQqysG
-Jkukehhmn+iiUGT32R+CBo/NvIjXG5AYSigUbQ+r9FXAQAKQ4wL8MsmddX+l4Bsr
-HFz8m7+Mua8+zpkiPEtxq7mJ2PXkrqIX2meCN0hzAoGBAIcufuifo7M2CZWvNNur
-Hm4aNxu/ejERvFjXMBwDMU9VnzY1vV8WqpQZyiZhILM5fUf6kHLLI9IorsZMe2On
-BenT06uNM6FONqO7/Tb3gOsu1c84OdhqBNSnlkGJhh9FhVekiLxDZ7tcEsXMz60J
-+3yMTr67bSSBK9nxU7C8NQ1R
------END PRIVATE KEY-----
diff --git a/test/server/main.go b/test/server/main.go
@@ -1,26 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"log"
-	"net/http"
-)
-
-func main() {
-	// 1. Define the handler function for all requests.
-	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
-		log.Printf("Received request for: %s %s", r.Method, r.URL.Path)
-		w.WriteHeader(http.StatusOK)
-		fmt.Fprintf(w, "Hello! You have reached the test server. All is well.")
-	})
-
-	// 2. Set the address for the server to listen on.
-	addr := "127.0.0.1:8443"
-
-	// 3. Start the HTTPS server.
-	log.Printf("Starting HTTPS test server on %s", addr)
-	err := http.ListenAndServeTLS(addr, "cert.pem", "key.pem", nil)
-	if err != nil {
-		log.Fatalf("Test server failed: %v", err)
-	}
-}