mirror of
https://github.com/hanzoai/proxy.git
synced 2026-10-30 18:23:27 +00:00
Pool braided three independent things into one struct — what an upstream can
serve, how to reach it, and how it has been doing — and Dial braided four more:
fencing, selection, failover, transport. Every new policy meant another field
and another branch inside a method nobody outside the package could extend.
There is one type now:
type Exit func(ctx, Need, addr) (net.Conn, error)
A vendor, our own egress, a test double and the composition of a dozen of them
are all that, so the rest of the package is functions that return one. Rule is
Exit to Exit and holds fencing and capability; Order is a ranking over a set and
holds health and session pinning; Try walks an order; Chain composes. A vendor
stops being behaviour and becomes a Gate, which is data with an Exit() on it.
What this buys is that a policy no longer has to be anticipated. Fence wraps a
whole route, so an exit added later cannot miss it — it could before, being a
per-Dial check on a struct anyone could append to. Health hands back its Order
and the Rule that feeds it as a pair, because it is one fact observed in two
places and returning only the Order left the counting to be wired by hand.
Chain is generic: composing endomorphisms is not a fact about proxies.
Co-authored-by: Hanzo Dev <[email protected]>
187 lines
5.7 KiB
Go
187 lines
5.7 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// front stands the listener up in front of a live upstream and returns a
|
|
// client configured to use it, which is the whole contract: point any HTTP
|
|
// client at a URL and it works.
|
|
func front(t *testing.T, check func(context.Context, string) error) (*http.Client, *upstream, *url.URL) {
|
|
t.Helper()
|
|
up := serveUp(t, false)
|
|
s := &Server{
|
|
Exit: open(t, Gate{
|
|
Name: "vendor", Addr: up.addr(), Pass: "secret",
|
|
User: `acct{{with .Country}}-c-{{.}}{{end}}{{with .Session}}-s-{{.}}{{end}}`,
|
|
}),
|
|
Check: check,
|
|
}
|
|
front := httptest.NewServer(s)
|
|
t.Cleanup(front.Close)
|
|
u, err := url.Parse(front.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &http.Client{Transport: &http.Transport{
|
|
Proxy: http.ProxyURL(u),
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}}, up, u
|
|
}
|
|
|
|
// The end-to-end shape: an unmodified http.Client, a proxy URL carrying the
|
|
// need in the username, a TLS origin on the far side. TLS matters here — it is
|
|
// what proves the tunnel is a tunnel and not something reading the payload.
|
|
func TestClientReachesOriginThroughTunnel(t *testing.T) {
|
|
site := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, "hello")
|
|
}))
|
|
defer site.Close()
|
|
|
|
c, up, u := front(t, nil)
|
|
u.User = url.UserPassword("de.residential.s-cart42", "token")
|
|
c.Transport.(*http.Transport).Proxy = http.ProxyURL(u)
|
|
|
|
resp, err := c.Get(site.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if string(body) != "hello" {
|
|
t.Fatalf("got %q through the tunnel, want %q", body, "hello")
|
|
}
|
|
// And the need the caller wrote in the URL reached the provider.
|
|
if got := up.seen(); len(got) != 1 || got[0] != "acct-c-de-s-cart42" {
|
|
t.Fatalf("provider saw %q; the need did not survive the hop", got)
|
|
}
|
|
}
|
|
|
|
// No credential must be answered with the challenge, not with a tunnel.
|
|
func TestAuthRequired(t *testing.T) {
|
|
c, _, _ := front(t, func(context.Context, string) error { return nil })
|
|
resp, err := c.Get("https://example.invalid/")
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
t.Fatal("an unauthenticated CONNECT was tunnelled")
|
|
}
|
|
if !strings.Contains(err.Error(), "Proxy Authentication Required") {
|
|
t.Fatalf("got %v, want the 407 challenge", err)
|
|
}
|
|
}
|
|
|
|
// A rejected token and a malformed one must look identical from outside, so
|
|
// the surface cannot be used to sort real tokens from invented ones.
|
|
func TestDeniedTokenIsRefused(t *testing.T) {
|
|
c, up, u := front(t, func(_ context.Context, tok string) error {
|
|
if tok != "good" {
|
|
return ErrDenied
|
|
}
|
|
return nil
|
|
})
|
|
u.User = url.UserPassword("", "bad")
|
|
c.Transport.(*http.Transport).Proxy = http.ProxyURL(u)
|
|
|
|
if resp, err := c.Get("https://example.invalid/"); err == nil {
|
|
resp.Body.Close()
|
|
t.Fatal("a denied token was tunnelled")
|
|
}
|
|
if len(up.seen()) != 0 {
|
|
t.Fatal("a denied caller reached the provider")
|
|
}
|
|
}
|
|
|
|
// Plain proxied GET is refused on purpose: serving it would put this in the
|
|
// middle of unencrypted requests carrying the caller's own credentials.
|
|
func TestPlainGetIsRefused(t *testing.T) {
|
|
up := serveUp(t, false)
|
|
s := &Server{Exit: open(t, Gate{Name: "v", Addr: up.addr()})}
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, httptest.NewRequest("GET", "http://example.com/", nil))
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("plain GET got %d, want 405", w.Code)
|
|
}
|
|
}
|
|
|
|
// A need nothing serves must not read as a transient upstream failure.
|
|
func TestUnservableNeedIsNotABadGateway(t *testing.T) {
|
|
s := &Server{Exit: Only(In(nil, []Kind{Datacenter}))(
|
|
exit(t, Gate{Name: "dc", Addr: "http://127.0.0.1:1"}))}
|
|
// Authority-form, as net/http hands a real CONNECT to a handler.
|
|
r := httptest.NewRequest("CONNECT", "/", nil)
|
|
r.URL = &url.URL{Host: "example.com:443"}
|
|
r.Header.Set("Proxy-Authorization", basic("mobile", "t"))
|
|
w := httptest.NewRecorder()
|
|
s.ServeHTTP(w, r)
|
|
if w.Code != http.StatusNotImplemented {
|
|
t.Fatalf("got %d, want 501 for a need no pool serves", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestParse(t *testing.T) {
|
|
for _, c := range []struct {
|
|
in string
|
|
want Need
|
|
}{
|
|
{"", Need{}},
|
|
{"de", Need{Country: "de"}},
|
|
{"DE", Need{Country: "de"}},
|
|
{"mobile", Need{Kind: Mobile}},
|
|
{"de.residential.s-cart42", Need{Country: "de", Kind: Residential, Session: "cart42"}},
|
|
// Order is not significant: the fields name themselves.
|
|
{"s-cart42.mobile.br", Need{Country: "br", Kind: Mobile, Session: "cart42"}},
|
|
// A session may look like anything, including a country or a kind.
|
|
{"s-de", Need{Session: "de"}},
|
|
{"s-", Need{}},
|
|
} {
|
|
got, err := Parse(c.in)
|
|
if err != nil {
|
|
t.Errorf("Parse(%q): %v", c.in, err)
|
|
continue
|
|
}
|
|
if !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("Parse(%q) = %+v, want %+v", c.in, got, c.want)
|
|
}
|
|
}
|
|
// An unknown field is refused rather than ignored: silently dropping it
|
|
// would serve traffic from the wrong country and look like it worked.
|
|
for _, bad := range []string{"zzz", "resident", "de.nope"} {
|
|
if _, err := Parse(bad); err == nil {
|
|
t.Errorf("Parse(%q) accepted an unknown field", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProxyAuth(t *testing.T) {
|
|
u, p, ok := proxyAuth(basic("de.mobile", "tok"))
|
|
if !ok || u != "de.mobile" || p != "tok" {
|
|
t.Fatalf("got %q %q %v", u, p, ok)
|
|
}
|
|
for _, bad := range []string{"", "Bearer x", "Basic !!!"} {
|
|
if _, _, ok := proxyAuth(bad); ok {
|
|
t.Errorf("accepted %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestErrDenied(t *testing.T) {
|
|
if !errors.Is(ErrDenied, ErrDenied) {
|
|
t.Fatal("ErrDenied is not itself")
|
|
}
|
|
}
|
|
|
|
func basic(u, p string) string {
|
|
r := http.Request{Header: http.Header{}}
|
|
r.SetBasicAuth(u, p)
|
|
return r.Header.Get("Authorization")
|
|
}
|