fix: correct module path to github.com/kreatoo/ihtc

This commit is contained in:
Kreato 2026-06-01 00:10:46 +03:00
commit f480c77602
No known key found for this signature in database
2 changed files with 950 additions and 1 deletions

View file

@ -0,0 +1,949 @@
# ihtc Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local HTTP forward proxy that fragments the TLS ClientHello across multiple TCP segments to bypass SNI-based DPI censorship.
**Architecture:** Single Go binary. Browser points at `127.0.0.1:8080`. For HTTPS CONNECT tunnels, the proxy reads the TLS ClientHello, splits it into small randomized chunks, writes each chunk as a separate TCP segment (via TCP_NODELAY + microsecond delays), then relays the rest transparently.
**Tech Stack:** Go 1.22+, stdlib only — no external dependencies.
**Note on TCP_NODELAY:** The spec mentioned Nagle being disabled by default. The reverse is true: `TCP_NODELAY` (SetNoDelay=true) _disables_ Nagle so writes flush immediately. Without it, Nagle coalesces small writes. This plan uses `SetNoDelay(true)` + `time.Sleep` between chunks — the correct, portable approach.
---
## File Map
```
ihtc/
├── go.mod
├── cmd/ihtc/main.go ← CLI, flags, signals, wiring
├── internal/log/log.go ← Structured logger
├── internal/obfuscate/obfuscate.go ← TLS fragmentation engine
├── internal/obfuscate/obfuscate_test.go
├── internal/proxy/proxy.go ← HTTP forward proxy
├── internal/proxy/proxy_test.go
```
---
### Task 1: Initialize Go Module
**Files:**
- Create: `go.mod`
- [ ] **Step 1: Init module**
```bash
go mod init github.com/kreatoo/ihtc
```
Expected: Creates `go.mod` with module path and Go version.
- [ ] **Step 2: Verify**
```bash
cat go.mod
```
Expected: `module github.com/kreatoo/ihtc`
- [ ] **Step 3: Commit**
```bash
git add go.mod
git commit -m "feat: init go module"
```
---
### Task 2: Structured Logger
**Files:**
- Create: `internal/log/log.go`
- [ ] **Step 1: Ensure directory exists**
```bash
mkdir -p internal/log
```
- [ ] **Step 2: Write internal/log/log.go**
```go
package log
import (
"fmt"
"os"
"sync"
"time"
)
type Level int
const (
LevelInfo Level = iota
LevelWarn
LevelError
)
type Logger struct {
mu sync.Mutex
verbose bool
}
func New(verbose bool) *Logger {
return &Logger{verbose: verbose}
}
func (l *Logger) log(level string, format string, args ...interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
ts := time.Now().Format("15:04:05")
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "[%s] %s %s\n", ts, level, msg)
}
func (l *Logger) Info(format string, args ...interface{}) {
l.log("INFO", format, args...)
}
func (l *Logger) Warn(format string, args ...interface{}) {
l.log("WARN", format, args...)
}
func (l *Logger) Error(format string, args ...interface{}) {
l.log("ERROR", format, args...)
}
func (l *Logger) Debug(format string, args ...interface{}) {
if l.verbose {
l.log("DEBUG", format, args...)
}
}
```
- [ ] **Step 3: Verify it compiles**
```bash
go build ./internal/log/...
```
Expected: No output (compiles cleanly).
- [ ] **Step 4: Commit**
```bash
git add internal/log/log.go
git commit -m "feat: add structured logger"
```
---
### Task 3: TLS Fragmentation Engine
**Files:**
- Create: `internal/obfuscate/obfuscate.go`
- Create: `internal/obfuscate/obfuscate_test.go`
- [ ] **Step 1: Ensure directory exists**
```bash
mkdir -p internal/obfuscate
```
- [ ] **Step 2: Write internal/obfuscate/obfuscate.go**
```go
package obfuscate
import (
"crypto/rand"
"io"
"math/big"
"net"
"time"
)
type Config struct {
MinChunk int
MaxChunk int
DelayUs int
}
func Split(data []byte, cfg Config) [][]byte {
var chunks [][]byte
offset := 0
for offset < len(data) {
chunkSize := cfg.MinChunk
if cfg.MaxChunk > cfg.MinChunk {
n, err := rand.Int(rand.Reader, big.NewInt(int64(cfg.MaxChunk-cfg.MinChunk)))
if err == nil {
chunkSize = cfg.MinChunk + int(n.Int64())
}
}
if offset+chunkSize > len(data) {
chunkSize = len(data) - offset
}
chunks = append(chunks, data[offset:offset+chunkSize])
offset += chunkSize
}
return chunks
}
func writeFragmented(conn net.Conn, chunks [][]byte, cfg Config) error {
for i, chunk := range chunks {
_, err := conn.Write(chunk)
if err != nil {
return err
}
if i < len(chunks)-1 && cfg.DelayUs > 0 {
var delay int
n, err := rand.Int(rand.Reader, big.NewInt(int64(cfg.DelayUs)))
if err == nil {
delay = int(n.Int64())
}
time.Sleep(time.Duration(delay) * time.Microsecond)
}
}
return nil
}
func HandleClientHello(dst net.Conn, src io.Reader, cfg Config) (int, error) {
if tcpConn, ok := dst.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true)
}
header := make([]byte, 5)
n, err := io.ReadFull(src, header)
if err != nil {
return n, err
}
recordLen := int(header[3])<<8 | int(header[4])
body := make([]byte, recordLen)
bn, err := io.ReadFull(src, body)
if err != nil {
return n + bn, err
}
fullHello := append(header, body...)
chunks := Split(fullHello, cfg)
if err := writeFragmented(dst, chunks, cfg); err != nil {
return len(fullHello), err
}
return len(fullHello), nil
}
```
- [ ] **Step 3: Write internal/obfuscate/obfuscate_test.go**
```go
package obfuscate
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
)
func buildFakeClientHello(sni string) []byte {
var buf bytes.Buffer
buf.Write([]byte{0x16, 0x03, 0x01}) // record type, version
body := make([]byte, 0, 512)
body = append(body, 0x01) // handshake type
handshakeLen := make([]byte, 3)
body = append(body, handshakeLen...) // placeholder length
body = append(body, 0x03, 0x03) // version
body = append(body, make([]byte, 32)...) // random
body = append(body, 0x00) // session ID length
body = append(body, 0x00, 0x02) // cipher suites length + 1 suite
body = append(body, 0x00, 0x9d) // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
body = append(body, 0x01, 0x00) // compression
extLen := 2 + 2 + len(sni) + 2 + 1 + 2 + 5 + 2 + 1 + 2 + 1 + 2 // estimate
extLenBytes := make([]byte, 2)
binary.BigEndian.PutUint16(extLenBytes, uint16(extLen))
body = append(body, extLenBytes...)
// SNI extension
body = append(body, 0x00, 0x00) // type: server_name
sniLen := 2 + 2 + len(sni)
sniLenBytes := make([]byte, 2)
binary.BigEndian.PutUint16(sniLenBytes, uint16(sniLen))
body = append(body, sniLenBytes...)
nameLenBytes := make([]byte, 2)
binary.BigEndian.PutUint16(nameLenBytes, uint16(len(sni)))
body = append(body, 0x00, nameLenBytes[0], nameLenBytes[1])
body = append(body, []byte(sni)...)
// pad with a few more extension types
body = append(body, 0x00, 0x0b, 0x00, 0x01, 0x00) // EC point formats
body = append(body, 0x00, 0x0a, 0x00, 0x01, 0x00) // supported groups
body = append(body, 0x00, 0x0d, 0x00, 0x01, 0x01) // signature algorithms
// Fix lengths
binary.BigEndian.PutUint16(handshakeLen, uint16(len(body)-4))
recordLen := uint16(len(body))
lenBytes := make([]byte, 2)
binary.BigEndian.PutUint16(lenBytes, recordLen)
buf.Write(lenBytes)
buf.Write(body)
return buf.Bytes()
}
func TestSplit_ChunkSizesWithinBounds(t *testing.T) {
hello := buildFakeClientHello("discord.com")
cfg := Config{MinChunk: 3, MaxChunk: 8}
chunks := Split(hello, cfg)
total := 0
for _, c := range chunks {
if len(c) < cfg.MinChunk && len(c) != len(hello[total:]) {
t.Errorf("chunk size %d < min %d (not the last partial chunk)", len(c), cfg.MinChunk)
}
total += len(c)
}
if total != len(hello) {
t.Errorf("total chunk bytes %d != input length %d", total, len(hello))
}
}
func TestSplit_InputUnchanged(t *testing.T) {
hello := buildFakeClientHello("example.com")
original := make([]byte, len(hello))
copy(original, hello)
cfg := Config{MinChunk: 2, MaxChunk: 5}
chunks := Split(hello, cfg)
var reassembled []byte
for _, c := range chunks {
reassembled = append(reassembled, c...)
}
if !bytes.Equal(reassembled, original) {
t.Error("reassembled chunks do not match original input")
}
}
func TestSplit_SmallInput(t *testing.T) {
data := []byte{0x16, 0x03, 0x01, 0x00, 0x00}
cfg := Config{MinChunk: 10, MaxChunk: 20}
chunks := Split(data, cfg)
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk for small input, got %d", len(chunks))
}
if !bytes.Equal(chunks[0], data) {
t.Error("single chunk does not match input")
}
}
func TestSplit_MinEqualsMax(t *testing.T) {
hello := buildFakeClientHello("test.local")
cfg := Config{MinChunk: 5, MaxChunk: 5}
chunks := Split(hello, cfg)
for i, c := range chunks {
if i < len(chunks)-1 && len(c) != 5 {
t.Errorf("chunk %d size %d != 5", i, len(c))
}
}
}
func TestWriteFragmented_ReassemblesCorrectly(t *testing.T) {
hello := buildFakeClientHello("discord.com")
cfg := Config{MinChunk: 3, MaxChunk: 7, DelayUs: 0}
chunks := Split(hello, cfg)
var buf net.Buffers
for _, c := range chunks {
buf = append(buf, c)
}
joined := bytes.Join([][]byte(buf), nil)
if !bytes.Equal(joined, hello) {
t.Error("joined chunks do not match original")
}
}
func TestHandleClientHello_ReadsExactlyClientHello(t *testing.T) {
hello := buildFakeClientHello("discord.com")
extra := []byte("some extra data after handshake")
var fullInput bytes.Buffer
fullInput.Write(hello)
fullInput.Write(extra)
server, client := net.Pipe()
defer server.Close()
done := make(chan struct{})
var received bytes.Buffer
go func() {
io.Copy(&received, server)
close(done)
}()
cfg := Config{MinChunk: 3, MaxChunk: 7, DelayUs: 0}
n, err := HandleClientHello(client, &fullInput, cfg)
if err != nil {
t.Fatalf("HandleClientHello failed: %v", err)
}
if n != len(hello) {
t.Errorf("read %d bytes, expected %d", n, len(hello))
}
client.Close()
<-done
if !bytes.Equal(received.Bytes(), hello) {
t.Error("server did not receive correct ClientHello bytes")
}
}
```
- [ ] **Step 4: Run obfuscate tests**
```bash
go test ./internal/obfuscate/... -v
```
Expected: All tests PASS.
- [ ] **Step 5: Commit**
```bash
git add internal/obfuscate/obfuscate.go internal/obfuscate/obfuscate_test.go
git commit -m "feat: add TLS ClientHello fragmentation engine"
```
---
### Task 4: HTTP Forward Proxy
**Files:**
- Create: `internal/proxy/proxy.go`
- Create: `internal/proxy/proxy_test.go`
- [ ] **Step 1: Ensure directory exists**
```bash
mkdir -p internal/proxy
```
- [ ] **Step 2: Write internal/proxy/proxy.go**
```go
package proxy
import (
"context"
"io"
"net"
"net/http"
"time"
"github.com/kreatoo/ihtc/internal/log"
"github.com/kreatoo/ihtc/internal/obfuscate"
)
type Proxy struct {
listenAddr string
obfuscateCfg obfuscate.Config
logger *log.Logger
srv *http.Server
}
func New(addr string, cfg obfuscate.Config, logger *log.Logger) *Proxy {
p := &Proxy{
listenAddr: addr,
obfuscateCfg: cfg,
logger: logger,
}
p.srv = &http.Server{
Addr: addr,
Handler: http.HandlerFunc(p.handle),
}
return p
}
func (p *Proxy) ListenAddr() string {
return p.listenAddr
}
func (p *Proxy) Start() error {
l, err := net.Listen("tcp", p.listenAddr)
if err != nil {
return err
}
p.listenAddr = l.Addr().String()
p.logger.Info("listening on %s", p.listenAddr)
return p.srv.Serve(l)
}
func (p *Proxy) Shutdown(ctx context.Context) error {
return p.srv.Shutdown(ctx)
}
func (p *Proxy) handle(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
p.handleConnect(w, r)
return
}
p.handleHTTP(w, r)
}
func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
target := r.Host
if target == "" {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
dest, err := net.DialTimeout("tcp", target, 10*time.Second)
if err != nil {
p.logger.Warn("connect to %s failed: %v", target, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer dest.Close()
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, bufrw, err := hijacker.Hijack()
if err != nil {
return
}
defer clientConn.Close()
if _, err := bufrw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
}
if err := bufrw.Flush(); err != nil {
return
}
n, err := obfuscate.HandleClientHello(dest, clientConn, p.obfuscateCfg)
if err != nil && err != io.EOF {
p.logger.Debug("ClientHello frag: wrote %d bytes, err: %v", n, err)
}
p.logger.Debug("tunnel %s established (%d byte ClientHello fragmented)", target, n)
done := make(chan struct{}, 2)
go func() {
io.Copy(dest, clientConn)
done <- struct{}{}
}()
go func() {
io.Copy(clientConn, dest)
done <- struct{}{}
}()
<-done
}
func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
r.RequestURI = ""
resp, err := http.DefaultTransport.RoundTrip(r)
if err != nil {
p.logger.Warn("forward %s %s failed: %v", r.Method, r.URL, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
```
- [ ] **Step 3: Write internal/proxy/proxy_test.go**
```go
package proxy
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/kreatoo/ihtc/internal/log"
"github.com/kreatoo/ihtc/internal/obfuscate"
)
func generateCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{"localhost"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(nil, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyBytes, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("key pair: %v", err)
}
return cert
}
func startTLSHandler(t *testing.T, handler http.Handler) (*http.Server, string) {
t.Helper()
cert := generateCert(t)
tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &http.Server{
Handler: handler,
TLSConfig: tlsCfg,
}
go srv.ServeTLS(l, "", "")
// Wait for server to be ready
addr := l.Addr().String()
time.Sleep(50 * time.Millisecond)
return srv, addr
}
func TestCONNECT(t *testing.T) {
// Start TLS origin server
tlsSrv, tlsAddr := startTLSHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello from tls"))
}))
defer tlsSrv.Close()
// Start proxy
p := New("127.0.0.1:0", obfuscate.Config{MinChunk: 3, MaxChunk: 8, DelayUs: 0}, log.New(false))
go p.Start()
time.Sleep(50 * time.Millisecond)
proxyAddr := p.ListenAddr()
proxyURL, err := url.Parse("http://" + proxyAddr)
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
// Create client that trusts self-signed cert and uses proxy
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://" + tlsAddr)
if err != nil {
t.Fatalf("request through proxy failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "hello from tls" {
t.Errorf("expected 'hello from tls', got '%s'", string(body))
}
p.Shutdown(context.Background())
}
func TestHTTP(t *testing.T) {
// Start plain HTTP origin server
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello from %s", r.URL.Path)
}))
defer origin.Close()
// Start proxy
p := New("127.0.0.1:0", obfuscate.Config{MinChunk: 3, MaxChunk: 8, DelayUs: 0}, log.New(false))
go p.Start()
time.Sleep(50 * time.Millisecond)
proxyURL, err := url.Parse("http://" + p.ListenAddr())
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
Timeout: 10 * time.Second,
}
resp, err := client.Get(origin.URL + "/test")
if err != nil {
t.Fatalf("request through proxy failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "hello from /test" {
t.Errorf("expected 'hello from /test', got '%s'", string(body))
}
p.Shutdown(context.Background())
}
func TestBadGateway(t *testing.T) {
p := New("127.0.0.1:0", obfuscate.Config{MinChunk: 3, MaxChunk: 8, DelayUs: 0}, log.New(false))
go p.Start()
time.Sleep(50 * time.Millisecond)
proxyURL, err := url.Parse("http://" + p.ListenAddr())
if err != nil {
t.Fatalf("parse proxy URL: %v", err)
}
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
Timeout: 5 * time.Second,
}
_, err = client.Get("http://192.0.2.1:9999/")
if err == nil {
t.Error("expected error for unreachable target")
}
p.Shutdown(context.Background())
}
```
- [ ] **Step 4: Run proxy tests**
```bash
go test ./internal/proxy/... -v
```
Expected: All tests PASS (TestCONNECT, TestHTTP, TestBadGateway).
- [ ] **Step 5: Commit**
```bash
git add internal/proxy/proxy.go internal/proxy/proxy_test.go
git commit -m "feat: add HTTP forward proxy with CONNECT tunneling"
```
---
### Task 5: CLI Entry Point
**Files:**
- Create: `cmd/ihtc/main.go`
- [ ] **Step 1: Ensure directory exists**
```bash
mkdir -p cmd/ihtc
```
- [ ] **Step 2: Write cmd/ihtc/main.go**
```go
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/kreatoo/ihtc/internal/log"
"github.com/kreatoo/ihtc/internal/obfuscate"
"github.com/kreatoo/ihtc/internal/proxy"
)
func main() {
listen := flag.String("listen", "127.0.0.1:8080", "Address to bind")
minChunk := flag.Int("min-chunk", 3, "Minimum bytes per fragment")
maxChunk := flag.Int("max-chunk", 8, "Maximum bytes per fragment")
delayUs := flag.Int("delay-us", 500, "Max microsecond delay between fragments")
verbose := flag.Bool("verbose", false, "Enable debug logging")
flag.Parse()
logger := log.New(*verbose)
if *minChunk < 1 {
fmt.Fprintln(os.Stderr, "min-chunk must be >= 1")
os.Exit(1)
}
if *maxChunk < *minChunk {
fmt.Fprintln(os.Stderr, "max-chunk must be >= min-chunk")
os.Exit(1)
}
cfg := obfuscate.Config{
MinChunk: *minChunk,
MaxChunk: *maxChunk,
DelayUs: *delayUs,
}
p := proxy.New(*listen, cfg, logger)
errCh := make(chan error, 1)
go func() {
errCh <- p.Start()
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
if err != nil {
logger.Error("failed to start: %v", err)
os.Exit(1)
}
case sig := <-sigCh:
logger.Info("received %s, shutting down", sig)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := p.Shutdown(ctx); err != nil {
logger.Error("shutdown error: %v", err)
os.Exit(1)
}
logger.Info("shutdown complete")
}
```
- [ ] **Step 3: Build the binary**
```bash
go build -o ihtc ./cmd/ihtc/
```
Expected: No output, binary `ihtc` created.
- [ ] **Step 4: Run smoke test**
Start the proxy in background:
```bash
./ihtc --listen 127.0.0.1:18180 &
PID=$!
sleep 1
```
Make a test request through it:
```bash
curl -x http://127.0.0.1:18180 http://example.com -s -o /dev/null -w "%{http_code}"
```
Stop the proxy:
```bash
kill $PID
wait $PID 2>/dev/null
```
Expected: `200` from curl.
- [ ] **Step 5: Commit**
```bash
git add cmd/ihtc/main.go
git commit -m "feat: add CLI entry point with signal handling"
```
---
### Task 6: Final Build and Verification
**Files:** None (verify only)
- [ ] **Step 1: Run all tests**
```bash
go test ./... -v
```
Expected: All tests PASS.
- [ ] **Step 2: Build release binary**
```bash
go build -o ihtc ./cmd/ihtc/
```
Expected: Binary built without errors.
- [ ] **Step 3: Verify binary help**
```bash
./ihtc -h
```
Expected: Usage output with all flags listed.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "chore: final build verification"
```

View file

@ -27,7 +27,7 @@ internal/obfuscate/obfuscate.go — TLS ClientHello splitting engine
internal/log/log.go — minimal structured logging
```
Go module path: `github.com/kreato/ihtc`
Go module path: `github.com/kreatoo/ihtc`
## CLI