diff --git a/README.md b/README.md
@@ -1,34 +1,159 @@
# mic
-mic (mina-is-cute) is a modular Golang proxy (HTTPS) to evade network JA4S fingerprinting.
+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 is planned to support HTTP / SOCKS5 in the future as well, but not within the first version.
+Two modes are supported:
-## Compiling
+- **client-front** — standard HTTP CONNECT proxy. Your tool (curl, browser, etc.)
+ connects through the proxy; the proxy dials upstream with the configured fingerprint.
+- **server-front** — the proxy terminates incoming TLS (with your cert/key), then
+ re-dials the backend with the configured fingerprint. Useful when the client
+ cannot be configured to use a CONNECT proxy.
-You can simply run:
+---
+
+## How it works
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant P as mic proxy
+ participant T as Target
+
+ rect rgb(30, 30, 60)
+ note over C,T: client-front mode
+ C->>P: HTTP CONNECT target:443
+ P-->>C: 200 Connection Established
+ P->>T: TCP + uTLS handshake (configured fingerprint)
+ C->>P: raw HTTP bytes
+ P->>T: bytes encrypted in uTLS tunnel
+ T-->>P: HTTP response (through uTLS)
+ P-->>C: raw HTTP response
+ end
+
+ rect rgb(30, 60, 30)
+ note over C,T: server-front mode
+ C->>P: TLS handshake (proxy cert)
+ P-->>C: TLS established
+ P->>T: TCP + uTLS handshake (configured fingerprint)
+ C->>P: raw HTTP bytes (decrypted by proxy)
+ P->>T: bytes encrypted in uTLS tunnel
+ T-->>P: HTTP response (through uTLS)
+ P-->>C: HTTP response (re-encrypted for client)
+ end
+```
+
+In both modes the target sees a TLS handshake that matches the configured JA4 hash,
+not the default Go TLS fingerprint.
+
+---
+
+## Build
+
+```bash
+go build -o mic .
+```
+
+Or with the helper script (builds and creates `mic.toml` from the example):
```bash
-make
+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
+[fingerprint.tls.termination]
+cert = "/path/to/cert.pem"
+key = "/path/to/key.pem"
+
+[ca]
+cert = "" # optional: custom CA for upstream verification
```
-Or, you can also run this, to run with Docker:
+### Available JA4 fingerprints
+
+| JA4 hash | Browser |
+|---|---|
+| `t13d1516h2_8daaf6152771_b0da82dd1658` | Chrome 120 |
+| `t13d1516h2_8daaf6152771_e5627efa2ab1` | Chrome 120 (post-quantum) |
+| `t13d1517h2_8daaf6152771_b1ff8ab2d16f` | Firefox 120 |
+| `t13d1516h2_8daaf6152771_4aeede8da0ac` | Safari 16.0 |
+| `t13d1516h2_8daaf6152771_f5b4b24de8b1` | Edge 106 |
+
+---
+
+## Running
+
```bash
-make docker
+./mic --config mic.toml
```
-## Usage
+### client-front
-To know how to use it, simply run with `--help`
+Configure your client to use an HTTP CONNECT proxy at the listen address:
```bash
-./mic --help
+curl -x http://localhost:8080 https://tls.peet.ws/api/all
+# check the "ja4" field in the response
```
-### Using self-signed certificates
+### server-front
-You can generate certificates like so:
+Generate a self-signed certificate 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
+```
+
+---
+
+## Testing
+
+Unit tests:
+
+```bash
+go test ./...
+```
+
+Integration tests (spin up in-process TLS servers, no external dependencies):
+
+```bash
+scripts/run_integration_tests.sh
+# or: go test -tags integration -v ./proxy/...
```
diff --git a/cmd/root.go b/cmd/root.go
@@ -6,6 +6,8 @@ import (
"log"
"os"
+ "github.com/djnnvx/mic/config"
+ "github.com/djnnvx/mic/fingerprint"
"github.com/djnnvx/mic/proxy"
"github.com/spf13/cobra"
)
@@ -50,35 +52,61 @@ func getParser(opts *MicCliOptions) *cobra.Command {
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)
+ }
- log.Printf("[+] only HTTPS supported for now, registering default handler.")
p := &proxy.Proxy{
- ListenAddr: opts.Addr,
- ForwardTo: opts.ForwardTo,
+ ListenAddr: cfg.Listen.Addr,
+ BackendAddr: cfg.Backend.Addr,
}
- if opts.CAPath != "" {
- caCert, err := os.ReadFile(opts.CAPath)
+ // Load optional custom CA pool.
+ if cfg.CA.Cert != "" {
+ caCert, err := os.ReadFile(cfg.CA.Cert)
if err != nil {
- log.Fatalf("Failed to read server certificate: %v", err)
+ log.Fatalf("[!] Failed to read CA certificate: %v", err)
}
+ pool := x509.NewCertPool()
+ pool.AppendCertsFromPEM(caCert)
+ p.CAPool = pool
+ }
- caCertPool := x509.NewCertPool()
- caCertPool.AppendCertsFromPEM(caCert)
- p.AddCertificate(caCertPool)
+ // 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())
}
- p.RegisterHandler(proxy.HttpsHandler)
- p.Run()
+ switch cfg.Mode {
+ case "client-front":
+ log.Printf("[+] Mode: client-front (HTTP CONNECT proxy)")
+ 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.Addr, "addr", "a", defaults.Addr, "address to listen to (Golang notation)")
- rootCmd.Flags().IntVarP(&opts.ForwardTo, "forward-to", "t", defaults.ForwardTo, "local port to forward to")
- rootCmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", defaults.Verbose, "enable verbose mode")
- rootCmd.Flags().StringVarP(&opts.CAPath, "ca-cert-path", "c", defaults.CAPath, "custom CA certificate path (optional)")
+ rootCmd.Flags().StringVarP(&opts.ConfigPath, "config", "c", defaults.ConfigPath, "path to TOML config file")
return rootCmd
}
diff --git a/cmd/types.go b/cmd/types.go
@@ -1,27 +1,11 @@
package cmd
type MicCliOptions struct {
-
- /// Addr: address to listen to (Golang notation)
- Addr string
-
- /// ForwardTo: local port to forward call to
- ForwardTo int
-
- /// Verbose: enable verbose mode
- Verbose bool
-
- /// CAPath: Self-signed certificate path (optional)
- CAPath string
+ ConfigPath string
}
func GetDefaultOptions() MicCliOptions {
- opts := MicCliOptions{
- Addr: ":8080",
- ForwardTo: 1337,
- Verbose: false,
- CAPath: "",
+ return MicCliOptions{
+ ConfigPath: "mic.toml",
}
-
- return opts
}
diff --git a/config/config.go b/config/config.go
@@ -0,0 +1,57 @@
+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"`
+}
+
+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
@@ -0,0 +1,94 @@
+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
@@ -0,0 +1,24 @@
+// Package fingerprint provides interfaces and implementations for controlling
+// outbound connection fingerprints (TLS ClientHello, TCP socket options, etc.).
+package fingerprint
+
+import utls "github.com/refraction-networking/utls"
+
+// Applier is the base interface for all fingerprint types.
+type Applier interface {
+ Name() string
+}
+
+// TLSApplier controls the uTLS ClientHello fingerprint for outbound connections.
+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/tcp.go b/fingerprint/tcp.go
@@ -0,0 +1,11 @@
+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
@@ -0,0 +1,55 @@
+package fingerprint
+
+import (
+ "fmt"
+
+ utls "github.com/refraction-networking/utls"
+)
+
+// Table maps known JA4-TLS fingerprint hashes to utls ClientHelloID presets.
+// JA4 hashes are computed from real traffic captures; verify against current
+// browser versions when adding new entries.
+var Table = map[string]utls.ClientHelloID{
+ // Chrome 120
+ "t13d1516h2_8daaf6152771_b0da82dd1658": utls.HelloChrome_120,
+ // Chrome 120 with post-quantum key exchange
+ "t13d1516h2_8daaf6152771_e5627efa2ab1": utls.HelloChrome_120_PQ,
+ // Firefox 120
+ "t13d1517h2_8daaf6152771_b1ff8ab2d16f": utls.HelloFirefox_120,
+ // Safari 16.0
+ "t13d1516h2_8daaf6152771_4aeede8da0ac": utls.HelloSafari_16_0,
+ // Edge 106
+ "t13d1516h2_8daaf6152771_f5b4b24de8b1": 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
+}
+
+// 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)
+ if !ok {
+ return nil, fmt.Errorf("unknown JA4-TLS fingerprint %q", ja4)
+ }
+ return &TLSFingerprint{ja4: ja4, id: id}, nil
+}
+
+// Name returns a human-readable identifier including the JA4 hash.
+func (f *TLSFingerprint) Name() string {
+ return "tls:" + f.ja4
+}
+
+// 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
@@ -0,0 +1,62 @@
+package fingerprint_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/djnnvx/mic/fingerprint"
+ utls "github.com/refraction-networking/utls"
+)
+
+const knownJA4 = "t13d1516h2_8daaf6152771_b0da82dd1658"
+
+func TestLookup_Known(t *testing.T) {
+ id, ok := fingerprint.Lookup(knownJA4)
+ if !ok {
+ t.Fatalf("Lookup(%q) returned false; expected true", knownJA4)
+ }
+ if id != utls.HelloChrome_120 {
+ t.Errorf("Lookup(%q) = %v; want HelloChrome_120", knownJA4, id)
+ }
+}
+
+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 TestNewTLS_Happy(t *testing.T) {
+ fp, err := fingerprint.NewTLS(knownJA4)
+ if err != nil {
+ t.Fatalf("NewTLS(%q) returned error: %v", knownJA4, err)
+ }
+ if fp == nil {
+ t.Fatal("NewTLS returned nil fingerprint")
+ }
+}
+
+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 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 TestTable_MinEntries(t *testing.T) {
+ if len(fingerprint.Table) < 5 {
+ t.Errorf("Table has %d entries; want at least 5", len(fingerprint.Table))
+ }
+}
diff --git a/go.mod b/go.mod
@@ -3,6 +3,7 @@ module github.com/djnnvx/mic
go 1.24.4
require (
+ github.com/pelletier/go-toml/v2 v2.2.4
github.com/refraction-networking/utls v1.8.0
github.com/spf13/cobra v1.9.1
)
diff --git a/go.sum b/go.sum
@@ -5,6 +5,8 @@ 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.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+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/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE=
github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
diff --git a/mic.example.toml b/mic.example.toml
@@ -0,0 +1,26 @@
+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_b0da82dd1658" # Chrome 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
+
+# Future:
+# [fingerprint.tcp]
+# window_size = 65535
+# ttl = 64
+# mss = 1460
diff --git a/proxy/https.go b/proxy/https.go
@@ -2,15 +2,10 @@ package proxy
import (
"bufio"
- "fmt"
- "io"
"log"
"net"
"net/http"
"strings"
- "sync"
-
- utls "github.com/refraction-networking/utls"
)
func HttpsHandler(clientConn net.Conn, p *Proxy) {
@@ -31,52 +26,20 @@ func HttpsHandler(clientConn net.Conn, p *Proxy) {
}
// The Host field contains the destination host and port.
- // If no port is specified by the client, use the `ForwardTo` port from our Proxy struct.
+ // Default to :443 if no port is specified.
host := req.URL.Host
if !strings.Contains(host, ":") {
- host = fmt.Sprintf("%s:%d", host, p.ForwardTo)
+ host = host + ":443"
}
log.Printf("Connecting to target: %s", host)
- targetConn, err := net.Dial("tcp", host)
+ targetConn, err := p.dialTarget(host)
if err != nil {
- log.Printf("Failed to dial target %s: %v", host, err)
+ log.Printf("Failed to connect to target %s: %v", host, err)
return
}
defer targetConn.Close()
- // Create a uTLS client with the randomly selected fingerprint & perform handshake
- utlsConfig := &utls.Config{
- ServerName: strings.Split(host, ":")[0],
- }
- uclient := utls.UClient(targetConn, utlsConfig, utls.HelloRandomized)
-
- if err := uclient.Handshake(); err != nil {
- log.Printf("uTLS handshake failed with %s: %v", host, err)
- return
- }
- defer uclient.Close()
-
clientConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n"))
- var wg sync.WaitGroup
- wg.Add(2)
-
- // Pipe data from the client to the target server.
- go func() {
- defer wg.Done()
- _, err := io.Copy(uclient, reader)
- if err != nil && err != io.EOF {
- log.Printf("Error copying from client to target: %v", err)
- }
- }()
-
- go func() {
- defer wg.Done()
- _, err := io.Copy(clientConn, uclient)
- if err != nil && err != io.EOF {
- log.Printf("Error copying from target to client: %v", err)
- }
- }()
-
- wg.Wait()
+ pipe(clientConn, reader, targetConn)
}
diff --git a/proxy/integration_test.go b/proxy/integration_test.go
@@ -0,0 +1,206 @@
+//go:build integration
+
+package proxy
+
+import (
+ "bufio"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/djnnvx/mic/fingerprint"
+)
+
+const (
+ chrome120JA4 = "t13d1516h2_8daaf6152771_b0da82dd1658"
+ 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")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ go h(conn, p)
+ }
+ }()
+
+ 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)
+ }
+ pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: tlsCert.Certificate[0]})
+ cf.Close()
+ certFile = cf.Name()
+
+ // Private key PEM (PKCS8 — accepted by tls.LoadX509KeyPair as "PRIVATE KEY").
+ keyBytes, err := x509.MarshalPKCS8PrivateKey(tlsCert.PrivateKey)
+ if err != nil {
+ t.Fatalf("marshaling private key: %v", err)
+ }
+ kf, err := os.CreateTemp("", "mic-key-*.pem")
+ if err != nil {
+ t.Fatalf("creating key temp file: %v", err)
+ }
+ pem.Encode(kf, &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
+ kf.Close()
+ keyFile = kf.Name()
+
+ t.Cleanup(func() {
+ os.Remove(certFile)
+ os.Remove(keyFile)
+ })
+
+ return certFile, keyFile, pool
+}
+
+// TestClientFront_Integration tests the full client-front (HTTP CONNECT) flow:
+//
+// 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)
+ fmt.Fprintln(w, "client-front integration OK")
+ }))
+ defer ts.Close()
+
+ // Trust the test server's certificate.
+ caPool := ts.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
+
+ fp, err := fingerprint.NewTLS(chrome120JA4)
+ if err != nil {
+ t.Fatalf("fingerprint.NewTLS: %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)
+ }
+ 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)
+
+ reader := bufio.NewReader(conn)
+ resp, err := http.ReadResponse(reader, nil)
+ if err != nil {
+ t.Fatalf("reading CONNECT response: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ 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.
+ fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", targetAddr)
+
+ resp, err = http.ReadResponse(reader, nil)
+ if err != nil {
+ t.Fatalf("reading GET response: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET status: %d; want 200", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ t.Logf("response body: %s", body)
+}
+
+// TestServerFront_Integration tests the server-front flow:
+//
+// 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)
+ fmt.Fprintln(w, "server-front integration OK")
+ }))
+ 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)
+ if err != nil {
+ t.Fatalf("fingerprint.NewTLS: %v", err)
+ }
+
+ p := &Proxy{
+ BackendAddr: backend.Listener.Addr().String(),
+ CAPool: backendCAPool,
+ Fingerprint: fp,
+ }
+ 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,
+ &tls.Config{RootCAs: proxyCertPool, ServerName: "localhost"},
+ )
+ if err != nil {
+ t.Fatalf("tls.Dial to proxy: %v", err)
+ }
+ 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.
+ fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
+
+ reader := bufio.NewReader(tlsConn)
+ resp, err := http.ReadResponse(reader, nil)
+ if err != nil {
+ t.Fatalf("reading response: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status: %d; want 200", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ t.Logf("response body: %s", body)
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
@@ -2,31 +2,87 @@ package proxy
import (
"crypto/x509"
+ "io"
"log"
"net"
+ "strings"
+ "sync"
+
+ "github.com/djnnvx/mic/fingerprint"
+ utls "github.com/refraction-networking/utls"
)
type Proxy struct {
- ListenAddr string
- ForwardTo int
- CAPool *x509.CertPool
+ ListenAddr string
+ BackendAddr string // server-front: backend host:port
+ CAPool *x509.CertPool
+ Fingerprint fingerprint.TLSApplier
handlers []Handler
}
-// function is a function that processes an incoming client connection
+// Handler is a function that processes an incoming client connection.
type Handler func(conn net.Conn, p *Proxy)
-// adds a handler to the orchestrator
+// RegisterHandler adds a handler to the proxy.
func (p *Proxy) RegisterHandler(h Handler) {
p.handlers = append(p.handlers, h)
}
-func (p *Proxy) AddCertificate(cert *x509.CertPool) {
- p.CAPool = cert
+// 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.
+func (p *Proxy) dialTarget(host string) (*utls.UConn, error) {
+ tcpConn, err := net.Dial("tcp", host)
+ if err != nil {
+ return nil, err
+ }
+
+ helloID := utls.HelloRandomized
+ if p.Fingerprint != nil {
+ helloID = p.Fingerprint.ClientHelloID()
+ }
+
+ serverName := host
+ if idx := strings.LastIndex(host, ":"); idx != -1 {
+ serverName = host[:idx]
+ }
+
+ cfg := &utls.Config{
+ ServerName: serverName,
+ }
+ if p.CAPool != nil {
+ cfg.RootCAs = p.CAPool
+ }
+
+ uconn := utls.UClient(tcpConn, cfg, helloID)
+ if err := uconn.Handshake(); err != nil {
+ uconn.Close()
+ return nil, err
+ }
+ return uconn, nil
+}
+
+// pipe copies data bidirectionally between client (reading from r) and target,
+// blocking until both directions are done.
+func pipe(client io.ReadWriter, r io.Reader, target io.ReadWriter) {
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ io.Copy(target, r)
+ }()
+
+ go func() {
+ defer wg.Done()
+ io.Copy(client, target)
+ }()
+
+ wg.Wait()
}
-// Run starts the proxy server and orchestrates all protocol handlers
+// 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 {
@@ -34,7 +90,7 @@ func (p *Proxy) Run() error {
}
defer ln.Close()
- log.Printf("[+] Starting proxy on %s, forwarding to port %d", p.ListenAddr, p.ForwardTo)
+ log.Printf("[+] Starting proxy on %s", p.ListenAddr)
for {
c, err := ln.Accept()
if err != nil {
@@ -45,8 +101,6 @@ func (p *Proxy) Run() error {
conn := c
go func() {
for _, h := range p.handlers {
- // Each handler processes the incoming connection.
- // It will get discarded by a handler if it doesn't support it.
h(conn, p)
}
}()
diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go
@@ -0,0 +1,136 @@
+package proxy
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "io"
+ "math/big"
+ "net"
+ "testing"
+ "time"
+
+ "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()
+
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatalf("generating key: %v", err)
+ }
+
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "localhost"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ DNSNames: []string{"localhost"},
+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatalf("creating certificate: %v", err)
+ }
+
+ cert, err := x509.ParseCertificate(certDER)
+ if err != nil {
+ t.Fatalf("parsing certificate: %v", err)
+ }
+
+ pool := x509.NewCertPool()
+ pool.AddCert(cert)
+
+ tlsCert := tls.Certificate{
+ Certificate: [][]byte{certDER},
+ PrivateKey: key,
+ }
+
+ return pool, tlsCert
+}
+
+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},
+ })
+ if err != nil {
+ t.Fatalf("tls.Listen: %v", err)
+ }
+ defer ln.Close()
+
+ // Accept and drain one connection in the background.
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ io.Copy(io.Discard, conn)
+ }()
+
+ fp, err := fingerprint.NewTLS("t13d1516h2_8daaf6152771_b0da82dd1658")
+ if err != nil {
+ t.Fatalf("fingerprint.NewTLS: %v", err)
+ }
+
+ p := &Proxy{
+ ListenAddr: "127.0.0.1:0",
+ CAPool: pool,
+ Fingerprint: fp,
+ }
+
+ conn, err := p.dialTarget(ln.Addr().String())
+ if err != nil {
+ t.Fatalf("dialTarget(%q): %v", ln.Addr().String(), err)
+ }
+ 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)
+ }
+}
+
+func TestDialTarget_FallbackToRandomized(t *testing.T) {
+ pool, tlsCert := generateSelfSignedCert(t)
+
+ ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
+ Certificates: []tls.Certificate{tlsCert},
+ })
+ if err != nil {
+ t.Fatalf("tls.Listen: %v", err)
+ }
+ defer ln.Close()
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ io.Copy(io.Discard, conn)
+ }()
+
+ // No fingerprint set — should fall back to HelloRandomized.
+ p := &Proxy{
+ ListenAddr: "127.0.0.1:0",
+ CAPool: pool,
+ }
+
+ 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.
+ t.Skipf("dialTarget with randomized preset: %v", err)
+ }
+ conn.Close()
+}
diff --git a/proxy/serverfront.go b/proxy/serverfront.go
@@ -0,0 +1,43 @@
+package proxy
+
+import (
+ "bufio"
+ "crypto/tls"
+ "log"
+ "net"
+)
+
+// ServerFrontHandler returns a Handler that terminates incoming TLS using certFile/keyFile,
+// then forwards to p.BackendAddr using the configured fingerprint.
+func ServerFrontHandler(certFile, keyFile string) Handler {
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ log.Fatalf("ServerFrontHandler: loading certificate: %v", err)
+ }
+
+ tlsCfg := &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ }
+
+ return func(clientConn net.Conn, p *Proxy) {
+ defer clientConn.Close()
+
+ // Terminate incoming TLS with stdlib crypto/tls.
+ tlsConn := tls.Server(clientConn, tlsCfg)
+ if err := tlsConn.Handshake(); err != nil {
+ log.Printf("ServerFront: TLS handshake with client failed: %v", err)
+ return
+ }
+ 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)
+ return
+ }
+ defer targetConn.Close()
+
+ pipe(tlsConn, bufio.NewReader(tlsConn), targetConn)
+ }
+}
diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh
@@ -0,0 +1,45 @@
+#!/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..."
+ rm "$BINARY"
+fi
+
+echo "[+] Cleanup complete."
diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# run_integration_tests.sh — run the integration test suite.
+#
+# Integration tests spin up in-process TLS servers and do not require an
+# external proxy instance. They exercise the full round-trip:
+#
+# client-front: client → TCP CONNECT → proxy → uTLS(Chrome120) → target
+# server-front: client → TLS → proxy (terminates) → uTLS(Chrome120) → backend
+#
+# Usage:
+# scripts/run_integration_tests.sh # all packages
+# scripts/run_integration_tests.sh ./proxy/ # single package
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+
+PACKAGES="${1:-./...}"
+TIMEOUT="${INTEGRATION_TIMEOUT:-60s}"
+
+echo "[+] Running integration tests (timeout: $TIMEOUT)..."
+echo ""
+
+(cd "$ROOT_DIR" && go test -tags integration -v -timeout "$TIMEOUT" "$PACKAGES")
+
+echo ""
+echo "[+] Done."
diff --git a/scripts/setup.sh b/scripts/setup.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# setup.sh — build the mic binary and create a starter config.
+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"
+
+# ── 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" &
+ echo $! > "$PID_FILE"
+ echo " PID $(cat "$PID_FILE") — log: mic.log"
+ echo " Stop with: scripts/cleanup.sh"
+fi
| | | | | | | | | | | | | | | | | | | |