From 92113ae46931fd8a6c6941d2ca6d9ce50ce99fa8 Mon Sep 17 00:00:00 2001 From: Breezy <923803814@qq.com> Date: Tue, 16 Dec 2025 16:53:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0SOCKS5=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4=E6=96=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=92=8C=E5=88=9D=E5=A7=8B=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/gotun/cli/root.go | 71 ++++++++++++++++++++++++-- go.mod | 2 + go.sum | 4 ++ internal/config/config.go | 2 + internal/proxy/http.go | 23 +-------- internal/proxy/socks5.go | 104 ++++++++++++++++++++++++++++++++++++++ internal/proxy/ssh.go | 9 ++++ 7 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 internal/proxy/socks5.go diff --git a/cmd/gotun/cli/root.go b/cmd/gotun/cli/root.go index 6b4a9f1..e69b067 100644 --- a/cmd/gotun/cli/root.go +++ b/cmd/gotun/cli/root.go @@ -1,7 +1,9 @@ package cli import ( + "errors" "fmt" + "net" "os" "os/signal" "strings" @@ -11,6 +13,7 @@ import ( "github.com/Sesame2/gotun/internal/config" "github.com/Sesame2/gotun/internal/logger" "github.com/Sesame2/gotun/internal/proxy" + "github.com/Sesame2/gotun/internal/router" "github.com/Sesame2/gotun/internal/sysproxy" "github.com/spf13/cobra" ) @@ -57,9 +60,38 @@ var rootCmd = &cobra.Command{ } log.Infof("GoTun %s 启动中...", Version) - httpProxy, err := proxy.NewHTTPOverSSH(cfg, log) + // 1. 初始化 Router + var r *router.Router + if cfg.RuleFile != "" { + var err error + r, err = router.NewRouter(cfg.RuleFile) + if err != nil { + log.Warnf("加载规则文件失败: %v。将以全局代理模式运行。", err) + } else { + log.Infof("已加载规则文件: %s", cfg.RuleFile) + } + } + + // 2. 初始化 SSHClient + sshClient, err := proxy.NewSSHClient(cfg, log) if err != nil { - return fmt.Errorf("代理初始化失败: %w", err) + return fmt.Errorf("SSH连接失败: %w", err) + } + defer sshClient.Close() + + // 3. 初始化 HTTP 代理 + httpProxy, err := proxy.NewHTTPOverSSH(cfg, log, sshClient, r) + if err != nil { + return fmt.Errorf("HTTP代理初始化失败: %w", err) + } + + // 4. 初始化 SOCKS5 代理 + var socksProxy *proxy.SOCKS5OverSSH + if cfg.SocksAddr != "" { + socksProxy, err = proxy.NewSOCKS5OverSSH(cfg, log, sshClient, r) + if err != nil { + return fmt.Errorf("SOCKS5代理初始化失败: %w", err) + } } var proxyMgr *sysproxy.Manager @@ -77,12 +109,33 @@ var rootCmd = &cobra.Command{ } } if err := httpProxy.Start(); err != nil { - log.Errorf("代理服务启动失败: %v", err) + log.Errorf("HTTP代理服务启动失败: %v", err) sigChan <- syscall.SIGTERM } }() - fmt.Println("\n代理服务已启动:", "http://"+cfg.ListenAddr) + if socksProxy != nil { + go func() { + if err := socksProxy.Start(); err != nil { + // 判断是否是正常关闭导致的错误 + // FIXME: 这么处理还是不够优雅,后续可以考虑换一个socks的实现来避免这个问题 + if errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), "use of closed network connection") { + // 这是正常的关闭流程,不需要报错 + return + } + + log.Errorf("SOCKS5代理服务启动失败: %v", err) + sigChan <- syscall.SIGTERM + } + }() + } + + fmt.Println("\n代理服务已启动:") + fmt.Println("HTTP Proxy:", "http://"+cfg.ListenAddr) + if cfg.SocksAddr != "" { + fmt.Println("SOCKS5 Proxy:", "socks5://"+cfg.SocksAddr) + } + if len(cfg.JumpHosts) > 0 { fmt.Printf("跳板机链: %s -> %s\n", fmt.Sprintf("%v", cfg.JumpHosts), cfg.SSHServer) } else { @@ -106,7 +159,14 @@ var rootCmd = &cobra.Command{ } if err := httpProxy.Close(); err != nil { - log.Errorf("关闭代理服务失败: %v", err) + log.Errorf("关闭HTTP代理服务失败: %v", err) + } + + // 新增关闭逻辑 + if socksProxy != nil { + if err := socksProxy.Close(); err != nil { + log.Errorf("关闭SOCKS5代理服务失败: %v", err) + } } return nil @@ -127,6 +187,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfg.LogFile, "log", "", "日志文件路径") rootCmd.PersistentFlags().BoolVar(&cfg.SystemProxy, "sys-proxy", true, "自动设置/恢复系统代理") rootCmd.PersistentFlags().StringVar(&cfg.RuleFile, "rules", "", "代理规则配置文件路径") + rootCmd.PersistentFlags().StringVar(&cfg.SocksAddr, "socks5", ":1080", "SOCKS5 代理监听地址") } func Execute(version string) { diff --git a/go.mod b/go.mod index 279d48c..73704a3 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/Sesame2/gotun go 1.24.1 require ( + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/spf13/cobra v1.10.1 golang.org/x/crypto v0.42.0 golang.org/x/term v0.35.0 @@ -12,5 +13,6 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 59fea3d..1d0a267 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -9,6 +11,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= diff --git a/internal/config/config.go b/internal/config/config.go index f7cfa33..248ea38 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { SSHKeyFile string SSHTargetDial string SSHPort string // 添加SSH端口配置 + SocksAddr string // SOCKS5 监听地址 JumpHosts []string // 跳板机列表 Timeout time.Duration Verbose bool @@ -37,6 +38,7 @@ func NewConfig() *Config { InteractiveAuth: true, SystemProxy: true, RuleFile: "", + SocksAddr: "", } } diff --git a/internal/proxy/http.go b/internal/proxy/http.go index 099e83b..f7ee342 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -25,24 +25,9 @@ type HTTPOverSSH struct { } // NewHTTPOverSSH 创建HTTP代理 -func NewHTTPOverSSH(cfg *config.Config, log *logger.Logger) (*HTTPOverSSH, error) { +func NewHTTPOverSSH(cfg *config.Config, log *logger.Logger, sshClient *SSHClient, r *router.Router) (*HTTPOverSSH, error) { log.Info("初始化HTTP-over-SSH代理") - sshClient, err := NewSSHClient(cfg, log) - if err != nil { - return nil, err - } - - var r *router.Router - if cfg.RuleFile != "" { - r, err = router.NewRouter(cfg.RuleFile) - if err != nil { - log.Warnf("加载规则文件失败: %v。将以全局代理模式运行。", err) - } else { - log.Infof("已加载规则文件: %s", cfg.RuleFile) - } - } - proxy := &HTTPOverSSH{ cfg: cfg, ssh: sshClient, @@ -220,12 +205,6 @@ func (p *HTTPOverSSH) Close() error { defer cancel() err = p.server.Shutdown(ctx) } - if p.ssh != nil { - sshErr := p.ssh.Close() - if sshErr != nil && err == nil { - err = sshErr - } - } return err } diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go new file mode 100644 index 0000000..6655dc8 --- /dev/null +++ b/internal/proxy/socks5.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/Sesame2/gotun/internal/config" + "github.com/Sesame2/gotun/internal/logger" + "github.com/Sesame2/gotun/internal/router" + "github.com/armon/go-socks5" +) + +type SOCKS5OverSSH struct { + server *socks5.Server + cfg *config.Config + logger *logger.Logger + listener net.Listener +} + +// SSHDialer 这是一个实现了 proxy.Dialer 接口的结构体 +// 它负责把 SOCKS5 库的拨号请求“劫持”到我们的逻辑里 +type SSHDialer struct { + ssh *SSHClient + router *router.Router + logger *logger.Logger +} + +// Dial 实现 DialContext 接口 +func (d *SSHDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) { + // 1. 路由判断 + // FIXME: 使用 SOCKS5 时,建议客户端开启 "Proxy DNS when using SOCKS5" 或类似选项,以确保域名规则生效。 + if d.router != nil { + host, _, _ := net.SplitHostPort(addr) + action := d.router.Match(host) + + // 如果规则是直连 + if action == router.ActionDirect { + d.logger.Infof("[SOCKS5] 规则匹配: %s -> DIRECT", host) + // 使用本地网络直连 + dialer := net.Dialer{Timeout: 10 * time.Second} + return dialer.DialContext(ctx, network, addr) + } + d.logger.Infof("[SOCKS5] 规则匹配: %s -> PROXY", host) + } + // 2. 默认走 SSH 代理 + d.logger.Debugf("[SOCKS5] SSH Tunneling to %s", addr) + + // ssh.Client.Dial 没有 Context 参数,这里忽略 ctx + return d.ssh.Dial(network, addr) +} + +// NewSOCKS5OverSSH 创建 SOCKS5 代理 +// 注意:这里传入已经建立好的 sshClient +func NewSOCKS5OverSSH(cfg *config.Config, log *logger.Logger, sshClient *SSHClient, r *router.Router) (*SOCKS5OverSSH, error) { + // 创建自定义 Dialer + sshDialer := &SSHDialer{ + ssh: sshClient, + router: r, + logger: log, + } + + // 配置 socks5 库 + conf := &socks5.Config{ + Dial: sshDialer.Dial, // 注入我们的拨号逻辑 + Logger: nil, // fixme: 注入日志(需要适配接口,或者置为 nil 自己打日志) + } + + server, err := socks5.New(conf) + if err != nil { + return nil, fmt.Errorf("创建 SOCKS5 server 失败: %v", err) + } + + return &SOCKS5OverSSH{ + server: server, + cfg: cfg, + logger: log, + }, nil +} + +// Start 启动监听 +func (s *SOCKS5OverSSH) Start() error { + address := s.cfg.SocksAddr // 需要在 Config 里加这个字段 + if address == "" { + return nil // 没配地址就不启动 + } + + l, err := net.Listen("tcp", address) // <--- 手动创建 Listener + if err != nil { + return err + } + s.listener = l // <--- 保存 Listener + s.logger.Infof("SOCKS5 代理服务器启动在 %s", address) + return s.server.Serve(l) +} + +// Close 关闭 SOCKS5 代理服务 +func (s *SOCKS5OverSSH) Close() error { + if s.listener != nil { + return s.listener.Close() + } + return nil +} diff --git a/internal/proxy/ssh.go b/internal/proxy/ssh.go index b989b58..d3bb0b9 100644 --- a/internal/proxy/ssh.go +++ b/internal/proxy/ssh.go @@ -2,6 +2,7 @@ package proxy import ( "fmt" + "net" "os" "path/filepath" "strings" @@ -242,3 +243,11 @@ func loadPrivateKey(path string) (ssh.Signer, error) { return signer, nil } + +// 增加Dial方法的实现,使其满足常见的 Dialer 接口 +func (s *SSHClient) Dial(network, addr string) (net.Conn, error) { + if s.client == nil { + return nil, fmt.Errorf("ssh client not ready") + } + return s.client.Dial(network, addr) +} From ce5a371e4da01b470a14466b0e06972a8006cb14 Mon Sep 17 00:00:00 2001 From: Breezy <923803814@qq.com> Date: Wed, 17 Dec 2025 10:06:47 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0SOCKS5=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=E7=9A=84=E6=96=87=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-CN.md | 8 +++++++- README.md | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README-CN.md b/README-CN.md index 823c4c4..0fdc7fe 100644 --- a/README-CN.md +++ b/README-CN.md @@ -135,8 +135,13 @@ gotun --listen :8888 user@example.com # 自动设置系统代理(默认开启) # 若你希望启动时不修改系统代理,请显式关闭: gotun --sys-proxy=false user@example.com + +# 开启 SOCKS5 代理 (默认 :1080) +gotun --socks5 :1080 user@example.com ``` +> **注意**: 使用 SOCKS5 代理配合自定义路由规则时,建议在客户端(如浏览器或代理插件)中开启 "Proxy DNS when using SOCKS5" (远程DNS解析) 选项。否则客户端可能会在本地解析域名为 IP,导致基于域名的路由规则失效。 + ### 在浏览器中使用 启动代理后,在浏览器中配置HTTP代理: @@ -158,6 +163,7 @@ gotun --sys-proxy=false user@example.com | `--identity_file` | `-i` | 用于认证的私钥文件路径 | | | `--jump` | `-J` | 跳板机列表,用逗号分隔 (格式: user@host:port) | | | `--target` | | 可选的目标网络覆盖 | | +| `--socks5` | | SOCKS5 代理监听端口 | `:1080` | | `--timeout` | | 连接超时时间 | `10s` | | `--verbose` | `-v` | 启用详细日志 | `false` | | `--log` | | 日志文件路径 | 输出到标准输出 | @@ -350,10 +356,10 @@ sudo gotun user@example.com - [x] **跳板机 (Jump Host)**: 支持单级和多级SSH跳板机 - [x] **自定义路由规则**: 支持自定义的规则文件进行流量分流 - [x] **命令行自动补全**: 基于 Cobra 的智能提示 +- [x] **SOCKS5 代理支持**: 更广泛的协议支持 - [ ] **RDP网关**:支持RDP远程桌面网关 - [ ] **托盘 GUI 界面**: 图形化用户界面 - [ ] **配置文件导出/导入**: 配置管理功能 -- [ ] **SOCKS5 代理支持**: 更广泛的协议支持 - [ ] **连接池优化**: 提升性能和稳定性 - [ ] **统计和监控**: 流量统计和连接监控 diff --git a/README.md b/README.md index d1a2011..3ea7827 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Your machine SSH (tcp/22) Bastion I - Rule-based traffic splitting via configuration file - Shell completion support for Bash, Zsh, Fish, PowerShell - Structured logging and verbose mode for debugging +- SOCKS5 proxy support --- @@ -141,8 +142,13 @@ gotun --listen :8888 user@example.com # Disable automatic system proxy configuration gotun --sys-proxy=false user@example.com + +# Enable SOCKS5 proxy (default listen on :1080) +gotun --socks5 :1080 user@example.com ``` +> **Note**: When using SOCKS5 with custom routing rules, it is recommended to enable "Proxy DNS when using SOCKS5" (Remote DNS) in your client. Otherwise, the client might resolve domains to IPs locally, causing domain-based routing rules to fail. + ### Browser configuration By default, gotun listens on `127.0.0.1:8080` (unless changed by `--listen`). @@ -166,6 +172,7 @@ If system proxy support is enabled, some platforms can be configured automatical | `--identity_file` | `-i` | Private key file path | | | `--jump` | `-J` | Comma-separated jump hosts (`user@host:port`) | | | `--target` | | Optional target network scope/coverage | | +| `--socks5` | | SOCKS5 proxy bind address | `:1080` | | `--timeout` | | SSH connection timeout | `10s` | | `--verbose` | `-v` | Enable verbose logging | `false` | | `--log` | | Log file path | stdout | @@ -400,13 +407,13 @@ Implemented: - [x] Single and multi-hop jump host support - [x] Rule-based routing - [x] Shell completion for common shells +- [x] SOCKS5 proxy support Planned: - [ ] RDP gateway support - [ ] Tray/GUI frontend - [ ] Export/import of configuration profiles -- [ ] SOCKS5 proxy support - [ ] Connection pooling and performance tuning - [ ] Traffic statistics and basic monitoring From e438ace3159a146d747216eade879ae65d8bf09d Mon Sep 17 00:00:00 2001 From: Breezy <923803814@qq.com> Date: Wed, 17 Dec 2025 14:07:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=B0=86socks5=E7=9A=84=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=9B=BF=E6=8D=A2=E4=B8=BA=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README-CN.md | 2 +- cmd/gotun/cli/root.go | 9 -- go.mod | 2 - go.sum | 4 - internal/proxy/socks5.go | 276 ++++++++++++++++++++++++++++++--------- 5 files changed, 214 insertions(+), 79 deletions(-) diff --git a/README-CN.md b/README-CN.md index 0fdc7fe..d5f9aa6 100644 --- a/README-CN.md +++ b/README-CN.md @@ -163,7 +163,7 @@ gotun --socks5 :1080 user@example.com | `--identity_file` | `-i` | 用于认证的私钥文件路径 | | | `--jump` | `-J` | 跳板机列表,用逗号分隔 (格式: user@host:port) | | | `--target` | | 可选的目标网络覆盖 | | -| `--socks5` | | SOCKS5 代理监听端口 | `:1080` | +| `--socks5` | | SOCKS5 代理监听地址 | `:1080` | | `--timeout` | | 连接超时时间 | `10s` | | `--verbose` | `-v` | 启用详细日志 | `false` | | `--log` | | 日志文件路径 | 输出到标准输出 | diff --git a/cmd/gotun/cli/root.go b/cmd/gotun/cli/root.go index e69b067..519eb41 100644 --- a/cmd/gotun/cli/root.go +++ b/cmd/gotun/cli/root.go @@ -1,9 +1,7 @@ package cli import ( - "errors" "fmt" - "net" "os" "os/signal" "strings" @@ -117,13 +115,6 @@ var rootCmd = &cobra.Command{ if socksProxy != nil { go func() { if err := socksProxy.Start(); err != nil { - // 判断是否是正常关闭导致的错误 - // FIXME: 这么处理还是不够优雅,后续可以考虑换一个socks的实现来避免这个问题 - if errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), "use of closed network connection") { - // 这是正常的关闭流程,不需要报错 - return - } - log.Errorf("SOCKS5代理服务启动失败: %v", err) sigChan <- syscall.SIGTERM } diff --git a/go.mod b/go.mod index 73704a3..279d48c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/Sesame2/gotun go 1.24.1 require ( - github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/spf13/cobra v1.10.1 golang.org/x/crypto v0.42.0 golang.org/x/term v0.35.0 @@ -13,6 +12,5 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 1d0a267..59fea3d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -11,8 +9,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index 6655dc8..c58e268 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -1,104 +1,254 @@ package proxy import ( - "context" + "encoding/binary" "fmt" + "io" "net" + "strconv" + "sync" "time" "github.com/Sesame2/gotun/internal/config" "github.com/Sesame2/gotun/internal/logger" "github.com/Sesame2/gotun/internal/router" - "github.com/armon/go-socks5" ) type SOCKS5OverSSH struct { - server *socks5.Server cfg *config.Config logger *logger.Logger + ssh *SSHClient + router *router.Router listener net.Listener + mu sync.Mutex // 互斥锁,保证 Close 的线程安全 } -// SSHDialer 这是一个实现了 proxy.Dialer 接口的结构体 -// 它负责把 SOCKS5 库的拨号请求“劫持”到我们的逻辑里 -type SSHDialer struct { - ssh *SSHClient - router *router.Router - logger *logger.Logger +// NewSOCKS5OverSSH 创建 SOCKS5 代理实例 +func NewSOCKS5OverSSH(cfg *config.Config, log *logger.Logger, sshClient *SSHClient, r *router.Router) (*SOCKS5OverSSH, error) { + return &SOCKS5OverSSH{ + cfg: cfg, + logger: log, + ssh: sshClient, + router: r, + }, nil } -// Dial 实现 DialContext 接口 -func (d *SSHDialer) Dial(ctx context.Context, network, addr string) (net.Conn, error) { - // 1. 路由判断 - // FIXME: 使用 SOCKS5 时,建议客户端开启 "Proxy DNS when using SOCKS5" 或类似选项,以确保域名规则生效。 - if d.router != nil { - host, _, _ := net.SplitHostPort(addr) - action := d.router.Match(host) - - // 如果规则是直连 - if action == router.ActionDirect { - d.logger.Infof("[SOCKS5] 规则匹配: %s -> DIRECT", host) - // 使用本地网络直连 - dialer := net.Dialer{Timeout: 10 * time.Second} - return dialer.DialContext(ctx, network, addr) +// Start 启动 SOCKS5 监听循环 +func (s *SOCKS5OverSSH) Start() error { + addr := s.cfg.SocksAddr + if addr == "" { + s.logger.Debug("SOCKS5 代理地址未配置,跳过启动") + return nil + } + + l, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("SOCKS5 监听启动失败: %w", err) + } + + s.mu.Lock() + s.listener = l + s.mu.Unlock() + + s.logger.Infof("SOCKS5 代理已启动,监听地址: %s (支持远程 DNS 解析)", addr) + + for { + conn, err := l.Accept() + if err != nil { + s.mu.Lock() + closing := s.listener == nil + s.mu.Unlock() + + if closing { + return nil // 正常退出 + } + s.logger.Errorf("SOCKS5 Accept 错误: %v", err) + continue } - d.logger.Infof("[SOCKS5] 规则匹配: %s -> PROXY", host) + + // 异步处理每个连接 + go s.handleConnection(conn) } - // 2. 默认走 SSH 代理 - d.logger.Debugf("[SOCKS5] SSH Tunneling to %s", addr) +} - // ssh.Client.Dial 没有 Context 参数,这里忽略 ctx - return d.ssh.Dial(network, addr) +// Close 优雅关闭服务 +func (s *SOCKS5OverSSH) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.listener == nil { + return nil + } + + s.logger.Info("正在关闭 SOCKS5 代理服务...") + err := s.listener.Close() + s.listener = nil // 置空,防止重复关闭 + return err } -// NewSOCKS5OverSSH 创建 SOCKS5 代理 -// 注意:这里传入已经建立好的 sshClient -func NewSOCKS5OverSSH(cfg *config.Config, log *logger.Logger, sshClient *SSHClient, r *router.Router) (*SOCKS5OverSSH, error) { - // 创建自定义 Dialer - sshDialer := &SSHDialer{ - ssh: sshClient, - router: r, - logger: log, +// handleConnection 处理单个 SOCKS5 连接的主逻辑 +func (s *SOCKS5OverSSH) handleConnection(conn net.Conn) { + defer conn.Close() + clientAddr := conn.RemoteAddr().String() + + // 1. 协商阶段 (Handshake) + // Client: [VER, NMETHODS, METHODS...] + // Server: [VER, METHOD] + if err := s.handshake(conn); err != nil { + s.logger.Debugf("[%s] SOCKS5 协商失败: %v", clientAddr, err) + return } - // 配置 socks5 库 - conf := &socks5.Config{ - Dial: sshDialer.Dial, // 注入我们的拨号逻辑 - Logger: nil, // fixme: 注入日志(需要适配接口,或者置为 nil 自己打日志) + // 2. 请求阶段 (Request) + // Client: [VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT] + targetAddr, hostForRoute, err := s.readRequest(conn) + if err != nil { + s.logger.Debugf("[%s] SOCKS5 请求解析失败: %v", clientAddr, err) + return } - server, err := socks5.New(conf) + // 3. 路由与连接 (Dial) + start := time.Now() + destConn, ruleAction, err := s.dialTarget(targetAddr, hostForRoute) if err != nil { - return nil, fmt.Errorf("创建 SOCKS5 server 失败: %v", err) + s.logger.Warnf("[%SOCKS5] 连接目标 %s 失败: %v", targetAddr, err) + s.reply(conn, 0x05) // 0x05: Connection refused + return } + defer destConn.Close() - return &SOCKS5OverSSH{ - server: server, - cfg: cfg, - logger: log, - }, nil + // 4. 回复客户端成功 (Reply Success) + // Server: [VER, REP, RSV, ATYP, BND.ADDR, BND.PORT] + s.reply(conn, 0x00) // 0x00: Succeeded + + s.logger.Infof("[SOCKS5] 建立连接 -> %s (规则: %s)", targetAddr, ruleAction) + + // 5. 数据传输 (Transfer) + var wg sync.WaitGroup + wg.Add(2) + + // Browser -> SSH/Target + go func() { + defer wg.Done() + io.Copy(destConn, conn) + // 如果是 TCP 连接,通常不需要手动 CloseWrite,但在某些场景下可以加速关闭 + if c, ok := destConn.(*net.TCPConn); ok { + c.CloseWrite() + } + }() + + // SSH/Target -> Browser + go func() { + defer wg.Done() + io.Copy(conn, destConn) + if c, ok := conn.(*net.TCPConn); ok { + c.CloseWrite() + } + }() + + wg.Wait() + s.logger.Debugf("[%s] 连接断开: %s, 耗时: %v", clientAddr, targetAddr, time.Since(start)) } -// Start 启动监听 -func (s *SOCKS5OverSSH) Start() error { - address := s.cfg.SocksAddr // 需要在 Config 里加这个字段 - if address == "" { - return nil // 没配地址就不启动 +// handshake 处理 SOCKS5 认证协商 +func (s *SOCKS5OverSSH) handshake(conn net.Conn) error { + buf := make([]byte, 256) + // 读取: VER, NMETHODS + if _, err := io.ReadFull(conn, buf[:2]); err != nil { + return err } - - l, err := net.Listen("tcp", address) // <--- 手动创建 Listener - if err != nil { + if buf[0] != 0x05 { + return fmt.Errorf("不支持的 SOCKS 版本: %d", buf[0]) + } + nmethods := int(buf[1]) + // 读取: METHODS + if _, err := io.ReadFull(conn, buf[:nmethods]); err != nil { return err } - s.listener = l // <--- 保存 Listener - s.logger.Infof("SOCKS5 代理服务器启动在 %s", address) - return s.server.Serve(l) + + // 回复: VER=5, METHOD=0 (No Authentication Required) + _, err := conn.Write([]byte{0x05, 0x00}) + return err } -// Close 关闭 SOCKS5 代理服务 -func (s *SOCKS5OverSSH) Close() error { - if s.listener != nil { - return s.listener.Close() +// readRequest 读取并解析客户端请求,返回完整目标地址(host:port)和用于路由匹配的主机名 +func (s *SOCKS5OverSSH) readRequest(conn net.Conn) (string, string, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(conn, header); err != nil { + return "", "", err + } + + if header[1] != 0x01 { // CMD: 0x01 = CONNECT + s.reply(conn, 0x07) // Command not supported + return "", "", fmt.Errorf("不支持的命令: %d", header[1]) + } + + var host string + switch header[3] { // ATYP + case 0x01: // IPv4 + ip := make([]byte, 4) + if _, err := io.ReadFull(conn, ip); err != nil { + return "", "", err + } + host = net.IP(ip).String() + case 0x03: // Domain Name (关键:直接读取域名,不进行本地 DNS 解析) + lenBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + return "", "", err + } + domainLen := int(lenBuf[0]) + domain := make([]byte, domainLen) + if _, err := io.ReadFull(conn, domain); err != nil { + return "", "", err + } + host = string(domain) + case 0x04: // IPv6 + ip := make([]byte, 16) + if _, err := io.ReadFull(conn, ip); err != nil { + return "", "", err + } + host = net.IP(ip).String() + default: + s.reply(conn, 0x08) // Address type not supported + return "", "", fmt.Errorf("不支持的地址类型: %d", header[3]) } - return nil + + portBuf := make([]byte, 2) + if _, err := io.ReadFull(conn, portBuf); err != nil { + return "", "", err + } + port := binary.BigEndian.Uint16(portBuf) + + targetAddr := net.JoinHostPort(host, strconv.Itoa(int(port))) + return targetAddr, host, nil +} + +// dialTarget 根据路由规则连接目标 +func (s *SOCKS5OverSSH) dialTarget(addr string, hostForRoute string) (net.Conn, string, error) { + action := router.ActionProxy + + // 1. 路由判断 + if s.router != nil { + action = s.router.Match(hostForRoute) + } + + // 2. 根据动作执行连接 + if action == router.ActionDirect { + s.logger.Debugf("[SOCKS5] 路由直连: %s", addr) + conn, err := net.DialTimeout("tcp", addr, s.cfg.Timeout) + return conn, "DIRECT", err + } + + // 默认走 Proxy (SSH) + // SSH 服务器会在远端进行 DNS 解析,从而解决本地 DNS 污染和 HSTS 问题 + s.logger.Debugf("[SOCKS5] SSH 转发: %s", addr) + conn, err := s.ssh.Dial("tcp", addr) + return conn, "PROXY", err +} + +// reply 发送 SOCKS5 响应包 +func (s *SOCKS5OverSSH) reply(conn net.Conn, rep byte) { + // [VER, REP, RSV, ATYP, BND.ADDR(4), BND.PORT(2)] + // 这里 BND.ADDR 和 BND.PORT 填全 0 即可,客户端通常不关心 + conn.Write([]byte{0x05, rep, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) } From a37a19ad34ac6152496b2efa313ff8b903c32318 Mon Sep 17 00:00:00 2001 From: Breezy <923803814@qq.com> Date: Wed, 17 Dec 2025 14:38:50 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E7=AE=A1=E7=90=86=E5=99=A8=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81SOCKS5=E5=9C=B0=E5=9D=80=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/gotun/cli/root.go | 2 +- internal/proxy/socks5.go | 2 +- internal/sysproxy/sysproxy.go | 79 ++++++++++++++++++++++++++++------- 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/cmd/gotun/cli/root.go b/cmd/gotun/cli/root.go index 519eb41..b63dc7e 100644 --- a/cmd/gotun/cli/root.go +++ b/cmd/gotun/cli/root.go @@ -94,7 +94,7 @@ var rootCmd = &cobra.Command{ var proxyMgr *sysproxy.Manager if cfg.SystemProxy { - proxyMgr = sysproxy.NewManager(log, cfg.ListenAddr) + proxyMgr = sysproxy.NewManager(log, cfg.ListenAddr, cfg.SocksAddr) } sigChan := make(chan os.Signal, 1) diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index c58e268..238c4f8 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -50,7 +50,7 @@ func (s *SOCKS5OverSSH) Start() error { s.listener = l s.mu.Unlock() - s.logger.Infof("SOCKS5 代理已启动,监听地址: %s (支持远程 DNS 解析)", addr) + s.logger.Infof("SOCKS5 代理已启动,监听地址: %s", addr) for { conn, err := l.Accept() diff --git a/internal/sysproxy/sysproxy.go b/internal/sysproxy/sysproxy.go index fbbd951..6e20118 100644 --- a/internal/sysproxy/sysproxy.go +++ b/internal/sysproxy/sysproxy.go @@ -15,32 +15,44 @@ import ( // Manager 管理系统代理设置 type Manager struct { logger *logger.Logger - proxyAddress string + httpAddr string + socksAddr string enabled bool origSettings map[string]string // 保存原始设置 } // NewManager 创建新的系统代理管理器 -func NewManager(log *logger.Logger, listenAddr string) *Manager { +func NewManager(log *logger.Logger, httpListenAddr, socksListenAddr string) *Manager { // 解析地址,确保格式正确 - host, portStr, err := net.SplitHostPort(listenAddr) + host, portStr, err := net.SplitHostPort(httpListenAddr) if err != nil { - log.Warnf("无法解析监听地址 %s: %v, 使用原始地址", listenAddr, err) - return &Manager{ - logger: log, - proxyAddress: listenAddr, - origSettings: make(map[string]string), - } + log.Warnf("无法解析监听地址 %s: %v, 使用原始地址", httpListenAddr, err) + host = "127.0.0.1" + portStr = "8080" } // 如果主机为空,使用localhost if host == "" || host == "0.0.0.0" { host = "127.0.0.1" } + httpAddr := fmt.Sprintf("%s:%s", host, portStr) + + // 解析 socksAddr + var finalSocksAddr string + if socksListenAddr != "" { + shost, sportStr, serr := net.SplitHostPort(socksListenAddr) + if serr == nil { + if shost == "" || shost == "0.0.0.0" { + shost = "127.0.0.1" + } + finalSocksAddr = fmt.Sprintf("%s:%s", shost, sportStr) + } + } return &Manager{ logger: log, - proxyAddress: fmt.Sprintf("%s:%s", host, portStr), + httpAddr: httpAddr, + socksAddr: finalSocksAddr, origSettings: make(map[string]string), } } @@ -74,7 +86,10 @@ func (m *Manager) Enable() error { } m.enabled = true - m.logger.Infof("系统代理已设置为 %s", m.proxyAddress) + m.logger.Infof("系统代理已设置为 HTTP:%s", m.httpAddr) + if m.socksAddr != "" { + m.logger.Infof("系统代理已设置为 SOCKS5:%s", m.socksAddr) + } return nil } @@ -165,7 +180,7 @@ func (m *Manager) saveSettingsMacOS() error { func (m *Manager) enableMacOS() error { m.logger.Debug("设置MacOS系统代理...") - host, portStr, _ := net.SplitHostPort(m.proxyAddress) + host, portStr, _ := net.SplitHostPort(m.httpAddr) port, _ := strconv.Atoi(portStr) // 获取网络服务列表 @@ -197,6 +212,18 @@ func (m *Manager) enableMacOS() error { continue } + // 设置SOCKS代理 + if m.socksAddr != "" { + shost, sportStr, _ := net.SplitHostPort(m.socksAddr) + cmd = exec.Command("networksetup", "-setsocksfirewallproxy", service, shost, sportStr) + if err := cmd.Run(); err != nil { + m.logger.Warnf("为服务 %s 设置SOCKS代理失败: %v", service, err) + } else { + cmd = exec.Command("networksetup", "-setsocksfirewallproxystate", service, "on") + cmd.Run() + } + } + // 启用代理 cmd = exec.Command("networksetup", "-setwebproxystate", service, "on") cmd.Run() @@ -225,6 +252,10 @@ func (m *Manager) disableMacOS() error { // 禁用HTTPS代理 cmd = exec.Command("networksetup", "-setsecurewebproxystate", service, "off") cmd.Run() + + // 禁用SOCKS代理 + cmd = exec.Command("networksetup", "-setsocksfirewallproxystate", service, "off") + cmd.Run() } return nil @@ -284,7 +315,13 @@ func (m *Manager) enableWindows() error { m.logger.Debug("设置Windows系统代理...") // 设置代理服务器 - cmd := exec.Command("reg", "add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "/v", "ProxyServer", "/t", "REG_SZ", "/d", m.proxyAddress, "/f") + // 格式: http=127.0.0.1:8080;https=127.0.0.1:8080;socks=127.0.0.1:1080 + proxyStr := fmt.Sprintf("http=%s;https=%s", m.httpAddr, m.httpAddr) + if m.socksAddr != "" { + proxyStr += fmt.Sprintf(";socks=%s", m.socksAddr) + } + + cmd := exec.Command("reg", "add", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings", "/v", "ProxyServer", "/t", "REG_SZ", "/d", proxyStr, "/f") if err := cmd.Run(); err != nil { return fmt.Errorf("设置代理服务器失败: %v", err) } @@ -389,7 +426,7 @@ func (m *Manager) enableLinux() error { m.logger.Debug("设置Linux系统代理...") // 尝试GNOME设置 - host, portStr, _ := net.SplitHostPort(m.proxyAddress) + host, portStr, _ := net.SplitHostPort(m.httpAddr) // 设置HTTP代理 cmd := exec.Command("gsettings", "set", "org.gnome.system.proxy.http", "host", host) @@ -405,6 +442,16 @@ func (m *Manager) enableLinux() error { cmd = exec.Command("gsettings", "set", "org.gnome.system.proxy.https", "port", portStr) cmd.Run() + // 设置SOCKS代理 + if m.socksAddr != "" { + shost, sportStr, _ := net.SplitHostPort(m.socksAddr) + cmd = exec.Command("gsettings", "set", "org.gnome.system.proxy.socks", "host", shost) + cmd.Run() + + cmd = exec.Command("gsettings", "set", "org.gnome.system.proxy.socks", "port", sportStr) + cmd.Run() + } + // 清空忽略主机列表 cmd = exec.Command("gsettings", "set", "org.gnome.system.proxy", "ignore-hosts", "[]") cmd.Run() @@ -414,8 +461,8 @@ func (m *Manager) enableLinux() error { cmd.Run() // 设置环境变量 - os.Setenv("http_proxy", "http://"+m.proxyAddress) - os.Setenv("https_proxy", "http://"+m.proxyAddress) + os.Setenv("http_proxy", "http://"+m.httpAddr) + os.Setenv("https_proxy", "http://"+m.httpAddr) os.Setenv("no_proxy", "") return nil