Files
yetone 3691c2047d plugin, agent: bun is given a SOCKS proxy through the bridge, so a plugin installs with only a SOCKS system proxy (#1409)
With only a SOCKS system proxy on macOS (127.0.0.1:4401), every plugin
install failed: netproxy.Env put socks5://… in bun add's *_PROXY, and Bun
takes only http:// and https:// proxies, so it stopped with
"UnsupportedProxyProtocol downloading package manifest". The plugin
host's own fetches already went through netproxy.Bridge (hostEnv), but
bunCommand, which runs bun add, update and remove, did not.

- netproxy.EnvForBun rewrites a SOCKS5 HTTPS_PROXY/HTTP_PROXY/ALL_PROXY
  (any case) to the loopback HTTP bridge in front of it; others as they are.
- bunCommand uses it, so add, update, remove and the host's run all get it.
- Sibling: an agent CLI installed with bun is updated by `bun add -g`
  with the same env (updateEnv in cliupdate.go); npm is left as it is.
- provider-plugins.md says so.

Run:
- TestInstallThroughSOCKSProxy (real bun 1.4.2, fake registry, SOCKS
  proxy only): on the old bunCommand it fails with the reporter's exact
  "error: UnsupportedProxyProtocol downloading package manifest"; passes
  with the fix, through the SOCKS proxy.
- TestEnvForBunBridgesSystemSOCKS (system SOCKS proxy via Env) and
  TestUpdateWithBunBridgesSOCKS fail with EnvForBun / the bun branch
  broken ("bun is given …socks5://…"), pass with it.
- Real use: a throwaway test with a real SOCKS5 server and only
  *_PROXY=socks5://… installed @magpie-community/opencode-cursor-auth
  0.2.2 from registry.npmjs.org through it; on the old code the same
  failed as UnsupportedProxyProtocol. Scratch HOME, not committed.
- gofmt -l clean; go vet ./...; GOOS=linux go vet -tags nogui ./...;
  GOOS=windows go vet ./...; go build; GOOS=linux go build -tags nogui;
  GOOS=windows go build; go test -tags nogui ./... (scratch HOME) all ok.
2026-10-09 19:35:35 +08:00

288 lines
7.4 KiB
Go

package netproxy
// The plugin host's Bun speaks http:// and https:// proxies only: a fetch
// given socks5:// fails as UnsupportedProxyProtocol. Bridge stands an
// http:// proxy on loopback in front of a SOCKS5 one, so a plugin's
// requests go through the SOCKS proxy a built-in's go through.
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
var (
bridgesMu sync.Mutex
bridges = map[string]string{}
)
// IsSOCKS is whether the proxy's address is a SOCKS5 one.
func IsSOCKS(u *url.URL) bool { return u != nil && (u.Scheme == "socks5" || u.Scheme == "socks5h") }
// Bridge is the http:// address of a proxy on loopback carrying what it is
// given through the SOCKS5 proxy socks, one for each, kept while magpie
// runs.
func Bridge(socks *url.URL) (string, error) {
key := socks.String()
bridgesMu.Lock()
defer bridgesMu.Unlock()
if a, ok := bridges[key]; ok {
return a, nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
s := *socks
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go bridgeConn(c, &s)
}
}()
a := "http://" + ln.Addr().String()
bridges[key] = a
return a, nil
}
// bridgeConn serves one client of a bridge: a CONNECT is tunnelled, a
// plain http:// request sent on and answered.
func bridgeConn(c net.Conn, socks *url.URL) {
defer c.Close()
br := bufio.NewReader(c)
_ = c.SetReadDeadline(time.Now().Add(time.Minute))
req, err := http.ReadRequest(br)
if err != nil {
return
}
_ = c.SetReadDeadline(time.Time{})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if req.Method == http.MethodConnect {
up, err := DialSOCKS(ctx, socks, req.Host)
if err != nil {
refuse(c, err)
return
}
defer up.Close()
if _, err := io.WriteString(c, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil {
return
}
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(up, br); closeWrite(up); done <- struct{}{} }()
go func() { _, _ = io.Copy(c, up); closeWrite(c); done <- struct{}{} }()
<-done
<-done
return
}
t := &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) {
return DialSOCKS(ctx, socks, addr)
},
}
defer t.CloseIdleConnections()
req.RequestURI = ""
req.Header.Del("Proxy-Connection")
req.Header.Del("Proxy-Authorization")
res, err := t.RoundTrip(req.WithContext(context.Background()))
if err != nil {
refuse(c, err)
return
}
defer res.Body.Close()
res.Close = true
_ = res.Write(c)
}
func closeWrite(c net.Conn) {
if tc, ok := c.(interface{ CloseWrite() error }); ok {
_ = tc.CloseWrite()
}
}
// refuse answers a client the SOCKS proxy didn't carry: a 502 saying why,
// in the words Go's own transport uses ("socks connect …").
func refuse(c net.Conn, err error) {
msg := err.Error()
fmt.Fprintf(c, "HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(msg), msg)
}
// DialSOCKS connects to addr (host:port) through the SOCKS5 proxy socks,
// the user and password in its address used when it has them. The host
// is sent by name, as Go's own transport sends it, so the proxy resolves
// it.
func DialSOCKS(ctx context.Context, socks *url.URL, addr string) (net.Conn, error) {
proxy := socks.Host
if socks.Port() == "" {
proxy = net.JoinHostPort(socks.Hostname(), "1080")
}
fail := func(err error) error { return fmt.Errorf("socks connect tcp %s->%s: %w", proxy, addr, err) }
host, portS, err := net.SplitHostPort(addr)
if err != nil {
return nil, fail(err)
}
port, err := strconv.Atoi(portS)
if err != nil || port < 1 || port > 65535 {
return nil, fail(fmt.Errorf("bad port %q", portS))
}
c, err := (&net.Dialer{Timeout: 15 * time.Second}).DialContext(ctx, "tcp", proxy)
if err != nil {
return nil, fail(err)
}
if d, ok := ctx.Deadline(); ok {
_ = c.SetDeadline(d)
} else {
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
}
if err := socksHandshake(c, socks.User, host, port); err != nil {
c.Close()
return nil, fail(err)
}
_ = c.SetDeadline(time.Time{})
return c, nil
}
var socksReplies = map[byte]string{
1: "general SOCKS server failure",
2: "connection not allowed by ruleset",
3: "network unreachable",
4: "host unreachable",
5: "connection refused",
6: "TTL expired",
7: "command not supported",
8: "address type not supported",
}
func socksHandshake(c net.Conn, user *url.Userinfo, host string, port int) error {
methods := []byte{0}
if user != nil {
methods = append(methods, 2)
}
if _, err := c.Write(append([]byte{5, byte(len(methods))}, methods...)); err != nil {
return err
}
var b [2]byte
if _, err := io.ReadFull(c, b[:]); err != nil {
return err
}
if b[0] != 5 {
return errors.New("not a SOCKS5 proxy")
}
switch b[1] {
case 0:
case 2:
if user == nil {
return errors.New("the proxy asks for a user and password")
}
pw, _ := user.Password()
u := user.Username()
if len(u) > 255 || len(pw) > 255 {
return errors.New("user or password too long")
}
msg := append([]byte{1, byte(len(u))}, u...)
msg = append(append(msg, byte(len(pw))), pw...)
if _, err := c.Write(msg); err != nil {
return err
}
if _, err := io.ReadFull(c, b[:]); err != nil {
return err
}
if b[1] != 0 {
return errors.New("the proxy turned the user and password away")
}
default:
return errors.New("no authentication method the proxy takes")
}
req := []byte{5, 1, 0}
if ip := net.ParseIP(host); ip != nil {
if ip4 := ip.To4(); ip4 != nil {
req = append(append(req, 1), ip4...)
} else {
req = append(append(req, 4), ip.To16()...)
}
} else {
if len(host) > 255 {
return errors.New("host name too long")
}
req = append(append(req, 3, byte(len(host))), host...)
}
req = binary.BigEndian.AppendUint16(req, uint16(port))
if _, err := c.Write(req); err != nil {
return err
}
var h [4]byte
if _, err := io.ReadFull(c, h[:]); err != nil {
return err
}
if h[1] != 0 {
if m, ok := socksReplies[h[1]]; ok {
return errors.New(m)
}
return fmt.Errorf("unknown SOCKS reply %d", h[1])
}
var skip int
switch h[3] {
case 1:
skip = 4
case 4:
skip = 16
case 3:
var n [1]byte
if _, err := io.ReadFull(c, n[:]); err != nil {
return err
}
skip = int(n[0])
default:
return fmt.Errorf("unknown address type %d", h[3])
}
_, err := io.CopyN(io.Discard, c, int64(skip+2))
return err
}
// ForBun is a proxy's address as the plugin host's Bun can use it: a
// SOCKS5 one is bridged, any other is as it is.
func ForBun(proxy string) string {
u, err := Parse(proxy)
if err != nil || !IsSOCKS(u) {
return proxy
}
if a, err := Bridge(u); err == nil {
return a
}
return proxy
}
// EnvForBun is env for a bun that magpie starts (bun add, update, remove,
// run, or an agent CLI's bun add -g): its *_PROXY with a SOCKS5 one
// bridged (ForBun). Bun takes only http:// and https:// proxies, and with
// a socks5:// one every registry fetch failed as UnsupportedProxyProtocol
// (#1409), which a Mac with only a SOCKS system proxy hands it through Env.
func EnvForBun(env []string) []string {
out := make([]string, 0, len(env))
for _, e := range env {
k, v, _ := strings.Cut(e, "=")
switch strings.ToUpper(k) {
case "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY":
if v = strings.TrimSpace(v); v != "" {
e = k + "=" + ForBun(v)
}
}
out = append(out, e)
}
return out
}