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.
288 lines
7.4 KiB
Go
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
|
|
}
|