refactor(handler/http2): split handler into 3 focused files with 52 tests

Extract authenticate/probe-resistance to auth.go and roundTrip/forwarding
to proxy.go, following the handler/http/ pattern. Export sentinel errors
(ErrAuthFailed, ErrWrongConnType) for precise test assertions. Fix
flushWriter panic recovery to handle non-string/non-error panics. Default
HTTPS port to 443 when no port is specified in the Host header.
This commit is contained in:
ginuerzh
2026-05-30 18:01:19 +08:00
parent a33c6f4a92
commit 6ccaae0573
11 changed files with 1228 additions and 382 deletions
+146
View File
@@ -0,0 +1,146 @@
package http2
import (
"context"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/http/httputil"
"os"
"strconv"
"strings"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
)
func (h *http2Handler) basicProxyAuth(proxyAuth string) (username, password string, ok bool) {
if proxyAuth == "" {
return
}
if !strings.HasPrefix(proxyAuth, "Basic ") {
return
}
c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(proxyAuth, "Basic "))
if err != nil {
return
}
cs := string(c)
s := strings.IndexByte(cs, ':')
if s < 0 {
return
}
return cs[:s], cs[s+1:], true
}
func (h *http2Handler) authenticate(ctx context.Context, w http.ResponseWriter, r *http.Request, resp *http.Response, log logger.Logger) (id string, ok bool) {
u, p, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization"))
if h.options.Auther == nil {
return "", true
}
if id, ok = h.options.Auther.Authenticate(ctx, u, p, auth.WithService(h.options.Service)); ok {
return
}
pr := h.md.probeResistance
// probing resistance is enabled, and knocking host is mismatch.
if pr != nil && (pr.Knock == "" || !knockMatch(r.URL.Hostname(), pr.Knock)) {
resp.StatusCode = http.StatusServiceUnavailable // default status code
switch pr.Type {
case "code":
resp.StatusCode, _ = strconv.Atoi(pr.Value)
case "web":
url := pr.Value
if !strings.HasPrefix(url, "http") {
url = "http://" + url
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
log.Error(err)
break
}
r, err := http.DefaultClient.Do(req)
if err != nil {
log.Error(err)
break
}
resp = r
defer resp.Body.Close()
case "host":
cc, err := net.Dial("tcp", pr.Value)
if err != nil {
log.Error(err)
break
}
defer cc.Close()
if err := h.forwardRequest(w, r, cc); err != nil {
log.Error(err)
}
return
case "file":
f, _ := os.Open(pr.Value)
if f != nil {
defer f.Close()
resp.StatusCode = http.StatusOK
if finfo, _ := f.Stat(); finfo != nil {
resp.ContentLength = finfo.Size()
}
resp.Header.Set("Content-Type", "text/html")
resp.Body = f
}
}
}
if resp.StatusCode == 0 {
realm := defaultRealm
if h.md.authBasicRealm != "" {
realm = h.md.authBasicRealm
}
resp.StatusCode = http.StatusProxyAuthRequired
resp.Header.Add("Proxy-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
if strings.ToLower(r.Header.Get("Proxy-Connection")) == "keep-alive" {
// XXX libcurl will keep sending auth request in same conn
// which we don't supported yet.
resp.Header.Set("Connection", "close")
resp.Header.Set("Proxy-Connection", "close")
}
log.Debug("proxy authentication required")
} else {
resp.Header = http.Header{}
// resp.Header.Set("Server", "nginx/1.20.1")
// resp.Header.Set("Date", time.Now().Format(http.TimeFormat))
if resp.StatusCode == http.StatusOK {
resp.Header.Set("Connection", "keep-alive")
}
}
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
h.writeResponse(w, resp)
return
}
// knockMatch reports whether hostname matches any entry in the
// comma-separated knock list. Matching is case-insensitive. An empty
// knock string returns false (no match).
func knockMatch(hostname, knock string) bool {
if knock == "" {
return false
}
for _, h := range strings.Split(knock, ",") {
if strings.EqualFold(hostname, strings.TrimSpace(h)) {
return true
}
}
return false
}
+182
View File
@@ -0,0 +1,182 @@
package http2
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/handler"
)
func TestKnockMatch(t *testing.T) {
tests := []struct {
hostname string
knock string
want bool
}{
{"", "", false},
{"example.com", "", false},
{"example.com", "example.com", true},
{"EXAMPLE.COM", "example.com", true},
{"example.com", "foo,example.com,bar", true},
{"other.com", "foo, bar , baz", false},
{"other.com", "foo,bar,baz", false},
{"bar", "foo, bar , baz", true},
{"BAR", "foo, bar , baz", true},
}
for _, tt := range tests {
got := knockMatch(tt.hostname, tt.knock)
if got != tt.want {
t.Errorf("knockMatch(%q, %q) = %v, want %v", tt.hostname, tt.knock, got, tt.want)
}
}
}
func TestBasicProxyAuth(t *testing.T) {
h := newTestHandler()
tests := []struct {
name string
auth string
wantUser string
wantPass string
wantOK bool
}{
{"empty", "", "", "", false},
{"not basic", "Bearer token", "", "", false},
{"no colon", "Basic " + base64.StdEncoding.EncodeToString([]byte("nocolon")), "", "", false},
{"valid", "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass")), "user", "pass", true},
{"password with colon", "Basic " + base64.StdEncoding.EncodeToString([]byte("user:pass:word")), "user", "pass:word", true},
{"invalid base64", "Basic not-valid-base64!!!", "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, pass, ok := h.basicProxyAuth(tt.auth)
if ok != tt.wantOK {
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
}
if user != tt.wantUser {
t.Errorf("user = %q, want %q", user, tt.wantUser)
}
if pass != tt.wantPass {
t.Errorf("pass = %q, want %q", pass, tt.wantPass)
}
})
}
}
type testAuther struct {
ok bool
id string
}
func (a *testAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
return a.id, a.ok
}
func TestAuthenticate(t *testing.T) {
t.Run("no auther", func(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
w := httptest.NewRecorder()
resp := &http.Response{Header: w.Header(), Body: io.NopCloser(strings.NewReader(""))}
id, ok := h.authenticate(context.Background(), w, req, resp, &testLogger{})
if !ok {
t.Error("expected ok without auther")
}
if id != "" {
t.Errorf("id = %q, want empty", id)
}
})
t.Run("auth succeeds", func(t *testing.T) {
h := newTestHandler(handler.AutherOption(&testAuther{ok: true, id: "client1"}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:pass")))
w := httptest.NewRecorder()
resp := &http.Response{Header: w.Header(), Body: io.NopCloser(strings.NewReader(""))}
id, ok := h.authenticate(context.Background(), w, req, resp, &testLogger{})
if !ok {
t.Error("expected ok with valid auth")
}
if id != "client1" {
t.Errorf("id = %q, want %q", id, "client1")
}
})
t.Run("auth fails - returns 407", func(t *testing.T) {
h := newTestHandler(handler.AutherOption(&testAuther{ok: false}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
w := httptest.NewRecorder()
resp := &http.Response{Header: w.Header(), Body: io.NopCloser(strings.NewReader(""))}
_, ok := h.authenticate(context.Background(), w, req, resp, &testLogger{})
if ok {
t.Error("expected !ok with failed auth")
}
if resp.StatusCode != http.StatusProxyAuthRequired {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusProxyAuthRequired)
}
if !strings.Contains(resp.Header.Get("Proxy-Authenticate"), "Basic") {
t.Error("missing Proxy-Authenticate header")
}
})
}
func TestAuthenticate_ProbeResistanceCode(t *testing.T) {
h := newTestHandler(handler.AutherOption(&testAuther{ok: false}))
h.md.probeResistance = &probeResistance{Type: "code", Value: "503"}
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
w := httptest.NewRecorder()
resp := &http.Response{Header: http.Header{}, Body: io.NopCloser(strings.NewReader(""))}
_, ok := h.authenticate(context.Background(), w, req, resp, &testLogger{})
if ok {
t.Error("expected !ok")
}
if resp.StatusCode != 503 {
t.Errorf("status = %d, want 503", resp.StatusCode)
}
}
func TestAuthenticate_ProbeResistanceKnockBypass(t *testing.T) {
h := newTestHandler(handler.AutherOption(&testAuther{ok: false}))
h.md.probeResistance = &probeResistance{Type: "code", Value: "403", Knock: "safe.example.com"}
req := httptest.NewRequest(http.MethodGet, "http://safe.example.com/", nil)
w := httptest.NewRecorder()
resp := &http.Response{Header: http.Header{}, Body: io.NopCloser(strings.NewReader(""))}
_, ok := h.authenticate(context.Background(), w, req, resp, &testLogger{})
if ok {
t.Error("expected !ok")
}
if resp.StatusCode != http.StatusProxyAuthRequired {
t.Errorf("status = %d, want %d (knock matched, probe resistance skipped)", resp.StatusCode, http.StatusProxyAuthRequired)
}
}
func TestAuthenticate_CustomRealm(t *testing.T) {
h := newTestHandler(handler.AutherOption(&testAuther{ok: false}))
h.md.authBasicRealm = "testrealm"
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
w := httptest.NewRecorder()
resp := &http.Response{Header: w.Header(), Body: io.NopCloser(strings.NewReader(""))}
_, ok := h.authenticate(context.Background(), w, req, resp, &testLogger{})
if ok {
t.Error("expected !ok")
}
if !strings.Contains(resp.Header.Get("Proxy-Authenticate"), `realm="testrealm"`) {
t.Errorf("Proxy-Authenticate = %q, want realm=testrealm", resp.Header.Get("Proxy-Authenticate"))
}
}
+8 -4
View File
@@ -2,6 +2,7 @@ package http2
import (
"errors"
"fmt"
"io"
"net/http"
)
@@ -13,11 +14,14 @@ type flushWriter struct {
func (fw flushWriter) Write(p []byte) (n int, err error) {
defer func() {
if r := recover(); r != nil {
if s, ok := r.(string); ok {
err = errors.New(s)
return
switch v := r.(type) {
case string:
err = errors.New(v)
case error:
err = v
default:
err = fmt.Errorf("%v", v)
}
err = r.(error)
}
}()
+110
View File
@@ -0,0 +1,110 @@
package http2
import (
"bytes"
"errors"
"net/http"
"testing"
)
type testFlusher struct {
bytes.Buffer
flushed bool
}
func (f *testFlusher) Flush() {
f.flushed = true
}
func TestFlushWriter_Write(t *testing.T) {
t.Run("normal write", func(t *testing.T) {
var buf bytes.Buffer
fw := flushWriter{w: &buf}
n, err := fw.Write([]byte("hello"))
if err != nil {
t.Fatalf("Write: %v", err)
}
if n != 5 {
t.Errorf("n = %d, want 5", n)
}
if buf.String() != "hello" {
t.Errorf("buf = %q, want %q", buf.String(), "hello")
}
})
t.Run("write and flush", func(t *testing.T) {
tf := &testFlusher{}
fw := flushWriter{w: tf}
_, err := fw.Write([]byte("hello"))
if err != nil {
t.Fatalf("Write: %v", err)
}
if !tf.flushed {
t.Error("expected flush after write")
}
if tf.String() != "hello" {
t.Errorf("buf = %q, want %q", tf.String(), "hello")
}
})
}
func TestFlushWriter_PanicRecovery(t *testing.T) {
t.Run("string panic", func(t *testing.T) {
pw := &panicWriter{panicVal: "boom"}
fw := flushWriter{w: pw}
_, err := fw.Write([]byte("data"))
if err == nil {
t.Fatal("expected error from panic recovery")
}
if err.Error() != "boom" {
t.Errorf("err = %q, want %q", err.Error(), "boom")
}
})
t.Run("error panic", func(t *testing.T) {
pw := &panicWriter{panicVal: errors.New("err boom")}
fw := flushWriter{w: pw}
_, err := fw.Write([]byte("data"))
if err == nil {
t.Fatal("expected error from panic recovery")
}
if err.Error() != "err boom" {
t.Errorf("err = %q, want %q", err.Error(), "err boom")
}
})
t.Run("int panic", func(t *testing.T) {
pw := &panicWriter{panicVal: 42}
fw := flushWriter{w: pw}
_, err := fw.Write([]byte("data"))
if err == nil {
t.Fatal("expected error from panic recovery")
}
if err.Error() != "42" {
t.Errorf("err = %q, want %q", err.Error(), "42")
}
})
}
type panicWriter struct {
panicVal any
}
func (w *panicWriter) Write([]byte) (int, error) {
panic(w.panicVal)
}
func TestFlushWriter_ImplementsHTTPFlusher(t *testing.T) {
// flushWriter should allow the wrapped value to be used as http.Flusher.
tf := &testFlusher{}
fw := flushWriter{w: tf}
_, err := fw.Write([]byte("x"))
if err != nil {
t.Fatal(err)
}
if !tf.flushed {
t.Error("expected Flush() to be called after Write()")
}
// Verify that testFlusher is recognized as http.Flusher.
var _ http.Flusher = tf
}
+34 -375
View File
@@ -1,48 +1,55 @@
// Package http2 implements an HTTP/2 proxy handler. It supports both forward
// proxy (GET/POST/CONNECT) and tunnel modes with optional probe resistance,
// bypass, traffic limiting, and observability.
//
// The handler is registered under the name "http2" and created via
// NewHandler in init(). It is designed for use with HTTP/2 (h2/h2c) listeners.
//
// # Request processing flow
//
// Each inbound net.Conn is handled by Handle, which delegates to roundTrip
// for authentication, bypass checks, routing, and forwarding.
//
// Handle()
// ├─ checkRateLimit (connection rate limiter, if configured)
// ├─ ictx.MetadataFromContext (extract w, r from HTTP/2 stream context)
// └─ roundTrip()
// ├─ decodeServerName (GOST 2.x compatibility headers)
// ├─ authenticate (basic proxy auth + probe resistance)
// ├─ bypass check
// ├─ Router.Dial (connect to upstream)
// ├─ forwardRequest (non-CONNECT: proxy a single request)
// └─ CONNECT tunnel (bi-directional pipe)
package http2
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"io"
"net"
"net/http"
"net/http/httputil"
"os"
"strconv"
"strings"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/limiter/traffic"
"github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/core/observer"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xbypass "github.com/go-gost/x/bypass"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xio "github.com/go-gost/x/internal/io"
xnet "github.com/go-gost/x/internal/net"
xhttp "github.com/go-gost/x/internal/net/http"
stats_util "github.com/go-gost/x/internal/util/stats"
rate_limiter "github.com/go-gost/x/limiter/rate"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
)
// Sentinel errors returned by the HTTP/2 handler.
var (
ErrAuthFailed = errors.New("http2: authentication failed")
ErrWrongConnType = errors.New("http2: wrong connection type")
)
func init() {
registry.HandlerRegistry().Register("http2", NewHandler)
}
@@ -56,6 +63,10 @@ type http2Handler struct {
recorder recorder.RecorderObject
}
// NewHandler creates a new HTTP/2 proxy handler. It supports forward proxy
// (GET/POST/CONNECT) and tunnel modes with optional probe resistance, bypass,
// traffic limiting, and observability. The handler is registered under the
// name "http2" and is intended for use with HTTP/2 (h2/h2c) listeners.
func NewHandler(opts ...handler.Option) handler.Handler {
options := handler.Options{}
for _, opt := range opts {
@@ -68,9 +79,7 @@ func NewHandler(opts ...handler.Option) handler.Handler {
}
func (h *http2Handler) Init(md md.Metadata) error {
if err := h.parseMetadata(md); err != nil {
return err
}
h.parseMetadata(md)
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
@@ -141,7 +150,7 @@ func (h *http2Handler) Handle(ctx context.Context, conn net.Conn, opts ...handle
md := ictx.MetadataFromContext(ctx)
if md == nil {
err = errors.New("http2: wrong connection type")
err = ErrWrongConnType
log.Error(err)
return err
}
@@ -159,341 +168,6 @@ func (h *http2Handler) Close() error {
return nil
}
// NOTE: there is an issue (golang/go#43989) will cause the client hangs
// when server returns an non-200 status code,
// May be fixed in go1.18.
func (h *http2Handler) roundTrip(ctx context.Context, w http.ResponseWriter, req *http.Request, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
if w == nil || req == nil {
return nil
}
// Try to get the actual host.
// Compatible with GOST 2.x.
if v := req.Header.Get("Gost-Target"); v != "" {
if h, err := h.decodeServerName(v); err == nil {
req.Host = h
}
}
if v := req.Header.Get("X-Gost-Target"); v != "" {
if h, err := h.decodeServerName(v); err == nil {
req.Host = h
}
}
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
ro.ClientIP = clientIP.String()
}
host := req.Host
if _, port, _ := net.SplitHostPort(host); port == "" {
host = net.JoinHostPort(strings.Trim(host, "[]"), "80")
}
ro.Host = host
fields := map[string]any{
"dst": host,
"host": host,
}
if u, _, _ := h.basicProxyAuth(req.Header.Get("Proxy-Authorization")); u != "" {
fields["user"] = u
ro.ClientID = u
}
log = log.WithFields(fields)
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(req, false)
log.Trace(string(dump))
}
log.Debugf("%s >> %s", req.RemoteAddr, host)
for k := range h.md.header {
w.Header().Set(k, h.md.header.Get(k))
}
resp := &http.Response{
ProtoMajor: 2,
ProtoMinor: 0,
Header: w.Header(),
Body: io.NopCloser(bytes.NewReader([]byte{})),
}
ro.HTTP = &xrecorder.HTTPRecorderObject{
Host: req.Host,
Proto: req.Proto,
Scheme: req.URL.Scheme,
Method: req.Method,
URI: req.RequestURI,
Request: xrecorder.HTTPRequestRecorderObject{
ContentLength: req.ContentLength,
Header: req.Header.Clone(),
},
}
defer func() {
ro.HTTP.StatusCode = resp.StatusCode
ro.HTTP.Response.Header = resp.Header
}()
clientID, ok := h.authenticate(ctx, w, req, resp, log)
if !ok {
return errors.New("authentication failed")
}
log = log.WithFields(map[string]any{"clientID": clientID})
ro.ClientID = clientID
ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(clientID))
if h.options.Bypass != nil && h.options.Bypass.Contains(ctx, "tcp", host, bypass.WithService(h.options.Service)) {
resp.StatusCode = http.StatusForbidden
w.WriteHeader(resp.StatusCode)
log.Debug("bypass: ", host)
return xbypass.ErrBypass
}
// delete the proxy related headers.
req.Header.Del("Proxy-Authorization")
req.Header.Del("Proxy-Connection")
req.Header.Del("Gost-Target")
req.Header.Del("X-Gost-Target")
switch h.md.hash {
case "host":
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: host})
}
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", host)
ro.Route = buf.String()
if err != nil {
log.Error(err)
resp.StatusCode = http.StatusServiceUnavailable
w.WriteHeader(resp.StatusCode)
return err
}
defer cc.Close()
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
if req.Method != http.MethodConnect {
start := time.Now()
log.Infof("%s <-> %s", req.RemoteAddr, host)
if err := h.forwardRequest(w, req, cc); err != nil {
log.Info("%s - %s: %s", req.RemoteAddr, host, err)
}
log.WithFields(map[string]any{
"duration": time.Since(start),
}).Infof("%s >-< %s", req.RemoteAddr, host)
return nil
}
resp.StatusCode = http.StatusOK
w.WriteHeader(http.StatusOK)
if fw, ok := w.(http.Flusher); ok {
fw.Flush()
}
rw := xio.NewReadWriter(req.Body, flushWriter{w})
// compatible with HTTP1.x
if hj, ok := w.(http.Hijacker); ok && req.ProtoMajor == 1 {
// we take over the underly connection
conn, _, err := hj.Hijack()
if err != nil {
log.Error(err)
resp.StatusCode = http.StatusInternalServerError
w.WriteHeader(http.StatusInternalServerError)
return err
}
defer conn.Close()
rw = conn
}
rw = traffic_wrapper.WrapReadWriter(
h.limiter,
rw,
clientID,
limiter.ScopeOption(limiter.ScopeClient),
limiter.ServiceOption(h.options.Service),
limiter.NetworkOption("tcp"),
limiter.AddrOption(host),
limiter.ClientOption(clientID),
limiter.SrcOption(req.RemoteAddr),
)
if h.options.Observer != nil {
pstats := h.stats.Stats(clientID)
pstats.Add(stats.KindTotalConns, 1)
pstats.Add(stats.KindCurrentConns, 1)
defer pstats.Add(stats.KindCurrentConns, -1)
rw = stats_wrapper.WrapReadWriter(rw, pstats)
}
start := time.Now()
log.Infof("%s <-> %s", req.RemoteAddr, host)
// xnet.Transport(rw, cc)
xnet.Pipe(ctx, xio.NewReadWriteCloser(rw, rw, req.Body), cc)
log.WithFields(map[string]any{
"duration": time.Since(start),
}).Infof("%s >-< %s", req.RemoteAddr, host)
return nil
}
func (h *http2Handler) decodeServerName(s string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return "", err
}
if len(b) < 4 {
return "", errors.New("invalid name")
}
v, err := base64.RawURLEncoding.DecodeString(string(b[4:]))
if err != nil {
return "", err
}
if crc32.ChecksumIEEE(v) != binary.BigEndian.Uint32(b[:4]) {
return "", errors.New("invalid name")
}
return string(v), nil
}
func (h *http2Handler) basicProxyAuth(proxyAuth string) (username, password string, ok bool) {
if proxyAuth == "" {
return
}
if !strings.HasPrefix(proxyAuth, "Basic ") {
return
}
c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(proxyAuth, "Basic "))
if err != nil {
return
}
cs := string(c)
s := strings.IndexByte(cs, ':')
if s < 0 {
return
}
return cs[:s], cs[s+1:], true
}
func (h *http2Handler) authenticate(ctx context.Context, w http.ResponseWriter, r *http.Request, resp *http.Response, log logger.Logger) (id string, ok bool) {
u, p, _ := h.basicProxyAuth(r.Header.Get("Proxy-Authorization"))
if h.options.Auther == nil {
return "", true
}
if id, ok = h.options.Auther.Authenticate(ctx, u, p, auth.WithService(h.options.Service)); ok {
return
}
pr := h.md.probeResistance
// probing resistance is enabled, and knocking host is mismatch.
if pr != nil && (pr.Knock == "" || !knockMatch(r.URL.Hostname(), pr.Knock)) {
resp.StatusCode = http.StatusServiceUnavailable // default status code
switch pr.Type {
case "code":
resp.StatusCode, _ = strconv.Atoi(pr.Value)
case "web":
url := pr.Value
if !strings.HasPrefix(url, "http") {
url = "http://" + url
}
r, err := http.Get(url)
if err != nil {
log.Error(err)
break
}
resp = r
defer resp.Body.Close()
case "host":
cc, err := net.Dial("tcp", pr.Value)
if err != nil {
log.Error(err)
break
}
defer cc.Close()
if err := h.forwardRequest(w, r, cc); err != nil {
log.Error(err)
}
return
case "file":
f, _ := os.Open(pr.Value)
if f != nil {
defer f.Close()
resp.StatusCode = http.StatusOK
if finfo, _ := f.Stat(); finfo != nil {
resp.ContentLength = finfo.Size()
}
resp.Header.Set("Content-Type", "text/html")
resp.Body = f
}
}
}
if resp.StatusCode == 0 {
realm := defaultRealm
if h.md.authBasicRealm != "" {
realm = h.md.authBasicRealm
}
resp.StatusCode = http.StatusProxyAuthRequired
resp.Header.Add("Proxy-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
if strings.ToLower(r.Header.Get("Proxy-Connection")) == "keep-alive" {
// XXX libcurl will keep sending auth request in same conn
// which we don't supported yet.
resp.Header.Set("Connection", "close")
resp.Header.Set("Proxy-Connection", "close")
}
log.Debug("proxy authentication required")
} else {
resp.Header = http.Header{}
// resp.Header.Set("Server", "nginx/1.20.1")
// resp.Header.Set("Date", time.Now().Format(http.TimeFormat))
if resp.StatusCode == http.StatusOK {
resp.Header.Set("Connection", "keep-alive")
}
}
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
h.writeResponse(w, resp)
return
}
func (h *http2Handler) forwardRequest(w http.ResponseWriter, r *http.Request, rw io.ReadWriter) (err error) {
if err = r.Write(rw); err != nil {
return
}
resp, err := http.ReadResponse(bufio.NewReader(rw), r)
if err != nil {
return
}
defer resp.Body.Close()
return h.writeResponse(w, resp)
}
func (h *http2Handler) writeResponse(w http.ResponseWriter, resp *http.Response) error {
for k, v := range resp.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.WriteHeader(resp.StatusCode)
_, err := io.Copy(flushWriter{w}, resp.Body)
return err
}
func (h *http2Handler) checkRateLimit(addr net.Addr) bool {
if h.options.RateLimiter == nil {
return true
@@ -536,18 +210,3 @@ func (h *http2Handler) observeStats(ctx context.Context) {
}
}
}
// knockMatch reports whether hostname matches any entry in the
// comma-separated knock list. Matching is case-insensitive. An empty
// knock string returns false (no match).
func knockMatch(hostname, knock string) bool {
if knock == "" {
return false
}
for _, h := range strings.Split(knock, ",") {
if strings.EqualFold(hostname, strings.TrimSpace(h)) {
return true
}
}
return false
}
+116
View File
@@ -0,0 +1,116 @@
package http2
import (
"context"
"net"
"testing"
"time"
"github.com/go-gost/core/handler"
)
func TestNewHandler(t *testing.T) {
h := NewHandler()
if h == nil {
t.Fatal("NewHandler returned nil")
}
h2, ok := h.(*http2Handler)
if !ok {
t.Fatalf("NewHandler returned %T, want *http2Handler", h)
}
if h2.options.Logger != nil {
t.Error("expected nil logger by default")
}
}
func TestClose(t *testing.T) {
h := newTestHandler()
if err := h.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestCloseAfterInit(t *testing.T) {
h := newTestHandler()
if err := h.Init(testMD(map[string]any{})); err != nil {
t.Fatalf("Init: %v", err)
}
if h.cancel == nil {
t.Fatal("cancel not set after Init")
}
if err := h.Close(); err != nil {
t.Fatalf("Close after Init: %v", err)
}
}
func TestInit(t *testing.T) {
h := newTestHandler(handler.ObserverOption(&testObserver{}))
if err := h.Init(testMD(map[string]any{})); err != nil {
t.Fatalf("Init: %v", err)
}
if h.stats == nil {
t.Error("stats not set when observer configured")
}
if h.cancel == nil {
t.Error("cancel not set")
}
h.Close()
}
func TestInit_Limiter(t *testing.T) {
h := newTestHandler(handler.TrafficLimiterOption(&stubTrafficLimiter{}))
if err := h.Init(testMD(map[string]any{})); err != nil {
t.Fatalf("Init: %v", err)
}
if h.limiter == nil {
t.Error("limiter not set")
}
h.Close()
}
func TestObserveStats_NilObserver(t *testing.T) {
h := newTestHandler()
// Should return immediately without panicking.
h.observeStats(context.Background())
}
func TestObserveStats_CancelledContext(t *testing.T) {
h := newTestHandler()
h.md.observerPeriod = 5 * time.Second
ctx, cancel := context.WithCancel(context.Background())
cancel()
h.observeStats(ctx)
}
func TestCheckRateLimit(t *testing.T) {
t.Run("no limiter", func(t *testing.T) {
h := newTestHandler()
addr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345}
if !h.checkRateLimit(addr) {
t.Error("expected true without limiter")
}
})
t.Run("with limiter", func(t *testing.T) {
h := newTestHandler(handler.RateLimiterOption(newStubRateLimiter()))
addr := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345}
if !h.checkRateLimit(addr) {
t.Error("expected true with always-allow limiter")
}
})
}
func TestHandle_WrongConnType(t *testing.T) {
h := newTestHandler()
if err := h.Init(testMD(map[string]any{})); err != nil {
t.Fatalf("Init: %v", err)
}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
err := h.Handle(context.Background(), server)
if err != ErrWrongConnType {
t.Errorf("err = %v, want ErrWrongConnType", err)
}
}
+95
View File
@@ -0,0 +1,95 @@
package http2
import (
"context"
"net"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/limiter/rate"
"github.com/go-gost/core/limiter/traffic"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer"
cmdata "github.com/go-gost/core/metadata"
xmetadata "github.com/go-gost/x/metadata"
)
// --- Shared test helpers ---
type testLogger struct{}
func (l *testLogger) WithFields(map[string]any) logger.Logger { return l }
func (l *testLogger) Debug(...any) {}
func (l *testLogger) Debugf(string, ...any) {}
func (l *testLogger) Info(...any) {}
func (l *testLogger) Infof(string, ...any) {}
func (l *testLogger) Warn(...any) {}
func (l *testLogger) Warnf(string, ...any) {}
func (l *testLogger) Error(...any) {}
func (l *testLogger) Errorf(string, ...any) {}
func (l *testLogger) Fatal(...any) {}
func (l *testLogger) Fatalf(string, ...any) {}
func (l *testLogger) GetLevel() logger.LogLevel { return logger.InfoLevel }
func (l *testLogger) IsLevelEnabled(logger.LogLevel) bool { return false }
func (l *testLogger) Trace(...any) {}
func (l *testLogger) Tracef(string, ...any) {}
type testObserver struct{}
func (o *testObserver) Observe(context.Context, []observer.Event, ...observer.Option) error { return nil }
func testMD(m map[string]any) cmdata.Metadata { return xmetadata.NewMetadata(m) }
func newTestHandler(opts ...handler.Option) *http2Handler {
options := handler.Options{Logger: &testLogger{}}
for _, opt := range opts {
opt(&options)
}
return &http2Handler{options: options}
}
// --- Stub types ---
type stubRateLimiter struct {
allow bool
}
func newStubRateLimiter() *stubRateLimiter {
return &stubRateLimiter{allow: true}
}
func (l *stubRateLimiter) Limiter(key string) rate.Limiter { return l }
func (l *stubRateLimiter) Allow(n int) bool { return l.allow }
func (l *stubRateLimiter) Limit() float64 { return 0 }
type stubTrafficLimiter struct{}
func (l *stubTrafficLimiter) In(ctx context.Context, key string, opts ...limiter.Option) traffic.Limiter {
return l
}
func (l *stubTrafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Option) traffic.Limiter {
return l
}
func (l *stubTrafficLimiter) Wait(ctx context.Context, n int) int { return n }
func (l *stubTrafficLimiter) Limit() int { return 0 }
func (l *stubTrafficLimiter) Set(n int) {}
type stubRouter struct{}
func (r *stubRouter) Options() *chain.RouterOptions { return nil }
func (r *stubRouter) Dial(ctx context.Context, network, address string, opts ...chain.DialOption) (net.Conn, error) {
return net.Dial(network, address)
}
func (r *stubRouter) Bind(ctx context.Context, network, address string, opts ...chain.BindOption) (net.Listener, error) {
return nil, nil
}
type stubBypass struct {
contains bool
}
func (b *stubBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
return b.contains
}
+1 -3
View File
@@ -25,7 +25,7 @@ type metadata struct {
limiterCleanupInterval time.Duration
}
func (h *http2Handler) parseMetadata(md mdata.Metadata) error {
func (h *http2Handler) parseMetadata(md mdata.Metadata) {
if m := mdutil.GetStringMapString(md, "http.header", "header"); len(m) > 0 {
hd := http.Header{}
for k, v := range m {
@@ -57,8 +57,6 @@ func (h *http2Handler) parseMetadata(md mdata.Metadata) error {
h.md.limiterRefreshInterval = mdutil.GetDuration(md, "limiter.refreshInterval")
h.md.limiterCleanupInterval = mdutil.GetDuration(md, "limiter.cleanupInterval")
return nil
}
type probeResistance struct {
+120
View File
@@ -0,0 +1,120 @@
package http2
import (
"testing"
"time"
)
func TestParseMetadata_ProbeResistance(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{
"probeResist": "code:403",
"knock": "example.com,foo.com",
})
h.parseMetadata(md)
pr := h.md.probeResistance
if pr == nil {
t.Fatal("probeResistance not set")
}
if pr.Type != "code" {
t.Errorf("type = %q, want %q", pr.Type, "code")
}
if pr.Value != "403" {
t.Errorf("value = %q, want %q", pr.Value, "403")
}
if pr.Knock != "example.com,foo.com" {
t.Errorf("knock = %q, want %q", pr.Knock, "example.com,foo.com")
}
}
func TestParseMetadata_Header(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{
"header": map[string]any{"X-Custom": "value1", "X-Other": "value2"},
})
h.parseMetadata(md)
if h.md.header.Get("X-Custom") != "value1" {
t.Errorf("X-Custom = %q, want %q", h.md.header.Get("X-Custom"), "value1")
}
if h.md.header.Get("X-Other") != "value2" {
t.Errorf("X-Other = %q, want %q", h.md.header.Get("X-Other"), "value2")
}
}
func TestParseMetadata_Hash(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{"hash": "host"})
h.parseMetadata(md)
if h.md.hash != "host" {
t.Errorf("hash = %q, want %q", h.md.hash, "host")
}
}
func TestParseMetadata_ObserverDefaults(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{})
h.parseMetadata(md)
if h.md.observerPeriod != 5*time.Second {
t.Errorf("observerPeriod = %v, want 5s", h.md.observerPeriod)
}
}
func TestParseMetadata_ObserverPeriodClamped(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{"observePeriod": "100ms"})
h.parseMetadata(md)
if h.md.observerPeriod != time.Second {
t.Errorf("observerPeriod = %v, want 1s (clamped)", h.md.observerPeriod)
}
}
func TestParseMetadata_ObserverResetTraffic(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{"observer.resetTraffic": true})
h.parseMetadata(md)
if !h.md.observerResetTraffic {
t.Error("observerResetTraffic = false, want true")
}
}
func TestParseMetadata_LimiterIntervals(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{
"limiter.refreshInterval": "30s",
"limiter.cleanupInterval": "60s",
})
h.parseMetadata(md)
if h.md.limiterRefreshInterval != 30*time.Second {
t.Errorf("limiterRefreshInterval = %v, want 30s", h.md.limiterRefreshInterval)
}
if h.md.limiterCleanupInterval != 60*time.Second {
t.Errorf("limiterCleanupInterval = %v, want 60s", h.md.limiterCleanupInterval)
}
}
func TestParseMetadata_HTTPHeaderAlias(t *testing.T) {
h := newTestHandler()
md := testMD(map[string]any{
"http.header": map[string]any{"X-Alias": "value"},
})
h.parseMetadata(md)
if h.md.header.Get("X-Alias") != "value" {
t.Errorf("X-Alias = %q, want %q", h.md.header.Get("X-Alias"), "value")
}
}
+258
View File
@@ -0,0 +1,258 @@
package http2
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"hash/crc32"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
xbypass "github.com/go-gost/x/bypass"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xio "github.com/go-gost/x/internal/io"
xnet "github.com/go-gost/x/internal/net"
xhttp "github.com/go-gost/x/internal/net/http"
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
xrecorder "github.com/go-gost/x/recorder"
)
// NOTE: there is an issue (golang/go#43989) will cause the client hangs
// when server returns an non-200 status code,
// May be fixed in go1.18.
func (h *http2Handler) roundTrip(ctx context.Context, w http.ResponseWriter, req *http.Request, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
if w == nil || req == nil {
return nil
}
// Try to get the actual host.
// Compatible with GOST 2.x.
if v := req.Header.Get("Gost-Target"); v != "" {
if h, err := h.decodeServerName(v); err == nil {
req.Host = h
}
}
if v := req.Header.Get("X-Gost-Target"); v != "" {
if h, err := h.decodeServerName(v); err == nil {
req.Host = h
}
}
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
ro.ClientIP = clientIP.String()
}
host := req.Host
if _, port, _ := net.SplitHostPort(host); port == "" {
port := "80"
if req.URL.Scheme == "https" || req.TLS != nil {
port = "443"
}
host = net.JoinHostPort(strings.Trim(host, "[]"), port)
}
ro.Host = host
fields := map[string]any{
"dst": host,
"host": host,
}
if u, _, _ := h.basicProxyAuth(req.Header.Get("Proxy-Authorization")); u != "" {
fields["user"] = u
ro.ClientID = u
}
log = log.WithFields(fields)
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(req, false)
log.Trace(string(dump))
}
log.Debugf("%s >> %s", req.RemoteAddr, host)
for k := range h.md.header {
w.Header().Set(k, h.md.header.Get(k))
}
resp := &http.Response{
ProtoMajor: 2,
ProtoMinor: 0,
Header: w.Header(),
Body: io.NopCloser(bytes.NewReader([]byte{})),
}
ro.HTTP = &xrecorder.HTTPRecorderObject{
Host: req.Host,
Proto: req.Proto,
Scheme: req.URL.Scheme,
Method: req.Method,
URI: req.RequestURI,
Request: xrecorder.HTTPRequestRecorderObject{
ContentLength: req.ContentLength,
Header: req.Header.Clone(),
},
}
defer func() {
ro.HTTP.StatusCode = resp.StatusCode
ro.HTTP.Response.Header = resp.Header
}()
clientID, ok := h.authenticate(ctx, w, req, resp, log)
if !ok {
return ErrAuthFailed
}
log = log.WithFields(map[string]any{"clientID": clientID})
ro.ClientID = clientID
ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(clientID))
if h.options.Bypass != nil && h.options.Bypass.Contains(ctx, "tcp", host, bypass.WithService(h.options.Service)) {
resp.StatusCode = http.StatusForbidden
w.WriteHeader(resp.StatusCode)
log.Debug("bypass: ", host)
return xbypass.ErrBypass
}
// delete the proxy related headers.
req.Header.Del("Proxy-Authorization")
req.Header.Del("Proxy-Connection")
req.Header.Del("Gost-Target")
req.Header.Del("X-Gost-Target")
switch h.md.hash {
case "host":
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: host})
}
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", host)
ro.Route = buf.String()
if err != nil {
log.Error(err)
resp.StatusCode = http.StatusServiceUnavailable
w.WriteHeader(resp.StatusCode)
return err
}
defer cc.Close()
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
if req.Method != http.MethodConnect {
start := time.Now()
log.Infof("%s <-> %s", req.RemoteAddr, host)
err = h.forwardRequest(w, req, cc)
log.WithFields(map[string]any{
"duration": time.Since(start),
}).Infof("%s >-< %s", req.RemoteAddr, host)
return err
}
resp.StatusCode = http.StatusOK
w.WriteHeader(http.StatusOK)
if fw, ok := w.(http.Flusher); ok {
fw.Flush()
}
rw := xio.NewReadWriter(req.Body, flushWriter{w})
// compatible with HTTP1.x
if hj, ok := w.(http.Hijacker); ok && req.ProtoMajor == 1 {
// we take over the underly connection
conn, _, err := hj.Hijack()
if err != nil {
log.Error(err)
resp.StatusCode = http.StatusInternalServerError
w.WriteHeader(http.StatusInternalServerError)
return err
}
defer conn.Close()
rw = conn
}
rw = traffic_wrapper.WrapReadWriter(
h.limiter,
rw,
clientID,
limiter.ScopeOption(limiter.ScopeClient),
limiter.ServiceOption(h.options.Service),
limiter.NetworkOption("tcp"),
limiter.AddrOption(host),
limiter.ClientOption(clientID),
limiter.SrcOption(req.RemoteAddr),
)
if h.options.Observer != nil {
pstats := h.stats.Stats(clientID)
pstats.Add(stats.KindTotalConns, 1)
pstats.Add(stats.KindCurrentConns, 1)
defer pstats.Add(stats.KindCurrentConns, -1)
rw = stats_wrapper.WrapReadWriter(rw, pstats)
}
start := time.Now()
log.Infof("%s <-> %s", req.RemoteAddr, host)
// xnet.Transport(rw, cc)
xnet.Pipe(ctx, xio.NewReadWriteCloser(rw, rw, req.Body), cc)
log.WithFields(map[string]any{
"duration": time.Since(start),
}).Infof("%s >-< %s", req.RemoteAddr, host)
return nil
}
func (h *http2Handler) decodeServerName(s string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return "", err
}
if len(b) < 4 {
return "", errors.New("invalid name")
}
v, err := base64.RawURLEncoding.DecodeString(string(b[4:]))
if err != nil {
return "", err
}
if crc32.ChecksumIEEE(v) != binary.BigEndian.Uint32(b[:4]) {
return "", errors.New("invalid name")
}
return string(v), nil
}
func (h *http2Handler) forwardRequest(w http.ResponseWriter, r *http.Request, rw io.ReadWriter) (err error) {
if err = r.Write(rw); err != nil {
return
}
resp, err := http.ReadResponse(bufio.NewReader(rw), r)
if err != nil {
return
}
defer resp.Body.Close()
return h.writeResponse(w, resp)
}
func (h *http2Handler) writeResponse(w http.ResponseWriter, resp *http.Response) error {
for k, v := range resp.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.WriteHeader(resp.StatusCode)
_, err := io.Copy(flushWriter{w}, resp.Body)
return err
}
+158
View File
@@ -0,0 +1,158 @@
package http2
import (
"context"
"encoding/base64"
"encoding/binary"
"hash/crc32"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-gost/core/handler"
xrecorder "github.com/go-gost/x/recorder"
)
func TestDecodeServerName(t *testing.T) {
encode := func(name string) string {
data := []byte(name)
checksum := make([]byte, 4)
binary.BigEndian.PutUint32(checksum, crc32.ChecksumIEEE(data))
payload := base64.RawURLEncoding.EncodeToString(data)
return base64.RawURLEncoding.EncodeToString(append(checksum, []byte(payload)...))
}
h := newTestHandler()
t.Run("valid", func(t *testing.T) {
encoded := encode("example.com:443")
got, err := h.decodeServerName(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "example.com:443" {
t.Errorf("got %q, want %q", got, "example.com:443")
}
})
t.Run("invalid base64", func(t *testing.T) {
_, err := h.decodeServerName("!!!invalid!!!")
if err == nil {
t.Error("expected error for invalid base64")
}
})
t.Run("too short", func(t *testing.T) {
_, err := h.decodeServerName(base64.RawURLEncoding.EncodeToString([]byte("ab")))
if err == nil {
t.Error("expected error for short input")
}
})
t.Run("wrong checksum", func(t *testing.T) {
data := []byte("target")
badChecksum := make([]byte, 4)
binary.BigEndian.PutUint32(badChecksum, 0xDEADBEEF)
payload := base64.RawURLEncoding.EncodeToString(data)
encoded := base64.RawURLEncoding.EncodeToString(append(badChecksum, []byte(payload)...))
_, err := h.decodeServerName(encoded)
if err == nil {
t.Error("expected error for wrong checksum")
}
})
}
func TestWriteResponse(t *testing.T) {
h := newTestHandler()
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"X-Test": []string{"value"}},
Body: io.NopCloser(strings.NewReader("response body")),
}
w := httptest.NewRecorder()
if err := h.writeResponse(w, resp); err != nil {
t.Fatalf("writeResponse: %v", err)
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Header().Get("X-Test") != "value" {
t.Errorf("X-Test = %q, want %q", w.Header().Get("X-Test"), "value")
}
if w.Body.String() != "response body" {
t.Errorf("body = %q, want %q", w.Body.String(), "response body")
}
}
func TestForwardRequest(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Backend", "ok")
w.WriteHeader(http.StatusOK)
w.Write([]byte("backend response"))
}))
defer backend.Close()
h := newTestHandler()
cc, err := net.Dial("tcp", strings.TrimPrefix(backend.URL, "http://"))
if err != nil {
t.Fatalf("dial backend: %v", err)
}
defer cc.Close()
req := httptest.NewRequest(http.MethodGet, "http://example.com/path", nil)
w := httptest.NewRecorder()
if err := h.forwardRequest(w, req, cc); err != nil {
t.Fatalf("forwardRequest: %v", err)
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Header().Get("X-Backend") != "ok" {
t.Errorf("X-Backend = %q, want %q", w.Header().Get("X-Backend"), "ok")
}
if w.Body.String() != "backend response" {
t.Errorf("body = %q, want %q", w.Body.String(), "backend response")
}
}
func TestForwardRequest_WriteError(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
client, server := net.Pipe()
server.Close()
defer client.Close()
err := h.forwardRequest(httptest.NewRecorder(), req, client)
if err == nil {
t.Error("expected error from failed write")
}
}
func TestRoundTrip_NilRequest(t *testing.T) {
h := newTestHandler()
h.Init(testMD(map[string]any{}))
if err := h.roundTrip(context.Background(), nil, nil, nil, &testLogger{}); err != nil {
t.Errorf("roundTrip with nil args: %v", err)
}
}
func TestRoundTrip_AuthFailed(t *testing.T) {
h := newTestHandler(handler.AutherOption(&testAuther{ok: false}))
h.Init(testMD(map[string]any{}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
w := httptest.NewRecorder()
ro := &xrecorder.HandlerRecorderObject{}
err := h.roundTrip(context.Background(), w, req, ro, &testLogger{})
if err != ErrAuthFailed {
t.Errorf("err = %v, want ErrAuthFailed", err)
}
}