Isolate extension WebView profiles and follow host proxy settings

This commit is contained in:
Levi committed 2026-09-14 00:30:52 +08:00
1 parent cee5f3336e
commit a0e84a75b1
556 files changed
+71097 -31

No files matched your search

+341
View File
@@ -0,0 +1,341 @@
// Package proxy provides the host-owned WebView forward proxy. Browser engines
// always use this endpoint; Gopeed decides the upstream route for each request.
package proxy
import (
"bufio"
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/armon/go-socks5"
xproxy "golang.org/x/net/proxy"
)
type Selector func(*http.Request) (*url.URL, error)
type Server struct {
listener net.Listener
socksListener net.Listener
server *http.Server
transport *http.Transport
selectProxy Selector
mu sync.Mutex
connections map[io.Closer]struct{}
closed bool
}
func New(selectProxy Selector) (*Server, error) {
if selectProxy == nil {
selectProxy = func(*http.Request) (*url.URL, error) { return nil, nil }
}
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, err
}
s := &Server{listener: listener, selectProxy: selectProxy, connections: make(map[io.Closer]struct{})}
s.transport = &http.Transport{Proxy: selectProxy, DisableKeepAlives: true, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second}
s.server = &http.Server{Handler: s, ReadHeaderTimeout: 15 * time.Second}
s.socksListener, err = net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
listener.Close()
return nil, err
}
socks, err := socks5.New(&socks5.Config{Logger: log.New(io.Discard, "", 0), Resolver: unresolvedDNS{}, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
_, port, _ := net.SplitHostPort(address)
scheme := "https"
if port == "80" {
scheme = "http"
}
request := &http.Request{URL: &url.URL{Scheme: scheme, Host: address}, Header: make(http.Header)}
upstream, err := selectProxy(request)
if err != nil {
return nil, err
}
timed, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
conn, err := dialTunnel(timed, address, upstream)
if err != nil {
return nil, err
}
return s.track(conn)
}})
if err != nil {
listener.Close()
s.socksListener.Close()
return nil, err
}
go s.server.Serve(listener)
go socks.Serve(&trackedListener{Listener: s.socksListener, server: s})
return s, nil
}
func (s *Server) URL() string { return "http://" + s.listener.Addr().String() }
func (s *Server) SOCKSURL() string { return "socks5://" + s.socksListener.Addr().String() }
func (s *Server) Close() error {
s.mu.Lock()
s.closed = true
connections := make([]io.Closer, 0, len(s.connections))
for c := range s.connections {
connections = append(connections, c)
}
s.mu.Unlock()
for _, c := range connections {
c.Close()
}
s.socksListener.Close()
s.transport.CloseIdleConnections()
return s.server.Close()
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
s.connect(w, r)
return
}
if !r.URL.IsAbs() || (r.URL.Scheme != "http" && r.URL.Scheme != "https") {
http.Error(w, "absolute HTTP URL required", http.StatusBadRequest)
return
}
out := r.Clone(r.Context())
out.RequestURI = ""
upgrade := out.Header.Get("Upgrade")
stripHopHeaders(out.Header)
if upgrade != "" {
out.Header.Set("Connection", "Upgrade")
out.Header.Set("Upgrade", upgrade)
}
resp, err := s.transport.RoundTrip(out)
if err != nil {
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusSwitchingProtocols {
remote, ok := resp.Body.(io.ReadWriteCloser)
if !ok {
http.Error(w, "invalid upstream upgrade", 502)
return
}
client, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer client.Close()
resp.Body = nil
if err := resp.Write(buffer); err != nil {
return
}
if err := buffer.Flush(); err != nil {
return
}
s.bridge(client, buffer, remote)
return
}
stripHopHeaders(resp.Header)
for k, v := range resp.Header {
w.Header()[k] = v
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func stripHopHeaders(h http.Header) {
for _, key := range strings.Split(h.Get("Connection"), ",") {
h.Del(strings.TrimSpace(key))
}
for _, key := range []string{"Connection", "Proxy-Connection", "Proxy-Authorization", "Proxy-Authenticate", "Keep-Alive", "TE", "Trailer", "Transfer-Encoding", "Upgrade"} {
h.Del(key)
}
}
func (s *Server) connect(w http.ResponseWriter, r *http.Request) {
if _, _, err := net.SplitHostPort(r.Host); err != nil {
http.Error(w, "invalid tunnel destination", 400)
return
}
target := r.Clone(r.Context())
target.URL = &url.URL{Scheme: "https", Host: r.Host}
upstream, err := s.selectProxy(target)
if err != nil {
http.Error(w, "proxy selection failed", 502)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
remote, err := dialTunnel(ctx, r.Host, upstream)
if err != nil {
http.Error(w, "upstream connection failed", 502)
return
}
defer remote.Close()
client, buffer, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer client.Close()
if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
}
if err := buffer.Flush(); err != nil {
return
}
s.bridge(client, buffer, remote)
}
func (s *Server) bridge(client net.Conn, buffer *bufio.ReadWriter, remote io.ReadWriteCloser) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.connections[client] = struct{}{}
s.connections[remote] = struct{}{}
s.mu.Unlock()
defer func() { s.mu.Lock(); delete(s.connections, client); delete(s.connections, remote); s.mu.Unlock() }()
done := make(chan struct{})
go func() { io.Copy(remote, buffer); remote.Close(); close(done) }()
io.Copy(client, remote)
client.Close()
<-done
}
type bufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
func dialTunnel(ctx context.Context, target string, upstream *url.URL) (net.Conn, error) {
dialer := &net.Dialer{}
if upstream == nil {
return dialer.DialContext(ctx, "tcp", target)
}
if upstream.Scheme == "socks5" || upstream.Scheme == "socks5h" {
u := *upstream
u.Scheme = "socks5"
d, err := xproxy.FromURL(&u, dialer)
if err != nil {
return nil, err
}
return d.(xproxy.ContextDialer).DialContext(ctx, "tcp", target)
}
if upstream.Scheme != "http" && upstream.Scheme != "https" {
return nil, errors.New("unsupported proxy scheme")
}
host := upstream.Host
if upstream.Port() == "" {
port := "80"
if upstream.Scheme == "https" {
port = "443"
}
host = net.JoinHostPort(upstream.Hostname(), port)
}
conn, err := dialer.DialContext(ctx, "tcp", host)
if err != nil {
return nil, err
}
ok := false
defer func() {
if !ok {
conn.Close()
}
}()
if deadline, exists := ctx.Deadline(); exists {
conn.SetDeadline(deadline)
}
if upstream.Scheme == "https" {
secure := tls.Client(conn, &tls.Config{ServerName: upstream.Hostname(), MinVersion: tls.VersionTLS12})
if err := secure.HandshakeContext(ctx); err != nil {
return nil, err
}
conn = secure
}
request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)}
if upstream.User != nil {
password, _ := upstream.User.Password()
request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(upstream.User.Username()+":"+password)))
}
if err := request.Write(conn); err != nil {
return nil, err
}
reader := bufio.NewReader(conn)
response, err := http.ReadResponse(reader, request)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
response.Body.Close()
return nil, fmt.Errorf("upstream CONNECT status %d", response.StatusCode)
}
conn.SetDeadline(time.Time{})
ok = true
return &bufferedConn{Conn: conn, reader: reader}, nil
}
// Leave hostname resolution to the selected upstream (SOCKS remote DNS / CONNECT).
type unresolvedDNS struct{}
func (unresolvedDNS) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) {
return ctx, nil, nil
}
type trackedListener struct {
net.Listener
server *Server
}
func (l *trackedListener) Accept() (net.Conn, error) {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return l.server.track(c)
}
type trackedConn struct {
net.Conn
server *Server
}
func (c *trackedConn) Close() error {
err := c.Conn.Close()
c.server.mu.Lock()
delete(c.server.connections, c)
c.server.mu.Unlock()
return err
}
func (s *Server) track(c net.Conn) (net.Conn, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
c.Close()
return nil, net.ErrClosed
}
tracked := &trackedConn{Conn: c, server: s}
s.connections[tracked] = struct{}{}
return tracked, nil
}
func (c *trackedConn) CloseWrite() error { return closeWrite(c.Conn) }
func (c *bufferedConn) CloseWrite() error { return closeWrite(c.Conn) }
func closeWrite(c net.Conn) error {
if writer, ok := c.(interface{ CloseWrite() error }); ok {
return writer.CloseWrite()
}
return c.Close()
}
+211
View File
@@ -0,0 +1,211 @@
package proxy
import (
"bufio"
"context"
"crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/armon/go-socks5"
)
func TestRoutesAndAuthentication(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "" {
t.Error("proxy credentials leaked to origin")
}
w.Write([]byte("origin"))
}))
defer origin.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("secure")) }))
defer secure.Close()
upstream, err := New(func(*http.Request) (*url.URL, error) { return nil, nil })
if err != nil {
t.Fatal(err)
}
defer upstream.Close()
var authenticated atomic.Int32
authProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" {
http.Error(w, "authentication required", 407)
return
}
authenticated.Add(1)
upstream.ServeHTTP(w, r)
}))
defer authProxy.Close()
socks, err := socks5.New(&socks5.Config{Credentials: socks5.StaticCredentials{"user": "pass"}})
if err != nil {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go socks.Serve(listener)
for _, route := range []string{"", authProxy.URL, "socks5://" + listener.Addr().String()} {
t.Run(route, func(t *testing.T) {
var selected *url.URL
if route != "" {
selected, _ = url.Parse(route)
selected.User = url.UserPassword("user", "pass")
}
local, err := New(func(r *http.Request) (*url.URL, error) { return selected, nil })
if err != nil {
t.Fatal(err)
}
defer local.Close()
proxyURL, _ := url.Parse(local.URL())
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
for _, test := range []struct{ url, want string }{{origin.URL, "origin"}, {secure.URL, "secure"}} {
response, err := client.Get(test.url)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(response.Body)
response.Body.Close()
if string(body) != test.want {
t.Fatalf("got status %d, body %q", response.StatusCode, body)
}
}
})
}
if authenticated.Load() != 2 {
t.Fatalf("expected authenticated HTTP and CONNECT, got %d", authenticated.Load())
}
}
func TestRoutingChangesAndFailureDoesNotBypassProxy(t *testing.T) {
var hits atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.Write([]byte("direct")) }))
defer origin.Close()
var useProxy atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("proxied")) }))
upstreamURL, _ := url.Parse(upstream.URL)
local, err := New(func(*http.Request) (*url.URL, error) {
if useProxy.Load() {
return upstreamURL, nil
}
return nil, nil
})
if err != nil {
t.Fatal(err)
}
defer local.Close()
endpoint, _ := url.Parse(local.URL())
transport := &http.Transport{Proxy: http.ProxyURL(endpoint)}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
for _, want := range []string{"direct", "proxied", "upstream request failed\n"} {
resp, err := client.Get(origin.URL)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if string(body) != want {
t.Fatalf("got %q want %q", body, want)
}
if want == "direct" {
useProxy.Store(true)
} else if want == "proxied" {
upstream.Close()
}
}
if hits.Load() != 1 {
t.Fatal("failed proxy must not fall back to direct")
}
}
func TestCloseTerminatesTunnels(t *testing.T) {
echo, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer echo.Close()
go func() {
c, e := echo.Accept()
if e == nil {
defer c.Close()
io.Copy(c, c)
}
}()
s, err := New(func(*http.Request) (*url.URL, error) { return nil, nil })
if err != nil {
t.Fatal(err)
}
endpoint, _ := url.Parse(s.URL())
c, err := dialTunnel(context.Background(), echo.Addr().String(), endpoint)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(3 * time.Second))
c.Write([]byte("hello\n"))
line, err := bufio.NewReader(c).ReadString('\n')
if err != nil || line != "hello\n" {
t.Fatalf("echo %q %v", line, err)
}
s.Close()
if _, err := c.Read(make([]byte, 1)); err == nil {
t.Fatal("tunnel stayed open")
}
}
func TestSOCKSIngressPreservesRemoteDNS(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "CONNECT" || r.Host != "unresolved.invalid:80" {
t.Errorf("lost destination: %s %s", r.Method, r.Host)
}
c, b, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer c.Close()
b.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
b.Flush()
request, err := http.ReadRequest(b.Reader)
if err != nil {
t.Error(err)
return
}
request.Body.Close()
b.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nproxied")
b.Flush()
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL)
local, err := New(func(r *http.Request) (*url.URL, error) {
if r.URL.Host != "unresolved.invalid:80" {
t.Errorf("selector lost hostname: %s", r.URL)
}
return target, nil
})
if err != nil {
t.Fatal(err)
}
defer local.Close()
endpoint, _ := url.Parse(local.SOCKSURL())
transport := &http.Transport{Proxy: http.ProxyURL(endpoint)}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
response, err := client.Get("http://unresolved.invalid/")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
if string(body) != "proxied" {
t.Fatalf("unexpected body %q", body)
}
}