From acfb10efc081b990ecb733d5abf5578d14475e91 Mon Sep 17 00:00:00 2001 From: Toby Date: Fri, 18 Aug 2023 16:30:31 -0700 Subject: [PATCH] feat: SOCKS5 outbound --- app/cmd/server.go | 16 +++ app/cmd/server_test.go | 9 ++ app/cmd/server_test.yaml | 6 + extras/outbounds/ob_socks5.go | 257 ++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 extras/outbounds/ob_socks5.go diff --git a/app/cmd/server.go b/app/cmd/server.go index 63d6d6d3c8..f8b0be64af 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -147,10 +147,17 @@ type serverConfigOutboundDirect struct { BindDevice string `mapstructure:"bindDevice"` } +type serverConfigOutboundSOCKS5 struct { + Addr string `mapstructure:"addr"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` +} + type serverConfigOutboundEntry struct { Name string `mapstructure:"name"` Type string `mapstructure:"type"` Direct serverConfigOutboundDirect `mapstructure:"direct"` + SOCKS5 serverConfigOutboundSOCKS5 `mapstructure:"socks5"` } type serverConfigMasqueradeFile struct { @@ -315,6 +322,13 @@ func serverConfigOutboundDirectToOutbound(c serverConfigOutboundDirect) (outboun return outbounds.NewDirectOutboundSimple(mode), nil } +func serverConfigOutboundSOCKS5ToOutbound(c serverConfigOutboundSOCKS5) (outbounds.PluggableOutbound, error) { + if c.Addr == "" { + return nil, configError{Field: "outbounds.socks5.addr", Err: errors.New("empty socks5 address")} + } + return outbounds.NewSOCKS5Outbound(c.Addr, c.Username, c.Password), nil +} + func (c *serverConfig) fillOutboundConfig(hyConfig *server.Config) error { // Resolver, ACL, actual outbound are all implemented through the Outbound interface. // Depending on the config, we build a chain like this: @@ -339,6 +353,8 @@ func (c *serverConfig) fillOutboundConfig(hyConfig *server.Config) error { switch strings.ToLower(entry.Type) { case "direct": ob, err = serverConfigOutboundDirectToOutbound(entry.Direct) + case "socks5": + ob, err = serverConfigOutboundSOCKS5ToOutbound(entry.SOCKS5) default: err = configError{Field: "outbounds.type", Err: errors.New("unsupported outbound type")} } diff --git a/app/cmd/server_test.go b/app/cmd/server_test.go index a2941204d1..f215521a7e 100644 --- a/app/cmd/server_test.go +++ b/app/cmd/server_test.go @@ -114,6 +114,15 @@ func TestServerConfig(t *testing.T) { BindDevice: "eth233", }, }, + { + Name: "badstuff", + Type: "socks5", + SOCKS5: serverConfigOutboundSOCKS5{ + Addr: "shady.proxy.ru:1080", + Username: "hackerman", + Password: "Elliot Alderson", + }, + }, }, Masquerade: serverConfigMasquerade{ Type: "proxy", diff --git a/app/cmd/server_test.yaml b/app/cmd/server_test.yaml index e22f16c087..be3c083b66 100644 --- a/app/cmd/server_test.yaml +++ b/app/cmd/server_test.yaml @@ -85,6 +85,12 @@ outbounds: bindIPv4: 2.4.6.8 bindIPv6: 0:0:0:0:0:ffff:0204:0608 bindDevice: eth233 + - name: badstuff + type: socks5 + socks5: + addr: shady.proxy.ru:1080 + username: hackerman + password: Elliot Alderson masquerade: type: proxy diff --git a/extras/outbounds/ob_socks5.go b/extras/outbounds/ob_socks5.go new file mode 100644 index 0000000000..21bc6b6f5d --- /dev/null +++ b/extras/outbounds/ob_socks5.go @@ -0,0 +1,257 @@ +package outbounds + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "time" + + "github.com/txthinking/socks5" +) + +const ( + socks5NegotiationTimeout = 10 * time.Second + socks5RequestTimeout = 10 * time.Second +) + +var errSOCKS5AuthFailed = errors.New("SOCKS5 authentication failed") + +type errSOCKS5UnsupportedAuthMethod struct { + Method byte +} + +func (e errSOCKS5UnsupportedAuthMethod) Error() string { + return fmt.Sprintf("unsupported SOCKS5 authentication method: %d", e.Method) +} + +type errSOCKS5RequestFailed struct { + Rep byte +} + +func (e errSOCKS5RequestFailed) Error() string { + return fmt.Sprintf("SOCKS5 request failed: %d", e.Rep) +} + +// socks5Outbound is a PluggableOutbound that connects to the target using +// a SOCKS5 proxy server. +// Since SOCKS5 supports using either IP or domain name as the target address, +// it will ignore ResolveInfo in AddrEx and always only use Host. +type socks5Outbound struct { + Dialer *net.Dialer + Addr string + Username string + Password string +} + +func NewSOCKS5Outbound(addr, username, password string) PluggableOutbound { + return &socks5Outbound{ + Dialer: &net.Dialer{ + Timeout: defaultDialerTimeout, + }, + Addr: addr, + Username: username, + Password: password, + } +} + +// dialAndNegotiate creates a new TCP connection to the SOCKS5 proxy server +// and performs the negotiation. Returns an established connection ready to +// handle requests, or an error if the process fails. +func (o *socks5Outbound) dialAndNegotiate() (net.Conn, error) { + conn, err := o.Dialer.Dial("tcp", o.Addr) + if err != nil { + return nil, err + } + if err := conn.SetDeadline(time.Now().Add(socks5NegotiationTimeout)); err != nil { + _ = conn.Close() + return nil, err + } + authMethods := []byte{socks5.MethodNone} + if o.Username != "" && o.Password != "" { + authMethods = append(authMethods, socks5.MethodUsernamePassword) + } + req := socks5.NewNegotiationRequest(authMethods) + if _, err := req.WriteTo(conn); err != nil { + _ = conn.Close() + return nil, err + } + resp, err := socks5.NewNegotiationReplyFrom(conn) + if err != nil { + _ = conn.Close() + return nil, err + } + if resp.Method == socks5.MethodUsernamePassword { + upReq := socks5.NewUserPassNegotiationRequest([]byte(o.Username), []byte(o.Password)) + if _, err := upReq.WriteTo(conn); err != nil { + _ = conn.Close() + return nil, err + } + upResp, err := socks5.NewUserPassNegotiationReplyFrom(conn) + if err != nil { + _ = conn.Close() + return nil, err + } + if upResp.Status != socks5.UserPassStatusSuccess { + _ = conn.Close() + return nil, errSOCKS5AuthFailed + } + } else if resp.Method != socks5.MethodNone { + // We only support none & username/password authentication methods. + _ = conn.Close() + return nil, errSOCKS5UnsupportedAuthMethod{resp.Method} + } + // Negotiation succeeded, reset the deadline. + if err := conn.SetDeadline(time.Time{}); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +// request sends a SOCKS5 request to the proxy server and returns the reply. +// Note that it will return an error if the reply from the server indicates +// a failure. +func (o *socks5Outbound) request(conn net.Conn, req *socks5.Request) (*socks5.Reply, error) { + if err := conn.SetDeadline(time.Now().Add(socks5RequestTimeout)); err != nil { + return nil, err + } + if _, err := req.WriteTo(conn); err != nil { + return nil, err + } + resp, err := socks5.NewReplyFrom(conn) + if err != nil { + return nil, err + } + if resp.Rep != socks5.RepSuccess { + return nil, errSOCKS5RequestFailed{resp.Rep} + } + if err := conn.SetDeadline(time.Time{}); err != nil { + return nil, err + } + return resp, nil +} + +func (s *socks5Outbound) TCP(reqAddr *AddrEx) (net.Conn, error) { + conn, err := s.dialAndNegotiate() + if err != nil { + return nil, err + } + atyp, dstAddr, dstPort := addrExToSOCKS5Addr(reqAddr) + req := socks5.NewRequest(socks5.CmdConnect, atyp, dstAddr, dstPort) + if _, err := s.request(conn, req); err != nil { + _ = conn.Close() + return nil, err + } + return conn, nil +} + +func (s *socks5Outbound) UDP(reqAddr *AddrEx) (UDPConn, error) { + conn, err := s.dialAndNegotiate() + if err != nil { + return nil, err + } + atyp, dstAddr, dstPort := addrExToSOCKS5Addr(reqAddr) + req := socks5.NewRequest(socks5.CmdUDP, atyp, dstAddr, dstPort) + resp, err := s.request(conn, req) + if err != nil { + _ = conn.Close() + return nil, err + } + return newSOCKS5UDPConn(conn, resp.Address()) +} + +type socks5UDPConn struct { + tcpConn net.Conn + udpConn net.Conn +} + +func newSOCKS5UDPConn(tcpConn net.Conn, udpAddr string) (*socks5UDPConn, error) { + udpConn, err := net.Dial("udp", udpAddr) + if err != nil { + return nil, err + } + sc := &socks5UDPConn{ + tcpConn: tcpConn, + udpConn: udpConn, + } + go sc.hold() + return sc, nil +} + +func (c *socks5UDPConn) hold() { + _, _ = io.Copy(io.Discard, c.tcpConn) + _ = c.tcpConn.Close() + _ = c.udpConn.Close() +} + +func (c *socks5UDPConn) ReadFrom(b []byte) (int, *AddrEx, error) { + n, err := c.udpConn.Read(b) + if err != nil { + return 0, nil, err + } + d, err := socks5.NewDatagramFromBytes(b[:n]) + if err != nil { + return 0, nil, err + } + addr := socks5AddrToAddrEx(d.Atyp, d.DstAddr, d.DstPort) + n = copy(b, d.Data) + return n, addr, nil +} + +func (c *socks5UDPConn) WriteTo(b []byte, addr *AddrEx) (int, error) { + atyp, dstAddr, dstPort := addrExToSOCKS5Addr(addr) + d := socks5.NewDatagram(atyp, dstAddr, dstPort, b) + _, err := c.udpConn.Write(d.Bytes()) + if err != nil { + return 0, err + } + return len(b), nil +} + +func (c *socks5UDPConn) Close() error { + _ = c.tcpConn.Close() + _ = c.udpConn.Close() + return nil +} + +func addrExToSOCKS5Addr(addr *AddrEx) (atyp byte, dstAddr, dstPort []byte) { + // Host + ip := net.ParseIP(addr.Host) + if ip != nil { + if ip.To4() != nil { + atyp = socks5.ATYPIPv4 + dstAddr = ip.To4() + } else { + atyp = socks5.ATYPIPv6 + dstAddr = ip.To16() + } + } else { + atyp = socks5.ATYPDomain + dstAddr = []byte(addr.Host) + } + // Port + dstPort = make([]byte, 2) + binary.BigEndian.PutUint16(dstPort, addr.Port) + return +} + +func socks5AddrToAddrEx(atyp byte, dstAddr, dstPort []byte) *AddrEx { + // Host + var host string + if atyp == socks5.ATYPIPv4 { + host = net.IP(dstAddr).To4().String() + } else if atyp == socks5.ATYPIPv6 { + host = net.IP(dstAddr).To16().String() + } else if atyp == socks5.ATYPDomain { + // Need to strip the first byte which is the domain length. + host = string(dstAddr[1:]) + } + // Port + port := binary.BigEndian.Uint16(dstPort) + return &AddrEx{ + Host: host, + Port: port, + } +}