Compare commits

..

No commits in common. "725f6501937cd410a071f85cf3037b8279be4179" and "fa9b7b183800b7ae6c85f6cbdf7150b69268affe" have entirely different histories.

17 changed files with 41 additions and 244 deletions

2
.gitignore vendored
View File

@ -21,8 +21,6 @@ build/
*.pb.go *.pb.go
# Go workspace file # Go workspace file
cryptolib
go.work go.work
.idea .idea
config.yml config.yml
!examples/config.yml

View File

@ -1,19 +0,0 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY ./ ./
RUN set -eux; \
apk add --no-cache bash make git protoc protobuf-dev dumb-init mailcap ca-certificates tzdata; \
update-ca-certificates; \
make install_protobuf build
FROM scratch
WORKDIR /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /etc/mime.types /etc/mime.types
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /usr/bin/dumb-init /bin/dumb-init
COPY --from=builder /app/build/sshpoke /sshpoke
EXPOSE 25680
EXPOSE 25681
ENTRYPOINT ["/bin/dumb-init", "--"]
CMD ["/sshpoke"]

View File

@ -8,7 +8,7 @@ GO_VERSION=$(shell go version | sed -e 's/go version //')
VERSION=$(shell git describe --tags 2>/dev/null || git log --format="v0.0.0-%h" -n 1 || echo "v0.0.0-unknown") VERSION=$(shell git describe --tags 2>/dev/null || git log --format="v0.0.0-%h" -n 1 || echo "v0.0.0-unknown")
LDFLAGS="-X 'github.com/Neur0toxine/sshpoke/cmd.Version=${VERSION}'" LDFLAGS="-X 'github.com/Neur0toxine/sshpoke/cmd.Version=${VERSION}'"
.PHONY: run clean_backend clone_sshlib .PHONY: run clean_backend
build: generate deps fmt build: generate deps fmt
@echo " ► Building with ${GO_VERSION}" @echo " ► Building with ${GO_VERSION}"
@ -42,23 +42,17 @@ install_protobuf:
@go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28 @go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
@go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2 @go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
clone_cryptolib: update_sshlib:
@rm -rf cryptolib && \ @rm -rf cryptolib && \
git clone https://go.googlesource.com/crypto cryptolib && \ git clone https://go.googlesource.com/crypto cryptolib && \
mv cryptolib/internal/poly1305 cryptolib/ssh/internal/ && \ rm -rf pkg/proto/ssh && \
find cryptolib/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/ssh?github.com/Neur0toxine/sshpoke/pkg/proto/ssh?g' {} \; && \
find cryptolib/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/internal/poly1305?github.com/Neur0toxine/sshpoke/pkg/proto/ssh/internal/poly1305?g' {} \; && \
find cryptolib/ssh -type f -name '*_test.go' -delete && \
rm -rf cryptolib/ssh/test && \
rm -rf cryptolib/ssh/testdata
update_sshlib_patch:
@diff -Naru cryptolib/ssh pkg/proto/ssh > patch/ssh_proto_patches.patch
update_sshlib: clone_cryptolib
@mv pkg/proto/ssh pkg/proto/ssh.bak && \
mv cryptolib/ssh pkg/proto/ && \ mv cryptolib/ssh pkg/proto/ && \
patch -p0 < patch/ssh_proto_patches.patch && \ mv cryptolib/internal/poly1305 pkg/proto/ssh/internal/ && \
rm -rf pkg/proto/ssh.bak && \ find pkg/proto/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/ssh?github.com/Neur0toxine/sshpoke/pkg/proto/ssh?g' {} \; && \
find pkg/proto/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/internal/poly1305?github.com/Neur0toxine/sshpoke/pkg/proto/ssh/internal/poly1305?g' {} \; && \
find pkg/proto/ssh -type f -name '*_test.go' -delete && \
patch -p0 < patch/ssh_fakehost.patch && \
rm -rf pkg/proto/ssh/test && \
rm -rf pkg/proto/ssh/testdata && \
rm -rf cryptolib rm -rf cryptolib

View File

@ -1,21 +1,18 @@
# Enable or disable debug logging. Default: false # Enable or disable debug logging.
debug: true debug: true
# API settings. # API settings.
api: api:
web: # Local port for Web API. Will be bound to localhost.
# Local port for Web API. Will be bound to localhost. web_port: 25680
port: 25680 # Local port for plugin API. Will listen on all interfaces because it has auth.
token: "VbNG6T6wYmj9YHM6etuZgN35" plugin_port: 25681
plugin:
# Local port for plugin API. Will listen on all interfaces because it has auth.
port: 25681
# Docker client preferences. # Docker client preferences.
docker: docker:
# Extract client params from the environment. Default: true # Extract client params from the environment.
from_env: true from_env: true
# Cert path for the Docker client. # Cert path for the Docker client.
cert_path: ~ cert_path: ~
# Set it to false to disable TLS cert verification. Default: true # Set it to false to disable TLS cert verification.
tls_verify: true tls_verify: true
# Docker host. Can be useful for running containers alongside remote plugin (although it sounds weird to do so). # Docker host. Can be useful for running containers alongside remote plugin (although it sounds weird to do so).
host: ~ host: ~
@ -37,19 +34,14 @@ servers:
# This disables remote host resolution and forcibly uses server IP for remote host. # This disables remote host resolution and forcibly uses server IP for remote host.
# It's the same as this syntax for sish: `ssh -R addr:80:localhost:80 your.sish.server` # It's the same as this syntax for sish: `ssh -R addr:80:localhost:80 your.sish.server`
# Set this to true if you're using sish, otherwise you'll get weird domains with IP's in them. # Set this to true if you're using sish, otherwise you'll get weird domains with IP's in them.
# Default: false
fake_remote_host: true fake_remote_host: true
# Disables PTY request for this server. Default: false # Disables PTY request for this server.
nopty: true nopty: true
# Requests interactive shell for SSH sessions. Should be `true` for the `commands`. # Requests interactive shell for SSH sessions. Should be `true` for the `commands`.
# You can also pass a string with shell binary, for example, "/bin/sh". # You can also pass a string with shell binary, for example, "/bin/sh".
# Note: commands will be executed using provided shell binary. # Note: commands will be executed using provided shell binary.
# Note (2): some servers won't send you any data until you request a shell even without a PTY.
# You can use a combination of `nopty: true` & `shell: true`. Also, even with PTY you may need `shell` to be `true`.
# Default: false
shell: false shell: false
# Spoof client version with provided (value below is taken directly from OpenSSH). This value must be compliant with RFC-4253. # Spoof client version with provided (value below is taken directly from OpenSSH). This value must be compliant with RFC-4253.
# Default: SSH-2.0-Go
client_version: "SSH-2.0-OpenSSH_9.5" client_version: "SSH-2.0-OpenSSH_9.5"
# Authentication data. # Authentication data.
auth: auth:
@ -65,9 +57,9 @@ servers:
mode: multi mode: multi
# Keep-alive settings. Remove to disable keep-alive completely. # Keep-alive settings. Remove to disable keep-alive completely.
keepalive: keepalive:
# Interval for keep-alive requests in seconds. Default: 0 (disabled). # Interval for keep-alive requests in seconds.
interval: 1 interval: 1
# How many attempts should fail to forcibly restart the connection. Default: 0 (disabled). # How many attempts should fail to forcibly restart the connection.
max_attempts: 2 max_attempts: 2
# Regular expression that will be used to extract domain from stdout & stderr. Useful for services like sish or # Regular expression that will be used to extract domain from stdout & stderr. Useful for services like sish or
# localhost.run. `commands` output will also be parsed by this regex. # localhost.run. `commands` output will also be parsed by this regex.
@ -75,7 +67,6 @@ servers:
# - !webUrl - any HTTP or HTTPS URL. # - !webUrl - any HTTP or HTTPS URL.
# - !httpUrl - any HTTP URL. # - !httpUrl - any HTTP URL.
# - !httpsUrl - any HTTPS URL. # - !httpsUrl - any HTTPS URL.
# Default: "" (disabled).
domain_extract_regex: "!httpsUrl" domain_extract_regex: "!httpsUrl"
# Host keys to prevent MITM. You can obtain those via `ssh-keyscan <address>` (specify `-p` for non-standard port). # Host keys to prevent MITM. You can obtain those via `ssh-keyscan <address>` (specify `-p` for non-standard port).
# Always use '|' YAML syntax here (not '>') or sshpoke won't be able to parse keys. # Always use '|' YAML syntax here (not '>') or sshpoke won't be able to parse keys.
@ -104,13 +95,6 @@ servers:
interval: 1 interval: 1
max_attempts: 2 max_attempts: 2
domain_extract_regex: "!webUrl" domain_extract_regex: "!webUrl"
# Read output data from raw SSH packets. This can help if domain_extract_regex couldn't catch the domain.
# You can also enable debug logging - it should contain outputs from ssh server.
# Default: false
read_raw_packets: true
# Enable or disable sessions output. Disabling this will stop domain_extract_regex from reading commands output.
# Default: true
read_sessions_output: false
- name: ssh-demo-commands - name: ssh-demo-commands
driver: ssh driver: ssh
params: params:

View File

@ -1,10 +0,0 @@
version: "3.8"
services:
sshpoke:
image: "neur0toxine/sshpoke:latest"
container_name: "sshpoke"
network_mode: host
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./config.yml:/config.yml

View File

@ -61,7 +61,7 @@ func (p *pluginAPI) receiverForContext(ctx context.Context) plugin.Plugin {
} }
func StartPluginAPI() { func StartPluginAPI() {
port := config.Default.API.Plugin.Port port := config.Default.API.PluginPort
if port == 0 { if port == 0 {
port = plugin2.DefaultPort port = plugin2.DefaultPort
} }

View File

@ -19,17 +19,8 @@ type Config struct {
} }
type API struct { type API struct {
Web WebAPI `mapstructure:"web"` WebPort int `mapstructure:"web_port" validate:"gte=0,lte=65535"`
Plugin PluginAPI `mapstructure:"plugin"` PluginPort int `mapstructure:"plugin_port" validate:"gte=0,lte=65535"`
}
type WebAPI struct {
Port int `mapstructure:"port" validate:"gte=0,lte=65535"`
Token string `mapstructure:"token"`
}
type PluginAPI struct {
Port int `mapstructure:"port" validate:"gte=0,lte=65535"`
} }
type DockerConfig struct { type DockerConfig struct {

View File

@ -20,7 +20,6 @@ import (
"github.com/Neur0toxine/sshpoke/pkg/dto" "github.com/Neur0toxine/sshpoke/pkg/dto"
"github.com/Neur0toxine/sshpoke/pkg/proto/ssh" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh"
"github.com/Neur0toxine/sshpoke/pkg/proto/ssh/knownhosts" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh/knownhosts"
"go.uber.org/zap"
) )
const KnownHostsFile = "known_hosts" const KnownHostsFile = "known_hosts"
@ -86,40 +85,15 @@ func (d *SSH) forward(val sshtun.Forward, domainMatcher func(string)) conn {
d.Log()) d.Log())
ctx, cancel := context.WithCancel(d.Context()) ctx, cancel := context.WithCancel(d.Context())
tunDbgLog := d.Log().With("ssh-output", val.Remote.String()) tunDbgLog := d.Log().With("ssh-output", val.Remote.String())
var outputReaderCb sshtun.SessionCallback
if d.params.ReadSessionsOutput == nil || (*d.params.ReadSessionsOutput) {
outputReaderCb = sshtun.OutputReaderCallback(func(msg string) {
msg = strings.TrimSpace(msg)
if msg == "" {
return
}
tunDbgLog.Debug("session: ", msg)
if domainMatcher != nil {
domainMatcher(msg)
}
})
}
go tun.Connect(ctx, go tun.Connect(ctx,
d.buildHandshakeLineCallback(domainMatcher, tunDbgLog),
sshtun.BannerDebugLogCallback(tunDbgLog), sshtun.BannerDebugLogCallback(tunDbgLog),
outputReaderCb) sshtun.OutputReaderCallback(func(msg string) {
return conn{ctx: ctx, cancel: cancel, tun: tun} d.Log().Debug(msg)
}
func (d *SSH) buildHandshakeLineCallback(domainMatcher func(string), tunDbgLog *zap.SugaredLogger) func(string) {
if d.params.ReadRawPackets {
return func(msg string) {
msg = strings.TrimSpace(msg)
if msg == "" {
return
}
tunDbgLog.Debugf("ssh: %s", msg)
if domainMatcher != nil { if domainMatcher != nil {
domainMatcher(msg) domainMatcher(msg)
} }
} }))
} return conn{ctx: ctx, cancel: cancel, tun: tun}
return nil
} }
func (d *SSH) buildHostKeyCallback() ssh.HostKeyCallback { func (d *SSH) buildHostKeyCallback() ssh.HostKeyCallback {
@ -306,10 +280,6 @@ func (d *SSH) remoteEndpoint(remoteHost string) sshtun.Endpoint {
if port == 0 { if port == 0 {
port = 80 port = 80
} }
if remoteHost == "" && !d.params.FakeRemoteHost {
// Listen on all interfaces if no host was provided.
remoteHost = "0.0.0.0"
}
return sshtun.Endpoint{ return sshtun.Endpoint{
Host: remoteHost, Host: remoteHost,
Port: port, Port: port,

View File

@ -15,8 +15,6 @@ type Params struct {
Auth types.Auth `mapstructure:"auth"` Auth types.Auth `mapstructure:"auth"`
KeepAlive types.KeepAlive `mapstructure:"keepalive"` KeepAlive types.KeepAlive `mapstructure:"keepalive"`
DomainExtractRegex string `mapstructure:"domain_extract_regex" validate:"validregexp"` DomainExtractRegex string `mapstructure:"domain_extract_regex" validate:"validregexp"`
ReadRawPackets bool `mapstructure:"read_raw_packets"`
ReadSessionsOutput *bool `mapstructure:"read_sessions_output"`
Mode types.DomainMode `mapstructure:"mode" validate:"required,oneof=single multi"` Mode types.DomainMode `mapstructure:"mode" validate:"required,oneof=single multi"`
FakeRemoteHost bool `mapstructure:"fake_remote_host"` FakeRemoteHost bool `mapstructure:"fake_remote_host"`
NoPTY bool `mapstructure:"nopty"` NoPTY bool `mapstructure:"nopty"`

View File

@ -39,11 +39,11 @@ type TunnelConfig struct {
type BoolOrStr string type BoolOrStr string
func (b BoolOrStr) IsBool() bool { func (b BoolOrStr) IsBool() bool {
return b == "true" || b == "false" || b == "1" || b == "0" || b == "" return b == "true" || b == "false"
} }
func (b BoolOrStr) Falsy() bool { func (b BoolOrStr) Falsy() bool {
return b == "" || b == "0" || b == "false" return b == "" || b == "false"
} }
func (b BoolOrStr) String() string { func (b BoolOrStr) String() string {
@ -60,8 +60,7 @@ func New(address, user string, auth []ssh.AuthMethod, sc TunnelConfig, log *zap.
} }
} }
func (t *Tunnel) Connect(ctx context.Context, func (t *Tunnel) Connect(ctx context.Context, bannerCb ssh.BannerCallback, sessionCb SessionCallback) {
hsLineCb func(msg string), bannerCb ssh.BannerCallback, sessionCb SessionCallback) {
if t.connected.Load() { if t.connected.Load() {
return return
} }
@ -70,7 +69,7 @@ func (t *Tunnel) Connect(ctx context.Context,
backoffTime := backoff.ExponentialWithCappedMax(100*time.Millisecond, 5*time.Second) backoffTime := backoff.ExponentialWithCappedMax(100*time.Millisecond, 5*time.Second)
for { for {
t.connected.Store(true) t.connected.Store(true)
err := t.connect(ctx, hsLineCb, bannerCb, sessionCb) err := t.connect(ctx, bannerCb, sessionCb)
if err != nil { if err != nil {
t.log.Error("connect error: ", err) t.log.Error("connect error: ", err)
} }
@ -87,13 +86,9 @@ func (t *Tunnel) Connect(ctx context.Context,
// connect once to the SSH server. if the connection breaks, we return error and the caller // connect once to the SSH server. if the connection breaks, we return error and the caller
// will try to re-connect // will try to re-connect
func (t *Tunnel) connect(ctx context.Context, func (t *Tunnel) connect(ctx context.Context, bannerCb ssh.BannerCallback, sessionCb SessionCallback) error {
hsLineCb func(string), bannerCb ssh.BannerCallback, sessionCb SessionCallback) error {
t.log.Debug("connecting") t.log.Debug("connecting")
sshConfig := &ssh.ClientConfig{ sshConfig := &ssh.ClientConfig{
Config: ssh.Config{
HandshakePacketReader: newHandshakePerLineReader(hsLineCb),
},
User: t.user, User: t.user,
Auth: t.authMethods, Auth: t.authMethods,
HostKeyCallback: t.tunConfig.HostKeyCallback, HostKeyCallback: t.tunConfig.HostKeyCallback,

View File

@ -15,7 +15,7 @@ import (
// assume that the underlying net.Conn abruptly died. // assume that the underlying net.Conn abruptly died.
func (t *Tunnel) keepAlive(ctx context.Context, client *ssh.Client, wg *sync.WaitGroup) { func (t *Tunnel) keepAlive(ctx context.Context, client *ssh.Client, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
if t.tunConfig.KeepAliveInterval <= 0 || t.tunConfig.KeepAliveMax <= 0 { if t.tunConfig.KeepAliveInterval == 0 || t.tunConfig.KeepAliveMax == 0 {
return return
} }

View File

@ -1,31 +0,0 @@
package sshtun
type handshakePerLineReader struct {
buf []byte
off int
cb func(line string)
}
func newHandshakePerLineReader(cb func(string)) func([]byte) {
if cb == nil {
return nil
}
lr := &handshakePerLineReader{cb: cb, buf: make([]byte, 1024)}
return lr.packet
}
func (h *handshakePerLineReader) packet(p []byte) {
for _, char := range p {
if (char == 10 || char == 13) && len(h.buf) > 0 {
h.cb(string(h.buf[:h.off]))
h.off = 0
}
if cap(h.buf) <= (h.off + 1) {
newBuf := make([]byte, cap(h.buf)+256)
copy(newBuf, h.buf)
h.buf = newBuf
}
h.buf[h.off] = char
h.off++
}
}

View File

@ -37,7 +37,7 @@ func OutputReaderCallback(callback func(string)) SessionCallback {
func BannerDebugLogCallback(log *zap.SugaredLogger) ssh.BannerCallback { func BannerDebugLogCallback(log *zap.SugaredLogger) ssh.BannerCallback {
return func(msg string) error { return func(msg string) error {
log.Debug("banner: ", msg) log.Debug(msg)
return nil return nil
} }
} }

View File

@ -2,5 +2,5 @@ package types
type KeepAlive struct { type KeepAlive struct {
Interval int `mapstructure:"interval" validate:"gte=0"` Interval int `mapstructure:"interval" validate:"gte=0"`
MaxAttempts int `mapstructure:"max_attempts" validate:"gte=0"` MaxAttempts int `mapstructure:"max_attempts" validate:"gte=1"`
} }

View File

@ -1,68 +1,5 @@
diff -Naru cryptolib/ssh/cipher.go pkg/proto/ssh/cipher.go --- cryptolib/ssh/tcpip.go 2023-11-18 22:20:50.609146922 +0300
--- cryptolib/ssh/cipher.go 2023-11-21 18:52:03.117248053 +0300 +++ pkg/proto/ssh/tcpip.go 2023-11-18 22:19:08.891684669 +0300
+++ pkg/proto/ssh/cipher.go 2023-11-21 17:19:04.688042738 +0300
@@ -16,8 +16,8 @@
"hash"
"io"
- "golang.org/x/crypto/chacha20"
"github.com/Neur0toxine/sshpoke/pkg/proto/ssh/internal/poly1305"
+ "golang.org/x/crypto/chacha20"
)
const (
diff -Naru cryptolib/ssh/common.go pkg/proto/ssh/common.go
--- cryptolib/ssh/common.go 2023-11-21 18:52:03.217249582 +0300
+++ pkg/proto/ssh/common.go 2023-11-21 17:28:23.221203344 +0300
@@ -7,14 +7,13 @@
import (
"crypto"
"crypto/rand"
+ _ "crypto/sha1"
+ _ "crypto/sha256"
+ _ "crypto/sha512"
"fmt"
"io"
"math"
"sync"
-
- _ "crypto/sha1"
- _ "crypto/sha256"
- _ "crypto/sha512"
)
// These are string constants in the SSH protocol.
@@ -279,6 +278,9 @@
// The allowed MAC algorithms. If unspecified then a sensible default is
// used. Unsupported values are silently ignored.
MACs []string
+
+ // Called on every incoming handshake packet for client. Only receives data and extended data packets.
+ HandshakePacketReader func(p []byte)
}
// SetDefaults sets sensible values for unset fields in config. This is
diff -Naru cryptolib/ssh/handshake.go pkg/proto/ssh/handshake.go
--- cryptolib/ssh/handshake.go 2023-11-21 18:52:03.209249460 +0300
+++ pkg/proto/ssh/handshake.go 2023-11-21 18:51:58.681180283 +0300
@@ -401,6 +401,14 @@
t.printPacket(p, false)
}
+ if t.config.HandshakePacketReader != nil && p[0] == msgChannelData {
+ data, err := decode(p)
+ packetData, ok := data.(*channelDataMsg)
+ if err == nil && ok && packetData != nil {
+ t.config.HandshakePacketReader(packetData.Rest)
+ }
+ }
+
if first && p[0] != msgKexInit {
return nil, fmt.Errorf("ssh: first packet should be msgKexInit")
}
diff -Naru cryptolib/ssh/tcpip.go pkg/proto/ssh/tcpip.go
--- cryptolib/ssh/tcpip.go 2023-11-21 18:52:03.129248237 +0300
+++ pkg/proto/ssh/tcpip.go 2023-11-21 17:19:04.688042738 +0300
@@ -101,14 +101,18 @@ @@ -101,14 +101,18 @@
// ListenTCP requests the remote peer open a listening socket // ListenTCP requests the remote peer open a listening socket
// on laddr. Incoming connections will be available by calling // on laddr. Incoming connections will be available by calling

View File

@ -7,13 +7,14 @@ package ssh
import ( import (
"crypto" "crypto"
"crypto/rand" "crypto/rand"
_ "crypto/sha1"
_ "crypto/sha256"
_ "crypto/sha512"
"fmt" "fmt"
"io" "io"
"math" "math"
"sync" "sync"
_ "crypto/sha1"
_ "crypto/sha256"
_ "crypto/sha512"
) )
// These are string constants in the SSH protocol. // These are string constants in the SSH protocol.
@ -278,9 +279,6 @@ type Config struct {
// The allowed MAC algorithms. If unspecified then a sensible default is // The allowed MAC algorithms. If unspecified then a sensible default is
// used. Unsupported values are silently ignored. // used. Unsupported values are silently ignored.
MACs []string MACs []string
// Called on every incoming handshake packet for client. Only receives data and extended data packets.
HandshakePacketReader func(p []byte)
} }
// SetDefaults sets sensible values for unset fields in config. This is // SetDefaults sets sensible values for unset fields in config. This is

View File

@ -401,14 +401,6 @@ func (t *handshakeTransport) readOnePacket(first bool) ([]byte, error) {
t.printPacket(p, false) t.printPacket(p, false)
} }
if t.config.HandshakePacketReader != nil && p[0] == msgChannelData {
data, err := decode(p)
packetData, ok := data.(*channelDataMsg)
if err == nil && ok && packetData != nil {
t.config.HandshakePacketReader(packetData.Rest)
}
}
if first && p[0] != msgKexInit { if first && p[0] != msgKexInit {
return nil, fmt.Errorf("ssh: first packet should be msgKexInit") return nil, fmt.Errorf("ssh: first packet should be msgKexInit")
} }