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:
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user