Add ssh outbound

This commit is contained in:
世界 2022-08-21 19:36:08 +08:00
parent c71f6ba377
commit dc6bb7ab1b
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
12 changed files with 319 additions and 5 deletions

View file

@ -17,6 +17,7 @@ const (
TypeWireGuard = "wireguard" TypeWireGuard = "wireguard"
TypeHysteria = "hysteria" TypeHysteria = "hysteria"
TypeTor = "tor" TypeTor = "tor"
TypeSSH = "ssh"
) )
const ( const (

View file

@ -1,6 +1,7 @@
#### 2022/08/21 #### 2022/08/21
* Add [Tor outbound](/configuration/outbound/tor) * Add [Tor outbound](/configuration/outbound/tor)
* Add [SSH outbound](/configuration/outbound/ssh)
#### 2022/08/20 #### 2022/08/20

View file

@ -25,6 +25,7 @@
| `wireguard` | [Wireguard](./wireguard) | | `wireguard` | [Wireguard](./wireguard) |
| `hysteria` | [Hysteria](./hysteria) | | `hysteria` | [Hysteria](./hysteria) |
| `tor` | [Tor](./tor) | | `tor` | [Tor](./tor) |
| `ssh` | [SSH](./ssh) |
| `dns` | [DNS](./dns) | | `dns` | [DNS](./dns) |
| `selector` | [Selector](./selector) | | `selector` | [Selector](./selector) |

View file

@ -0,0 +1,121 @@
### Structure
```json
{
"outbounds": [
{
"type": "ssh",
"tag": "ssh-out",
"server": "127.0.0.1",
"server_port": 22,
"user": "root",
"password": "admin",
"private_key": "",
"private_key_path": "$HOME/.ssh/id_rsa",
"private_key_passphrase": "",
"host_key_algorithms": [],
"client_version": "SSH-2.0-OpenSSH_7.4p1",
"detour": "upstream-out",
"bind_interface": "en0",
"routing_mark": 1234,
"reuse_addr": false,
"connect_timeout": "5s",
"tcp_fast_open": false,
"domain_strategy": "prefer_ipv6",
"fallback_delay": "300ms"
}
]
}
```
### SSH Fields
#### server
==Required==
Server address.
#### server_port
Server port. 22 will be used if empty.
#### user
SSH user, root will be used if empty.
#### password
Password.
#### private_key
Private key content.
#### private_key_path
Private key path.
#### private_key_passphrase
Private key passphrase.
#### host_key_algorithms
Host key algorithms.
#### client_version
Client version. Random version will be used if empty.
### Dial Fields
#### detour
The tag of the upstream outbound.
Other dial fields will be ignored when enabled.
#### bind_interface
The network interface to bind to.
#### routing_mark
!!! error ""
Linux only
The iptables routing mark.
#### reuse_addr
Reuse listener address.
#### connect_timeout
Connect timeout, in golang's Duration format.
A duration string is a possibly signed sequence of
decimal numbers, each with optional fraction and a unit suffix,
such as "300ms", "-1.5h" or "2h45m".
Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
#### domain_strategy
One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`.
If set, the server domain name will be resolved to IP before connecting.
`dns.strategy` will be used if empty.
#### fallback_delay
The length of time to wait before spawning a RFC 6555 Fast Fallback connection.
That is, is the amount of time to wait for IPv6 to succeed before assuming
that IPv6 is misconfigured and falling back to IPv4 if `prefer_ipv4` is set.
If zero, a default delay of 300ms is used.
Only take effect when `domain_strategy` is `prefer_ipv4` or `prefer_ipv6`.

View file

@ -133,7 +133,7 @@ func NewTLSConfig(ctx context.Context, logger log.Logger, options option.Inbound
var acmeService adapter.Service var acmeService adapter.Service
var err error var err error
if options.ACME != nil && len(options.ACME.Domain) > 0 { if options.ACME != nil && len(options.ACME.Domain) > 0 {
tlsConfig, acmeService, err = startACME(ctx, logger, common.PtrValueOrDefault(options.ACME)) tlsConfig, acmeService, err = startACME(ctx, common.PtrValueOrDefault(options.ACME))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -11,7 +11,6 @@ import (
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
) )
type acmeWrapper struct { type acmeWrapper struct {
@ -29,7 +28,7 @@ func (w *acmeWrapper) Close() error {
return nil return nil
} }
func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.Service, error) { func startACME(ctx context.Context, options option.InboundACMEOptions) (*tls.Config, adapter.Service, error) {
var acmeServer string var acmeServer string
switch options.Provider { switch options.Provider {
case "", "letsencrypt": case "", "letsencrypt":

View file

@ -9,9 +9,8 @@ import (
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
) )
func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.Service, error) { func startACME(ctx context.Context, options option.InboundACMEOptions) (*tls.Config, adapter.Service, error) {
return nil, nil, E.New(`ACME is not included in this build, rebuild with -tags with_acme`) return nil, nil, E.New(`ACME is not included in this build, rebuild with -tags with_acme`)
} }

View file

@ -68,6 +68,7 @@ nav:
- WireGuard: configuration/outbound/wireguard.md - WireGuard: configuration/outbound/wireguard.md
- Hysteria: configuration/outbound/hysteria.md - Hysteria: configuration/outbound/hysteria.md
- Tor: configuration/outbound/tor.md - Tor: configuration/outbound/tor.md
- SSH: configuration/outbound/ssh.md
- DNS: configuration/outbound/dns.md - DNS: configuration/outbound/dns.md
- Selector: configuration/outbound/selector.md - Selector: configuration/outbound/selector.md
- Route: - Route:

View file

@ -19,6 +19,7 @@ type _Outbound struct {
WireGuardOptions WireGuardOutboundOptions `json:"-"` WireGuardOptions WireGuardOutboundOptions `json:"-"`
HysteriaOptions HysteriaOutboundOptions `json:"-"` HysteriaOptions HysteriaOutboundOptions `json:"-"`
TorOptions TorOutboundOptions `json:"-"` TorOptions TorOutboundOptions `json:"-"`
SSHOptions SSHOutboundOptions `json:"-"`
SelectorOptions SelectorOutboundOptions `json:"-"` SelectorOptions SelectorOutboundOptions `json:"-"`
} }
@ -47,6 +48,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) {
v = h.HysteriaOptions v = h.HysteriaOptions
case C.TypeTor: case C.TypeTor:
v = h.TorOptions v = h.TorOptions
case C.TypeSSH:
v = h.SSHOptions
case C.TypeSelector: case C.TypeSelector:
v = h.SelectorOptions v = h.SelectorOptions
default: default:
@ -82,6 +85,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error {
v = &h.HysteriaOptions v = &h.HysteriaOptions
case C.TypeTor: case C.TypeTor:
v = &h.TorOptions v = &h.TorOptions
case C.TypeSSH:
v = &h.SSHOptions
case C.TypeSelector: case C.TypeSelector:
v = &h.SelectorOptions v = &h.SelectorOptions
default: default:

13
option/ssh.go Normal file
View file

@ -0,0 +1,13 @@
package option
type SSHOutboundOptions struct {
OutboundDialerOptions
ServerOptions
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
PrivateKey string `json:"private_key,omitempty"`
PrivateKeyPath string `json:"private_key_path,omitempty"`
PrivateKeyPassphrase string `json:"private_key_passphrase,omitempty"`
HostKeyAlgorithms Listable[string] `json:"host_key_algorithms,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
}

View file

@ -37,6 +37,8 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, o
return NewHysteria(ctx, router, logger, options.Tag, options.HysteriaOptions) return NewHysteria(ctx, router, logger, options.Tag, options.HysteriaOptions)
case C.TypeTor: case C.TypeTor:
return NewTor(ctx, router, logger, options.Tag, options.TorOptions) return NewTor(ctx, router, logger, options.Tag, options.TorOptions)
case C.TypeSSH:
return NewSSH(ctx, router, logger, options.Tag, options.SSHOptions)
case C.TypeSelector: case C.TypeSelector:
return NewSelector(router, logger, options.Tag, options.SelectorOptions) return NewSelector(router, logger, options.Tag, options.SelectorOptions)
default: default:

171
outbound/ssh.go Normal file
View file

@ -0,0 +1,171 @@
package outbound
import (
"context"
"math/rand"
"net"
"os"
"strconv"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
var _ adapter.Outbound = (*SSH)(nil)
type SSH struct {
myOutboundAdapter
ctx context.Context
dialer N.Dialer
serverAddr M.Socksaddr
user string
hostKeyAlgorithms []string
clientVersion string
authMethod []ssh.AuthMethod
clientAccess sync.Mutex
clientConn net.Conn
client *ssh.Client
}
func NewSSH(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHOutboundOptions) (*SSH, error) {
outbound := &SSH{
myOutboundAdapter: myOutboundAdapter{
protocol: C.TypeSSH,
network: []string{N.NetworkTCP},
router: router,
logger: logger,
tag: tag,
},
ctx: ctx,
dialer: dialer.NewOutbound(router, options.OutboundDialerOptions),
serverAddr: options.ServerOptions.Build(),
user: options.User,
hostKeyAlgorithms: options.HostKeyAlgorithms,
clientVersion: options.ClientVersion,
}
if outbound.serverAddr.Port == 0 {
outbound.serverAddr.Port = 22
}
if outbound.user == "" {
outbound.user = "root"
}
if outbound.clientVersion == "" {
outbound.clientVersion = randomVersion()
}
if options.Password != "" {
outbound.authMethod = append(outbound.authMethod, ssh.Password(options.Password))
}
if options.PrivateKey != "" || options.PrivateKeyPath != "" {
var privateKey []byte
if options.PrivateKey != "" {
privateKey = []byte(options.PrivateKey)
} else {
var err error
privateKey, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath))
if err != nil {
return nil, E.Cause(err, "read private key")
}
}
var signer ssh.Signer
var err error
if options.PrivateKeyPassphrase == "" {
signer, err = ssh.ParsePrivateKey(privateKey)
} else {
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(options.PrivateKeyPassphrase))
}
if err != nil {
return nil, E.Cause(err, "parse private key")
}
outbound.authMethod = append(outbound.authMethod, ssh.PublicKeys(signer))
}
return outbound, nil
}
func randomVersion() string {
version := "SSH-2.0-OpenSSH_"
if rand.Intn(2) == 0 {
version += "7." + strconv.Itoa(rand.Intn(10))
} else {
version += "8." + strconv.Itoa(rand.Intn(9))
}
return version
}
func (s *SSH) connect() (*ssh.Client, error) {
if s.client != nil {
return s.client, nil
}
s.clientAccess.Lock()
defer s.clientAccess.Unlock()
if s.client != nil {
return s.client, nil
}
conn, err := s.dialer.DialContext(s.ctx, N.NetworkTCP, s.serverAddr)
if err != nil {
return nil, err
}
config := &ssh.ClientConfig{
User: s.user,
Auth: s.authMethod,
ClientVersion: s.clientVersion,
HostKeyAlgorithms: s.hostKeyAlgorithms,
}
clientConn, chans, reqs, err := ssh.NewClientConn(conn, s.serverAddr.Addr.String(), config)
if err != nil {
conn.Close()
return nil, E.Cause(err, "connect to ssh server")
}
client := ssh.NewClient(clientConn, chans, reqs)
s.clientConn = conn
s.client = client
go func() {
client.Wait()
conn.Close()
s.clientAccess.Lock()
s.client = nil
s.clientConn = nil
s.clientAccess.Unlock()
}()
return client, nil
}
func (s *SSH) Close() error {
return common.Close(s.clientConn)
}
func (s *SSH) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
client, err := s.connect()
if err != nil {
return nil, err
}
return client.Dial(network, destination.String())
}
func (s *SSH) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return nil, os.ErrInvalid
}
func (s *SSH) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
return NewConnection(ctx, s, conn, metadata)
}
func (s *SSH) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
return os.ErrInvalid
}