0

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/httpHijacker để làm tunnel, net.DialerDialContext. 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 = nilhttp.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 ReverseProxy làm gì bên trong. Nó là hàm tiện lợi viết từ trước khi trường Rewrite tồ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.

Directorfunc(*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.

Rewritefunc(*httputil.ProxyRequest), và ProxyRequesthai 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 OutForwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto. Mặc định của Rewritekhông tin gì cả; mặc định của Directortin 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ới Director, kẻ tấn công gửi X-Forwarded-For: 8.8.8.8 thì upstream nhận 8.8.8.8, <IP thật>. Code kiểu strings.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
  • ModifyResponse trả 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ết res.Body ở đây trừ khi chấp nhận mất streaming.
  • FlushInterval = -1 nghĩa là flush ngay sau mỗi lần ghi. Nhưng ReverseProxy đã tự chọn chế độ flush-ngay cho phản hồi có Content-Type: text/event-stream và cho phản hồi không biết trước Content-Length. Đặt -1 là để 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 Protocols thì ReverseProxy tự chuyển sang tunnel hai chiều — nhưng WriteTimeout ở §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.Timeout cho 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 ReverseProxy nhận http.RoundTripper chứ không nhận *http.Client. Cách đúng: ResponseHeaderTimeout chặn upstream treo, cộng middleware.Timeout theo 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 ReadHeaderTimeoutReadTimeout/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.com phâ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úc Dial thậ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 hook net.Dialer.Control — nó nhận address là 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 đó:

  1. 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).
  2. localhost và địa chỉ loopback luôn được bỏ qua, không đi qua proxy, kể cả khi không có trong NO_PROXY.
  3. HTTPS_PROXY trỏ tới một proxy HTTP là bình thường — nó nói "dùng proxy nào cho request https://", không phải "proxy nói HTTPS". Transport tự phát CONNECT tới proxy đó, và nếu proxy URL có user:pass thì tự sinh Proxy-Authorization.

Mức 2 — hàm động. Transport.Proxyfunc(*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_PROXYNO_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.RemoteAddr vẫ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ận 429. 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: RealIP tin 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()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 Rewrite thành httputil.NewSingleHostReverseProxy(target) rồi assert rằng XFF bịa 203.0.113.9 vẫ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ề Director sẽ 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, ModifyResponse và hai chiều io.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 đặt HTTP_PORT=8000, còn deployments/docker-compose.yml đang chiếm cổng 8080 trên host cho kafka-ui. Toàn bộ cấu hình dưới đây dùng 8080 cho cmd/api. Trong mạng Docker nội bộ không có xung đột — api:8080kafka-ui là hai container, mỗi container một không gian cổng riêng. Chỉ khi chạy go run ./cmd/api thẳng trên host mới phải chọn: hoặc đặt HTTP_PORT=8080 rồi đổi cổng publish của kafka-ui, hoặc giữ 8000 và 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ủa gzip_proxiedoff: 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ật gzip on, mở DevTools, thấy Content-Encoding trố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_timeout phía nginx phải nhỏ hơn IdleTimeout của http.Server trong cmd/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áo 502, 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí