Add SSH inbound, log level. Update MTPROXY. Fixes

This commit is contained in:
Shtorm
2026-06-07 07:59:43 +03:00
parent 6f6af8e902
commit 9f5ccf43d4
115 changed files with 2742 additions and 527 deletions

View File

@@ -0,0 +1,91 @@
package ssh
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"os"
"strings"
"time"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/crypto/ssh"
)
func parseCAKey(options *option.SSHCAOptions) (ssh.Signer, error) {
var keyData []byte
var err error
if len(options.PrivateKey) > 0 {
keyData = []byte(strings.Join(options.PrivateKey, "\n"))
} else if options.PrivateKeyPath != "" {
keyData, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath))
if err != nil {
return nil, E.Cause(err, "read CA private key")
}
} else {
return nil, E.New("missing CA private key")
}
if options.PrivateKeyPassphrase == "" {
return ssh.ParsePrivateKey(keyData)
}
return ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(options.PrivateKeyPassphrase))
}
func verifyCertificate(signer ssh.Signer, metadata ssh.ConnMetadata, key ssh.PublicKey) bool {
if signer == nil {
return false
}
certificate, ok := key.(*ssh.Certificate)
if !ok {
return false
}
checker := &ssh.CertChecker{
IsUserAuthority: func(auth ssh.PublicKey) bool {
return bytes.Equal(auth.Marshal(), signer.PublicKey().Marshal())
},
}
if !checker.IsUserAuthority(certificate.SignatureKey) {
return false
}
return checker.CheckCert(metadata.User(), certificate) == nil
}
func issueCertificate(signer ssh.Signer, user string) (ssh.Signer, error) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
ephemeral, err := ssh.NewSignerFromSigner(privateKey)
if err != nil {
return nil, err
}
now := time.Now()
certificate := &ssh.Certificate{
Key: ephemeral.PublicKey(),
Serial: uint64(now.UnixNano()),
CertType: ssh.UserCert,
KeyId: user,
ValidPrincipals: []string{user},
ValidAfter: uint64(now.Add(-1 * time.Minute).Unix()),
ValidBefore: uint64(now.Add(5 * time.Minute).Unix()),
Permissions: ssh.Permissions{
Extensions: map[string]string{
"permit-pty": "",
"permit-port-forwarding": "",
"permit-agent-forwarding": "",
"permit-X11-forwarding": "",
"permit-user-rc": "",
},
},
}
if err := certificate.SignCert(rand.Reader, signer); err != nil {
return nil, E.Cause(err, "sign certificate")
}
certSigner, err := ssh.NewCertSigner(certificate, ephemeral)
if err != nil {
return nil, E.Cause(err, "create certificate signer")
}
return certSigner, nil
}

322
protocol/ssh/fallback.go Normal file
View File

@@ -0,0 +1,322 @@
package ssh
import (
"bytes"
"context"
"encoding/base64"
"io"
"net"
"os"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/onclose"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
var _ Service = (*Fallback)(nil)
type Fallback struct {
Service
ctx context.Context
logger logger.ContextLogger
dialer N.Dialer
serverAddr M.Socksaddr
clientVersion string
mainSigner ssh.Signer
issueSigner ssh.Signer
hostKeys []ssh.PublicKey
keyAlgorithms []string
pending map[string]*upstreamConn
mtx sync.Mutex
}
type upstreamConn struct {
conn net.Conn
client ssh.Conn
channels <-chan ssh.NewChannel
requests <-chan *ssh.Request
}
func NewFallback(ctx context.Context, logger logger.ContextLogger, inner Service, options *option.SSHFallbackServerOptions) (*Fallback, error) {
serverAddr := options.Build()
if serverAddr.Port == 0 {
serverAddr.Port = 22
}
if !serverAddr.Addr.IsValid() && serverAddr.Fqdn == "" {
return nil, E.New("missing upstream server address")
}
upstreamDialer, err := dialer.New(ctx, options.DialerOptions, serverAddr.IsFqdn())
if err != nil {
return nil, err
}
fallback := &Fallback{
Service: inner,
ctx: ctx,
logger: logger,
dialer: upstreamDialer,
serverAddr: serverAddr,
clientVersion: options.ClientVersion,
keyAlgorithms: options.HostKeyAlgorithms,
pending: make(map[string]*upstreamConn),
}
if fallback.clientVersion == "" {
fallback.clientVersion = "SSH-2.0-OpenSSH_9.6"
}
if options.CA != nil {
signer, err := parseCAKey(options.CA)
if err != nil {
return nil, E.Cause(err, "parse CA")
}
fallback.mainSigner = signer
}
if options.IssueCA != nil {
signer, err := parseCAKey(options.IssueCA)
if err != nil {
return nil, E.Cause(err, "parse issue CA")
}
fallback.issueSigner = signer
}
if fallback.issueSigner == nil && fallback.mainSigner != nil {
fallback.issueSigner = fallback.mainSigner
}
for _, hostKey := range options.HostKey {
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(hostKey))
if err != nil {
return nil, E.New("parse upstream host key ", hostKey)
}
fallback.hostKeys = append(fallback.hostKeys, key)
}
for _, hostKeyPath := range options.HostKeyPath {
content, err := os.ReadFile(os.ExpandEnv(hostKeyPath))
if err != nil {
return nil, E.Cause(err, "read upstream host key ", hostKeyPath)
}
key, _, _, _, err := ssh.ParseAuthorizedKey(content)
if err != nil {
return nil, E.Cause(err, "parse upstream host key ", hostKeyPath)
}
fallback.hostKeys = append(fallback.hostKeys, key)
}
return fallback, nil
}
func (f *Fallback) PasswordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
if permissions, err := f.Service.PasswordCallback(conn, password); err == nil {
return permissions, nil
}
if err := f.dial(string(conn.SessionID()), conn.User(), ssh.Password(string(password))); err != nil {
return nil, E.Cause(err, "upstream authentication failed for user ", conn.User())
}
return &ssh.Permissions{Extensions: map[string]string{"user": conn.User(), "fallback": "1"}}, nil
}
func (f *Fallback) PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if permissions, err := f.Service.PublicKeyCallback(conn, key); err == nil {
return permissions, nil
}
if verifyCertificate(f.mainSigner, conn, key) {
signer, err := issueCertificate(f.issueSigner, conn.User())
if err != nil {
return nil, E.Cause(err, "upstream authentication failed for user ", conn.User())
}
if err := f.dial(string(conn.SessionID()), conn.User(), ssh.PublicKeys(signer)); err != nil {
return nil, E.Cause(err, "upstream authentication failed for user ", conn.User())
}
return &ssh.Permissions{Extensions: map[string]string{"user": conn.User(), "fallback": "1"}}, nil
}
return nil, E.New("public key authentication failed for user ", conn.User())
}
func (f *Fallback) Handle(ctx context.Context, serverConn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, metadata adapter.InboundContext, user string) {
if serverConn.Permissions == nil || serverConn.Permissions.Extensions["fallback"] != "1" {
f.Service.Handle(ctx, serverConn, channels, requests, metadata, user)
return
}
sessionID := string(serverConn.SessionID())
f.mtx.Lock()
upstream := f.pending[sessionID]
delete(f.pending, sessionID)
f.mtx.Unlock()
if upstream == nil {
serverConn.Close()
return
}
f.logger.InfoContext(ctx, "[", user, "] forwarded SSH connection from ", metadata.Source)
go proxyDownstreamRequests(requests, upstream.client)
go proxyGlobalRequests(upstream.requests, serverConn)
go func() {
for newChannel := range upstream.channels {
go proxyChannel(newChannel, serverConn)
}
}()
var wg sync.WaitGroup
for newChannel := range channels {
wg.Go(func() {
proxyChannel(newChannel, upstream.client)
})
}
wg.Wait()
upstream.client.Close()
upstream.conn.Close()
serverConn.Close()
}
func (f *Fallback) Close() error {
f.mtx.Lock()
connections := make([]net.Conn, 0, len(f.pending))
for id, upstream := range f.pending {
if upstream != nil {
connections = append(connections, upstream.conn)
}
delete(f.pending, id)
}
f.mtx.Unlock()
for _, conn := range connections {
conn.Close()
}
return f.Service.Close()
}
func (f *Fallback) dial(sessionID string, user string, auth ssh.AuthMethod) error {
f.mtx.Lock()
if _, attempted := f.pending[sessionID]; attempted {
f.mtx.Unlock()
return E.New("fallback already attempted")
}
f.pending[sessionID] = nil
f.mtx.Unlock()
conn, err := f.dialer.DialContext(f.ctx, N.NetworkTCP, f.serverAddr)
if err != nil {
f.mtx.Lock()
delete(f.pending, sessionID)
f.mtx.Unlock()
return err
}
conn = onclose.NewConn(conn, func() {
f.mtx.Lock()
delete(f.pending, sessionID)
f.mtx.Unlock()
})
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{auth},
ClientVersion: f.clientVersion,
HostKeyAlgorithms: f.keyAlgorithms,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
if len(f.hostKeys) == 0 {
return nil
}
serverKey := key.Marshal()
for _, hostKey := range f.hostKeys {
if bytes.Equal(serverKey, hostKey.Marshal()) {
return nil
}
}
return E.New("upstream host key mismatch, server sent ", key.Type(), " ", base64.StdEncoding.EncodeToString(serverKey))
},
Timeout: C.TCPTimeout,
}
client, channels, requests, err := ssh.NewClientConn(conn, f.serverAddr.String(), config)
if err != nil {
conn.Close()
return err
}
f.mtx.Lock()
f.pending[sessionID] = &upstreamConn{conn: conn, client: client, channels: channels, requests: requests}
f.mtx.Unlock()
return nil
}
func proxyChannel(newChannel ssh.NewChannel, target ssh.Conn) {
targetChannel, targetRequests, err := target.OpenChannel(newChannel.ChannelType(), newChannel.ExtraData())
if err != nil {
if openErr, ok := err.(*ssh.OpenChannelError); ok {
newChannel.Reject(openErr.Reason, openErr.Message)
} else {
newChannel.Reject(ssh.ConnectionFailed, err.Error())
}
return
}
sourceChannel, sourceRequests, err := newChannel.Accept()
if err != nil {
targetChannel.Close()
return
}
go proxyChannelRequests(sourceRequests, targetChannel)
go io.Copy(targetChannel.Stderr(), sourceChannel.Stderr())
go io.Copy(sourceChannel.Stderr(), targetChannel.Stderr())
go func() {
io.Copy(targetChannel, sourceChannel)
targetChannel.CloseWrite()
}()
go func() {
io.Copy(sourceChannel, targetChannel)
sourceChannel.CloseWrite()
}()
proxyChannelRequests(targetRequests, sourceChannel)
sourceChannel.Close()
targetChannel.Close()
}
func proxyGlobalRequests(requests <-chan *ssh.Request, target ssh.Conn) {
for request := range requests {
if request.Type == "hostkeys-00@openssh.com" {
if request.WantReply {
request.Reply(false, nil)
}
continue
}
ok, payload, err := target.SendRequest(request.Type, request.WantReply, request.Payload)
if request.WantReply {
if err != nil {
request.Reply(false, nil)
} else {
request.Reply(ok, payload)
}
}
}
}
func proxyDownstreamRequests(requests <-chan *ssh.Request, target ssh.Conn) {
for request := range requests {
switch request.Type {
case "no-more-sessions@openssh.com", "hostkeys-prove-00@openssh.com":
if request.WantReply {
request.Reply(false, nil)
}
continue
}
ok, payload, err := target.SendRequest(request.Type, request.WantReply, request.Payload)
if request.WantReply {
if err != nil {
request.Reply(false, nil)
} else {
request.Reply(ok, payload)
}
}
}
}
func proxyChannelRequests(requests <-chan *ssh.Request, target ssh.Channel) {
for request := range requests {
ok, err := target.SendRequest(request.Type, request.WantReply, request.Payload)
if request.WantReply {
if err != nil {
request.Reply(false, nil)
} else {
request.Reply(ok, nil)
}
}
}
}

152
protocol/ssh/inbound.go Normal file
View File

@@ -0,0 +1,152 @@
package ssh
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net"
"os"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
"github.com/sagernet/sing-box/common/listener"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
func RegisterInbound(registry *inbound.Registry) {
inbound.Register[option.SSHInboundOptions](registry, C.TypeSSH, NewInbound)
}
var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
type Inbound struct {
inbound.Adapter
logger logger.ContextLogger
listener *listener.Listener
serverConfig *ssh.ServerConfig
service Service
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHInboundOptions) (adapter.Inbound, error) {
if len(options.Users) == 0 && options.Fallback == nil {
return nil, E.New("missing users")
}
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeSSH, tag),
logger: logger,
}
defaultService := newService(router, logger, options.Users)
if options.Fallback != nil {
fallback, err := NewFallback(ctx, logger, defaultService, options.Fallback)
if err != nil {
return nil, err
}
inbound.service = fallback
} else {
inbound.service = defaultService
}
serverVersion := options.ServerVersion
if serverVersion == "" {
serverVersion = "SSH-2.0-OpenSSH_9.6"
}
serverConfig := &ssh.ServerConfig{
ServerVersion: serverVersion,
MaxAuthTries: options.MaxAuthTries,
PasswordCallback: inbound.service.PasswordCallback,
PublicKeyCallback: inbound.service.PublicKeyCallback,
}
var hostKeys []ssh.Signer
for _, hostKey := range options.HostKey {
signer, err := ssh.ParsePrivateKey([]byte(hostKey))
if err != nil {
return nil, E.Cause(err, "parse host key")
}
hostKeys = append(hostKeys, signer)
}
for _, hostKeyPath := range options.HostKeyPath {
content, err := os.ReadFile(os.ExpandEnv(hostKeyPath))
if err != nil {
return nil, E.Cause(err, "read host key ", hostKeyPath)
}
signer, err := ssh.ParsePrivateKey(content)
if err != nil {
return nil, E.Cause(err, "parse host key ", hostKeyPath)
}
hostKeys = append(hostKeys, signer)
}
if len(hostKeys) == 0 {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, E.Cause(err, "generate host key")
}
signer, err := ssh.NewSignerFromSigner(privateKey)
if err != nil {
return nil, E.Cause(err, "generate host key")
}
hostKeys = append(hostKeys, signer)
}
for _, hostKey := range hostKeys {
serverConfig.AddHostKey(hostKey)
}
inbound.serverConfig = serverConfig
inbound.listener = listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
ConnectionHandler: inbound,
})
return inbound, nil
}
func (h *Inbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
return h.listener.Start()
}
func (h *Inbound) Close() error {
return E.Errors(h.service.Close(), h.listener.Close())
}
func (h *Inbound) UpdateUsers(users []option.SSHUser) {
h.service.UpdateUsers(users)
}
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
metadata.Inbound = h.Tag()
metadata.InboundType = h.Type()
serverConn, channels, requests, err := ssh.NewServerConn(conn, h.serverConfig)
if err != nil {
N.CloseOnHandshakeFailure(conn, onClose, err)
if E.IsClosedOrCanceled(err) {
h.logger.DebugContext(ctx, "connection closed: ", err)
} else {
h.logger.DebugContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
}
return
}
var user string
if serverConn.Permissions != nil {
user = serverConn.Permissions.Extensions["user"]
}
if user == "" {
user = serverConn.User()
}
if user != "" {
metadata.User = user
}
go func() {
serverConn.Wait()
conn.Close()
}()
h.service.Handle(ctx, serverConn, channels, requests, metadata, user)
}

165
protocol/ssh/service.go Normal file
View File

@@ -0,0 +1,165 @@
package ssh
import (
"bytes"
"context"
"net"
"os"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/bufio/deadline"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"golang.org/x/crypto/ssh"
)
type Service interface {
PasswordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error)
PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error)
UpdateUsers(users []option.SSHUser)
Handle(ctx context.Context, serverConn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, metadata adapter.InboundContext, user string)
Close() error
}
var _ Service = (*service)(nil)
type service struct {
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
users []option.SSHUser
mtx sync.RWMutex
}
func newService(router adapter.ConnectionRouterEx, logger logger.ContextLogger, users []option.SSHUser) *service {
return &service{
router: router,
logger: logger,
users: users,
}
}
func (h *service) PasswordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
h.mtx.RLock()
users := h.users
h.mtx.RUnlock()
for _, user := range users {
if user.Name != "" && user.Name != conn.User() {
continue
}
if user.Password != "" && user.Password == string(password) {
return &ssh.Permissions{Extensions: map[string]string{"user": user.Name}}, nil
}
}
return nil, E.New("password authentication failed for user ", conn.User())
}
func (h *service) PublicKeyCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
h.mtx.RLock()
users := h.users
h.mtx.RUnlock()
for _, user := range users {
if user.Name != "" && user.Name != conn.User() {
continue
}
for _, authorizedKey := range user.AuthorizedKeys {
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKey))
if err != nil {
continue
}
if bytes.Equal(parsed.Marshal(), key.Marshal()) {
return &ssh.Permissions{Extensions: map[string]string{"user": user.Name}}, nil
}
}
}
return nil, E.New("public key authentication failed for user ", conn.User())
}
func (h *service) UpdateUsers(users []option.SSHUser) {
h.mtx.Lock()
h.users = users
h.mtx.Unlock()
}
func (h *service) Handle(ctx context.Context, serverConn *ssh.ServerConn, channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, metadata adapter.InboundContext, user string) {
h.logger.InfoContext(ctx, "[", user, "] authenticated SSH connection from ", metadata.Source)
go ssh.DiscardRequests(requests)
for newChannel := range channels {
switch newChannel.ChannelType() {
case "direct-tcpip":
go h.handleDirectChannel(ctx, metadata, newChannel)
default:
newChannel.Reject(ssh.UnknownChannelType, "only direct-tcpip is supported")
}
}
}
func (h *service) Close() error {
return nil
}
func (h *service) handleDirectChannel(ctx context.Context, metadata adapter.InboundContext, newChannel ssh.NewChannel) {
var payload directTCPIPData
if err := ssh.Unmarshal(newChannel.ExtraData(), &payload); err != nil {
newChannel.Reject(ssh.ConnectionFailed, "invalid direct-tcpip payload")
h.logger.ErrorContext(ctx, E.Cause(err, "parse direct-tcpip payload"))
return
}
channel, requests, err := newChannel.Accept()
if err != nil {
h.logger.ErrorContext(ctx, E.Cause(err, "accept direct-tcpip channel"))
return
}
go ssh.DiscardRequests(requests)
connMetadata := metadata
connMetadata.Destination = M.ParseSocksaddrHostPort(payload.HostToConnect, uint16(payload.PortToConnect))
conn := deadline.NewConn(&channelConn{
Channel: channel,
localAddr: metadata.OriginDestination.TCPAddr(),
remoteAddr: metadata.Source.TCPAddr(),
})
h.logger.InfoContext(ctx, "[", metadata.User, "] inbound connection to ", connMetadata.Destination)
h.router.RouteConnectionEx(ctx, conn, connMetadata, N.OnceClose(func(it error) {
channel.Close()
}))
}
type directTCPIPData struct {
HostToConnect string
PortToConnect uint32
OriginatorAddress string
OriginatorPort uint32
}
type channelConn struct {
ssh.Channel
localAddr net.Addr
remoteAddr net.Addr
}
func (c *channelConn) LocalAddr() net.Addr {
return c.localAddr
}
func (c *channelConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
func (c *channelConn) SetDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *channelConn) SetReadDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *channelConn) SetWriteDeadline(t time.Time) error {
return os.ErrInvalid
}