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]>
183 lines
5.3 KiB
Go
183 lines
5.3 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// Server answers HTTP CONNECT, which is the one interface every client already
|
|
// speaks — curl, Chrome, Go, Rust reqwest, Python requests, a headless browser
|
|
// in a container. Nothing has to be taught about us to use us.
|
|
//
|
|
// The caller states the need in the proxy USERNAME and authenticates with the
|
|
// password, so the whole configuration is a URL:
|
|
//
|
|
// http://de.residential.s-cart42:<token>@proxy.hanzo.ai:8080
|
|
//
|
|
// Fields are self-describing and order does not matter: a two-letter code is a
|
|
// country, a kind names itself, and s-<id> pins a session.
|
|
type Server struct {
|
|
// Exit is whatever the deployment composed — one gate, or a fenced,
|
|
// health-ordered, session-pinned route over a dozen. This type cannot tell
|
|
// and does not need to.
|
|
Exit Exit
|
|
// Check authenticates a caller. It is a function rather than a dependency
|
|
// so this package stays free of an identity provider; the cloud passes
|
|
// IAM, and there is no second way to be allowed in.
|
|
Check func(ctx context.Context, token string) error
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// ErrDenied is returned by Check when the token is not good.
|
|
var ErrDenied = errors.New("denied")
|
|
|
|
func (s *Server) log() *slog.Logger {
|
|
if s.Log != nil {
|
|
return s.Log
|
|
}
|
|
return slog.Default()
|
|
}
|
|
|
|
// ServeHTTP handles CONNECT and refuses everything else.
|
|
//
|
|
// Plain proxied GET is deliberately not served. It would put us in the middle
|
|
// of unencrypted requests carrying the caller's own cookies and credentials,
|
|
// to read or to log by accident. CONNECT keeps the payload end-to-end between
|
|
// the caller and the site, and leaves us with the one thing we need: the host.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodConnect {
|
|
http.Error(w, "this proxy tunnels; use CONNECT", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
user, token, ok := proxyAuth(r.Header.Get("Proxy-Authorization"))
|
|
if !ok {
|
|
w.Header().Set("Proxy-Authenticate", `Basic realm="hanzo"`)
|
|
http.Error(w, "", http.StatusProxyAuthRequired)
|
|
return
|
|
}
|
|
if s.Check != nil {
|
|
if err := s.Check(r.Context(), token); err != nil {
|
|
// One shape of answer whatever went wrong, so this cannot be used
|
|
// to sort real tokens from made-up ones.
|
|
w.Header().Set("Proxy-Authenticate", `Basic realm="hanzo"`)
|
|
http.Error(w, "", http.StatusProxyAuthRequired)
|
|
return
|
|
}
|
|
}
|
|
need, err := Parse(user)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
addr := r.URL.Host
|
|
if _, _, err := net.SplitHostPort(addr); err != nil {
|
|
addr = net.JoinHostPort(addr, "443")
|
|
}
|
|
|
|
up, err := s.Exit(r.Context(), need, addr)
|
|
if err != nil {
|
|
code := http.StatusBadGateway
|
|
if errors.Is(err, ErrNoExit) {
|
|
code = http.StatusNotImplemented
|
|
}
|
|
// The error names which upstreams failed and is for us, not for the
|
|
// caller, who is told only that no exit worked.
|
|
s.log().Warn("no exit", "host", addr, "country", need.Country, "kind", need.Kind.String(), "err", err)
|
|
http.Error(w, "no exit", code)
|
|
return
|
|
}
|
|
defer up.Close()
|
|
|
|
down, err := hijack(w)
|
|
if err != nil {
|
|
s.log().Error("hijack", "err", err)
|
|
return
|
|
}
|
|
defer down.Close()
|
|
if _, err := io.WriteString(down, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
|
|
return
|
|
}
|
|
pipe(down, up)
|
|
}
|
|
|
|
// hijack takes the raw connection out from under the HTTP server, with
|
|
// whatever the client had already sent, so the tunnel starts with no bytes
|
|
// stranded in a buffer.
|
|
func hijack(w http.ResponseWriter) (net.Conn, error) {
|
|
h, ok := w.(http.Hijacker)
|
|
if !ok {
|
|
return nil, errors.New("connection cannot be hijacked")
|
|
}
|
|
c, buf, err := h.Hijack()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n := buf.Reader.Buffered(); n > 0 {
|
|
return nil, fmt.Errorf("%d bytes buffered before the tunnel", n)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// pipe copies both directions and returns when either ends.
|
|
func pipe(a, b net.Conn) {
|
|
done := make(chan struct{}, 2)
|
|
go func() { io.Copy(a, b); done <- struct{}{} }()
|
|
go func() { io.Copy(b, a); done <- struct{}{} }()
|
|
<-done
|
|
// Closing both is what unblocks the other copy; the deferred closes in
|
|
// ServeHTTP then run against already-closed sockets, which is harmless.
|
|
a.Close()
|
|
b.Close()
|
|
<-done
|
|
}
|
|
|
|
// Parse reads a Need from a proxy username.
|
|
//
|
|
// Fields are dot-separated and name themselves, so a caller writes only what
|
|
// it cares about and in any order: "de", "mobile.br", "s-cart42", "".
|
|
func Parse(s string) (Need, error) {
|
|
var n Need
|
|
for _, f := range strings.Split(s, ".") {
|
|
switch {
|
|
case f == "":
|
|
case len(f) == 2 && isAlpha(f):
|
|
n.Country = strings.ToLower(f)
|
|
case strings.HasPrefix(f, "s-"):
|
|
n.Session = f[2:]
|
|
default:
|
|
k, ok := kinds[strings.ToLower(f)]
|
|
if !ok {
|
|
return Need{}, fmt.Errorf("unknown field %q; want a country, a kind, or s-<id>", f)
|
|
}
|
|
n.Kind = k
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func isAlpha(s string) bool {
|
|
for _, r := range s {
|
|
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// proxyAuth reads a Basic Proxy-Authorization header.
|
|
func proxyAuth(h string) (user, pass string, ok bool) {
|
|
const p = "Basic "
|
|
if len(h) < len(p) || !strings.EqualFold(h[:len(p)], p) {
|
|
return "", "", false
|
|
}
|
|
// Reuse net/http's decoder rather than writing a second one.
|
|
r := http.Request{Header: http.Header{"Authorization": []string{h}}}
|
|
return r.BasicAuth()
|
|
}
|