SSH, Tor и Tailscale
Эти три протокола выполняют специализированные сетевые роли: SSH обеспечивает TCP-туннелирование через SSH-каналы, Tor обеспечивает анонимную маршрутизацию через сеть Tor, а Tailscale обеспечивает mesh-сеть на основе WireGuard через координационный сервис Tailscale.
Исходный код: protocol/ssh/outbound.go, protocol/tor/outbound.go, protocol/tor/proxy.go, protocol/tailscale/endpoint.go
Исходящее соединение SSH (Outbound)
SSH-туннелирование использует библиотеку Go golang.org/x/crypto/ssh для установки SSH-соединения и создания TCP-туннелей через него.
Архитектура
type Outbound struct {
outbound.Adapter
ctx context.Context
logger logger.ContextLogger
dialer N.Dialer
serverAddr M.Socksaddr
user string
hostKey []ssh.PublicKey
hostKeyAlgorithms []string
clientVersion string
authMethod []ssh.AuthMethod
clientAccess sync.Mutex
clientConn net.Conn
client *ssh.Client
}Только TCP
SSH-туннелирование поддерживает только TCP:
outbound.NewAdapterWithDialerOptions(C.TypeSSH, tag, []string{N.NetworkTCP}, options.DialerOptions)
func (s *Outbound) ListenPacket(ctx, destination) (net.PacketConn, error) {
return nil, os.ErrInvalid
}Конфигурация по умолчанию
if outbound.serverAddr.Port == 0 {
outbound.serverAddr.Port = 22
}
if outbound.user == "" {
outbound.user = "root"
}
if outbound.clientVersion == "" {
outbound.clientVersion = randomVersion()
}Случайная версия клиента
Для предотвращения снятия отпечатков генерируется случайная строка версии SSH:
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
}Методы аутентификации
Поддерживается несколько методов аутентификации:
// Аутентификация по паролю
if options.Password != "" {
outbound.authMethod = append(outbound.authMethod, ssh.Password(options.Password))
}
// Аутентификация по закрытому ключу (с опциональной парольной фразой)
if len(options.PrivateKey) > 0 || options.PrivateKeyPath != "" {
var signer ssh.Signer
if options.PrivateKeyPassphrase == "" {
signer, _ = ssh.ParsePrivateKey(privateKey)
} else {
signer, _ = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(options.PrivateKeyPassphrase))
}
outbound.authMethod = append(outbound.authMethod, ssh.PublicKeys(signer))
}Проверка ключа хоста
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
if len(s.hostKey) == 0 {
return nil // Принимать все ключи
}
serverKey := key.Marshal()
for _, hostKey := range s.hostKey {
if bytes.Equal(serverKey, hostKey.Marshal()) {
return nil
}
}
return E.New("host key mismatch")
},Повторное использование соединения
SSH-клиентское соединение разделяется между несколькими туннелями:
func (s *Outbound) 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, _ := s.dialer.DialContext(s.ctx, N.NetworkTCP, s.serverAddr)
clientConn, chans, reqs, _ := ssh.NewClientConn(conn, s.serverAddr.Addr.String(), config)
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
}Подключение через SSH
func (s *Outbound) DialContext(ctx, network, destination) (net.Conn, error) {
client, _ := s.connect()
conn, _ := client.Dial(network, destination.String())
return &chanConnWrapper{Conn: conn}, nil
}chanConnWrapper оборачивает соединения ssh.Channel, отключая операции с дедлайнами (которые SSH-каналы не поддерживают).
Обновление интерфейса
При смене сетевого интерфейса SSH-соединение закрывается для переподключения:
func (s *Outbound) InterfaceUpdated() {
common.Close(s.clientConn)
}Исходящее соединение Tor (Outbound)
Интеграция Tor использует библиотеку cretz/bine для управления встроенным процессом Tor и маршрутизации соединений через сеть Tor.
Архитектура
type Outbound struct {
outbound.Adapter
ctx context.Context
logger logger.ContextLogger
proxy *ProxyListener
startConf *tor.StartConf
options map[string]string
events chan control.Event
instance *tor.Tor
socksClient *socks.Client
}Только TCP
outbound.NewAdapterWithDialerOptions(C.TypeTor, tag, []string{N.NetworkTCP}, options.DialerOptions)Конфигурация Tor
var startConf tor.StartConf
startConf.DataDir = os.ExpandEnv(options.DataDirectory)
startConf.TempDataDirBase = os.TempDir()
startConf.ExtraArgs = options.ExtraArgs
// Автоматическое обнаружение файлов GeoIP в каталоге данных
if geoIPPath := filepath.Join(dataDirAbs, "geoip"); rw.IsFile(geoIPPath) {
options.ExtraArgs = append(options.ExtraArgs, "--GeoIPFile", geoIPPath)
}Прокси-слушатель (мост для вышестоящего соединения)
Ключевая инновация — ProxyListener: локальный SOCKS5-прокси, который связывает систему dialer'ов sing-box с Tor. Tor настраивается на использование этого локального прокси как вышестоящего:
proxy := NewProxyListener(ctx, logger, outboundDialer)Прокси-слушатель:
- Слушает на случайном локальном порту со случайными учётными данными
- Принимает SOCKS5-соединения от процесса Tor
- Маршрутизирует их через dialer sing-box (который обрабатывает detour'ы, интерфейсы и т.д.)
type ProxyListener struct {
ctx context.Context
logger log.ContextLogger
dialer N.Dialer
tcpListener *net.TCPListener
username string // случайный 64-байтовый hex
password string // случайный 64-байтовый hex
authenticator *auth.Authenticator
}Последовательность запуска
func (t *Outbound) start() error {
// 1. Запустить процесс Tor
torInstance, _ := tor.Start(t.ctx, t.startConf)
// 2. Настроить логирование событий
torInstance.Control.AddEventListener(t.events, torLogEvents...)
go t.recvLoop()
// 3. Запустить локальный мост прокси
t.proxy.Start()
// 4. Настроить Tor на использование локального прокси
confOptions := []*control.KeyVal{
control.NewKeyVal("Socks5Proxy", "127.0.0.1:" + F.ToString(t.proxy.Port())),
control.NewKeyVal("Socks5ProxyUsername", t.proxy.Username()),
control.NewKeyVal("Socks5ProxyPassword", t.proxy.Password()),
}
torInstance.Control.ResetConf(confOptions...)
// 5. Применить пользовательские опции Tor
for key, value := range t.options {
torInstance.Control.SetConf(control.NewKeyVal(key, value))
}
// 6. Включить сеть Tor
torInstance.EnableNetwork(t.ctx, true)
// 7. Получить адрес SOCKS5 Tor
info, _ := torInstance.Control.GetInfo("net/listeners/socks")
t.socksClient = socks.NewClient(N.SystemDialer, M.ParseSocksaddr(info[0].Val), socks.Version5, "", "")
}Подключение через Tor
func (t *Outbound) DialContext(ctx, network, destination) (net.Conn, error) {
return t.socksClient.DialContext(ctx, network, destination)
}Endpoint Tailscale
Tailscale реализован как полный Endpoint (как и WireGuard), обеспечивая функциональность как входящих, так и исходящих соединений. Он использует tsnet.Server для запуска встроенного узла Tailscale.
Архитектура
type Endpoint struct {
endpoint.Adapter
ctx context.Context
router adapter.Router
logger logger.ContextLogger
dnsRouter adapter.DNSRouter
network adapter.NetworkManager
platformInterface adapter.PlatformInterface
server *tsnet.Server
stack *stack.Stack // сетевой стек gVisor
icmpForwarder *tun.ICMPForwarder
filter *atomic.Pointer[filter.Filter]
acceptRoutes bool
exitNode string
exitNodeAllowLANAccess bool
advertiseRoutes []netip.Prefix
advertiseExitNode bool
advertiseTags []string
relayServerPort *uint16
udpTimeout time.Duration
}Поддержка сетей
Tailscale поддерживает TCP, UDP и ICMP:
endpoint.NewAdapter(C.TypeTailscale, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil)Конфигурация tsnet.Server
server := &tsnet.Server{
Dir: stateDirectory,
Hostname: hostname,
Ephemeral: options.Ephemeral,
AuthKey: options.AuthKey,
ControlURL: options.ControlURL,
AdvertiseTags: options.AdvertiseTags,
Dialer: &endpointDialer{Dialer: outboundDialer, logger: logger},
LookupHook: func(ctx, host) ([]netip.Addr, error) {
return dnsRouter.Lookup(ctx, host, outboundDialer.(dialer.ResolveDialer).QueryOptions())
},
HTTPClient: &http.Client{
Transport: &http.Transport{
DialContext: func(ctx, network, address) (net.Conn, error) {
return outboundDialer.DialContext(ctx, network, M.ParseSocksaddr(address))
},
TLSClientConfig: &tls.Config{
RootCAs: adapter.RootPoolFromContext(ctx),
Time: ntp.TimeFuncFromContext(ctx),
},
},
},
}Обработчики Netstack
Tailscale использует сетевой стек gVisor. Входящие TCP/UDP-соединения регистрируются через обработчики потоков netstack:
func (t *Endpoint) registerNetstackHandlers() {
netstack := t.server.ExportNetstack()
netstack.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
return func(conn net.Conn) {
t.NewConnectionEx(ctx, conn, source, destination, nil)
}, true
}
netstack.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
return func(conn nettype.ConnPacketConn) {
t.NewPacketConnectionEx(ctx, bufio.NewPacketConn(conn), source, destination, nil)
}, true
}
}Пересылка ICMP
Tailscale настраивает пересылку ICMP через сетевой стек gVisor:
icmpForwarder := tun.NewICMPForwarder(t.ctx, ipStack, t, t.udpTimeout)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket)
ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket)Аутентификация и отслеживание состояния
Endpoint отслеживает требования аутентификации и отправляет уведомления на мобильных платформах:
func (t *Endpoint) watchState() {
localBackend.WatchNotifications(t.ctx, ipn.NotifyInitialState, nil, func(roNotify *ipn.Notify) (keepGoing bool) {
authURL := localBackend.StatusWithoutPeers().AuthURL
if authURL != "" {
t.logger.Info("Waiting for authentication: ", authURL)
if t.platformInterface != nil {
t.platformInterface.SendNotification(&adapter.Notification{
Title: "Tailscale Authentication",
OpenURL: authURL,
})
}
}
return true
})
}Поддержка Exit Node
После запуска узла Tailscale настраивается exit node:
if t.exitNode != "" {
status, _ := t.server.LocalClient().Status(t.ctx)
perfs := &ipn.MaskedPrefs{
Prefs: ipn.Prefs{
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
},
ExitNodeIPSet: true,
ExitNodeAllowLANAccessSet: true,
}
perfs.SetExitNodeIP(t.exitNode, status)
localBackend.EditPrefs(perfs)
}Исходящие подключения через gVisor
Исходящие соединения проходят непосредственно через TCP/IP-стек gVisor:
func (t *Endpoint) DialContext(ctx, network, destination) (net.Conn, error) {
addr4, addr6 := t.server.TailscaleIPs()
remoteAddr := tcpip.FullAddress{NIC: 1, Port: destination.Port, Addr: addressFromAddr(destination.Addr)}
switch N.NetworkName(network) {
case N.NetworkTCP:
return gonet.DialTCPWithBind(ctx, t.stack, localAddr, remoteAddr, networkProtocol)
case N.NetworkUDP:
return gonet.DialUDP(t.stack, &localAddr, &remoteAddr, networkProtocol)
}
}Предпочтительные маршруты
Tailscale объявляет предпочтительные домены и адреса на основе конфигурации WireGuard:
func (t *Endpoint) PreferredDomain(domain string) bool {
routeDomains := t.routeDomains.Load()
return routeDomains[strings.ToLower(domain)]
}
func (t *Endpoint) PreferredAddress(address netip.Addr) bool {
routePrefixes := t.routePrefixes.Load()
return routePrefixes.Contains(address)
}Хук реконфигурации
При изменении конфигурации WireGuard обновляются маршрутные домены и префиксы:
func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *tsDNS.Config) {
// Обновить маршрутные домены из конфигурации DNS
routeDomains := make(map[string]bool)
for fqdn := range dnsCfg.Routes {
routeDomains[fqdn.WithoutTrailingDot()] = true
}
t.routeDomains.Store(routeDomains)
// Обновить маршрутные префиксы из AllowedIPs пиров
var builder netipx.IPSetBuilder
for _, peer := range cfg.Peers {
for _, allowedIP := range peer.AllowedIPs {
builder.AddPrefix(allowedIP)
}
}
t.routePrefixes.Store(common.Must1(builder.IPSet()))
}Режим системного интерфейса
Tailscale может опционально создавать реальный TUN-интерфейс:
if t.systemInterface {
tunOptions := tun.Options{
Name: tunName,
MTU: mtu,
GSO: true,
}
systemTun, _ := tun.New(tunOptions)
systemTun.Start()
t.server.TunDevice = newTunDeviceAdapter(systemTun, int(mtu), t.logger)
}Примеры конфигурации
SSH
{
"type": "ssh",
"tag": "ssh-out",
"server": "example.com",
"server_port": 22,
"user": "admin",
"private_key_path": "/path/to/id_ed25519",
"host_key_algorithms": ["ssh-ed25519"],
"host_key": ["ssh-ed25519 AAAA..."]
}Tor
{
"type": "tor",
"tag": "tor-out",
"executable_path": "/usr/bin/tor",
"data_directory": "/var/lib/sing-box/tor",
"options": {
"ExitNodes": "{us}",
"StrictNodes": "1"
}
}Tailscale
{
"type": "tailscale",
"tag": "ts-ep",
"auth_key": "tskey-auth-xxxxx",
"hostname": "sing-box-node",
"state_directory": "/var/lib/sing-box/tailscale",
"accept_routes": true,
"exit_node": "100.64.0.1",
"exit_node_allow_lan_access": true,
"advertise_routes": ["10.0.0.0/24"],
"advertise_exit_node": false,
"udp_timeout": "5m"
}