diff --git a/handler/http2/auth.go b/handler/http2/auth.go new file mode 100644 index 00000000..3fe87418 --- /dev/null +++ b/handler/http2/auth.go @@ -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 +} diff --git a/handler/http2/auth_test.go b/handler/http2/auth_test.go new file mode 100644 index 00000000..05ad7354 --- /dev/null +++ b/handler/http2/auth_test.go @@ -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")) + } +} diff --git a/handler/http2/conn.go b/handler/http2/conn.go index d2b28b2a..c7bf9cc5 100644 --- a/handler/http2/conn.go +++ b/handler/http2/conn.go @@ -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) } }() diff --git a/handler/http2/conn_test.go b/handler/http2/conn_test.go new file mode 100644 index 00000000..92ac4f4b --- /dev/null +++ b/handler/http2/conn_test.go @@ -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 +} diff --git a/handler/http2/handler.go b/handler/http2/handler.go index 4c886001..46008da2 100644 --- a/handler/http2/handler.go +++ b/handler/http2/handler.go @@ -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 -} diff --git a/handler/http2/handler_test.go b/handler/http2/handler_test.go new file mode 100644 index 00000000..62c92765 --- /dev/null +++ b/handler/http2/handler_test.go @@ -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) + } +} diff --git a/handler/http2/helpers_test.go b/handler/http2/helpers_test.go new file mode 100644 index 00000000..0d1bfbaa --- /dev/null +++ b/handler/http2/helpers_test.go @@ -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 +} diff --git a/handler/http2/metadata.go b/handler/http2/metadata.go index 56fb6d3e..726d758a 100644 --- a/handler/http2/metadata.go +++ b/handler/http2/metadata.go @@ -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 { diff --git a/handler/http2/metadata_test.go b/handler/http2/metadata_test.go new file mode 100644 index 00000000..72df10ec --- /dev/null +++ b/handler/http2/metadata_test.go @@ -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") + } +} diff --git a/handler/http2/proxy.go b/handler/http2/proxy.go new file mode 100644 index 00000000..71508e3a --- /dev/null +++ b/handler/http2/proxy.go @@ -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 +} diff --git a/handler/http2/proxy_test.go b/handler/http2/proxy_test.go new file mode 100644 index 00000000..9cdc3b0c --- /dev/null +++ b/handler/http2/proxy_test.go @@ -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) + } +}