0

Proxy Network — Mạng Proxy toàn tập Phần 4

Tiêu chí Danh sách IP tĩnh Backconnect gateway
Cấu hình phía client N endpoint, tự chọn Một endpoint, không đổi
Ai chịu logic xoay vòng Bạn (§6.7) Nhà cung cấp
Quan sát được từng IP Có — chấm điểm chính xác Hạn chế
Số chặng thêm 1 (client → proxy → đích) 2 (client → gateway → exit → đích)
Khi một IP cháy Bạn cách ly nó Nhà cung cấp thay ngầm, bạn không biết
Phù hợp với Datacenter, ISP, hồ nhỏ tự quản Hồ residential/mobile quy mô lớn

Cái bẫy lớn nhất của backconnect: bạn không chấm điểm được IP, bạn chỉ chấm điểm được cái gateway.

Toàn bộ cơ chế ở §6.6 giả định bạn phân biệt được các exit. Với backconnect, mọi request đều tới cùng một host:port — áp thẳng vào thì hồ của bạn có đúng một entry và mọi phép thống kê thành vô nghĩa. Hai lối ra: (1) dùng sticky session id để biến mỗi phiên thành một "exit ảo" chấm điểm được; (2) hỏi IP thật qua một endpoint echo rồi gắn nhãn — tốn thêm một request mỗi phiên, chỉ làm khi thực sự cần.

Về giao thức ở tầng dây — HTTP proxy với URI tuyệt đối, CONNECT, SOCKS5 (RFC 1928) và xác thực username/password của nó (RFC 1929) — xem §3. Ở đây chỉ cần nhớ: CONNECT dựng một ống TCP đục, nên gateway không đọc được nội dung HTTPS của bạn, nhưng vẫn thấy tên miền đích cùng toàn bộ metadata kích thước và thời điểm.

6.5 Chiến lược xoay vòng

Xoay vòng không phải là "càng nhiều IP càng tốt". Nó là chọn đơn vị danh tính cho một chuỗi request có liên quan với nhau.

Chiến lược Cơ chế Khi nào đúng Cái giá
Mỗi request một IP Chọn lại exit trước mỗi lần gửi Request độc lập, không trạng thái, không cookie Khó tái dùng kết nối TCP/TLS → trả giá bắt tay nhiều hơn
Theo phiên (sticky N phút) Một khoá phiên gắn với một exit trong TTL Có đăng nhập, giỏ hàng, phân trang, cookie Một IP gánh nhiều request → dễ chạm rate limit của đích
Xoay khi lỗi Giữ nguyên exit tới khi lỗi thì đổi Hồ nhỏ, exit ổn định, muốn tái dùng kết nối Phản ứng chậm: chỉ biết IP hỏng sau khi đã hỏng
Xoay theo mã trạng thái 403/407/429 → đổi ngay; 5xx → không đổi Mọi hồ nghiêm túc; là lớp bổ sung, không thay thế ba cái trên Phân loại sai mã thì cách ly nhầm cả hồ

Ba cái đầu là chính sách chọn, cái thứ tư là phản xạ. Trong bản cài đặt ở §6.7, Pick(key) lo ba cái đầu (khoá rỗng = xoay mỗi request, khoá khác rỗng = sticky), còn Report lo cái thứ tư.

Sai lầm hay gặp nhất: xoay IP nhưng không xoay danh tính đi kèm.

Đổi IP giữa chừng mà vẫn gửi nguyên cookie phiên, cùng User-Agent, cùng dấu vân tay TLS thì bạn vừa tạo ra một tín hiệu mạnh hơn so với khi không xoay: một tài khoản dịch chuyển tức thời giữa các quốc gia. Đơn vị xoay vòng phải trùng với đơn vị danh tính — xem §7.

Cài đặt sticky bằng session id trong username

Với backconnect, giao thức proxy chỉ chừa cho bạn đúng hai chỗ để nói chuyện với gateway: host đíchthông tin xác thực. Host đích đã dùng cho việc khác, nên phần lớn nhà cung cấp mã hoá chính sách vào username:

username: acme-session-abc123
password: s3cr3t

Gateway tách chuỗi username, thấy session-abc123, tra bảng phiên, rồi ghim mọi request mang session id đó vào cùng một exit cho tới khi phiên hết hạn. Nhiều nơi cho thêm khoá khác trong cùng chuỗi (vùng địa lý, kiểu IP, thời hạn phiên).

Phía Go, khi proxy URL có phần userinfo thì net/http tự sinh header Proxy-Authorization kiểu Basic cho HTTP proxy — bạn chỉ cần dựng đúng *url.URL:

// WithSession sinh URL proxy mang session id trong username. Sao chép nông là
// đủ vì ta chỉ thay trường User — sửa thẳng base sẽ hỏng mọi phiên khác đang
// dùng chung con trỏ đó.
func WithSession(base *url.URL, user, pass, session string) *url.URL {
	u := *base
	u.User = url.UserPassword(user+"-session-"+session, pass)
	return &u
}

⚠️ Định dạng user-session-<id> là quy ước phổ biến, KHÔNG phải chuẩn. Dấu phân tách, tên khoá và thứ tự khác nhau giữa các nhà cung cấp; luôn đọc tài liệu của bên bạn dùng. Mục này giải thích cơ chế, không giới thiệu nhà cung cấp nào.

Với danh sách IP tĩnh thì đơn giản hơn nhiều: sticky chỉ là một map[khoá]→exit có TTL nằm ngay trong tiến trình của bạn. Đó là cách Pool bên dưới làm.

6.6 Chấm điểm sức khoẻ IP

Một exit không chỉ có hai trạng thái sống/chết. Nó có một vòng đời:

                   lỗi liên tiếp ≥ N, hoặc 403/407/429
     ┌───────────┐ ─────────────────────────────────► ┌────────────────┐
     │  HEALTHY  │                                    │  QUARANTINED   │
     │  (đủ tải) │ ◄───────────────────────────────── │ (0% lưu lượng) │
     └───────────┘        một lần thành công          └───────┬────────┘
           ▲                                                  │ hết hạn cách ly
           │           một lần thành công                     ▼
           │                     ┌────────────────────────────────────┐
           └─────────────────────│  SUSPECT  (~20% lưu lượng)         │
                                 └─────────────────┬──────────────────┘
                                                   │ lại lỗi → QUARANTINED, hạn ×2

Trạng thái SUSPECT là mấu chốt. Không có nó, bạn chỉ còn hai lựa chọn tồi: thả exit vừa hồi phục vào đủ tải ngay (và cháy lại lập tức), hoặc loại vĩnh viễn (và hồ teo dần cho tới khi rỗng). SUSPECT là "cho thử việc": nhận một phần nhỏ lưu lượng, thành công một lần thì phục chức, hỏng lần nữa thì cách ly với thời hạn gấp đôi.

Đọc sai mã lỗi là cách nhanh nhất để tự phá hồ của mình:

Tín hiệu Nghĩa nhiều khả năng Phản ứng đúng
Timeout, connection reset, TLS handshake fail Exit hỏng ở tầng mạng Tính là lỗi; cách ly sau vài lần liên tiếp
407 Proxy Authentication Required Sai credential hoặc hết hạn mức Cách ly exit — nhưng nếu mọi exit đều 407 thì lỗi ở tài khoản, đừng cách ly cả hồ
403 Forbidden IP bị chặn ở phía đích Cách ly exit đó
429 Too Many Requests IP vượt hạn mức của đích Cách ly và giảm tốc toàn cục, không chỉ đổi IP
5xx từ đích Đích đang hỏng, không phải lỗi IP Thử exit khác nhưng đừng vội cách ly
200 mà thân là trang captcha/challenge IP đã cháy dù mã vẫn đẹp Xem cảnh báo dưới

Bẫy nghiêm trọng nhất: mã 200 không có nghĩa là thành công.

Chặn mềm (soft block) trả về đúng 200 OK kèm một trang thử thách. Nếu bạn chỉ chấm điểm theo mã trạng thái, hồ sẽ báo cáo sức khoẻ 100% trong khi không thu được một byte dữ liệu hữu ích nào. Bắt buộc phải có một bước xác thực nội dung — dài/ngắn bất thường, thiếu selector mong đợi, Content-Type sai — rồi báo về Report như một lần thất bại.

Một phân biệt nữa mà chỉ dữ liệu mới trả lời được: exit hỏng hay đích hỏng? Cách duy nhất đáng tin là nhìn theo hai chiều.

                đích A   đích B   đích C
   exit 1         ✅       ❌       ✅     → exit 1 khoẻ
   exit 2         ✅       ❌       ✅     → exit 2 khoẻ
   exit 3         ❌       ❌       ❌     → exit 3 CHÁY, cách ly
                           └─► mọi exit đều hỏng ở B → ĐÍCH B hỏng, đừng cách ly ai

Bản cài đặt bên dưới cố ý không dựng ma trận này: nó tốn bộ nhớ theo tích số exit × đích. Cách rẻ hơn mà vẫn đúng phần lớn thời gian là chỉ cách ly ngay với 403/407/429 — những mã gắn với danh tính — và khoan dung với 5xx.

6.7 Viết ProxyPool bằng Go

Bản dưới biên dịch được trên Go 1.25 và chỉ dùng thư viện chuẩn. Các khối code trong mục này và §6.8 thuộc cùng một package, tách ra chỉ cho dễ đọc.

// Package proxypool quản lý một hồ proxy: chọn exit, chấm điểm sức khoẻ,
// cách ly exit hỏng và giữ sticky session theo khoá.
package proxypool

import (
	"context"
	"errors"
	"fmt"
	"io"
	"math"
	"math/rand/v2"
	"net/http"
	"net/url"
	"sync"
	"time"
)

// Tham số chấm điểm. Để ở hằng số cho gọn; hồ thật nên đưa ra file cấu hình.
const (
	alpha          = 0.2              // hệ số EWMA: ~5 lần gần nhất chi phối điểm số
	failStreak     = 3                // số lỗi LIÊN TIẾP trước khi cách ly (1 là quá gắt)
	baseQuarantine = 30 * time.Second // hạn cách ly lần đầu; tái phạm thì nhân đôi
	maxQuarantine  = 30 * time.Minute // trần backoff
	stickyTTL      = 5 * time.Minute  // tuổi thọ một ràng buộc khoá đến exit
)

// ErrNoHealthy: MỌI exit đang bị cách ly. Đây là tín hiệu phải dừng và báo
// động, KHÔNG phải lỗi để retry — retry lúc này chỉ đốt nốt phần hồ còn lại.
var ErrNoHealthy = errors.New("proxypool: không còn exit nào khả dụng")

type State int

const (
	StateHealthy     State = iota // nhận đủ tải
	StateSuspect                  // vừa ra khỏi cách ly, chỉ nhận tải nhỏ
	StateQuarantined              // bị loại tạm thời
)

// Outcome là kết quả một lần dùng exit, do caller báo về.
// StatusCode = 0 nghĩa là hỏng trước khi có response (timeout, reset, TLS).
type Outcome struct {
	OK         bool
	StatusCode int
	Latency    time.Duration
	Err        error
}

// Entry là một exit. Mọi trường được bảo vệ bởi Pool.mu: một khoá cho cả hồ
// đơn giản hơn hẳn khoá theo entry, và phần việc trong vùng khoá chỉ là vài
// phép số học — không I/O, không gọi ngược ra code người dùng.
type Entry struct {
	URL *url.URL

	health     float64 // EWMA tỉ lệ thành công, trong [0,1]
	latency    float64 // EWMA độ trễ, đơn vị giây
	consecFail int
	state      State
	until      time.Time // thời điểm hết cách ly
}

type binding struct {
	e       *Entry
	expires time.Time
}

// Pool là hồ proxy, dùng chung cho toàn tiến trình, an toàn đa goroutine.
type Pool struct {
	mu      sync.Mutex
	entries []*Entry
	sticky  map[string]*binding

	// Tách ra để test tất định được, không phải để cấu hình.
	now  func() time.Time
	rand func() float64
}

// New dựng hồ từ danh sách URL dạng http://user:pass@host:port
// hoặc socks5://host:port.
func New(raw []string) (*Pool, error) {
	p := &Pool{sticky: map[string]*binding{}, now: time.Now, rand: rand.Float64}
	for _, s := range raw {
		// url.Parse chấp nhận gần như mọi chuỗi; không chặn ở đây thì lỗi cấu
		// hình chỉ lộ ra lúc dial, dưới dạng một thông báo khó truy ngược.
		u, err := url.Parse(s)
		if err != nil || u.Scheme == "" || u.Host == "" {
			return nil, fmt.Errorf("proxypool: URL proxy không hợp lệ: %q", s)
		}
		p.entries = append(p.entries, &Entry{URL: u, health: 1, latency: 0.5})
	}
	if len(p.entries) == 0 {
		return nil, errors.New("proxypool: hồ rỗng")
	}
	return p, nil
}

// Pick chọn một exit. key rỗng = xoay vòng theo từng request; key khác rỗng =
// sticky, mọi lần gọi cùng key trong stickyTTL nhận cùng một exit.
func (p *Pool) Pick(key string) (*Entry, error) {
	now := p.now()
	p.mu.Lock()
	defer p.mu.Unlock()

	if b, ok := p.sticky[key]; ok {
		// TTL cố định từ lúc tạo, KHÔNG gia hạn mỗi lần dùng: nếu gia hạn, một
		// phiên bận rộn sẽ giữ mãi một IP và vô hiệu hoá việc xoay vòng.
		if now.Before(b.expires) && p.usable(b.e, now) {
			return b.e, nil
		}
		delete(p.sticky, key)
	}

	e := p.weightedPick(now)
	if e == nil {
		return nil, ErrNoHealthy
	}
	if key != "" {
		p.sticky[key] = &binding{e: e, expires: now.Add(stickyTTL)}
	}
	return e, nil
}

// usable phải được gọi khi đang giữ p.mu — nó tự chuyển entry từ quarantined
// sang suspect khi hết hạn cách ly.
func (p *Pool) usable(e *Entry, now time.Time) bool {
	if e.state != StateQuarantined {
		return true
	}
	if now.Before(e.until) {
		return false
	}
	e.state, e.consecFail = StateSuspect, 0
	// Nâng sàn sức khoẻ để nó có cơ hội được chọn lại, nhưng vẫn thấp hơn hẳn
	// nhóm healthy — đây là cho thử việc, không phải phục chức.
	e.health = max(e.health, 0.3)
	return true
}

// weightedPick chọn ngẫu nhiên có trọng số thay vì luôn lấy exit tốt nhất.
//
// Luôn lấy tốt nhất là cái bẫy: toàn bộ lưu lượng dồn vào một IP, IP đó cháy
// trước tiên, rồi cả đàn nhảy sang IP kế tiếp và đốt tiếp. Ngẫu nhiên có trọng
// số vẫn ưu tiên exit khoẻ nhưng trải tải ra cả hồ.
func (p *Pool) weightedPick(now time.Time) *Entry {
	weights := make([]float64, len(p.entries))
	var total float64
	for i, e := range p.entries {
		if !p.usable(e, now) {
			continue
		}
		// Luỹ thừa 3 phạt rất nặng exit hay lỗi: 0.9 còn 0.73 nhưng 0.5 còn
		// 0.125. Chia cho độ trễ để exit nhanh được ưu ái ở mức tuyến tính.
		w := math.Pow(e.health, 3) / (1 + e.latency)
		if e.state == StateSuspect {
			w *= 0.2 // exit đang thử việc chỉ nhận khoảng một phần năm lưu lượng
		}
		w = max(w, 1e-9) // không bao giờ để trọng số về 0 tuyệt đối
		weights[i], total = w, total+w
	}
	if total == 0 {
		return nil
	}
	r, last := p.rand()*total, -1
	for i, w := range weights {
		if w == 0 {
			continue
		}
		last = i
		if r -= w; r <= 0 {
			return p.entries[i]
		}
	}
	return p.entries[last] // chốt chặn cho sai số dấu phẩy động
}

// blockedByStatus phân biệt "đích chặn chính exit này" với "đích đang hỏng".
// Phân biệt sai thì bạn cách ly nhầm cả hồ khi server bên kia gặp sự cố.
func blockedByStatus(code int) bool {
	switch code {
	case http.StatusProxyAuthRequired, // 407: sai credential hoặc hết quota
		http.StatusForbidden,       // 403: thường là chặn theo IP
		http.StatusTooManyRequests: // 429: exit này đã vượt hạn mức
		return true
	}
	return false
}

// Report cập nhật điểm sức khoẻ. Bắt buộc gọi cho MỌI lần Pick, kể cả khi
// thành công — hồ chỉ chính xác bằng đúng dữ liệu bạn trả về cho nó.
func (p *Pool) Report(e *Entry, o Outcome) {
	if e == nil {
		return
	}
	now := p.now()
	p.mu.Lock()
	defer p.mu.Unlock()

	if o.Latency > 0 {
		e.latency = e.latency*(1-alpha) + o.Latency.Seconds()*alpha
	}
	if o.OK {
		e.health = e.health*(1-alpha) + alpha
		e.consecFail, e.state = 0, StateHealthy
		return
	}
	e.health *= 1 - alpha
	e.consecFail++

	// Mã "chặn" cách ly ngay; lỗi mạng lẻ tẻ phải đủ chuỗi mới cách ly.
	if !blockedByStatus(o.StatusCode) && e.consecFail < failStreak {
		return
	}
	// Backoff nhân đôi theo số lần tái phạm. Một IP đã cháy thật thì thử lại
	// càng sớm càng phí request và càng khẳng định dấu vết với phía đích.
	shift := min(max(e.consecFail-failStreak, 0), 6)
	e.state = StateQuarantined
	e.until = now.Add(min(baseQuarantine<<shift, maxQuarantine))
}

Ba thứ nên đẩy ra metric ngay từ đầu: số exit theo từng trạng thái, health thấp nhất trong hồ, và số lần cách ly mỗi phút — hồ cạn dần luôn là một đường dốc chứ không phải một sự kiện (§14). Khi log URL của exit, luôn dùng URL.Redacted(): nó thay mật khẩu trong userinfo bằng chuỗi che, còn String() thì in nguyên credential ra file log.

6.8 Nối vào http.Transport — một Transport hay nhiều

http.Transport có trường Proxy func(*http.Request) (*url.URL, error). Hàm này được gọi để xác định đường đi của từng request và nhận chính request đó — nghĩa là nó đọc được r.Context(). Đó là cách sạch nhất để truyền lựa chọn exit xuống tầng dial mà không cần một Transport cho mỗi IP.

type ctxKey struct{}

// WithEntry gắn exit đã chọn vào context của request.
func WithEntry(ctx context.Context, e *Entry) context.Context {
	return context.WithValue(ctx, ctxKey{}, e)
}

// Client dựng http.Client dùng MỘT Transport cho cả hồ, chọn proxy động theo
// context của từng request.
func (p *Pool) Client(base *http.Transport) *http.Client {
	if base == nil {
		base = http.DefaultTransport.(*http.Transport)
	}
	tr := base.Clone()
	tr.Proxy = func(r *http.Request) (*url.URL, error) {
		e, ok := r.Context().Value(ctxKey{}).(*Entry)
		if !ok {
			// (nil, nil) nghĩa là đi thẳng, KHÔNG qua proxy. Nếu lộ IP thật là
			// điều tuyệt đối không được phép, hãy trả lỗi ở đây thay vì nil.
			return nil, nil
		}
		return e.URL, nil
	}
	// Cố ý không đặt Client.Timeout: timeout tổng ở Client cắt cả lúc đang đọc
	// thân response. Thời hạn đặt bằng context cho từng lần thử, xem Do().
	return &http.Client{Transport: tr}
}

Vì sao một Transport là đủ trong đa số trường hợp?

http.Transport đánh chỉ mục pool kết nối nhàn rỗi theo tổ hợp (proxy, scheme, host đích) chứ không chỉ theo host đích. Nên một Transport duy nhất với hàm Proxy động vẫn giữ kết nối tới từng exit tách biệt nhau — bạn không bị lẫn kết nối giữa các IP. Đây là mặc định nên chọn: một chỗ cấu hình timeout, một chỗ gọi CloseIdleConnections(), và bộ nhớ không phình theo kích thước hồ.

Có ba lý do chính đáng để tách Transport theo từng exit: (1) giới hạn số kết nối theo từng exitMaxIdleConnsPerHostMaxConnsPerHost tính theo host đích, không theo exit; (2) cần dial hoặc TLS khác nhau cho từng exit; (3) muốn đóng sạch mọi kết nối tới một exit ngay khi nó cháy, việc mà một Transport chung không làm chọn lọc được.

Cách làm khi cần: giữ một map[string]*http.Transport có mutex bảo vệ; mỗi lần gặp exit mới thì base.Clone() rồi gán tr.Proxy = http.ProxyURL(u) — proxy cố định cho riêng transport đó; khi exit bị cách ly thì xoá khỏi map và gọi tr.CloseIdleConnections(). Giữ kết nối tới một IP đã cháy vừa vô ích vừa tốn file descriptor.

Retry có giới hạn và context timeout

// bodyWithCancel gắn context.CancelFunc vào thân response.
//
// Đây là chỗ rất dễ sai: gọi cancel() ngay sau khi Do() trả về thì context bị
// huỷ và thân response đang mở đứt giữa chừng; không gọi bao giờ thì rò tài
// nguyên. Đúng nhất là huỷ đúng lúc caller đóng body.
type bodyWithCancel struct {
	io.ReadCloser
	cancel context.CancelFunc
}

func (b *bodyWithCancel) Close() error {
	err := b.ReadCloser.Close()
	b.cancel()
	return err
}

// Do gửi request với retry có giới hạn, mỗi lần thử một exit khác. stickyKey
// rỗng = xoay vòng mỗi lần thử. perTry là thời hạn cho MỘT lần thử; ctx là
// thời hạn cho toàn bộ chuỗi thử — hai mức, đừng gộp làm một.
func (p *Pool) Do(ctx context.Context, cli *http.Client, req *http.Request,
	stickyKey string, attempts int, perTry time.Duration) (*http.Response, error) {

	// Không có GetBody thì thân request chỉ đọc được một lần; lần thử thứ hai
	// sẽ gửi body RỖNG mà không báo lỗi gì. http.NewRequest tự đặt GetBody cho
	// *bytes.Reader, *bytes.Buffer và *strings.Reader — ngoài ra thì không.
	if attempts < 1 || (req.Body != nil && req.GetBody == nil) {
		attempts = 1
	}

	var lastErr error
	for i := 0; i < attempts; i++ {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		e, err := p.Pick(stickyKey)
		if err != nil {
			return nil, err // ErrNoHealthy: retry chỉ đốt nốt phần hồ còn lại
		}

		tryCtx, cancel := context.WithTimeout(ctx, perTry)
		r := req.Clone(WithEntry(tryCtx, e))
		if req.GetBody != nil {
			if r.Body, err = req.GetBody(); err != nil {
				cancel()
				return nil, fmt.Errorf("proxypool: không phát lại được body: %w", err)
			}
		}

		start := time.Now()
		resp, err := cli.Do(r)
		lat := time.Since(start)

		switch {
		case err != nil:
			cancel()
			p.Report(e, Outcome{Latency: lat, Err: err})
			lastErr = err
		case resp.StatusCode >= 500 || blockedByStatus(resp.StatusCode):
			// Phải đọc cạn rồi mới đóng thì kết nối mới được trả về pool để tái
			// dùng; Close() khi còn dữ liệu chưa đọc khiến Transport bỏ kết nối.
			// Chặn ở 64 KiB vì trang lỗi có thể rất lớn mà ta trả tiền theo GB.
			_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
			resp.Body.Close()
			cancel()
			p.Report(e, Outcome{StatusCode: resp.StatusCode, Latency: lat})
			lastErr = fmt.Errorf("proxypool: exit %s trả mã %d", e.URL.Host, resp.StatusCode)
		default:
			p.Report(e, Outcome{OK: true, StatusCode: resp.StatusCode, Latency: lat})
			resp.Body = &bodyWithCancel{ReadCloser: resp.Body, cancel: cancel}
			return resp, nil
		}
	}
	return nil, fmt.Errorf("proxypool: thất bại sau %d lần thử: %w", attempts, lastErr)
}

Ba điểm đáng nhớ, vì cả ba đều là lỗi im lặng:

  1. Hai mức thời hạn. ctx chặn tổng thời gian, perTry chặn từng lần thử. Chỉ có một mức thì hoặc bạn không kịp retry, hoặc ba lần timeout cộng lại thành một hàm treo gấp ba dự kiến.
  2. GetBody quyết định có được retry hay khôngreq.Clone sao chép con trỏ body chứ không sao chép dữ liệu.
  3. ErrNoHealthy không phải lỗi để retry. Nó nghĩa là hồ đã cạn; việc đúng là dừng, báo động và chờ hết hạn cách ly.

6.9 Chi phí: theo GB, theo IP hay theo request

Mô hình tính tiền Thường đi với Bẫy chi phí
Theo GB Residential, mobile Băng thông phồng vì ảnh/JS/font — bạn trả tiền cho pixel bạn không đọc
Theo IP (thuê/tháng) Datacenter, ISP static Trả tiền cả lúc rảnh; IP bị chặn vẫn tính đủ tiền tới hết kỳ
Theo request Dịch vụ trọn gói Định nghĩa "thành công" là của nhà cung cấp: 200 kèm trang captcha vẫn có thể bị tính tiền

Phép tính ví dụ cho thấy độ lớn của bẫy băng thông (số minh hoạ, không phải số đo thực tế):

1 GB = 1024 MB
Tải cả trang (HTML + ảnh + JS + font) ≈ 2 MB/trang   → 1024 / 2   ≈    512 trang/GB
Chỉ tải HTML, đã nén                  ≈ 0,2 MB/trang → 1024 / 0,2 ≈  5 120 trang/GB
                                                        chênh khoảng 10 lần

Bảy cách cắt băng thông, xếp theo hiệu quả trên mỗi đơn vị công sức:

  1. Đừng dùng headless browser nếu không cần. Trình duyệt tải toàn bộ tài nguyên phụ; nếu dữ liệu đã nằm trong HTML đầu tiên thì một request là đủ.
  2. Nếu buộc phải dùng trình duyệt, chặn ảnh, font, media và tracker ngay ở tầng request.
  3. Luôn gửi Accept-Encoding: gzip, br và để client giải nén.
  4. Conditional request: lưu ETag/Last-Modified, gửi lại If-None-Match/If-Modified-Since. Một 304 Not Modified chỉ tốn phần header — quy tắc cache ở RFC 9111.
  5. Giới hạn kích thước thân bằng io.LimitReader như trong Do(). Không giới hạn thì một file lỗi vài trăm MB đủ thổi bay hạn mức của ngày.
  6. Cache ở phía bạn. Không tải lại thứ không đổi — rẻ hơn mọi tối ưu khác.
  7. Retry là chi phí. Mỗi lần thử lại là một lần trả tiền băng thông nữa; trần retry trong Do() vừa để ổn định vừa để giữ ngân sách.

Còn một khoản không nằm trên hoá đơn: thời gian kỹ sư. Một hồ proxy là hệ thống phân tán có trạng thái, cần giám sát và người trực.

6.10 Pháp lý và đạo đức — phần không được bỏ qua

Phạm vi của tài liệu này: đây không phải hướng dẫn vượt qua CAPTCHA, hệ thống chống bot, WAF, giới hạn tốc độ hay bất kỳ biện pháp bảo vệ nào của bên khác. Nếu công việc của bạn đòi hỏi làm điều đó thì vấn đề không nằm ở kỹ thuật proxy — bên kia đã nói "không", và thứ bạn cần là sự cho phép, không phải thêm IP.

Mục này cũng không phải tư vấn pháp lý. Nó liệt kê những thứ bạn phải hỏi luật sư, không phải câu trả lời.

robots.txt và giới hạn của nó

Robots Exclusion Protocol được chuẩn hoá trong RFC 9309: một tệp văn bản ở gốc tên miền, khai báo đường dẫn nào chủ site không muốn robot truy cập.

robots.txt robots.txt không phải là
Quy ước tự nguyện, máy đọc được Cơ chế kiểm soát truy cập — nó không chặn được ai
Cách rõ ràng nhất chủ site nói "đừng vào đây" Bằng chứng pháp lý về việc bạn có quyền hay không
Ràng buộc theo tiền tố đường dẫn Ràng buộc về cách dùng dữ liệu sau khi đã lấy
Thứ bạn nên tôn trọng Giấy phép — tuân thủ nó không làm mọi việc còn lại thành hợp pháp

Chỉ thị Crawl-delay được nhiều crawler hỗ trợ theo thông lệ nhưng không phải phần cốt lõi của giao thức chuẩn hoá; đừng phụ thuộc vào việc nó tồn tại hay được hiểu giống nhau ở mọi nơi. Tự đặt trần tốc độ vẫn là việc của bạn.

Điều khoản dịch vụ, luật, và dữ liệu cá nhân

  • Điều khoản dịch vụ (ToS) là hợp đồng. Ranh giới quan trọng nhất là có đăng nhập hay không: lấy dữ liệu trên trang công khai không cần tài khoản là một chuyện; đăng nhập rồi lấy là chuyện khác hẳn, vì bạn đã chủ động chấp nhận điều khoản.
  • CFAA (Hoa Kỳ) xoay quanh khái niệm "truy cập không được phép". Càng phải vượt qua một rào cản kỹ thuật — đăng nhập, chặn IP sau khi đã bị yêu cầu dừng, thư cảnh báo — thì rủi ro càng tăng. Án lệ gần đây có phân biệt dữ liệu công khai với dữ liệu sau đăng nhập, nhưng đây là lĩnh vực đang thay đổi và khác nhau theo toà: phải hỏi luật sư, không tra blog.
  • Dữ liệu cá nhân. GDPR ở EU và Nghị định 13/2023/NĐ-CP ở Việt Nam đều điều chỉnh việc xử lý dữ liệu cá nhân, và "công khai" không đồng nghĩa với "được tự do xử lý". Tên, email, ảnh đại diện, hồ sơ mạng xã hội đều là dữ liệu cá nhân dù ai cũng xem được. Ba câu phải trả lời trước khi lưu: cơ sở pháp lý là gì, giữ trong bao lâu, xoá bằng cách nào khi được yêu cầu.

Vận hành một hồ proxy nghĩa là bạn vừa tự tháo cái phanh mà bên kia lắp cho bạn.

Rate limit của một website hầu như luôn tính theo IP. Có 10 000 IP nghĩa là hạn mức hiệu dụng của bạn nhân lên 10 000 lần, và bạn có thể làm quá tải một site nhỏ mà không nhận được một tín hiệu cảnh báo nào — vì 429 không bao giờ kịp xuất hiện. Phanh đã bị bạn tháo, nên bạn có nghĩa vụ tự lắp lại: trần tốc độ phải đặt theo tên miền đích, không theo IP nguồn.

Bảy nguyên tắc tối thiểu

  1. Chỉ lấy dữ liệu công khai. Không đăng nhập, không vượt tường phí, không đi qua biện pháp bảo vệ.
  2. Tôn trọng rate limit — theo tên miền đích. Một giới hạn toàn cục cho mỗi host, độc lập với số IP bạn có.
  3. Tự định danh bằng User-Agent thật, có tên và đường liên hệ:
User-Agent: community-crawler/1.0 (+https://example.com/bot; abuse@example.com)
  1. Tôn trọng 429Retry-After. Backoff thật — đổi IP để né 429 chính là hành vi mục này khuyên bạn không làm.
  2. Cache và conditional request. Không tải lại thứ không đổi: tiết kiệm tiền của bạn và tài nguyên của họ.
  3. Có đường liên hệ và thực sự trả lời. Chủ site yêu cầu dừng thì dừng.
  4. Ghi nhật ký đủ để trả lời "bạn đã lấy gì, lúc nào, vì sao". Không trả lời được câu đó là dấu hiệu bạn đang làm sai.

Mặt an ninh của chính hạ tầng proxy — credential rò vào log, SSRF qua proxy, exit node độc hại đọc lưu lượng của bạn — thuộc §13.

6.11 Khi nào bạn KHÔNG cần proxy network

Phần lớn nhu cầu "em cần proxy network" thực ra là một bài toán khác đội lốt. Chạy qua bảng này trước:

Tình huống Giải pháp đúng Vì sao
Bên kia có API chính thức Dùng API Rẻ hơn, ổn định hơn, có tài liệu và hợp đồng; không vỡ khi họ đổi giao diện
dataset, dump, sitemap hoặc RSS Dùng chúng Đã được chuẩn bị sẵn cho đúng mục đích này
Cần vài nghìn request/ngày Một IP, một tiến trình 3 000 request/ngày ≈ 1 request mỗi 29 giây. Không IP nào bị chặn vì tốc độ đó
Chỉ cần IP ở một quốc gia khác Một VPS đặt ở vùng đó Rẻ hơn hồ residential nhiều bậc, và IP là của bạn
Cần egress IP cố định để bên kia allowlist NAT gateway / IP tĩnh Bài toán ngược hẳn: bạn cần IP không đổi, không phải IP xoay vòng
Cần vượt chống bot/CAPTCHA Dừng lại và xin phép Không phải bài toán proxy — xem §6.10

Phép thử một câu để khỏi dựng nhầm hệ thống: bạn đang bị chặn vì khối lượng hay vì danh tính?

  • Khối lượng → giảm tốc, cache, dùng conditional request. Thêm IP chỉ giấu triệu chứng.
  • Danh tính (bên kia cố ý không muốn bạn vào) → thêm IP là đang đi vòng qua một quyết định đã rõ ràng. Đó là lúc phải dừng, không phải lúc mở ví.

Với chính dự án community, lưu lượng đi ra là chuyện nhỏ và không cần proxy network: lấy ảnh Open Graph cho link preview, gọi webhook, gọi vài API bên thứ ba. Nhu cầu thật ở đây là một egress IP cố định để đối tác allowlist được, cộng timeout và circuit breaker cho mỗi lời gọi ra ngoài — không phải một hồ IP. Kiến trúc đề xuất ở §12; phần http.Client dùng chung, timeout và giám sát ở §10§14.


7. Ẩn danh, riêng tư và dấu vân tay — phía nào nhìn thấy gì

Phần này viết từ ghế của kỹ sư vận hành, không phải của người muốn giấu mình. Lý do rất thực dụng: bạn không thể thiết kế phòng thủ cho cmd/api nếu không hiểu bên kia có những công cụ gì. Một hệ thống phòng thủ được dựng bằng cách đoán mò sẽ chặn nhầm người dùng thật và bỏ lọt đúng thứ nó định chặn.

Phép thử một câu

Nếu ai đó ghi lại toàn bộ luồng ra khỏi máy này trong một giờ, họ dựng lại được những gì?

Câu trả lời gần như không bao giờ là "không gì cả". Ẩn danh không phải một công tắc bật/tắt — nó là câu hỏi ai nhìn thấy cặp nào. Cặp quan trọng nhất là (bạn, đích đến). Mọi công cụ trong phần này chỉ làm một việc: cắt cặp đó ra làm hai nửa rồi giao mỗi nửa cho một bên khác nhau.

7.1 Năm người quan sát — mỗi người thấy một mảnh

Trước khi so sánh công cụ, phải cố định khung nhìn. Có năm vị trí quan sát, và chúng thấy những thứ khác hẳn nhau:

Người quan sát Thấy được Không thấy được
Mạng LAN / ISP của client IP nguồn thật, IP đích của chặng đầu tiên, SNI trong ClientHello (đi ở dạng rõ, trừ khi bật Encrypted ClientHello), toàn bộ truy vấn DNS nếu còn dùng DNS cổng 53 dạng rõ thay vì DoH (RFC 8484) Nội dung bên trong TLS
Chủ proxy / VPN — đường một hop IP thật của client đích cuối cùng — cả hai nửa của cặp Nội dung nếu client dùng TLS đầu-cuối tới đích
Exit node Tor — đường nhiều hop Đích cuối cùng, và nội dung nếu chặng cuối không phải TLS IP thật của client — nó chỉ thấy relay đứng ngay trước nó. Đây chính là khác biệt cấu trúc của §7.4
Server đích (cmd/api của bạn) IP của chặng cuối, toàn bộ header HTTP, dấu vân tay TLS và TCP của bên đang nói chuyện trực tiếp IP thật của client, trừ khi có ai đó nói ra
Bên thứ ba trong trang (script phân tích, quảng cáo) Dấu vân tay trình duyệt, timezone, kích thước màn hình — cộng dồn qua nhiều site Đường đi mạng của bạn; IP nó ghi được cũng chỉ là IP chặng cuối, y như origin

Điểm mấu chốt: proxy không xoá thông tin, nó chuyển quyền biết từ server đích sang chủ proxy. Bạn không trở nên vô hình, bạn chỉ đổi người nhìn thấy mình. Câu này quay lại ám ảnh phần §6: ai vận hành hồ proxy thì người đó thấy hết.

7.2 Ba mức lộ diện của forward proxy

Giới bán proxy phân loại theo mức "ẩn danh". Cách phân loại đó đo đúng một thứ: proxy có thêm header vào request hay không.

Mức Tên thường gặp Header proxy thêm vào Server đích suy ra được gì
0 transparent X-Forwarded-For: <IP thật>, Via: 1.1 …, đôi khi X-Real-IP hoặc Forwarded: for=… Biết IP thật biết có proxy
1 anonymous Via có mặt, hoặc X-Forwarded-For chứa IP của chính proxy / giá trị rác Biết có proxy, không biết IP thật ở tầng HTTP
2 elite, high-anonymity Không thêm gì Trông như một client kết nối trực tiếp ở tầng HTTP

Đây là thứ origin nhìn thấy với một proxy mức 0 cấu hình mặc định:

GET /posts/hello-world HTTP/1.1
Host: community.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 …
Accept-Language: vi-VN,vi;q=0.9
X-Forwarded-For: 113.161.10.20
Via: 1.1 proxy-node-7 (squid/6.6)
Proxy-Connection: keep-alive

Ba dòng cuối là ba lời tự khai khác nhau. X-Forwarded-For khai IP thật. Via — được định nghĩa trong RFC 9110 — khai có ít nhất một trung gian. Proxy-Connection là header phi chuẩn mà client đáng lẽ chỉ gửi cho proxy, thấy nó ở origin nghĩa là ai đó chuyển tiếp ẩu.

Bẫy thuật ngữ: "transparent" có hai nghĩa hoàn toàn khác nhau.

Trong bảng trên, transparent nghĩa là "trong suốt với server" — server nhìn xuyên qua proxy thấy IP thật.

Nhưng trong ngữ cảnh hạ tầng mạng, transparent proxy (còn gọi intercepting proxy) nghĩa là "trong suốt với client" — client không hề biết mình đang đi qua proxy, vì router bẻ luồng vào proxy mà không cần cấu hình gì ở máy client. Hai nghĩa này gần như ngược nhau. Khi đọc tài liệu, luôn hỏi: trong suốt với ai?

Và đây là giới hạn lớn nhất của cả bảng trên:

Với HTTPS, ba mức này gần như vô nghĩa.

Kết nối HTTPS qua forward proxy đi bằng CONNECT — proxy chỉ mở một đường ống TCP rồi bơm byte đã mã hoá qua lại. Nó không thể chèn X-Forwarded-For vào một request mà nó không đọc được. Nghĩa là: mọi proxy đều "elite" với HTTPS, trừ khi nó MITM TLS bằng CA riêng — mà nếu nó làm được thế thì chuyện header là mối lo nhỏ nhất của bạn.

Hệ quả thực tế: các bảng xếp hạng "anonymity level" của nhà bán proxy hầu hết được đo qua HTTP thường. Chúng nói lên rất ít về những gì phần §7.7 mô tả.

Cách server lấy IP client cho đúng khi chính bạn là người vận hành proxy — tin header nào, cắt ở đâu — thuộc về §4.

7.3 Proxy, VPN, SSH tunnel và Tor — so sánh thẳng

Kỹ thuật Phạm vi Tầng Mã hoá chặng đầu Ai thấy gì Độ trễ thêm Chống được ai
HTTP proxy (CONNECT) Từng ứng dụng, thậm chí từng request L7 Không, trừ khi proxy nghe HTTPS Proxy thấy host:port đích; ISP thấy bạn nối tới proxy +1 RTT tới proxy cho mỗi kết nối mới (gửi CONNECT, chờ 200) Server đích
SOCKS5 proxy Từng ứng dụng L5 (trên TCP) Không (RFC 1928 không có lớp mã hoá) Như trên, nhưng cả TCP tuỳ ý chứ không riêng HTTP +2 RTT: chào/chọn method rồi mới tới lệnh CONNECT — thành +3 nếu bật xác thực user/pass của RFC 1929 Server đích
SSH tunnel (-D, -L) Từng ứng dụng -D dựng SOCKS5 cục bộ (L5), -L là port forward (L4) — cả hai chạy trong kênh SSH Có, SSH mã hoá tới máy chủ SSH Chủ máy chủ SSH thấy đích; ISP chỉ thấy một phiên SSH +RTT tới máy chủ, cộng head-of-line blocking vì mọi kênh ghép lên một kết nối TCP ISP, mạng LAN, server đích
VPN (WireGuard/OpenVPN) Toàn hệ điều hành L3 Nhà cung cấp VPN thấy toàn bộ; ISP chỉ thấy một luồng tới VPN + đường vòng qua máy chủ VPN cho mọi gói, không riêng lúc bắt tay ISP, mạng LAN, server đích
Tor Từng ứng dụng (qua SOCKS) hoặc cả máy L5 trên mạch nhiều hop Có, nhiều lớp lồng nhau Không relay đơn lẻ nào thấy cả hai đầu Bậc hàng trăm mili-giây trở lên, tuỳ mạch ISP, server đích, và từng relay riêng lẻ

Ba khác biệt đáng nhớ hơn cả bảng:

  1. Phạm vi. VPN nằm ở tầng IP nên nó tóm mọi thứ máy bạn gửi ra: trình duyệt, ứng dụng chat, dịch vụ cập nhật, đồng hồ NTP. Proxy chỉ tóm những ứng dụng được cấu hình trỏ vào nó. Đây là nguồn gốc của gần hết các vụ rò rỉ ở §7.5.
  2. Mã hoá chặng đầu. HTTP proxy và SOCKS5 thuần không mã hoá gì cả. Nếu bạn dùng chúng trên Wi-Fi công cộng để "an toàn hơn", bạn nhầm — thứ bảo vệ bạn ở đó là HTTPS đầu-cuối, không phải proxy.
  3. Mô hình tin cậy. Proxy và VPN chỉ dời điểm tin cậy. SSH tunnel khá hơn vì máy chủ SSH thường là máy của chính bạn. Tor là công cụ duy nhất trong bảng được thiết kế để bạn không phải tin bất kỳ một nút nào.

7.4 Tor ba hop — vì sao không nút nào biết cả hai đầu

   Client                Guard              Middle               Exit            Đích
     │                     │                   │                  │               │
     ├── lớp 1+2+3 ───────►│                   │                  │               │
     │                     ├── lớp 2+3 ───────►│                  │               │
     │                     │                   ├── lớp 3 ────────►│               │
     │                     │                   │                  ├── plaintext ─►│
     │                     │                   │                  │  (hoặc TLS)   │
  biết đích           biết IP thật        không biết          biết đích
  và IP thật          KHÔNG biết đích     cả hai đầu          KHÔNG biết IP thật

Client thoả thuận một khoá riêng với từng relay rồi bọc dữ liệu thành nhiều lớp: lớp ngoài cùng chỉ guard mở được, mở ra thì thấy "chuyển cho middle" chứ không thấy gì hơn. Middle mở lớp của mình, thấy "chuyển cho exit". Exit mở lớp cuối và thấy đích thật — nhưng nó nhận gói từ middle, nên nó không biết ai là người gửi đầu tiên.

Bốn tính chất đi kèm mà kỹ sư phòng thủ cần nhớ:

  • Exit thấy nội dung nếu bạn không dùng TLS. Tor bảo vệ ai nói với ai, không bảo vệ nói gì ở chặng cuối. Với https:// thì exit chỉ thấy tên miền qua bắt tay TLS.
  • Danh sách exit node là công khai. Chính mạng Tor công bố nó, vì client cần dữ liệu này để dựng mạch. Đây là lý do vì sao phía server nhận diện Tor chính xác gần như tuyệt đối — khác hẳn với việc đoán VPN. Xem §7.9.
  • Tor chuyển tiếp TCP, không chuyển tiếp UDP. Ứng dụng nào cần UDP — kể cả DNS thô — hoặc phải đi qua giao diện SOCKS của Tor, hoặc rò ra ngoài.
  • Mô hình này thua trước kẻ quan sát toàn cục: ai thấy được cả lưu lượng vào guard lẫn lưu lượng ra exit có thể khớp chúng theo thời điểm và khối lượng. Số hop không cứu được chuyện đó.

7.5 Rò rỉ ngoài luồng — thứ thật sự phá vỡ ẩn danh

Gần như không ai bị lộ vì proxy hỏng. Người ta bị lộ vì một thứ khác trên cùng cái máy đó không đi qua proxy.

                    ┌──────────────── máy client ────────────────┐
                    │                                            │
   Trình duyệt ─────┼──► SOCKS5 ─────────────────────► proxy ────┼──► Đích ✅
                    │                                            │
   DNS resolver ────┼───────────────────────────────────────────►┼──► ISP  ❌
   WebRTC (UDP) ────┼───────────────────────────────────────────►┼──► STUN ❌
   Kết nối IPv6 ────┼───────────────────────────────────────────►┼──► Đích ❌
   NTP / telemetry ─┼───────────────────────────────────────────►┼──► Hãng ❌
                    │                                            │
                    └────────────────────────────────────────────┘

DNS leak. Đây là chỗ khác biệt giữa các giao thức trở nên quan trọng thật:

  • SOCKS5 (RFC 1928) định nghĩa ba kiểu địa chỉ đích trong trường ATYP: IPv4 (0x01), tên miền (0x03) và IPv6 (0x04). Khi client chọn kiểu 0x03, nó gửi thẳng chuỗi example.com cho proxy và proxy mới là bên chạy DNS — không truy vấn DNS nào rời máy bạn. Nhưng để ý cách nói: giao thức cho phép, chứ không bắt buộc. Client hoàn toàn có thể tự phân giải rồi gửi 0x01, và đó chính là cái bẫy ở phần còn lại của mục này.
  • SOCKS4 chỉ có trường địa chỉ IPv4 4 byte. Client buộc phải tự phân giải trước khi kết nối. Rò rỉ ở đây không phải lỗi cấu hình — nó nằm trong thiết kế giao thức. (Bản mở rộng SOCKS4a về sau bổ sung hostname, nhưng nó là mở rộng phi chuẩn, đừng trông cậy.)
  • Trình duyệt có công tắc riêng cho chuyện này. Firefox có tuỳ chọn bật DNS từ xa cho SOCKS (network.proxy.socks_remote_dns) và API extension proxy của nó có cờ proxyDNS. Không có công tắc đó thì SOCKS5 vẫn rò DNS — giao thức hỗ trợ, nhưng phần mềm phải chọn dùng.

Và đây là cái bẫy làm nhiều người sai — hai công cụ phổ biến mặc định ngược nhau:

# curl: hai scheme này KHÁC nhau.
#   socks5://  -> curl tự phân giải tên miền ở máy mình rồi mới đưa IP cho proxy  (rò DNS)
#   socks5h:// -> curl giao nguyên tên miền cho proxy, proxy phân giải             (không rò)
curl -x socks5h://127.0.0.1:1080 https://example.com/
package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"time"
)

// leaky minh hoạ CÁCH SAI: tự phân giải tên miền ở máy mình rồi mới đưa IP
// cho proxy. Truy vấn DNS đó đi thẳng ra resolver của hệ điều hành —
// nằm ngoài đường hầm, nên ISP vẫn thấy bạn đang hỏi về tên miền nào.
func leaky(host string) ([]string, error) {
	return net.LookupHost(host)
}

// safe giữ nguyên tên miền trong URL và giao việc phân giải cho proxy.
func safe() error {
	// Tài liệu của net/http nói rõ: "socks5" được xử lý y như "socks5h".
	// Nghĩa là ngược với curl — thư viện chuẩn của Go LUÔN gửi nguyên tên
	// miền cho proxy dưới dạng địa chỉ FQDN của SOCKS5. Đây là mặc định an
	// toàn, nhưng đừng suy ra rằng mọi client đều thế.
	proxyURL, err := url.Parse("socks5://127.0.0.1:1080")
	if err != nil {
		return err
	}

	tr := &http.Transport{
		Proxy: http.ProxyURL(proxyURL),

		// Dễ hiểu sai: ForceAttemptHTTP2 CHỈ có tác dụng khi ta tự đặt Dial/
		// DialContext/DialTLS hoặc TLSClientConfig — mấy trường đó mới là thứ
		// âm thầm tắt HTTP/2. Chỉ đặt Proxy thì h2 vốn đã bật, dòng này là
		// no-op; giữ lại để mai kia ai thêm TLSClientConfig thì ALPN — và dấu
		// vân tay phía server ở §7.7 — không đổi lén.
		ForceAttemptHTTP2: true,

		TLSHandshakeTimeout: 10 * time.Second,
	}
	client := &http.Client{Transport: tr, Timeout: 30 * time.Second}

	resp, err := client.Get("https://example.com/")
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return err
	}
	fmt.Println(resp.Proto, resp.StatusCode, len(body))
	return nil
}

func main() {
	if _, err := leaky("example.com"); err != nil {
		fmt.Println("lookup:", err)
	}
	if err := safe(); err != nil {
		fmt.Println("request:", err)
	}
}

WebRTC leak. WebRTC cần biết mọi địa chỉ mà máy có thể nhận kết nối, nên nó thu thập "candidate": địa chỉ nội bộ trên từng card mạng, cộng với địa chỉ công cộng học được từ máy chủ STUN. Việc thu thập này chạy trên UDP, mà proxy HTTP/SOCKS thông thường không chuyển tiếp UDP. Kết quả: trang web dùng vài dòng JavaScript là lấy được IP thật của bạn trong khi tab bên cạnh vẫn "đi qua proxy". Chặn được bằng cách tắt hẳn tính năng ở trình duyệt (Firefox có media.peerconnection.enabled) hoặc dùng VPN L3 để cả UDP cũng nằm trong đường hầm.

IPv6 leak. Proxy chỉ nói IPv4, còn máy bạn có IPv6 hoạt động. Đích là dual-stack. Thuật toán Happy Eyeballs (RFC 8305) khiến hệ điều hành thử cả hai họ địa chỉ và thường ưu tiên IPv6 khi nó nhanh hơn — thế là kết nối đi thẳng, bỏ qua proxy. Chỉ có hai cách xử lý sạch: hoặc đường hầm mang được cả IPv6, hoặc tắt IPv6 ở tầng hệ điều hành.

NTP, telemetry và mọi thứ ngoài trình duyệt. Cấu hình proxy trong trình duyệt chỉ ràng buộc trình duyệt. Đồng bộ giờ, kiểm tra cập nhật, dịch vụ đồng bộ đám mây, phần mềm diệt virus — tất cả tiếp tục đi thẳng, mỗi cái mang theo một định danh máy. Đây là lý do cấu trúc khiến VPN L3 mạnh hơn proxy L5 về mặt rò rỉ: nó không cần từng ứng dụng hợp tác.

7.6 Chuỗi proxy — cơ chế, cái giá, và vì sao nó ít giá trị hơn người ta tưởng

Cơ chế thì đơn giản. Với HTTP proxy là các lệnh CONNECT lồng nhau; với SOCKS là mỗi hop dial tới hop kế tiếp:

  ①  client ──CONNECT p2:3128────► p1                          p1 biết IP thật của bạn
  ②  client ──CONNECT origin:443─► p1 ──chuyển tiếp──► p2       p1 CŨNG đọc được dòng ②
  ③                                                   p2 ──TCP──► origin

Dòng ② là chỗ cần nhìn kỹ: nó là lệnh bạn gửi cho p2, nhưng nó chạy bên trong cái ống chưa mã hoáp1 vừa mở ở bước ①. Nên p1 đọc được nó nguyên văn.

Cái giá — độ trễ cộng dồn. Mỗi lệnh CONNECT lồng nhau phải đi xa thêm một hop mới có câu trả lời, nên chi phí dựng chuỗi lớn hơn nhiều so với "một RTT tổng". Giả sử ba chặng là 60 ms + 60 ms + 80 ms, cộng lại thành RTT tổng 200 ms:

Giai đoạn Số vòng Thời gian
TCP tới p1 1 ~60 ms
CONNECT p2p1 phải mở TCP tới p2 rồi mới trả lời 1 ~120 ms
CONNECT origin — đi tới p2, p2 mở TCP tới origin rồi mới trả lời 1 ~200 ms
Bắt tay TLS 1.3 đầy đủ với origin (1-RTT, RFC 8446) 1 ~200 ms
Request + response đầu tiên 1 ~200 ms
Byte đầu tiên ~780 ms

Các con số này là giả định để minh hoạ cấu trúc, không phải đo đạc — nhưng cấu trúc thì luôn đúng: chi phí dựng chuỗi tăng theo bậc hai chứ không tuyến tính, vì hop thứ k phải trả tiền cho cả k chặng đứng trước. Chuỗi SOCKS5 còn đắt hơn nữa, do mỗi hop tốn 2 RTT chứ không phải 1 (xem bảng §7.3). Và chuỗi n hop nhân cả xác suất hỏng — nó chỉ sống khi mọi mắt xích sống.

Vì sao nó ít hữu ích:

  • Một mắt xích ghi log là đủ. Chuỗi chỉ mạnh bằng mắt xích trung thực nhất. Không có gì bảo đảm các hop độc lập với nhau — mua ba proxy từ ba trang khác nhau vẫn có thể trúng cùng một nhà vận hành.
  • Không có mã hoá theo lớp. Đây là khác biệt cốt tử với Tor: như dòng ② ở sơ đồ trên, hop đầu tiên biết cả IP thật lẫn đích cuối — đúng cái cặp mà bạn định cắt đôi. Chỉ khi mọi chặng đều là HTTPS proxy hoặc SOCKS-trên-TLS thì tính chất "không ai biết cả hai đầu" mới xuất hiện, và lúc đó bạn vừa tự dựng lại Tor, dở hơn.
  • Không chống được tương quan. Khớp thời điểm và khối lượng byte không quan tâm có mấy hop.
  • Gỡ lỗi thành bất khả thi. Một 502 xuất hiện — hop nào? Bạn không có log của hop nào cả.

Kết luận thực dụng: thêm hop chỉ giúp khi các hop thuộc những vùng tài phán và những chủ sở hữu thật sự khác nhau, mỗi chặng được mã hoá riêng. Không đủ hai điều đó thì bạn chỉ mua thêm độ trễ.

7.7 Dấu vân tay theo tầng

Đây là phần khiến mọi cuộc thảo luận về "elite proxy" trở nên ngây thơ. IP chỉ là một cột trong bảng nhận dạng.

Tầng Tín hiệu Tên gọi / công cụ Proxy có che được không?
IP / TCP TTL khởi tạo, TCP window size, MSS, tập và thứ tự TCP options p0f và các bộ nhận dạng OS thụ động Proxy L5/L7: có. VPN L3: không
TLS Danh sách + thứ tự cipher suite, tập + thứ tự extension, supported groups, signature algorithms, ALPN JA3, JA4 Không, trừ khi MITM
HTTP/2 Thứ tự và giá trị tham số trong khung SETTINGS, WINDOW_UPDATE ban đầu, thứ tự pseudo-header Dấu vân tay h2 Không
HTTP/1.1 Thứ tự header, cách viết hoa, khoảng trắng Không
Trình duyệt (JS) Canvas, WebGL, danh sách font, timezone, độ phân giải, số lõi CPU, AudioContext Fingerprinting phía client Không

TCP/IP. Hệ điều hành khác nhau chọn giá trị khởi tạo khác nhau: TTL ban đầu thường là 64 trên Linux/macOS và 128 trên Windows, kèm window size và thứ tự TCP options riêng. Đây là chỗ có một nghịch lý đáng nhớ:

Proxy che dấu vân tay TCP tốt hơn VPN.

Forward proxy kết thúc kết nối TCP của bạn rồi tự mở một kết nối mới tới origin — nên origin đo được stack TCP của máy proxy, không phải của bạn. VPN L3 thì chuyển tiếp gói IP gần như nguyên vẹn, nên stack TCP của bạn lộ nguyên. Đổi lại, đường hầm để lại dấu riêng: TTL đã bị trừ thêm, và MSS thường bị clamp xuống dưới mức 1460 quen thuộc của Ethernet MTU 1500 để chừa chỗ cho header đóng gói. Một MSS lẻ như 1380 là dấu hiệu khá rõ rằng có tunnel ở giữa.

TLS. JA3 dựng chuỗi từ phiên bản TLS, danh sách cipher, danh sách extension, elliptic curve và EC point format — theo đúng thứ tự client gửi — rồi băm MD5. (MD5 ở đây không phải lựa chọn bảo mật: nó chỉ là hàm rút gọn cho một nhãn định danh, không có bí mật nào cần bảo vệ.) JA4 là thế hệ sau: giữ dạng đọc được cho người (loại giao thức, phiên bản, có SNI hay không, số lượng cipher và extension, ALPN) và sắp xếp phần danh sách để bền hơn trước các client cố tình xáo trộn thứ tự. Cả hai đo cùng một thứ: thư viện TLS bạn đang dùng, chứ không phải bạn khai mình là ai.

Vì sao mỗi thư viện HTTP tạo một dấu khác nhau? Vì ClientHello hoàn toàn do thư viện TLS quyết định: nó ưu tiên cipher nào, gửi extension nào, theo thứ tự nào, có chèn giá trị GREASE (RFC 8701) hay không. crypto/tls của Go, OpenSSL, BoringSSL của Chrome, NSS của Firefox chọn khác nhau — nên chỉ cần nhìn ClientHello là biết ai đang gọi, dù User-Agent viết gì cũng vậy.

Thư viện chuẩn của Go cho bạn xem một phần thứ đó ngay ở server:

// Package hello quan sát ClientHello để đối chiếu với lời khai ở tầng HTTP.
package hello

import (
	"crypto/sha256"
	"crypto/tls"
	"encoding/hex"
	"fmt"
	"strings"
	"sync"
)

// Store lưu dấu vân tay TLS theo địa chỉ remote, để tầng HTTP phía trên
// (nơi chỉ còn thấy *http.Request) tra ngược lại được.
type Store struct {
	mu sync.Mutex
	m  map[string]string
}

func NewStore() *Store { return &Store{m: make(map[string]string)} }

func (s *Store) put(remote, fp string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.m[remote] = fp
}

// Get tra dấu vân tay theo r.RemoteAddr.
func (s *Store) Get(remote string) (string, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	fp, ok := s.m[remote]
	return fp, ok
}

// TLSConfig trả về cấu hình TLS có gắn hook đọc ClientHello.
//
// GetConfigForClient được gọi TRƯỚC khi bắt tay hoàn tất, và là chỗ duy nhất
// trong thư viện chuẩn nhìn thấy nội dung ClientHello. Trả về nil, nil nghĩa
// là "dùng cấu hình gốc" — ta chỉ mượn nó để quan sát, không đổi hành vi.
func TLSConfig(base *tls.Config, store *Store) *tls.Config {
	cfg := base.Clone()
	cfg.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) {
		if chi.Conn != nil {
			store.put(chi.Conn.RemoteAddr().String(), summarize(chi))
		}
		return nil, nil
	}
	return cfg
}

// summarize gom các trường ClientHello mà thư viện chuẩn phơi ra thành một
// chuỗi ổn định rồi băm lại.
//
// ⚠️ Đây KHÔNG phải JA3/JA4. Thư viện chuẩn không cho ta thứ tự extension
// trong ClientHello, mà thứ tự chính là một phần quan trọng của các lược đồ đó.
// Muốn dấu vân tay đúng chuẩn thì phải đọc ClientHello ở dạng byte thô,
// thường là ở tầng L4 trước khi trao cho crypto/tls.
func summarize(chi *tls.ClientHelloInfo) string {
	parts := []string{
		joinUint16(chi.SupportedVersions),
		joinUint16(chi.CipherSuites),
		joinCurves(chi.SupportedCurves),
		strings.Join(chi.SupportedProtos, "-"), // ALPN: h2, http/1.1...
	}
	sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
	return hex.EncodeToString(sum[:8])
}

func joinUint16(v []uint16) string {
	out := make([]string, len(v))
	for i, x := range v {
		out[i] = fmt.Sprintf("%04x", x)
	}
	return strings.Join(out, ",")
}

func joinCurves(v []tls.CurveID) string {
	out := make([]string, len(v))
	for i, x := range v {
		out[i] = fmt.Sprintf("%04x", uint16(x))
	}
	return strings.Join(out, ",")
}

HTTP/2. RFC 9113 bắt buộc pseudo-header (:method, :scheme, :authority, :path) đứng trước header thường, nhưng không quy định thứ tự giữa chúng — nên mỗi client tự chọn một thứ tự và giữ nguyên mãi. Cộng với việc mỗi client gửi tập tham số SETTINGS riêng, theo thứ tự riêng, và giá trị WINDOW_UPDATE khởi tạo riêng, ta có một dấu vân tay nữa hoàn toàn độc lập với header.

HTTP/1.1. Thứ tự và cách viết hoa header cũng đặc trưng theo client. Nhưng lưu ý một giới hạn thực tế:

net/http của Go xoá mất tín hiệu này. Header được chuẩn hoá tên rồi cất vào map, nên đến tay handler thì thứ tự gốc không còn. Muốn dùng thứ tự header làm tín hiệu, bạn phải bắt ở edge — nơi terminate TLS và phân tích byte thô — chứ không phải trong cmd/api.

Trình duyệt. Canvas rendering khác nhau theo GPU và driver; danh sách font khác theo hệ điều hành và phần mềm đã cài; timezone từ JavaScript là một tín hiệu độc lập hoàn toàn với IP. Đây là tầng mà proxy không chạm tới được một chút nào.

7.8 Bộ ba IP–TLS–HTTP không khớp

Đây là điểm then chốt của cả phần này.

Một request "ẩn danh" thường tự tố cáo mình không phải vì một tín hiệu xấu, mà vì ba tín hiệu không khớp nhau. Proxy chỉ sửa được cột IP. Hai cột còn lại vẫn nói thật.

Lời khai (HTTP) Đo được ở tầng dưới Kết luận
User-Agent là Chrome mới nhất Dấu vân tay TLS trùng với crypto/tls của Go, ALPN chỉ có http/1.1 Là chương trình Go, không phải trình duyệt
User-Agent là Safari trên iPhone Đi qua VPN L3, TTL và MSS cho thấy stack Windows Hệ điều hành khai sai
Accept-Language: vi-VN IP thuộc datacenter ở châu Âu, timezone JS là UTC Ba nguồn địa lý mâu thuẫn
Header đầy đủ như trình duyệt thật Thứ tự header lệch khỏi mọi bản Chrome đã biết Header được ghép tay
User-Agent là trình duyệt Chỉ gọi endpoint JSON, không tải một asset tĩnh nào, không có Referer nội bộ Trình duyệt không hành xử như vậy

Không cột nào trong bảng này một mình đủ để kết tội — nhưng sự mâu thuẫn thì rất khó nguỵ tạo, vì muốn khớp cả ba, bên gửi phải kiểm soát cả thư viện TLS, cả tầng HTTP, cả hành vi duyệt. Đó là công sức lớn hơn nhiều so với việc thuê một IP mới.

7.9 Phía phòng thủ — làm sao BIẾT request đến từ proxy

Giờ đổi ghế. Bạn vận hành cmd/api và muốn biết một request đáng tin đến đâu.

Tín hiệu Lấy ở đâu Sức mạnh False positive điển hình
ASN / loại mạng Đối chiếu IP với dữ liệu định tuyến công khai hoặc nguồn dữ liệu thương mại; phân biệt ASN hosting với ASN ISP dân cư Cao với datacenter Crawler công cụ tìm kiếm, uptime monitor, VPN của công ty, bot xem trước link
Danh sách exit node Tor Do chính mạng Tor công bố công khai Rất cao — là dữ kiện, không phải suy đoán Người dùng hợp pháp có nhu cầu riêng tư thật
rDNS (bản ghi PTR) Tra ngược IP; tên kiểu *.compute.*, *.vps.* lộ hosting Trung bình Nhiều ISP không đặt PTR, hoặc đặt sai
Cổng mở trên IP nguồn Quét ngược cổng 1080/3128/8080 Trung bình ⚠️ Quét ngược người dùng là hành vi chủ động, nhiều nơi coi là dò quét trái phép — không nên làm mặc định
Hành vi Log của chính bạn: tần suất, thứ tự đường dẫn, thiếu cookie/Referer, tỷ lệ 404, khoảng cách thời gian đều bất thường Cao, và không né được bằng cách đổi IP Ứng dụng di động hợp lệ, script tích hợp của đối tác
Header bất thường Thiếu Accept-Language, có Via/X-Forwarded-For chạm tới origin, thứ tự header lệch Trung bình Middlebox doanh nghiệp, ứng dụng khách hợp lệ
Cụm dấu vân tay Nhiều tài khoản mới, IP khác nhau hoàn toàn, nhưng cùng một dấu vân tay TLS/h2 Cao với hồ proxy xoay vòng Người dùng chung một mẫu thiết bị phổ biến

Dòng cuối đáng được nhấn mạnh, vì nó là câu trả lời trực tiếp cho §6:

Hồ proxy xoay vòng đổi được IP, nhưng thường không đổi được thư viện TLS.

Một trăm IP dân cư khác nhau, cùng đăng ký tài khoản trong mười phút, cùng một dấu vân tay ClientHello không khớp với User-Agent đã khai — đó là tín hiệu mạnh hơn hẳn bất kỳ danh sách IP nào, và nó không mất giá trị khi hồ proxy xoay vòng.

7.10 Vì sao chặn theo IP là vũ khí cùn

Tình huống Vì sao chặn IP sai Hậu quả
CGNAT Nhà mạng di động dùng chung một IPv4 công cộng cho hàng nghìn thuê bao — rất phổ biến ở Việt Nam Chặn một người là chặn cả một thành phố nhỏ
NAT trường học, công ty, quán cà phê Cả toà nhà ra ngoài bằng vài IP Mất nguyên một nhóm người dùng
VPN công ty Nhân viên bị bắt buộc đi qua VPN, và VPN đó nằm trong datacenter Người dùng trả tiền bị đối xử như bot
IPv6 Một thuê bao được cấp cả khối địa chỉ, máy còn tự đổi địa chỉ tạm định kỳ Chặn /128 là đuổi bắt vô nghĩa
Residential proxy Chính là IP của ISP dân cư thật Kiểm tra ASN không phát hiện được
Hồ xoay vòng Chi phí một IP mới của kẻ tấn công gần như bằng không Bạn tốn công, họ tốn vài xu

Bất đối xứng chi phí là điểm quyết định. Mỗi lần bạn chặn một IP, đối phương trả một khoản rất nhỏ để đổi IP, còn bạn trả bằng người dùng thật bị chặn nhầm — và người dùng bị chặn nhầm thường không khiếu nại, họ chỉ bỏ đi. Với một nền tảng cộng đồng như dự án này, mất một tác giả đang định viết bài đầu tiên là mất vĩnh viễn.

Vậy dùng IP thế nào cho đúng?một tín hiệu có trọng số, và là khoá gom nhóm để rate limit, chứ không phải bản án. Và luôn gắn hành động rủi ro vào tài khoản thay vì vào IP khi có thể — đăng bài, bình luận, follow trong dự án này đều đã yêu cầu đăng nhập.

7.11 Chiến lược phân tầng: quan sát → tăng ma sát → chặn

   điểm rủi ro
       │
   cao │  ╔═══════════════╗   chặn: từ chối, có thời hạn, có đường khiếu nại
       │  ╚═══════════════╝
       │  ┌───────────────┐   ma sát: rate limit chặt, captcha, chờ xác thực email,
   vừa │  └───────────────┘            hạ giới hạn upload
       │  ░░░░░░░░░░░░░░░░░   quan sát: chỉ ghi log + gắn nhãn, KHÔNG đổi hành vi
   thấp│

Thứ tự này không phải để lịch sự — nó là kỷ luật kỹ thuật. Tầng "quan sát" phải chạy trong sản xuất ít nhất vài tuần trước khi bật hai tầng trên, vì chỉ nhìn phân bố điểm thật bạn mới biết đặt ngưỡng ở đâu. Đặt ngưỡng bằng trực giác là cách chắc chắn nhất để chặn nhầm.

Đặt trong dự án này, chỗ tự nhiên là internal/platform/httpx/middleware/ — nó không chứa nghiệp vụ, đúng ranh giới của platform/ mô tả ở 02-nen-mong-platform.md.

// Package risk chấm điểm rủi ro của một request và chọn mức phản ứng.
// Nó KHÔNG tự quyết định chặn — nó gắn kết luận vào context để handler
// và middleware phía sau dùng. Tách như vậy để đổi chính sách mà không
// phải sửa chỗ thu thập tín hiệu.
package risk

import (
	"context"
	"net/http"
	"net/netip"
	"strings"
)

// Tier là mức phản ứng. Thứ tự tăng dần theo mức độ gây phiền cho người dùng.
type Tier int

const (
	TierObserve  Tier = iota // chỉ ghi log, không đổi hành vi
	TierFriction             // rate limit chặt hơn, captcha, chờ xác thực email
	TierBlock                // từ chối
)

// NetInfo là thứ ta biết về mạng chứa IP đó.
type NetInfo struct {
	ASN        uint32
	Org        string
	Datacenter bool // ASN thuộc nhóm hosting/cloud, không phải ISP dân cư
	TorExit    bool // IP nằm trong danh sách exit node Tor công bố công khai
}

// Lookup tra cứu thông tin mạng. Là interface vì NGUỒN dữ liệu sẽ đổi
// (file offline, API bên thứ ba, bảng nội bộ) còn logic chấm điểm thì không.
type Lookup interface {
	Net(ctx context.Context, ip netip.Addr) (NetInfo, bool)
}

// Verdict là kết luận cho một request.
type Verdict struct {
	Score   int
	Tier    Tier
	Reasons []string
}

type ctxKey struct{}

// FromContext lấy kết luận đã gắn vào request.
func FromContext(ctx context.Context) (Verdict, bool) {
	v, ok := ctx.Value(ctxKey{}).(Verdict)
	return v, ok
}

// Scorer gom các tín hiệu lại.
type Scorer struct {
	Lookup Lookup

	// ClientIP trả về IP client ĐÃ được tin cậy hoá ở tầng trước (§4).
	// Cố ý không tự đọc X-Forwarded-For ở đây: đó là dữ liệu do client viết,
	// chỉ dùng được sau khi đã cắt theo danh sách proxy tin cậy.
	ClientIP func(*http.Request) (netip.Addr, bool)

	// FrictionAt / BlockAt là ngưỡng. Để ở cấu hình vì ngưỡng đúng chỉ tìm
	// được bằng cách chạy ở chế độ quan sát rồi nhìn phân bố điểm thật.
	FrictionAt int
	BlockAt    int
}

// RateKey gom IP thành khoá dùng cho rate limit.
//
// Với IPv6, một thuê bao thường được cấp cả một khối địa chỉ chứ không phải
// một địa chỉ, và máy khách còn tự đổi địa chỉ tạm thời theo thời gian.
// Khoá theo từng địa chỉ /128 vì thế gần như vô dụng — gom về /64 là mức
// thực dụng: đủ hẹp để không trùng thuê bao khác, đủ rộng để không bị né.
func RateKey(ip netip.Addr) string {
	ip = ip.Unmap()
	if ip.Is6() {
		if p, err := ip.Prefix(64); err == nil {
			return p.String()
		}
	}
	return ip.String()
}

// Score chấm điểm. Mỗi tín hiệu có trọng số nhỏ và KHÔNG tín hiệu nào
// đủ để tự mình kết tội — đó là cách giảm false positive.
func (s *Scorer) Score(r *http.Request) Verdict {
	var v Verdict
	add := func(points int, reason string) {
		v.Score += points
		v.Reasons = append(v.Reasons, reason)
	}

	if s.ClientIP != nil && s.Lookup != nil {
		if ip, ok := s.ClientIP(r); ok {
			if info, found := s.Lookup.Net(r.Context(), ip); found {
				if info.Datacenter {
					// Người đọc bài viết hiếm khi ngồi trong datacenter.
					// Nhưng bot lành (crawler tìm kiếm, uptime monitor) thì có
					// — nên đây là điểm cộng, không phải bản án.
					add(30, "asn_datacenter")
				}
				if info.TorExit {
					add(25, "tor_exit")
				}
			}
		}
	}

	// Header mà client bình thường không bao giờ gửi tới ORIGIN.
	// Nếu edge của ta đã chuẩn hoá đúng thì thấy chúng ở đây là bất thường.
	for _, h := range []string{"Via", "X-Forwarded-For", "Forwarded", "Proxy-Connection"} {
		if r.Header.Get(h) != "" {
			add(10, "hop_header_"+strings.ToLower(h))
		}
	}

	ua := r.Header.Get("User-Agent")
	switch {
	case ua == "":
		add(20, "ua_empty")
	case strings.Contains(ua, "Chrome/") && r.Header.Get("Accept-Language") == "":
		// Trình duyệt thật gần như luôn gửi Accept-Language.
		add(15, "ua_browser_no_accept_language")
	}

	// Mâu thuẫn giữa lời khai và tầng vận chuyển: UA nói là trình duyệt hiện đại
	// nhưng phiên TLS lại không thoả thuận HTTP/2.
	// ⚠️ Có false positive thật: middlebox chặn TLS trong mạng doanh nghiệp
	// hay hạ xuống HTTP/1.1. Vì thế trọng số thấp.
	if r.TLS != nil && strings.Contains(ua, "Chrome/") &&
		r.TLS.NegotiatedProtocol != "h2" && r.ProtoMajor == 1 {
		add(10, "ua_h2_mismatch")
	}

	switch {
	case v.Score >= s.BlockAt:
		v.Tier = TierBlock
	case v.Score >= s.FrictionAt:
		v.Tier = TierFriction
	default:
		v.Tier = TierObserve
	}
	return v
}

// Middleware gắn Verdict vào context. Nó KHÔNG chặn — chặn là việc của
// middleware chính sách phía sau, để bật/tắt được mà không mất phần đo đạc.
func (s *Scorer) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		v := s.Score(r)
		ctx := context.WithValue(r.Context(), ctxKey{}, v)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Bốn nguyên tắc đằng sau đoạn code trên:

  1. Tách đo đạc khỏi chính sách. Middleware chỉ gắn nhãn. Việc chặn nằm ở middleware khác, bật/tắt bằng cấu hình. Nhờ vậy bạn tắt phần chặn lúc nghi có sự cố mà vẫn giữ nguyên số liệu để phân tích.
  2. Không tín hiệu nào tự mình kết tội. Trọng số nhỏ, cộng dồn. Một người dùng qua VPN công ty được +30 điểm, chưa đủ để bị gì cả — cho tới khi cộng thêm hành vi bất thường.
  3. Nguồn dữ liệu là interface. Dữ liệu ASN và danh sách Tor sẽ đổi nhà cung cấp; logic chấm điểm thì không nên đổi theo.
  4. Ghi lý do, không chỉ ghi điểm. Khi một người dùng thật khiếu nại, Reasons là thứ duy nhất giúp bạn biết mình sai ở đâu. Đây cũng là lý do phần này thuộc về §14 nhiều hơn là về bảo mật.

Về quyền riêng tư của chính người dùng bạn: IP là dữ liệu định danh cá nhân trong nhiều khung pháp lý. Log rủi ro nên có thời hạn lưu ngắn, và với mục đích thống kê thì lưu khoá đã gom (RateKey) hoặc bản băm có muối là đủ — bạn cần đếm, không cần nhận diện.

7.12 Ranh giới đạo đức

Nói thẳng để không ai hiểu nhầm mục đích của phần này:

  • Tài liệu này không hướng dẫn qua mặt hệ thống chống gian lận của bên thứ ba. Toàn bộ nội dung về dấu vân tay ở trên được viết theo chiều nhận diện, để bạn thiết kế phòng thủ cho hệ thống của mình. Chiều ngược lại — nguỵ tạo dấu vân tay để vượt kiểm soát của người khác — không nằm trong phạm vi, và ở nhiều nơi nó vi phạm điều khoản dịch vụ hoặc pháp luật.
  • Phòng thủ phải cân xứng. Một diễn đàn viết bài không cần bộ máy nhận dạng của một sàn giao dịch. Mỗi tín hiệu bạn thu thập là một khoản nợ về quyền riêng tư và một khoản nợ vận hành. Chỉ thu thập thứ bạn thật sự dùng để ra quyết định.
  • Dùng proxy hay VPN không phải bằng chứng có ý xấu. Nhà báo, người sống dưới kiểm duyệt, nhân viên bị bắt buộc đi qua VPN công ty, người đơn giản là không muốn bị theo dõi — tất cả đều là người dùng hợp lệ. Coi "có VPN" đồng nghĩa với "gian lận" là một lỗi thiết kế, không phải một chính sách nghiêm khắc.
  • Người bị chặn phải có đường ra. Chặn im lặng, không thông báo, không cách khiếu nại là cách nhanh nhất biến false positive thành mất người dùng vĩnh viễn mà bạn không bao giờ biết.
  • Đừng công bố ngưỡng của bạn. Nguyên lý thì công khai được — và nên công khai, đó là mục đích của phần này. Trọng số và ngưỡng cụ thể thì không.

8. CDN — mạng proxy ngược quy mô toàn cầu

8.1 Mô hình tinh thần: CDN là reverse proxy được nhân bản ra hàng trăm chỗ

Đừng nghĩ về CDN như một sản phẩm riêng biệt. CDN chính là reverse proxy ở §5, copy ra hàng trăm bản, đặt rải khắp thế giới, và cho tất cả cùng nhận chung một địa chỉ IP. Mọi thứ bạn đã biết về reverse proxy — nó nhận kết nối thay origin, nó đọc và sửa header, nó có upstream, nó che IP thật của backend — đều đúng nguyên vẹn. Ba thứ được thêm vào:

  1. Vị trí — bản sao nằm cách người dùng vài mili giây thay vì vài trăm.
  2. Bộ nhớ đệm dùng chung — một người ở Hà Nội tải bài viết, người thứ hai ở Hà Nội không chạm tới cmd/api nữa.
  3. Quy mô hấp thụ — băng thông tổng của mạng lớn hơn origin nhiều bậc độ lớn, nên nó chịu được thứ mà một server đơn không chịu được.

Phép thử một câu để biết bạn đã hiểu đúng CDN chưa:

Nếu bạn tắt hoàn toàn tính năng cache của CDN, nó còn làm được gì cho bạn không?

Câu trả lời là còn rất nhiều: TLS termination gần người dùng, chống DDoS, WAF, nén, gộp kết nối tới origin. Nếu bạn nghĩ tắt cache thì CDN thành vô dụng, bạn đang nhìn nó như một cái két chứa file — mô hình đó sẽ khiến bạn đặt sai header và bật CDN trước những endpoint không được phép cache.

Mặt tối phải nói ngay: CDN là một lớp trung gian mà bạn không kiểm soát được mã nguồn. Nó có cấu hình mặc định riêng, có cách chuẩn hoá header riêng, có thời điểm nó tự ý làm khác điều bạn nghĩ. Mọi lỗi ở lớp này đều khó tái hiện vì nó phụ thuộc vào việc request của bạn rơi vào PoP nào.

8.2 Kiến trúc: PoP, anycast và tiered cache

8.2.1 PoP — đơn vị vật lý

PoP (Point of Presence) là một cụm máy chủ đặt tại một điểm trao đổi lưu lượng (IXP) hoặc một trung tâm dữ liệu. Bên trong một PoP không phải một máy mà là một dàn máy có load balancer riêng — nghĩa là bản thân mỗi PoP đã là một hệ thống reverse proxy hoàn chỉnh, và cache của nó thường được băm theo URL để phân bố giữa các máy trong PoP (consistent hashing, xem §5).

Hệ quả thực tế: cùng một URL vẫn có thể MISS nhiều lần trong cùng một PoP. Khi số máy trong PoP đổi — thêm máy, một máy chết, một máy được rút ra để vá — vành băm bị xáo và một phần key nhảy sang máy chưa có bản sao. Consistent hashing chỉ giới hạn phần bị xáo chứ không xoá được nó. Đó là tầng nhân đôi request thứ nhất; tầng thứ hai — mỗi PoP đi hỏi origin một cách độc lập — là thứ mà phần 8.2.3 giải quyết.

8.2.2 Anycast BGP — một IP, nhiều nơi trả lời

Với unicast thông thường, một địa chỉ IP tương ứng với một vị trí. Với anycast, cùng một tiền tố địa chỉ được nhiều PoP cùng quảng bá ra Internet qua BGP. Router trên đường đi thấy nhiều tuyến tới cùng đích và chọn tuyến "tốt nhất" theo thuật toán chọn đường của BGP — local_preference do chính nhà mạng đặt được xét trước, độ dài AS path chỉ là bước sau đó. Kết quả: gói tin của bạn tự động rơi vào PoP gần nhất theo tô-pô định tuyến — thứ không nhất thiết trùng với gần nhất theo địa lý — và không cần DNS trả về IP khác nhau cho từng vùng.

                203.0.113.10   ← chỉ MỘT địa chỉ IP, công bố ở mọi nơi
                     │
    ┌───────────┬────┴─────┬────────────┬──────────────┐
    │           │          │            │              │
 PoP Hà Nội  PoP Sing.  PoP Tokyo   PoP Frankfurt  PoP São Paulo
    ▲           ▲
    │           │           BGP quyết định mũi tên này,
 người dùng  người dùng     không phải DNS, không phải ứng dụng
   ở VN        ở SG

Câu hỏi ai cũng hỏi: nếu BGP đổi tuyến giữa chừng, kết nối TCP có đứt không?

Có — và đó chính là rủi ro thật của anycast. TCP là giao thức có trạng thái: PoP A giữ số thứ tự, cửa sổ, khoá phiên TLS của kết nối. Nếu giữa chừng gói tin bắt đầu rơi vào PoP B, PoP B không có trạng thái đó và sẽ trả RST. Vậy tại sao anycast vẫn dùng được cho HTTPS?

Vì bảng định tuyến BGP ổn định trên thang thời gian dài hơn nhiều so với đời của một kết nối HTTP.

Một kết nối HTTP điển hình sống vài giây tới vài phút. Tuyến BGP tới một tiền tố thường giữ nguyên hàng giờ tới hàng ngày. Xác suất một sự kiện đổi tuyến rơi đúng vào khoảng sống của kết nối là nhỏ, và khi nó xảy ra thì trình duyệt chỉ việc kết nối lại. Nhà cung cấp CDN còn giảm rủi ro tiếp bằng cách hạn chế thay đổi quảng bá BGP trong giờ cao điểm và rút PoP ra khỏi mạng theo từng bước ("drain") thay vì tắt đột ngột.

Hai hệ quả kỹ thuật đáng nhớ:

  • Anycast không phải là load balancing. Bạn không điều khiển được tỉ lệ lưu lượng vào từng PoP; BGP điều khiển. Một PoP quá tải phải được rút quảng bá thì lưu lượng mới dịch đi chỗ khác.
  • Địa chỉ IP mà origin của bạn nhìn thấy là IP của PoP, không phải IP người dùng. Đây là nguồn gốc của toàn bộ vấn đề danh tính client ở §4 — nói lại ở bẫy số 7, mục 8.11.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.