feat: Implement system SSH command support for outbound connections, enabling SSH config alias usage and adding a build command example to README.

This commit is contained in:
刘金发 2025-12-02 21:32:46 +08:00
parent c5fe3670ef
commit 31c39dc96c
3 changed files with 215 additions and 7 deletions

View File

@ -99,3 +99,10 @@ API.
This software is released under the GPL-3.0 license. This software is released under the GPL-3.0 license.
**In addition, any downstream projects not affiliated with `MetaCubeX` shall not contain the word `mihomo` in their names.** **In addition, any downstream projects not affiliated with `MetaCubeX` shall not contain the word `mihomo` in their names.**
## 命令
```bash
# 打包
CGO_ENABLED=0 GOARCH=arm64 GOOS=darwin go build -tags with_gvisor -trimpath -ldflags '-X "github.com/metacubex/mihomo/constant.Version=v1.19.15-debug" -w -s -buildid=' -o verge-mihomo-alpha
```

View File

@ -24,6 +24,7 @@ type Ssh struct {
*Base *Base
option *SshOption option *SshOption
useSystemSsh bool
config *ssh.ClientConfig config *ssh.ClientConfig
client *ssh.Client client *ssh.Client
@ -41,6 +42,9 @@ type SshOption struct {
PrivateKeyPassphrase string `proxy:"private-key-passphrase,omitempty"` PrivateKeyPassphrase string `proxy:"private-key-passphrase,omitempty"`
HostKey []string `proxy:"host-key,omitempty"` HostKey []string `proxy:"host-key,omitempty"`
HostKeyAlgorithms []string `proxy:"host-key-algorithms,omitempty"` HostKeyAlgorithms []string `proxy:"host-key-algorithms,omitempty"`
UseSshConfigAlias bool `proxy:"use-ssh-config-alias,omitempty"`
SshUser string `proxy:"ssh-user,omitempty"`
SshUserHome string `proxy:"ssh-user-home,omitempty"`
} }
func (s *Ssh) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) { func (s *Ssh) DialContext(ctx context.Context, metadata *C.Metadata) (_ C.Conn, err error) {
@ -69,10 +73,22 @@ func (s *Ssh) connect(ctx context.Context, cDialer C.Dialer, addr string) (clien
if s.client != nil { if s.client != nil {
return s.client, nil return s.client, nil
} }
c, err := cDialer.DialContext(ctx, "tcp", addr)
var c net.Conn
// 如果使用系统 ssh 命令(完整 SSH config 支持)
if s.useSystemSsh {
c, err = s.dialViaSystemSsh(ctx, s.option.Server)
if err != nil {
return nil, fmt.Errorf("system ssh failed: %w", err)
}
} else {
// 原有的纯 Go 实现
c, err = cDialer.DialContext(ctx, "tcp", addr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
}
defer func(c net.Conn) { defer func(c net.Conn) {
safeConnClose(c, err) safeConnClose(c, err)
@ -190,6 +206,8 @@ func NewSsh(option SshOption) (*Ssh, error) {
} }
config.ClientVersion = version config.ClientVersion = version
useSystemSsh := option.UseSshConfigAlias
outbound := &Ssh{ outbound := &Ssh{
Base: &Base{ Base: &Base{
name: option.Name, name: option.Name,
@ -201,6 +219,7 @@ func NewSsh(option SshOption) (*Ssh, error) {
prefer: C.NewDNSPrefer(option.IPVersion), prefer: C.NewDNSPrefer(option.IPVersion),
}, },
option: &option, option: &option,
useSystemSsh: useSystemSsh,
config: &config, config: &config,
} }

View File

@ -0,0 +1,182 @@
package outbound
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"time"
"github.com/metacubex/mihomo/log"
)
// dialViaSystemSsh 使用系统 SSH 命令建立连接
func (s *Ssh) dialViaSystemSsh(ctx context.Context, hostAlias string) (net.Conn, error) {
log.Infoln("[SSH] Using system SSH with config alias: %s", hostAlias)
port := s.option.Port
if port == 0 {
port = 22
}
// 获取实际用户非root
var actualUser string
var cmdArgs []string
// 方法0: 用户显式指定(最高优先级)
if s.option.SshUser != "" {
actualUser = s.option.SshUser
log.Infoln("[SSH] Using configured user: %s", actualUser)
}
// 方法1: 从配置中的 ssh-user-home 提取
if actualUser == "" && s.option.SshUserHome != "" {
// 从路径提取用户名: /Users/fa -> fa
parts := strings.Split(s.option.SshUserHome, "/")
if len(parts) >= 3 && parts[1] == "Users" {
actualUser = parts[2]
log.Infoln("[SSH] Detected user from ssh-user-home: %s", actualUser)
}
}
// 方法2: 从环境变量获取
if actualUser == "" {
actualUser = os.Getenv("SUDO_USER")
if actualUser != "" {
log.Infoln("[SSH] Detected user from SUDO_USER: %s", actualUser)
}
}
// 方法3: 检查/Users目录macOS- 跳过隐藏目录
if actualUser == "" {
entries, err := os.ReadDir("/Users")
if err == nil {
for _, entry := range entries {
name := entry.Name()
// 跳过隐藏目录(.开头)、系统目录
if entry.IsDir() && !strings.HasPrefix(name, ".") &&
name != "Shared" && name != "Guest" {
actualUser = name
log.Infoln("[SSH] Auto-detected user from /Users: %s", actualUser)
break
}
}
}
}
targetAddr := fmt.Sprintf("localhost:%d", port)
if actualUser != "" {
// 以实际用户身份执行: sudo -u username -i ssh -W ...
// -i: 加载用户的登录环境(包括 PATH确保 cloudflared 等命令可用)
cmdArgs = []string{"-u", actualUser, "-i", "ssh", "-W", targetAddr, hostAlias}
log.Infoln("[SSH] Running as user: %s (with login env)", actualUser)
log.Infoln("[SSH] Command: sudo %s", strings.Join(cmdArgs, " "))
} else {
// 回退:直接执行(可能失败)
cmdArgs = []string{"-W", targetAddr, hostAlias}
log.Warnln("[SSH] Could not determine actual user, running ssh directly")
log.Infoln("[SSH] Command: ssh %s", strings.Join(cmdArgs, " "))
}
var cmd *exec.Cmd
if actualUser != "" {
cmd = exec.CommandContext(ctx, "sudo", cmdArgs...)
} else {
cmd = exec.CommandContext(ctx, "ssh", cmdArgs...)
}
// 设置 HOME 环境变量(如果用户指定)
if s.option.SshUserHome != "" {
cmd.Env = append(cmd.Environ(), "HOME="+s.option.SshUserHome)
log.Infoln("[SSH] Setting HOME=%s for SSH command", s.option.SshUserHome)
}
// 获取 stdin/stdout
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
// 捕获 stderr
var stderr bytes.Buffer
cmd.Stderr = &stderr
// 启动命令
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ssh: %w", err)
}
log.Infoln("[SSH] SSH subprocess started, PID: %d", cmd.Process.Pid)
// 监控进程退出
go func() {
if err := cmd.Wait(); err != nil {
output := stderr.String()
if len(output) > 0 {
log.Errorln("[SSH] SSH process exited with error: %v, stderr: %s", err, output)
} else {
log.Errorln("[SSH] SSH process exited with error: %v", err)
}
}
}()
// 返回包装的连接
return &sshCmdConn{
stdin: stdin,
stdout: stdout,
cmd: cmd,
stderr: &stderr,
}, nil
}
// sshCmdConn 实现 net.Conn 接口
type sshCmdConn struct {
stdin io.WriteCloser
stdout io.ReadCloser
cmd *exec.Cmd
stderr *bytes.Buffer
}
func (c *sshCmdConn) Read(b []byte) (n int, err error) {
return c.stdout.Read(b)
}
func (c *sshCmdConn) Write(b []byte) (n int, err error) {
return c.stdin.Write(b)
}
func (c *sshCmdConn) Close() error {
c.stdin.Close()
c.stdout.Close()
return c.cmd.Wait()
}
func (c *sshCmdConn) LocalAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4zero, Port: 0}
}
func (c *sshCmdConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4zero, Port: 0}
}
func (c *sshCmdConn) SetDeadline(t time.Time) error {
return nil
}
func (c *sshCmdConn) SetReadDeadline(t time.Time) error {
return nil
}
func (c *sshCmdConn) SetWriteDeadline(t time.Time) error {
return nil
}