Proxy Network — Mạng Proxy toàn tập Phần 6
Lắp vào một service nội bộ:
// cmd/search/main.go (phác thảo phần mạng)
package main
import (
"log/slog"
"net/http"
"time"
"github.com/chuongtd/community/internal/platform/mtls"
)
func main() {
cfg, err := mtls.ServerConfig("certs/search.crt", "certs/search.key", "certs/internal-ca.crt")
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /internal/reindex", func(w http.ResponseWriter, r *http.Request) {
// mTLS trả lời "AI đang gọi", KHÔNG trả lời "người đó được làm gì".
// Phân quyền vẫn phải viết tay — đây là danh sách trắng theo danh tính.
caller, ok := mtls.CallerID(r)
if !ok || caller != "spiffe://community.internal/svc/api" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
slog.Info("nhận lệnh reindex", "caller", caller)
w.WriteHeader(http.StatusAccepted)
})
srv := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: cfg,
// Thiếu dòng này thì một kết nối mở rồi im lặng giữ tài nguyên mãi mãi
// — cách tấn công rẻ nhất nhắm vào một HTTP server Go.
ReadHeaderTimeout: 5 * time.Second,
}
// Hai chuỗi rỗng vì chứng chỉ đã nằm trong TLSConfig.Certificates; truyền
// tên file ở đây thì chúng GHI ĐÈ cấu hình phía trên.
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
slog.Error("server dừng", "err", err)
}
}
Và phía cmd/api gọi sang, dùng lại NewInternalClient ở §9.6:
// internal/modules/post/searchclient/client.go
package searchclient
import (
"net/http"
"time"
"github.com/chuongtd/community/internal/platform/httpx"
"github.com/chuongtd/community/internal/platform/mtls"
)
// New được gọi MỘT LẦN lúc khởi động rồi dùng lại client trả về. Tạo http.Client
// mới cho mỗi request là vứt bỏ connection pool, và mỗi lời gọi phải trả lại giá
// bắt tay TLS — đắt hơn nhiều lần chính lời gọi đó.
func New() (*http.Client, error) {
tlsCfg, err := mtls.ClientConfig(
"certs/api.crt", "certs/api.key", "certs/internal-ca.crt",
"search.internal", // phải khớp SAN trong chứng chỉ của search
)
if err != nil {
return nil, err
}
return httpx.NewInternalClient(tlsCfg, 5*time.Second), nil
}
Ba điều phải nhớ khi tự làm mTLS
1. Xoay chứng chỉ mà không restart. tls.Config được đọc mỗi lần bắt tay, nhưng các trường trong nó thì không tự cập nhật. Muốn thay chứng chỉ nóng, dùng callback thay vì trường tĩnh: GetCertificate (phía server) và GetClientCertificate (phía client) được gọi ở từng lần bắt tay, nên chỉ cần trả về bản mới nhất đang giữ trong bộ nhớ. Lưu ý hai callback đó chỉ đổi được chứng chỉ; muốn đổi cả ClientCAs (khi CA nội bộ xoay) thì phải dùng GetConfigForClient để trả về nguyên một *tls.Config mới.
2. mTLS là xác thực, không phải phân quyền. Nó chứng minh "bên kia đúng là svc/api". Nó không nói gì về việc svc/api được gọi endpoint nào. Danh sách trắng theo SPIFFE ID như trong code trên là phần bạn vẫn phải viết — và cũng là phần mesh làm hộ khi bạn có mesh.
3. Đừng dựng CA nội bộ khi chưa cần. Một CA nội bộ là một khoá riêng mà nếu lộ thì mọi danh tính trong hệ thống bị giả mạo được. Nếu chỉ có một đường cần bảo vệ, chứng chỉ ngắn hạn cấp bằng script trong CI, cộng với ranh giới mạng chặt, thường là đủ và ít bề mặt tấn công hơn. Phần rủi ro của việc tự vận hành hạ tầng khoá thuộc §13.
9.8 Kết luận cho dự án community
| Giai đoạn | Đặc điểm | Nên dùng |
|---|---|---|
| Hiện tại — 3 binary, giao tiếp qua Kafka + Postgres | Không có lưu lượng east-west HTTP | Reverse proxy ở bìa (§5). Không gateway, không mesh |
| Có client bên thứ ba / app di động | Cần API key, quota, versioning, portal | Thêm API gateway ở bìa — giữ nguyên middleware.Auth trong app |
Tách service đầu tiên (ARCHITECTURE.md §15.2) |
Xuất hiện một hai lời gọi nội bộ | mTLS thủ công (§9.7) + NewInternalClient. Vẫn chưa cần mesh |
| Hàng chục service, nhiều đội, nhiều ngôn ngữ | Đồ thị phụ thuộc không ai vẽ nổi từ trí nhớ | Lúc này mới cân nhắc mesh — và cân nhắc ambient trước sidecar |
Câu chốt của cả mục này: proxy đi vào trong hệ thống là một quyết định vận hành, không phải một nâng cấp kiến trúc. API gateway trả lãi sớm vì nó giải một bài toán bạn thật sự có — người lạ gọi API của bạn. Service mesh trả lãi muộn vì nó giải một bài toán mà kiến trúc event-driven này đã cố tình tránh từ đầu — một mạng nhện lời gọi đồng bộ giữa các service. Cấu hình cụ thể cho Envoy, Traefik và các gateway nằm ở §11; cách ghép chúng vào sơ đồ triển khai của dự án nằm ở §12.
10. Viết proxy bằng Go — từ 20 dòng tới bản dùng được thật
Go là một trong số ít ngôn ngữ mà thư viện chuẩn cho gần đủ đồ để viết proxy: net/http/httputil có sẵn reverse proxy, net/http có Hijacker để làm tunnel, net.Dialer có DialContext. Chính vì dễ nên có một cái bẫy đặc trưng: bản 20 dòng chạy được ngay trên máy bạn, và cũng chính bản đó gây sự cố production sáu tháng sau.
chiều VÀO Client ─► http.Server ─► middleware ─► ReverseProxy ─► Transport ─► cmd/api
§10.4 §10.7 §10.2 §10.3
chiều RA Client ─► forward proxy có CONNECT (§10.5) ──────────────────────► Internet
worker ─► http.Client + Transport.Proxy (§10.6) ─────────────────► API bên thứ 3
Lẫn hai chiều này là nguồn của rất nhiều nhầm lẫn; phân loại đầy đủ ở §2.
10.1 Bản 20 dòng — và vì sao đừng đưa nó lên production
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
)
func main() {
target, err := url.Parse("http://127.0.0.1:8000")
if err != nil {
log.Fatal(err)
}
// Chạy được. Đây chính xác là vấn đề — nó chạy được nên không ai xem lại.
log.Fatal(http.ListenAndServe(":8080", httputil.NewSingleHostReverseProxy(target)))
}
Bản này thật sự hoạt động. Nhưng nó thiếu sáu thứ, và không thứ nào báo lỗi lúc chạy — chúng chỉ lộ ra khi có tải thật hoặc khi bị tấn công.
| Thiếu gì | Hậu quả cụ thể |
|---|---|
Không set X-Forwarded-Proto / -Host |
Backend tưởng request là http và không biết tên miền công khai → redirect http:// sau khi TLS đã terminate ở proxy (vòng lặp redirect), URL tuyệt đối sai trong email và canonical link |
Giữ nguyên Host của client |
Upstream nhận Host: proxy.example.com chứ không phải host của chính nó. Với vhost là 404 |
X-Forwarded-For bị nối thêm vào giá trị client gửi |
Client bịa X-Forwarded-For: 1.2.3.4 thì giá trị bịa nằm ở đầu chuỗi — đúng nơi hầu hết code đi lấy "IP thật" (§4) |
Transport = nil → http.DefaultTransport |
MaxIdleConnsPerHost mặc định là 2 (§10.3) |
ErrorHandler = nil |
Mọi lỗi — upstream chết, DNS hỏng, client đóng tab — đều thành 502 trần, không body, cùng một dòng log |
http.ListenAndServe |
http.Server mặc định không có timeout nào (§10.4) |
Còn một bẫy ít ai để ý: path joining. NewSingleHostReverseProxy ghép path của target với path của request. Mount proxy dưới prefix /api mà quên http.StripPrefix, trong khi target cũng là http://api:8000/api, bạn được /api/api/v1/posts. Lỗi này im lặng — upstream trả 404, còn log proxy sạch bong vì với nó việc chuyển tiếp đã thành công.
Vậy
NewSingleHostReverseProxyđể làm gì?Để thử nhanh lúc dev, và để đọc source cho hiểu
ReverseProxylàm gì bên trong. Nó là hàm tiện lợi viết từ trước khi trườngRewritetồn tại. Đừng coi nó là "cách chính thức".
10.2 Bản dùng được: ReverseProxy với Rewrite
10.2.1 Vì sao Rewrite an toàn hơn Director
Từ Go 1.20, httputil.ReverseProxy có trường Rewrite thay cho Director. Không phải đổi tên cho đẹp — hai cái có mô hình an toàn khác hẳn nhau.
Director là func(*http.Request): nó nhận một request — bản sao của request vào, và đó cũng là request sẽ đi ra. Bạn sửa tại chỗ. Hệ quả: mọi thứ client gửi mà bạn không chủ động xoá đều tự động chảy sang upstream.
Rewrite là func(*httputil.ProxyRequest), và ProxyRequest có hai trường tách bạch: In (request client gửi, chỉ đọc) và Out (request sẽ gửi đi). Bạn phải chủ động chép thứ mình muốn từ In sang Out. Quan trọng hơn: trước khi gọi Rewrite, ReverseProxy xoá bốn header forwarding khỏi Out — Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto. Mặc định của Rewrite là không tin gì cả; mặc định của Director là tin hết rồi nối thêm. Đặt cả hai trường cùng lúc là lỗi: ReverseProxy yêu cầu đúng một trong hai, ngược lại nó gọi thẳng ErrorHandler.
Director (API cũ) |
Rewrite (Go 1.20+) |
|
|---|---|---|
| Mô hình | Một request, sửa tại chỗ | In chỉ đọc / Out để ghi |
| Header client gửi | Chảy sang upstream trừ khi bạn xoá | Bốn header forwarding bị xoá sẵn |
X-Forwarded-For |
Nối IP client vào giá trị client gửi | SetXForwarded() đặt lại bằng đúng IP client |
X-Forwarded-Proto / -Host |
❌ phải tự viết, rất hay quên | ✅ SetXForwarded() làm luôn |
Host gửi đi |
Giữ nguyên của client | SetURL() đặt theo target (muốn giữ: r.Out.Host = r.In.Host) |
Vì sao "nối thêm" lại nguy hiểm đến thế?
X-Forwarded-Forđược đọc từ trái sang; phần tử đầu tiên theo quy ước là client gốc. VớiDirector, kẻ tấn công gửiX-Forwarded-For: 8.8.8.8thì upstream nhận8.8.8.8, <IP thật>. Code kiểustrings.Split(xff, ",")[0]— mẫu phổ biến nhất trên Internet — trả về8.8.8.8. Rate limit theo IP, audit log, geo-blocking, ban user: tất cả bị lái. Và bạn không thấy dấu vết gì, vì log cũng ghi cái IP giả đó.
10.2.2 Code
Đặt tại internal/platform/gateway/proxy.go — package này copy sang dự án Go bất kỳ vẫn chạy, nên theo phép thử của 02-nen-mong-platform.md nó thuộc platform/.
// Package gateway dựng reverse proxy đặt trước cmd/api. Không chứa nghiệp vụ.
package gateway
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"time"
)
// preserveHost = true khi upstream dùng Host để phân biệt vhost hoặc sinh URL tuyệt đối.
func New(target *url.URL, tr http.RoundTripper, log *slog.Logger, preserveHost bool) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
// SetURL ghép scheme/host/path của target vào Out và đặt Host đi ra theo
// target. Nó ghép path mà giữ nguyên phần đã escape — thứ rất dễ làm sai
// nếu tự nối chuỗi.
r.SetURL(target)
// Tới đây Out KHÔNG còn X-Forwarded-* nào của client: ReverseProxy đã xoá
// trước khi gọi Rewrite. Nên đây là giá trị duy nhất upstream nhìn thấy.
r.SetXForwarded()
if preserveHost {
r.Out.Host = r.In.Host
}
r.Out.Header.Set("X-Gateway", "community") // chặn ai đó gọi thẳng cmd/api
},
Transport: tr,
FlushInterval: -1, // âm = flush ngay sau mỗi lần ghi (xem ghi chú dưới)
BufferPool: newBufferPool(32 * 1024),
// ErrorLog là *log.Logger chứ không phải *slog.Logger. NewLogLogger bắc cầu để
// log nội bộ của ReverseProxy đi chung đường với log ứng dụng — nếu không,
// chúng ra thẳng stderr và biến mất khỏi hệ thu thập log.
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelError),
ModifyResponse: func(res *http.Response) error {
res.Header.Del("Server") // không để upstream rò rỉ phiên bản framework
res.Header.Del("X-Powered-By")
if res.StatusCode >= 500 {
// Chỉ đếm 5xx ở upstream thì mất góc nhìn "người dùng thấy gì".
log.Warn("upstream trả lỗi", "status", res.StatusCode)
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
// Client đóng tab KHÔNG phải sự cố hệ thống. Gộp chung với lỗi upstream
// thì dashboard lúc nào cũng đỏ và không ai còn tin nó nữa.
if errors.Is(err, context.Canceled) {
log.Debug("client huỷ request", "path", r.URL.Path)
return
}
// 504 khác 502 về vận hành: 504 = "upstream còn sống nhưng chậm",
// 502 = "không kết nối được". Hai nguyên nhân, hai cách xử lý.
status := http.StatusBadGateway
var nerr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &nerr) && nerr.Timeout()) {
status = http.StatusGatewayTimeout
}
log.Error("proxy lỗi", "err", err, "status", status, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":"upstream_unavailable","status":%d}`, status)
},
}
}
// bufferPool tái dùng buffer cho vòng copy thân phản hồi. Không có nó, mỗi request cấp
// phát một buffer 32 KiB — toàn rác cho GC, vì buffer nào cũng như buffer nào.
type bufferPool struct{ pool sync.Pool }
func newBufferPool(size int) *bufferPool {
// Giữ *[]byte chứ không phải []byte: Put([]byte) làm slice header thoát ra heap
// mỗi lần gọi, mất đúng thứ đang cố tiết kiệm.
return &bufferPool{pool: sync.Pool{New: func() any { b := make([]byte, size); return &b }}}
}
func (p *bufferPool) Get() []byte { return *(p.pool.Get().(*[]byte)) }
func (p *bufferPool) Put(b []byte) { p.pool.Put(&b) }
var _ = time.Second // import time dùng ở NewTransport/NewServer cùng package
ModifyResponsetrả về error →ErrorHandlerđược gọi, vàReverseProxyđóng body upstream giúp bạn. Đây là chỗ đúng để chuẩn hoá lỗi upstream hoặc chèn header cache; đừng đọc hếtres.Bodyở đây trừ khi chấp nhận mất streaming.FlushInterval = -1nghĩa là flush ngay sau mỗi lần ghi. NhưngReverseProxyđã tự chọn chế độ flush-ngay cho phản hồi cóContent-Type: text/event-streamvà cho phản hồi không biết trướcContent-Length. Đặt-1là để phòng các kiểu streaming khác (NDJSON, gRPC-Web) — không phải để "sửa" SSE.- Nâng cấp giao thức (WebSocket) chạy sẵn: upstream trả
101 Switching ProtocolsthìReverseProxytự chuyển sang tunnel hai chiều — nhưngWriteTimeoutở §10.4 vẫn cắt kết nối đó. Header hop-by-hop (Connection,Keep-Alive,Proxy-Authorization,Transfer-Encoding,Upgrade…) bị loại bỏ theo đúng ngữ nghĩa HTTP (§3); đừng dùng những tên đó để mang dữ liệu riêng.
10.3 Transport tuỳ chỉnh — nơi hiệu năng thật sự nằm
Để Transport = nil thì ReverseProxy dùng http.DefaultTransport: nó dùng chung cho cả tiến trình (hồ kết nối tới upstream phải chia với mọi lệnh gọi API bên ngoài), và được cấu hình cho client dùng chung chứ không cho proxy.
Cái bẫy hiệu năng số một là MaxIdleConnsPerHost, mặc định 2. Reverse proxy chỉ nói chuyện với một host. Dù MaxIdleConns có là 100, chỉ 2 kết nối được giữ khi rảnh; kết nối thứ 3 trở đi bị đóng ngay sau mỗi request và phải bắt tay TCP (+TLS) lại từ đầu. Triệu chứng dễ chẩn đoán nhầm: p99 cao và răng cưa, TIME_WAIT chất đống trên máy proxy, CPU upstream bình thường — bạn đi tối ưu query SQL trong khi lỗi nằm ở một dòng cấu hình.
func NewTransport() *http.Transport {
return &http.Transport{
DialContext: (&net.Dialer{
// Upstream cùng mạng nội bộ: không nối được trong 3s nghĩa là nó chết,
// chờ thêm chỉ dồn request lại. KeepAlive phát hiện kết nối chết âm thầm.
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
// Đặt DialContext thủ công sẽ TẮT HTTP/2 nếu không bật lại cờ này. Hành vi bảo
// thủ có chủ ý của net/http, và là chỗ rất hay vấp: thêm một dòng DialContext
// rồi mất HTTP/2 mà không có cảnh báo nào.
ForceAttemptHTTP2: true,
MaxIdleConns: 256, // tổng, cho mọi host
MaxIdleConnsPerHost: 64, // ★ với proxy một-upstream, đây mới là con số có tác dụng
MaxConnsPerHost: 128, // trần cứng: chặn proxy tự DDoS upstream khi bị dồn tải
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// Chỉ giới hạn thời gian CHỜ HEADER — không đụng thân phản hồi, nên vẫn stream
// và tải file lớn được.
ResponseHeaderTimeout: 10 * time.Second,
MaxResponseHeaderBytes: 64 << 10, // upstream lỗi có thể trả header khổng lồ
}
}
| Tham số | Để mặc định thì sao |
|---|---|
MaxIdleConnsPerHost |
2. Kết nối đóng/mở liên tục, p99 xấu, TIME_WAIT chất đống |
MaxIdleConns |
0 = không giới hạn. Rò rỉ kết nối rảnh khi có nhiều upstream |
MaxConnsPerHost |
0 = không giới hạn. Tải tăng đột biến → proxy mở vô hạn kết nối, upstream chết vì cạn file descriptor thay vì chỉ chậm |
IdleConnTimeout |
0 = giữ mãi. Kết nối đã bị NAT/LB giữa đường vứt bỏ vẫn nằm trong hồ → lỗi EOF ngẫu nhiên |
TLSHandshakeTimeout |
0 = chờ vô hạn. Upstream TLS treo giữ luôn goroutine và slot kết nối |
ExpectContinueTimeout |
0 = không chờ 100 Continue, gửi thẳng body. Phí băng thông với upstream hay từ chối sớm |
ForceAttemptHTTP2 |
Đặt DialContext/TLSClientConfig mà quên → tụt về HTTP/1.1, mất multiplexing |
ResponseHeaderTimeout |
0 = chờ header vô hạn. Upstream deadlock giữ goroutine của proxy tới hết đời tiến trình |
net.Dialer.Timeout |
Dùng timeout của hệ điều hành — có thể tới hơn một phút |
Vì sao không dùng
http.Client.Timeoutcho reverse proxy?Nó áp deadline lên toàn bộ vòng đời request, kể cả lúc đang đọc thân phản hồi — tải file 500 MB hay stream SSE đều bị cắt giữa chừng. Đó cũng là lý do
ReverseProxynhậnhttp.RoundTripperchứ không nhận*http.Client. Cách đúng:ResponseHeaderTimeoutchặn upstream treo, cộngmiddleware.Timeouttheo từng route (§10.7).
Chọn upstream, health check và retry thuộc §5. Ở tầng Go, chỗ để cắm là Rewrite (chọn SetURL theo backend khoẻ) và ErrorHandler (thử backend khác hay trả lỗi).
10.4 Timeout phía server — chống slow-loris mà không giết SSE
http.ListenAndServe dựng một http.Server không có timeout nào. Một client mở kết nối, gửi GET / HTTP/1.1\r\n rồi nhỏ giọt mỗi giây một byte header, và goroutine của bạn ngồi chờ vĩnh viễn. Vài nghìn kết nối như vậy là hết goroutine, hết file descriptor. Đó là slow-loris, và nó không cần băng thông.
func NewServer(addr string, h http.Handler, log *slog.Logger) *http.Server {
return &http.Server{
Addr: addr,
Handler: h,
ReadHeaderTimeout: 5 * time.Second, // ★ bắt buộc — xem bảng dưới
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, // ⚠️ thứ giết SSE
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 1 << 20,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelError),
}
}
| Timeout | Bao trùm giai đoạn nào | Để 0 thì sao |
|---|---|---|
ReadHeaderTimeout |
Nhận request line + header | Rơi về ReadTimeout; cả hai 0 = không giới hạn, đây là lỗ slow-loris ⚠️ |
ReadTimeout |
Nhận header + toàn bộ body | Không giới hạn. Route upload cần dài hơn phải tách sang server riêng |
WriteTimeout |
Từ hết header tới ghi xong phản hồi | Không giới hạn |
IdleTimeout |
Kết nối keep-alive đang rảnh | Rơi về ReadTimeout — cứ 15s client phải bắt tay lại, tốn vô ích |
MaxHeaderBytes |
Trần kích thước header | Mặc định 1 MiB; header phình to là vector tấn công rẻ tiền |
WriteTimeout là deadline tuyệt đối trên cả request, không phải "im lặng bao lâu thì cắt". Một stream SSE gửi đều mỗi 2 giây vẫn bị chặt đúng giây thứ 30. Client thấy đứt, tự kết nối lại, rồi lại đứt sau 30 giây — bạn có một vòng lặp reconnect mà log thì sạch bong. Hai cách xử lý: (1) gỡ deadline cho riêng request đó bằng http.ResponseController (Go 1.20+), hoặc (2) tách route streaming sang một http.Server khác với WriteTimeout: 0 nghe cổng riêng, rồi để reverse proxy định tuyến /events sang đó — tốn thêm một cổng nhưng gọn về mặt suy luận, vì không handler nào phải nhớ gọi SetWriteDeadline.
func SSEHandler(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
// Thời điểm zero = bỏ deadline; phải gọi TRƯỚC khi ghi byte đầu tiên. Nếu
// ResponseWriter bên dưới không hỗ trợ, err là http.ErrNotSupported — coi như lỗi
// cấu hình, đừng nuốt im lặng rồi để stream chết sau 30 giây.
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
http.Error(w, "streaming không khả dụng", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
// KHÔNG dùng http.CloseNotifier — đã lỗi thời; context của request mới là
// cách đúng, và chạy được cả trên HTTP/2.
return
case t := <-ticker.C:
fmt.Fprintf(w, "data: %d\n\n", t.Unix())
// Không Flush thì dữ liệu nằm trong buffer của net/http, client không nhận
// được gì cho tới khi buffer đầy hoặc handler thoát.
if err := rc.Flush(); err != nil {
return
}
}
}
}
10.5 Forward proxy hỗ trợ CONNECT
Reverse proxy biết trước upstream. Forward proxy thì không — client nói cho nó biết đích đến ngay trong request. Với HTTP thường, client gửi request có URI tuyệt đối (GET http://host/path); proxy chỉ cần r.Clone(ctx), đặt out.RequestURI = "" (bắt buộc — http.Client từ chối request có trường này), xoá Proxy-Authorization rồi RoundTrip. Với HTTPS, client không thể để proxy đọc nội dung nên nó dùng CONNECT để xin một tunnel byte-thô rồi bắt tay TLS xuyên qua đó.
Client Forward proxy api.github.com:443
│─ CONNECT api.github.com:443 ─►│──── TCP dial ────────────────►│
│◄─ 200 Connection Established ─│ │
│═════ byte thô, proxy chỉ copy, không hiểu gì ════════════════►│
Handler gốc chỉ cần phân nhánh if r.Method == http.MethodConnect { handleConnect(w, r, dialer); return }, phần còn lại đi đường HTTP thường. http.Server cho proxy này chỉ nên đặt ReadHeaderTimeout — ReadTimeout/WriteTimeout sẽ chặt tunnel giữa chừng vì CONNECT là kết nối sống lâu.
package main
import (
"io"
"net"
"net/http"
"golang.org/x/sync/errgroup"
)
func handleConnect(w http.ResponseWriter, r *http.Request, dialer *net.Dialer) {
// Với CONNECT, request-target là dạng authority "host:port": không scheme, không
// path. r.Host là chỗ đọc đúng; r.URL.Path luôn rỗng.
if _, _, err := net.SplitHostPort(r.Host); err != nil {
http.Error(w, "CONNECT cần dạng host:port", http.StatusBadRequest)
return
}
if !allowedTarget(r.Host) {
http.Error(w, "đích không nằm trong allowlist", http.StatusForbidden)
return
}
// Dùng ResponseController chứ không type-assert w.(http.Hijacker): bất kỳ
// middleware nào bọc ResponseWriter (logger, gzip, WrapResponseWriter của chi) đều
// làm type assertion thất bại, còn ResponseController lần theo Unwrap() để tìm tới
// cái thật.
rc := http.NewResponseController(w)
upstream, err := dialer.DialContext(r.Context(), "tcp", r.Host)
if err != nil {
http.Error(w, "không kết nối được đích", http.StatusBadGateway)
return
}
defer upstream.Close()
client, buf, err := rc.Hijack()
if err != nil {
http.Error(w, "tunnel không khả dụng", http.StatusInternalServerError) // HTTP/2
return
}
defer client.Close()
// Sau khi hijack, net/http không ghi gì lên kết nối này nữa, kể cả status line.
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return
}
// Client có thể đã gửi kèm byte đầu tiên (TLS ClientHello) ngay sau header; những
// byte đó nằm trong buffer đọc của net/http, không còn trên socket. Quên đẩy chúng
// đi = handshake treo, và lỗi chỉ xuất hiện với client "nhanh tay" nên gần như
// không tái hiện được trên máy dev.
if n := buf.Reader.Buffered(); n > 0 {
if _, err := io.CopyN(upstream, buf.Reader, int64(n)); err != nil {
return
}
}
var g errgroup.Group
g.Go(func() error {
_, err := io.Copy(upstream, client)
// Nửa đóng: báo cho đích "tôi hết gửi" nhưng vẫn còn nhận. Close() cả hai đầu
// ngay sẽ cắt mất phản hồi cuối cùng của đích.
halfClose(upstream)
return err
})
g.Go(func() error {
_, err := io.Copy(client, upstream)
halfClose(client)
return err
})
_ = g.Wait() // lỗi ở đây là chuyện thường (đầu kia đóng trước), không log ầm ĩ
}
func halfClose(c net.Conn) {
// Không phải net.Conn nào cũng cho nửa đóng; rơi về Close() là chấp nhận được.
if cw, ok := c.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
return
}
_ = c.Close()
}
Bản HTTP/2-friendly. Hijack không tồn tại trên HTTP/2 — ở đó không có "kết nối riêng" để chiếm, chỉ có stream ghép kênh. Thay hai io.Copy ở trên bằng r.Body (chiều vào) và w (chiều ra); phần dial và allowlist giữ nguyên:
rc := http.NewResponseController(w)
// Trên HTTP/1.1, server mặc định đọc nốt body chưa đọc TRƯỚC khi cho ghi phản hồi —
// với tunnel thì body không bao giờ hết, nên sẽ deadlock. HTTP/2 vốn song công nên
// có thể không cần; lỗi ở đây không phải lý do bỏ request.
if err := rc.EnableFullDuplex(); err != nil {
slog.Debug("không bật được full-duplex", "err", err)
}
w.WriteHeader(http.StatusOK)
if err := rc.Flush(); err != nil {
return
}
var g errgroup.Group
g.Go(func() error { _, err := io.Copy(upstream, r.Body); halfClose(upstream); return err })
g.Go(func() error { _, err := io.Copy(&flushWriter{w: w, rc: rc}, upstream); return err })
_ = g.Wait()
// flushWriter đẩy từng khối ra ngay. Không có nó, dữ liệu nằm trong buffer của net/http
// và tunnel tương tác (SSH, bắt tay TLS) treo cứng — vì cả hai phía đều chờ byte của nhau.
type flushWriter struct {
w io.Writer
rc *http.ResponseController
}
func (fw *flushWriter) Write(p []byte) (int, error) {
n, err := fw.w.Write(p)
if err != nil {
return n, err
}
return n, fw.rc.Flush()
}
⚠️ Cảnh báo bắt buộc: đừng mở proxy này ra Internet
Một forward proxy không xác thực, nghe trên 0.0.0.0, là open proxy. Máy quét tự động tìm ra nó trong vài giờ, không phải vài tuần. Khi đó: lưu lượng tấn công người khác đi qua IP của bạn (abuse report, blacklist, khoá tài khoản cloud); proxy đứng bên trong mạng nội bộ nên nó là công cụ SSRF hoàn hảo — kẻ tấn công CONNECT 10.0.0.5:5432 để nói chuyện thẳng với PostgreSQL; và băng thông ra là tiền, không phải tiền của kẻ dùng nó. Nghe trên 127.0.0.1 và bật cả hai lớp dưới đây.
// requireProxyAuth chặn ở cửa bằng Proxy-Authorization — header cho chặng client↔proxy,
// khác Authorization vốn dành cho đích cuối. Cần thêm import "crypto/subtle",
// "encoding/base64", "strings".
func requireProxyAuth(user, pass string, next http.Handler) http.Handler {
want := "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// So sánh hằng thời gian: "==" thoát sớm ở byte đầu khác nhau, đủ để dò từng
// ký tự bằng cách đo thời gian phản hồi.
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Proxy-Authorization")), []byte(want)) != 1 {
w.Header().Set("Proxy-Authenticate", `Basic realm="community-proxy"`)
http.Error(w, "cần xác thực proxy", http.StatusProxyAuthRequired)
return
}
r.Header.Del("Proxy-Authorization") // không đẩy sang đích cuối
next.ServeHTTP(w, r)
})
}
var allowedHosts = map[string]struct{}{"api.github.com": {}, "hooks.slack.com": {}}
// allowedTarget là danh sách CHO PHÉP, không phải danh sách CẤM.
// Danh sách cấm luôn thiếu một mục nào đó.
func allowedTarget(hostport string) bool {
host, port, err := net.SplitHostPort(hostport)
if err != nil || (port != "80" && port != "443") {
return false
}
// Chặn địa chỉ nội bộ khi client đưa thẳng IP — lỗ SSRF kinh điển: proxy hướng ra
// Internet nhưng lại đứng trong VPC.
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsLinkLocalMulticast() {
return false
}
}
_, ok := allowedHosts[strings.ToLower(host)]
return ok
}
Kiểm tra tên miền là chưa đủ.
Allowlist theo tên bị vượt qua bằng DNS rebinding:
evil.comphân giải thành IP công cộng lúc bạn kiểm tra, rồi thành địa chỉ nội bộ lúcDialthật sự chạy. Cách chặn triệt để là kiểm tra địa chỉ đã phân giải, ngay trước khi socket kết nối, bằng hooknet.Dialer.Control— nó nhậnaddresslà IP thật và trả error để huỷ kết nối. Mặt tấn công đầy đủ ở §13.
10.6 Chiều ngược lại: kết nối RA qua proxy
cmd/worker gọi API bên thứ ba từ trong mạng doanh nghiệp, hoặc cần đi qua hồ proxy để tránh bị chặn theo IP (§6). Ba mức, từ dễ tới linh hoạt.
Mức 1 — biến môi trường. http.ProxyFromEnvironment đọc HTTP_PROXY, HTTPS_PROXY, NO_PROXY (và bản chữ thường); đây cũng là mặc định của http.DefaultTransport.
$env:HTTPS_PROXY = "http://bot:s3cr3t@proxy.corp:3128"
$env:NO_PROXY = "localhost,127.0.0.1,.internal,postgres,kafka"
Ba điều bất ngờ, cả ba đều từng làm mất buổi chiều của ai đó:
- Giá trị được đọc một lần ở lần dùng đầu tiên rồi nhớ cho cả tiến trình. Đổi biến môi trường lúc chạy không có tác dụng — muốn đổi nóng phải tự viết hàm (mức 2).
localhostvà địa chỉ loopback luôn được bỏ qua, không đi qua proxy, kể cả khi không có trongNO_PROXY.HTTPS_PROXYtrỏ tới một proxy HTTP là bình thường — nó nói "dùng proxy nào cho requesthttps://", không phải "proxy nói HTTPS".Transporttự phátCONNECTtới proxy đó, và nếu proxy URL cóuser:passthì tự sinhProxy-Authorization.
Mức 2 — hàm động. Transport.Proxy là func(*http.Request) (*url.URL, error), được gọi cho mỗi request:
// Trả (nil, nil) = đi thẳng, không qua proxy. Transport đánh khoá hồ kết nối theo cặp
// (proxy, đích) nên đổi proxy giữa chừng không làm hỏng connection pool — nhưng mỗi
// proxy mới là một hồ mới, đừng xoay vòng trên hàng nghìn proxy nếu không muốn nổ số
// kết nối.
tr.Proxy = func(r *http.Request) (*url.URL, error) {
if strings.HasSuffix(r.URL.Hostname(), ".internal") {
return nil, nil
}
return pool.Pick()
}
Mức 3 — SOCKS5. SOCKS5 nằm dưới HTTP: nó chuyển tiếp TCP nên đi được cả giao thức không phải HTTP. Go không có client SOCKS5 trong thư viện chuẩn; dùng golang.org/x/net/proxy (go get golang.org/x/net).
package outbound
import (
"fmt"
"net/http"
"time"
"golang.org/x/net/proxy"
)
func NewSOCKS5Transport(addr, user, pass string) (*http.Transport, error) {
var auth *proxy.Auth
if user != "" {
auth = &proxy.Auth{User: user, Password: pass}
}
// proxy.SOCKS5 trả về proxy.Dialer — interface CHỈ có Dial(network, addr).
// proxy.Direct là dialer nền: bước kết nối tới chính con SOCKS server.
d, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct)
if err != nil {
return nil, fmt.Errorf("socks5: %w", err)
}
// ★ Bước hay vấp nhất của cả mục này. http.Transport cần DialContext để huỷ theo
// context, mà proxy.Dialer không có phương thức đó. Bỏ qua bước ép kiểu này, người
// ta hay bọc tay: func(_ context.Context, n, a string) { return d.Dial(n, a) }.
// Trông vô hại nhưng nó VỨT BỎ context — request bị huỷ, ctx hết hạn hay service
// shutdown đều không dừng được bước bắt tay SOCKS đang treo, goroutine kẹt tới khi
// timeout của hệ điều hành nổ.
cd, ok := d.(proxy.ContextDialer)
if !ok {
return nil, fmt.Errorf("socks5: dialer không hỗ trợ DialContext")
}
return &http.Transport{
DialContext: cd.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 60 * time.Second,
}, nil
}
| Cách đi ra | Đổi được lúc nào | Giao thức | Ghi chú |
|---|---|---|---|
ProxyFromEnvironment |
Lúc khởi động tiến trình | HTTP/HTTPS | Đọc env một lần; bỏ qua loopback |
Transport.Proxy là hàm |
Mỗi request | HTTP/HTTPS | Nơi cắm logic xoay vòng, sticky session |
SOCKS5 qua x/net/proxy |
Lúc dựng Transport |
Mọi TCP | Tên miền được gửi cho SOCKS server phân giải, không lộ truy vấn DNS ra mạng của bạn |
proxy.FromEnvironment() là bản tương ứng cho SOCKS — đọc ALL_PROXY và NO_PROXY rồi trả về proxy.Dialer, tiện khi cùng một binary chạy cả trong lẫn ngoài mạng doanh nghiệp.
10.7 Chuỗi middleware cho chi/v5 — và vì sao thứ tự quan trọng
func NewRouter(log *slog.Logger) *chi.Mux {
r := chi.NewRouter()
// ★ chi panic nếu r.Use được gọi SAU khi đã khai báo route.
r.Use(middleware.RequestID) // 1
r.Use(middleware.RealIP) // 2
r.Use(requestLogger(log)) // 3
r.Use(middleware.Recoverer) // 4
r.Use(rateLimitByIP(20, 40)) // 5
r.Group(func(r chi.Router) {
r.Use(middleware.Timeout(15 * time.Second)) // 6
r.Get("/v1/posts", listPosts)
})
// Route streaming KHÔNG có Timeout — nó huỷ context và giết stream.
r.Group(func(r chi.Router) { r.Get("/v1/events", SSEHandler) })
return r
}
r.Use xếp middleware theo kiểu củ hành: cái khai báo trước nằm ngoài cùng, chạy đầu tiên khi vào và cuối cùng khi ra.
| # | Middleware | Vì sao ở đúng chỗ đó |
|---|---|---|
| 1 | RequestID |
Ngoài cùng, vì mọi dòng log phía trong — kể cả log panic — phải mang được id này |
| 2 | RealIP |
Phải chạy trước mọi thứ đọc r.RemoteAddr: logger, rate limit, audit. Nó ghi đè r.RemoteAddr bằng giá trị lấy từ header forwarding |
| 3 | logger | Ngoài Recoverer: nếu ở trong, panic nhảy qua nó và request 500 không có dòng access log nào. Ngoài ra nó đo được cả độ trễ của rate limit |
| 4 | Recoverer |
Trong logger nhưng ngoài tất cả phần còn lại — nó phải bọc mọi code có thể panic: rate limiter, handler, và cả middleware của bạn |
| 5 | rate limit | Sau RealIP (bắt buộc) và càng sớm càng tốt, để request bị từ chối tốn ít công nhất |
| 6 | Timeout |
Trong cùng, theo từng nhóm route. Deadline chỉ nên bao phần việc thật của handler, và tuyệt đối không áp lên route streaming |
RealIPđặt sai chỗ = rate limit sập nguồn.Nếu rate limiter chạy trước
RealIP,r.RemoteAddrvẫn là địa chỉ của reverse proxy — cùng một giá trị cho mọi người dùng. Cả hệ thống dùng chung một cái xô token: đủ tải là ai cũng nhận429. Sự cố nhìn giống hệt DDoS và bạn sẽ đi tìm kẻ tấn công không tồn tại.Chiều ngược lại cũng nguy:
RealIPtin header vô điều kiện, nên chỉ được bật khi bạn chắc chắn có proxy tin cậy phía trước đã ghi đè header đó. Phơi thẳng ra Internet thì mỗi kẻ tấn công tự chọn IP cho mình và bypass rate limit bằng một dòng header. Đầy đủ ở §4.
// Import thêm: "sync", "net", "golang.org/x/time/rate" (go get golang.org/x/time).
// r = token nạp thêm mỗi giây, b = sức chứa (cho phép burst).
func rateLimitByIP(r rate.Limit, b int) func(http.Handler) http.Handler {
var (
mu sync.Mutex
visitors = map[string]*rate.Limiter{}
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ip, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
ip = req.RemoteAddr // RealIP có thể để lại IP trần, không kèm cổng
}
mu.Lock()
lim, ok := visitors[ip]
if !ok {
lim = rate.NewLimiter(r, b)
visitors[ip] = lim
}
mu.Unlock()
if !lim.Allow() {
// Thiếu Retry-After thì client tử tế cũng thành client hung hăng, vì nó
// thử lại ngay lập tức.
w.Header().Set("Retry-After", "1")
http.Error(w, "quá nhiều request", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, req)
})
}
}
Bản trên thiếu một thứ bắt buộc cho production: visitors lớn lên vô hạn theo số IP đã ghé qua. Thêm trường thời điểm cuối cùng vào mỗi mục và một goroutine time.Tick dọn các mục quá 10 phút không dùng — đây là kiểu rò rỉ bộ nhớ chậm, chỉ lộ ra sau nhiều ngày uptime.
requestLogger thì bọc w bằng middleware.WrapResponseWriter(w, r.ProtoMajor) để đọc lại được Status() và BytesWritten(), và ghi kèm middleware.GetReqID(r.Context()). Chính việc bọc này là lý do handler bên trong phải dùng http.NewResponseController thay vì type-assert w.(http.Flusher).
Lưu ý cuối về middleware.Timeout: nó không ép handler dừng lại — nó chỉ huỷ r.Context() rồi ghi 504 sau khi handler trả về. Handler nào phớt lờ context vẫn chạy tới hết. Đó là lý do mọi lệnh gọi DB và HTTP trong dự án phải nhận ctx và truyền xuống, đúng mẫu ở ARCHITECTURE.md.
10.8 Kiểm thử bằng httptest
Proxy là loại code mà "chạy thử bằng tay thấy ổn" gần như vô nghĩa: các lỗi nguy hiểm nhất — header giả mạo lọt qua, phản hồi bị buffer, upstream chết trả sai mã — đều trông giống thành công. httptest.NewServer cho bạn một upstream thật, cổng thật, HTTP thật: dựng upstream giả, dựng proxy trước nó, rồi soi xem upstream thực sự nhận được gì. Đặt tại internal/platform/gateway/proxy_test.go:
package gateway
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"testing"
"time"
)
// echoed là những gì upstream NHÌN THẤY. Assert trên struct này thay vì trên cấu hình
// proxy — đo kết quả, không đo ý định.
type echoed struct{ Host, Path, Query, XFF, XFH, XFP string }
func serve(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}
func echoUpstream(t *testing.T) *httptest.Server {
return serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(echoed{
Host: r.Host, Path: r.URL.Path, Query: r.URL.RawQuery,
XFF: r.Header.Get("X-Forwarded-For"),
XFH: r.Header.Get("X-Forwarded-Host"),
XFP: r.Header.Get("X-Forwarded-Proto"),
})
}))
}
func rewriteTo(t *testing.T, raw string) func(*httputil.ProxyRequest) {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("URL không hợp lệ %q: %v", raw, err)
}
return func(r *httputil.ProxyRequest) { r.SetURL(u); r.SetXForwarded() }
}
func get(t *testing.T, srv *httptest.Server, path string, hdr http.Header) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
if err != nil {
t.Fatalf("dựng request: %v", err)
}
if hdr != nil {
req.Header = hdr
}
res, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("gọi proxy thất bại: %v", err)
}
t.Cleanup(func() { _ = res.Body.Close() })
return res
}
// Test quan trọng nhất của cả file: header client bịa KHÔNG lọt sang upstream.
func TestRewriteResetsForwardedHeaders(t *testing.T) {
up := echoUpstream(t)
front := serve(t, &httputil.ReverseProxy{Rewrite: rewriteTo(t, up.URL)})
res := get(t, front, "/v1/posts?limit=10", http.Header{
"X-Forwarded-For": {"203.0.113.9"},
"X-Forwarded-Proto": {"https"},
"X-Forwarded-Host": {"evil.example"},
})
var got echoed
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("giải mã phản hồi: %v", err)
}
if strings.Contains(got.XFF, "203.0.113.9") {
t.Errorf("XFF client bịa đã lọt sang upstream: %q", got.XFF)
}
if got.XFH == "evil.example" {
t.Errorf("X-Forwarded-Host bị giả mạo: %q", got.XFH)
}
if got.XFP != "http" { // httptest.NewServer không dùng TLS
t.Errorf("X-Forwarded-Proto = %q, muốn %q", got.XFP, "http")
}
if u, _ := url.Parse(up.URL); got.Host != u.Host { // SetURL đổi Host theo target
t.Errorf("Host = %q, muốn %q", got.Host, u.Host)
}
if got.Path != "/v1/posts" || got.Query != "limit=10" {
t.Errorf("path/query bị sửa: %q ? %q", got.Path, got.Query)
}
}
// Hai kiểu hỏng của upstream, cùng một đường xử lý: ErrorHandler.
func TestErrorHandlerMapsUpstreamFailures(t *testing.T) {
dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
deadURL := dead.URL
dead.Close() // đóng ngay: cổng không còn ai nghe → dial thất bại tức thì
slow := serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done() // treo tới khi proxy bỏ đi, để test không phải chờ lâu
}))
cases := []struct {
name, target string
tr http.RoundTripper
want int
}{
{"upstream chết", deadURL, nil, http.StatusBadGateway},
// ResponseHeaderTimeout chỉ chặn thời gian chờ HEADER, không cắt thân phản hồi.
{"upstream chậm", slow.URL,
&http.Transport{ResponseHeaderTimeout: 150 * time.Millisecond},
http.StatusGatewayTimeout},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
front := serve(t, &httputil.ReverseProxy{
Rewrite: rewriteTo(t, tc.target),
Transport: tc.tr,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
w.WriteHeader(http.StatusGatewayTimeout)
return
}
w.WriteHeader(http.StatusBadGateway)
},
})
if res := get(t, front, "/v1/posts", nil); res.StatusCode != tc.want {
t.Fatalf("status = %d, muốn %d", res.StatusCode, tc.want)
}
})
}
}
func TestStreamingIsNotBuffered(t *testing.T) {
const tick = 40 * time.Millisecond
up := serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
rc := http.NewResponseController(w)
for i := 0; i < 3; i++ {
fmt.Fprintf(w, "data: tick-%d\n\n", i)
if err := rc.Flush(); err != nil {
return
}
time.Sleep(tick)
}
}))
front := serve(t, &httputil.ReverseProxy{
Rewrite: rewriteTo(t, up.URL),
FlushInterval: -1, // âm = flush ngay sau mỗi lần ghi
})
res := get(t, front, "/events", nil)
start := time.Now()
var firstAt time.Duration
n := 0
for sc := bufio.NewScanner(res.Body); sc.Scan(); {
if !strings.HasPrefix(sc.Text(), "data: ") {
continue
}
if n == 0 {
firstAt = time.Since(start)
}
n++
}
if n != 3 {
t.Fatalf("nhận %d sự kiện, muốn 3", n)
}
// Proxy buffer toàn bộ thì sự kiện đầu chỉ tới sau khi upstream đóng (~2*tick).
// Ngưỡng 1*tick: rộng hơn thời gian mạng nội bộ, hẹp hơn thời gian buffer.
if firstAt > tick {
t.Errorf("sự kiện đầu tới sau %v — phản hồi có vẻ bị buffer", firstAt)
}
}
Chạy bằng go test ./internal/platform/gateway/... -race -v. Bốn nguyên tắc rút ra, dùng được cho mọi proxy bạn viết sau này:
- Assert trên cái upstream nhận được, không trên cấu hình proxy. Test cấu hình chỉ chứng minh bạn gõ đúng cái mình vừa gõ.
- Thêm một test "chứng minh cái sai": đổi
Rewritethànhhttputil.NewSingleHostReverseProxy(target)rồi assert rằng XFF bịa203.0.113.9vẫn tới upstream và nằm ở đầu chuỗi. Nó khoá lại lý do của quyết định ở §10.2.1 — ai định "đơn giản hoá" bằng cách quay vềDirectorsẽ thấy test đỏ. - Test streaming bằng thời điểm, không bằng nội dung. Proxy buffer toàn bộ vẫn trả đủ ba sự kiện — chỉ mốc thời gian phân biệt được đúng với sai.
- Chạy với
-race. Proxy là code nhiều goroutine theo bản chất:ErrorHandler,ModifyResponsevà hai chiềuio.Copyđều chạy song song với test.
Đo lường phía vận hành — đếm request theo mã trạng thái, theo dõi số kết nối trong hồ, tách độ trễ của proxy khỏi độ trễ của upstream — thuộc §14. Còn khi lượng cấu hình trong Go vượt quá thứ bạn muốn tự bảo trì, §11 là lúc chuyển sang Nginx hoặc Envoy.
11. Cấu hình thực chiến — Nginx, HAProxy, Envoy, Caddy, Traefik
Mục này không giải thích lại lý thuyết. Nguyên lý reverse proxy nằm ở §5, cách lấy IP client đúng nằm ở §4. Ở đây chỉ có cấu hình chạy được, đặt trước cmd/api, kèm lý do cho từng dòng dễ sai.
11.1 Bản hợp đồng chung — tám việc mọi cấu hình dưới đây phải làm đủ
| Việc | Vì sao bắt buộc |
|---|---|
| Kết thúc TLS + HTTP/2 | Chứng chỉ chỉ ở một chỗ; cmd/api không cần biết TLS tồn tại |
| Chuyển tiếp danh tính client | Thiếu nó, mọi log và rate limit của app đều thấy đúng một IP: IP của proxy |
| Timeout ba tầng (connect/send/read) | Một upstream treo sẽ giữ kết nối của proxy đến cạn |
| Health check chủ động | Để proxy tự loại node hỏng, không đợi request người dùng phát hiện giùm |
| WebSocket / SSE đi lọt | Đây là chỗ cấu hình "chuẩn" hay chặn nhầm nhất |
| Nén gzip/brotli | JSON của feed nén rất tốt; nén ở proxy rẻ hơn nén trong app |
| Access log có request id | Không có id chung thì log proxy và log app là hai vũ trụ rời nhau |
| Rate limit ở biên | Chặn ở biên rẻ hơn nhiều bậc — app chưa kịp chạm DB |
Internet ──TLS/H2──► Reverse proxy (biên) ──HTTP/1.1 keepalive──► api:8080
chứng chỉ, rate limit, (mạng nội bộ) (cmd/api)
nén, X-Forwarded-*, │
request id, health check postgres ─ kafka
Chú ý về số cổng.
.env.exampleđặtHTTP_PORT=8000, còndeployments/docker-compose.ymlđang chiếm cổng 8080 trên host chokafka-ui. Toàn bộ cấu hình dưới đây dùng8080chocmd/api. Trong mạng Docker nội bộ không có xung đột —api:8080vàkafka-uilà hai container, mỗi container một không gian cổng riêng. Chỉ khi chạygo run ./cmd/apithẳng trên host mới phải chọn: hoặc đặtHTTP_PORT=8080rồi đổi cổng publish củakafka-ui, hoặc giữ8000và thay số trong cấu hình proxy. Đừng copy nguyên si rồi ngồi debug "connection refused".
11.2 Nginx
Nginx là mặc định của phần lớn hệ thống ở Việt Nam, nên phần này viết kỹ nhất; các phần sau tham chiếu ngược về đây.
11.2.1 Cấu hình đầy đủ
# /etc/nginx/nginx.conf — phần http (giữ nguyên khối user/events ở đầu file)
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Request id: ưu tiên id của client, thiếu thì nginx sinh. $request_id là biến dựng
# sẵn (32 ký tự hex, có từ nginx 1.11.0). Ghi đè vô điều kiện sẽ CẮT ĐỨT vết truy
# của client gửi từ mobile app.
map $http_x_request_id $req_id { default $http_x_request_id; '' $request_id; }
# WebSocket. Giá trị rỗng ở nhánh thứ hai KHÔNG phải sơ suất — xem §11.2.3.
map $http_upgrade $connection_upgrade { default upgrade; '' ''; }
log_format json_main escape=json
'{"time":"$time_iso8601","request_id":"$req_id","remote_addr":"$remote_addr",'
'"method":"$request_method","uri":"$uri","status":$status,"rt":$request_time,'
'"upstream":"$upstream_addr","upstream_status":"$upstream_status"}';
# Zone 10m chứa cỡ 160 nghìn state IPv4 ($binary_remote_addr chỉ 4 byte).
limit_req_zone $binary_remote_addr zone=api_rl:10m rate=10r/s;
upstream community_api {
server 127.0.0.1:8080 max_fails=3 fail_timeout=10s;
# server 127.0.0.1:8081 max_fails=3 fail_timeout=10s; # node thứ hai
# keepalive = số kết nối RẢNH giữ cho MỖI worker process, không phải tổng số
# kết nối. Thiếu nó thì mỗi request tốn một bắt tay TCP mới tới cmd/api.
keepalive 32; keepalive_timeout 60s; keepalive_requests 1000;
}
server {
listen 443 ssl;
http2 on; # nginx >= 1.25.1; bản cũ: `listen 443 ssl http2;`
server_name community.example.com;
ssl_certificate /etc/letsencrypt/live/community.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/community.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
# Tin XFF — phần dễ sai nhất cả file, xem §11.2.4.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
gzip on; gzip_vary on; gzip_comp_level 5;
gzip_min_length 1024; # dưới ngưỡng này nén còn tốn hơn không nén
gzip_types application/json application/javascript text/css text/plain;
gzip_proxied any; # BẮT BUỘC — xem ghi chú bên dưới
access_log /var/log/nginx/access.log json_main;
client_max_body_size 8m; # mặc định 1m: ảnh cover 3 MB ăn 413, log app im lặng
location / {
limit_req zone=api_rl burst=20 nodelay; limit_req_status 429;
proxy_pass http://community_api;
proxy_http_version 1.1; # BẮT BUỘC khi có keepalive, xem §11.2.2
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $req_id;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# connect: bắt tay TCP nội mạng, phải nhanh. read: chờ byte TIẾP THEO của
# response, không phải chờ cả response — đây là chỗ hay hiểu sai.
proxy_connect_timeout 5s; proxy_send_timeout 60s; proxy_read_timeout 60s;
# Mặc định nginx CHỈ thử lại request idempotent. Giữ nguyên như vậy: thêm
# `non_idempotent` là cho phép tạo hai bài viết từ một POST.
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
}
location /events { # SSE — xem §11.2.5
proxy_pass http://community_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $req_id;
proxy_buffering off;
proxy_read_timeout 1h; # SSE im lặng dài là bình thường
}
}
server { # HTTP → HTTPS + đường ACME của certbot
listen 80;
server_name community.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
}
gzip_proxied any— dòng bị bỏ quên nhiều nhất. Mặc định củagzip_proxiedlàoff: nginx không nén response đến từ upstream — tức gần như toàn bộ traffic của một reverse proxy. Bạn bậtgzip on, mở DevTools, thấyContent-Encodingtrống, rồi kết luận "nginx nén không ăn thua". Không phải; bạn chưa bật nén cho đúng loại traffic.
Brotli không có sẵn trong nginx open source — phải build kèm module ngx_brotli. Đây là một lý do rất thực tế để nhìn sang Caddy (§11.5). Kiểm tra và nạp lại (reload của nginx là graceful, worker cũ phục vụ nốt request đang dở): nginx -t && nginx -s reload.
11.2.2 proxy_http_version 1.1 — vì sao bắt buộc khi có keepalive
Mặc định nginx nói HTTP/1.0 với upstream, mà HTTP/1.0 không có kết nối bền: mỗi response xong là đóng. Hệ quả khi khai keepalive 32 nhưng quên proxy_http_version 1.1: nginx mở kết nối, gửi request HTTP/1.0, đọc response, rồi đóng — đúng như giao thức bắt nó làm. Directive keepalive vẫn nằm đó, không lỗi, không cảnh báo, chỉ đơn giản là không có tác dụng. Mỗi request tốn thêm một bắt tay TCP ba bước tới cmd/api, cộng một socket TIME_WAIT sau khi đóng. Triệu chứng lúc tải cao rất dễ chẩn đoán nhầm: netstat đầy TIME_WAIT, p99 nhảy bậc thang, và bạn đi tối ưu handler Go trong khi lỗi nằm ở một dòng cấu hình.
Có một cặp đôi nữa phải đi cùng. Trong HTTP/1.1, nginx không được chuyển tiếp header Connection của client xuống upstream — client gửi Connection: close thì upstream sẽ đóng đúng cái kết nối nginx đang định giữ. Tài liệu nginx vì thế khuyên proxy_set_header Connection "";. Nhưng ta lại cần Connection: upgrade cho WebSocket. Mâu thuẫn đó do map giải.
Quy tắc thời gian sống của kết nối.
keepalive_timeoutphía nginx phải nhỏ hơnIdleTimeoutcủahttp.Servertrongcmd/api. Ngược lại sẽ có khoảnh khắc Go đóng kết nối rảnh đúng lúc nginx vừa ghi request lên đó: nginx báo502, app không thấy gì cả — request chưa bao giờ tới handler. Lỗi rời rạc, vài phần vạn request, gần như không tái hiện được trên máy dev.
11.2.3 map cho Upgrade/Connection — và cái bẫy close
map $http_upgrade $connection_upgrade {
default upgrade;
# '' close; # ❌ bản được chép đi chép lại khắp nơi
'' ''; # ✅ header rỗng ⇒ nginx KHÔNG gửi header này đi
}
Bản close chạy được với WebSocket nên không ai xét lại. Nhưng với request HTTP thường — 99% traffic — $http_upgrade rỗng, nên nginx gửi Connection: close xuống cmd/api. Go đọc header đó và đóng kết nối sau khi trả lời: toàn bộ keepalive 32 bị vô hiệu, im lặng. Cơ chế của bản đúng: proxy_set_header với giá trị rỗng thì header đó không được chuyển tiếp; không gửi Connection nghĩa là giữ mặc định HTTP/1.1 — kết nối bền.
11.2.4 real_ip_module — cách duy nhất đúng để tin X-Forwarded-For
Vấn đề đã nêu ở §4: X-Forwarded-For là chuỗi người ngoài ghi được. Client gửi X-Forwarded-For: 1.2.3.4 thì $proxy_add_x_forwarded_for nối thêm IP thật vào sau, thành 1.2.3.4, 203.0.113.9. App nào đọc phần tử đầu tiên là vừa bị lừa.
Ba dòng set_real_ip_from / real_ip_header / real_ip_recursive ở cấu hình trên bật ngx_http_realip_module, và nó làm ba việc mà tự viết trong app rất dễ sai. Thứ nhất, nó duyệt X-Forwarded-For từ phải sang trái — hướng đúng, vì phần bên phải do hạ tầng của bạn ghi, phần bên trái do người lạ ghi. Thứ hai, với real_ip_recursive on nó bỏ qua các hop nằm trong set_real_ip_from và dừng ở địa chỉ đầu tiên không thuộc dải tin cậy — đó là IP client thật. Thứ ba, nó ghi đè $remote_addr ngay ở giai đoạn đọc request, trước khi limit_req chấm điểm, nhờ vậy limit_req_zone $binary_remote_addr giới hạn theo IP client thật; nếu không, một IP của LB ăn hết quota và cả thiên hạ bị 429 chung.
Điểm chết người: set_real_ip_from 0.0.0.0/0; biến header client tự khai thành sự thật tuyệt đối — mất rate limit, giả IP trong audit log, vượt allowlist theo IP. Chỉ liệt kê đúng dải bạn sở hữu hoặc của CDN. Module cần nginx biên dịch với --with-http_realip_module; ảnh nginx chính thức trên Docker Hub có sẵn, kiểm tra bằng nginx -V 2>&1 | tr ' ' '\n' | grep realip.
11.2.5 proxy_buffering — khi nào phải tắt
Mặc định proxy_buffering on: nginx đọc response vào buffer, giải phóng kết nối tới cmd/api càng sớm càng tốt, rồi mới nhẩn nha đẩy xuống client. Với client mạng chậm đây là tính năng quý — nếu không, một client 3G sẽ giữ một goroutine và một kết nối của app suốt thời gian tải. Nhưng với SSE (text/event-stream) và mọi thứ dạng stream, buffering giết chết ý nghĩa giao thức: sự kiện nằm im trong buffer cho tới khi đủ đầy hoặc upstream đóng kết nối — người dùng thấy giao diện đứng hình, còn log app ghi rõ là đã gửi.
Có cách mềm hơn location /events: app tự đặt header X-Accel-Buffering: no trong response, nginx đọc header đó và tắt buffering cho riêng response ấy. Quyền quyết định về tay cmd/api, không phải sửa cấu hình biên mỗi lần thêm endpoint stream.
11.3 HAProxy
Nginx là web server biết làm proxy. HAProxy ngược lại — nó chỉ là load balancer, và điều đó thể hiện ở chỗ những thứ khó (health check, stick table, thay đổi lúc chạy) đều là công dân hạng nhất chứ không phải phần thêm vào.
# /etc/haproxy/haproxy.cfg
global
log stdout format raw local0 info
# Runtime API — điểm mạnh lớn nhất so với nginx OSS: đổi trạng thái server, weight,
# maxconn... KHÔNG cần reload. `expose-fd listeners` cho phép reload mượt: tiến
# trình mới nhận lại socket đang lắng nghe, không rơi kết nối nào.
stats socket /var/run/haproxy.sock mode 660 level admin expose-fd listeners
stats timeout 30s
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
mode http
log global
option httplog
option forwardfor # tự thêm X-Forwarded-For
option http-keep-alive
timeout connect 5s
timeout client 60s
timeout server 60s
timeout http-request 10s # chống slowloris: header phải xong trong 10s
timeout http-keep-alive 10s
timeout tunnel 1h # WebSocket sống bằng timeout NÀY
compression algo gzip
compression type application/json application/javascript text/css text/plain
frontend fe_public
bind :80
bind :443 ssl crt /etc/haproxy/certs/community.pem alpn h2,http/1.1
http-request redirect scheme https unless { ssl_fc }
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Request id: nhận của client, thiếu thì sinh mới
http-request set-var(txn.req_id) req.hdr(X-Request-ID)
http-request set-var(txn.req_id) uuid() unless { req.hdr(X-Request-ID) -m found }
http-request set-header X-Request-ID %[var(txn.req_id)]
# cmd/api cần biết client vào bằng https hay http
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Proto http unless { ssl_fc }
# Rate limit. Khác limit_req của nginx ở chỗ bảng này ĐỌC ĐƯỢC lúc chạy qua runtime
# API — đang bị bắn thì thấy ngay IP nào bắn.
stick-table type ip size 200k expire 60s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
log-format "%ci:%cp %ST %B %Tr %{+Q}r id=%[var(txn.req_id)] be=%b srv=%s"
default_backend be_api
backend be_api
mode http
balance leastconn # ưu tiên node ít kết nối đang mở nhất — hợp với request
# có thời gian xử lý lệch nhau nhiều
option httpchk
http-check send meth GET uri /healthz ver HTTP/1.1 hdr Host community.internal
http-check expect status 200
http-reuse safe # tái dùng kết nối upstream, nhưng chỉ khi an toàn
# rise 2: hai lần OK liên tiếp mới cho về đội hình (tránh node vừa khởi động đã bị
# dội request). fall 3: ba lần fail mới loại (tránh loại nhầm vì một gói tin rớt).
# Đây là hai con số đáng chỉnh nhất trong cả file.
server api1 10.0.1.11:8080 check inter 3s rise 2 fall 3 maxconn 200
server api2 10.0.1.12:8080 check inter 3s rise 2 fall 3 maxconn 200
All rights reserved