mirror of
https://github.com/MetaCubeX/mihomo.git
synced 2025-12-19 16:30:07 +08:00
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:
parent
c5fe3670ef
commit
31c39dc96c
@ -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
|
||||||
|
```
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
182
adapter/outbound/ssh_system.go
Normal file
182
adapter/outbound/ssh_system.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user