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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -13,11 +14,14 @@ type flushWriter struct {
|
|||||||
func (fw flushWriter) Write(p []byte) (n int, err error) {
|
func (fw flushWriter) Write(p []byte) (n int, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
if s, ok := r.(string); ok {
|
switch v := r.(type) {
|
||||||
err = errors.New(s)
|
case string:
|
||||||
return
|
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
|
package http2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"hash/crc32"
|
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-gost/core/auth"
|
|
||||||
"github.com/go-gost/core/bypass"
|
|
||||||
"github.com/go-gost/core/handler"
|
"github.com/go-gost/core/handler"
|
||||||
"github.com/go-gost/core/limiter"
|
"github.com/go-gost/core/limiter"
|
||||||
"github.com/go-gost/core/limiter/traffic"
|
"github.com/go-gost/core/limiter/traffic"
|
||||||
"github.com/go-gost/core/logger"
|
|
||||||
md "github.com/go-gost/core/metadata"
|
md "github.com/go-gost/core/metadata"
|
||||||
"github.com/go-gost/core/observer"
|
"github.com/go-gost/core/observer"
|
||||||
"github.com/go-gost/core/observer/stats"
|
|
||||||
"github.com/go-gost/core/recorder"
|
"github.com/go-gost/core/recorder"
|
||||||
xbypass "github.com/go-gost/x/bypass"
|
|
||||||
xctx "github.com/go-gost/x/ctx"
|
xctx "github.com/go-gost/x/ctx"
|
||||||
ictx "github.com/go-gost/x/internal/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"
|
stats_util "github.com/go-gost/x/internal/util/stats"
|
||||||
rate_limiter "github.com/go-gost/x/limiter/rate"
|
rate_limiter "github.com/go-gost/x/limiter/rate"
|
||||||
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
|
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"
|
xrecorder "github.com/go-gost/x/recorder"
|
||||||
"github.com/go-gost/x/registry"
|
"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() {
|
func init() {
|
||||||
registry.HandlerRegistry().Register("http2", NewHandler)
|
registry.HandlerRegistry().Register("http2", NewHandler)
|
||||||
}
|
}
|
||||||
@@ -56,6 +63,10 @@ type http2Handler struct {
|
|||||||
recorder recorder.RecorderObject
|
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 {
|
func NewHandler(opts ...handler.Option) handler.Handler {
|
||||||
options := handler.Options{}
|
options := handler.Options{}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -68,9 +79,7 @@ func NewHandler(opts ...handler.Option) handler.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *http2Handler) Init(md md.Metadata) error {
|
func (h *http2Handler) Init(md md.Metadata) error {
|
||||||
if err := h.parseMetadata(md); err != nil {
|
h.parseMetadata(md)
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
h.cancel = cancel
|
h.cancel = cancel
|
||||||
@@ -141,7 +150,7 @@ func (h *http2Handler) Handle(ctx context.Context, conn net.Conn, opts ...handle
|
|||||||
|
|
||||||
md := ictx.MetadataFromContext(ctx)
|
md := ictx.MetadataFromContext(ctx)
|
||||||
if md == nil {
|
if md == nil {
|
||||||
err = errors.New("http2: wrong connection type")
|
err = ErrWrongConnType
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -159,341 +168,6 @@ func (h *http2Handler) Close() error {
|
|||||||
return nil
|
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 {
|
func (h *http2Handler) checkRateLimit(addr net.Addr) bool {
|
||||||
if h.options.RateLimiter == nil {
|
if h.options.RateLimiter == nil {
|
||||||
return true
|
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
|
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 {
|
if m := mdutil.GetStringMapString(md, "http.header", "header"); len(m) > 0 {
|
||||||
hd := http.Header{}
|
hd := http.Header{}
|
||||||
for k, v := range m {
|
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.limiterRefreshInterval = mdutil.GetDuration(md, "limiter.refreshInterval")
|
||||||
h.md.limiterCleanupInterval = mdutil.GetDuration(md, "limiter.cleanupInterval")
|
h.md.limiterCleanupInterval = mdutil.GetDuration(md, "limiter.cleanupInterval")
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type probeResistance struct {
|
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