fix: correct module path to github.com/kreatoo/ihtc
This commit is contained in:
parent
a41b35ee74
commit
f480c77602
2 changed files with 950 additions and 1 deletions
949
docs/superpowers/plans/2026-05-31-ihtc.md
Normal file
949
docs/superpowers/plans/2026-05-31-ihtc.md
Normal 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"
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue