mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-25 00:51:28 +02:00
Introduce a namespaced preference store owned by the profile manager, persisted next to the profile config as <id>.prefs.json and deleted with the profile. Sections are opaque JSON, so the profile manager stays free of any feature schema, and writes reuse the state file's atomic path. Migrate the Android SSH known-hosts store and session list onto it. Both previously lived outside the profile lifecycle: known hosts in a per-profile file under filesDir, the session list in Java SharedPreferences, each needing its own sweep against the live profile list to avoid outliving the profile they belonged to. A profile ID that got reused would have inherited the trusted keys of a deleted profile. Both now share the "ssh" and "ssh-sessions" namespaces of the profile's preferences, so deleting a profile takes them along and the Java-side pruning is gone. The known-hosts entries keep the OpenSSH line format, only the container changed, and host key verification keeps rejecting a changed key outright. SetKnownHostsPath becomes SetKnownHostsStore, taking the config dir and profile ID instead of a file path. Existing known-hosts files are not migrated: hosts trusted before this change prompt for confirmation once more, which errs towards safety.
650 lines
19 KiB
Go
650 lines
19 KiB
Go
//go:build android
|
|
|
|
package android
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
gossh "golang.org/x/crypto/ssh"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/auth"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
|
)
|
|
|
|
const (
|
|
sshDialTimeout = 30 * time.Second
|
|
sshDetectionTimeout = 5 * time.Second
|
|
)
|
|
|
|
// PasswordRequiredMarker tells Java to prompt for a password and retry. It is
|
|
// a string because gomobile flattens errors to their message, so a sentinel
|
|
// value would not survive the binding.
|
|
const PasswordRequiredMarker = "netbird-ssh-password-required"
|
|
|
|
// HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation,
|
|
// retry with TrustHostKey set. The presented fingerprint is appended after the
|
|
// marker so the prompt can display it and the retry can guard against a key
|
|
// that changed between the two connects. Only regular (non-NetBird) servers
|
|
// reach this: NetBird peers verify against the registry.
|
|
const HostKeyUnknownMarker = "netbird-ssh-hostkey-unknown"
|
|
|
|
var (
|
|
errPasswordRequired = errors.New(PasswordRequiredMarker)
|
|
errClientClosed = errors.New("ssh client closed")
|
|
)
|
|
|
|
// errHostKeyUnknown carries the presented fingerprint so Connect can build the
|
|
// marker message the Java side parses.
|
|
type errHostKeyUnknown struct {
|
|
fingerprint string
|
|
}
|
|
|
|
func (e *errHostKeyUnknown) Error() string {
|
|
return HostKeyUnknownMarker + ":" + e.fingerprint
|
|
}
|
|
|
|
// SSHTerminalListener receives SSH session events. It is implemented in Java.
|
|
//
|
|
// All callbacks are invoked from goroutines and may run concurrently with each
|
|
// other; the implementation must be safe to call from any thread.
|
|
type SSHTerminalListener interface {
|
|
OnConnected()
|
|
OnData(data []byte)
|
|
OnClose(reason string)
|
|
OnError(message string)
|
|
}
|
|
|
|
// SSHClient is a NetBird-aware SSH client exposed to Java via gomobile.
|
|
//
|
|
// It dials through the running NetBird tunnel and runs a standard SSH session
|
|
// on top with PTY enabled. Host-key verification uses the NetBird-provided
|
|
// peer SSH host keys, identical to the desktop client.
|
|
type SSHClient struct {
|
|
nb *Client
|
|
mu sync.Mutex
|
|
listener SSHTerminalListener
|
|
urlOpener URLOpener
|
|
|
|
sshClient *gossh.Client
|
|
session *gossh.Session
|
|
stdin io.WriteCloser
|
|
closed bool
|
|
|
|
// gen identifies the current connection attempt. Connect and Close bump it,
|
|
// so an in-flight dial or a reader left over from a previous connection
|
|
// finds itself stale and stays silent instead of publishing OnConnected or
|
|
// OnClose for a connection the caller already abandoned.
|
|
gen uint64
|
|
dialCancel context.CancelFunc
|
|
|
|
// knownHostsConfigDir and knownHostsProfile locate the TOFU store for
|
|
// regular SSH servers in the profile's preferences. Java supplies them,
|
|
// since an overlay IP is a different host under a different profile. Empty
|
|
// until set: without them a regular server cannot be verified and Connect
|
|
// refuses one.
|
|
knownHostsConfigDir string
|
|
knownHostsProfile string
|
|
// trustHostKey carries the fingerprint the user confirmed on a previous
|
|
// attempt, so the retry accepts exactly that key and persists it.
|
|
trustHostKey string
|
|
}
|
|
|
|
// NewSSHClient creates a new SSH client bound to the running NetBird Client.
|
|
func NewSSHClient(c *Client) *SSHClient {
|
|
return &SSHClient{nb: c}
|
|
}
|
|
|
|
// SetListener registers the Java listener. Must be called before Connect to
|
|
// receive any events.
|
|
func (s *SSHClient) SetListener(l SSHTerminalListener) {
|
|
s.mu.Lock()
|
|
s.listener = l
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// SetURLOpener registers the Java URL opener used to display the device-code
|
|
// authorization page in a Custom Tabs window when the target peer requires
|
|
// JWT authentication. Must be set before Connect to be effective.
|
|
func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
|
s.mu.Lock()
|
|
s.urlOpener = opener
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// SetKnownHostsStore points the TOFU host-key store at a profile's preferences.
|
|
// Must be set before connecting to a regular SSH server; without it such a
|
|
// server cannot be verified and Connect refuses one.
|
|
func (s *SSHClient) SetKnownHostsStore(configDir, profileID string) {
|
|
s.mu.Lock()
|
|
s.knownHostsConfigDir = configDir
|
|
s.knownHostsProfile = profileID
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// TrustHostKey records the fingerprint the user confirmed for a regular server,
|
|
// so the next Connect accepts that exact key and adds it to the known-hosts
|
|
// store. Passing a fingerprint that no longer matches makes the connect fail
|
|
// rather than trust a key that changed since the prompt.
|
|
func (s *SSHClient) TrustHostKey(fingerprint string) {
|
|
s.mu.Lock()
|
|
s.trustHostKey = fingerprint
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Connect dials the SSH server through the NetBird tunnel and performs the
|
|
// SSH handshake. It auto-detects the server type via SSH banner inspection
|
|
// and selects the appropriate authentication path:
|
|
//
|
|
// - NetBird-SSH server requiring JWT: launches the OAuth 2.0 device-code
|
|
// flow, opens the verification URL through the registered URLOpener, and
|
|
// uses the resulting token as the SSH password. Host-key verification
|
|
// uses the NetBird peer registry.
|
|
// - NetBird-SSH server without JWT: authenticates with the NetBird SSH
|
|
// private key. Host-key verification uses the NetBird peer registry.
|
|
// - Regular SSH server (e.g. OpenSSH): authenticates with the NetBird key
|
|
// first (so a user-installed NetBird public key works), then falls back
|
|
// to the supplied password if non-empty. Host-key verification is
|
|
// trust-on-first-use against the per-profile known-hosts store.
|
|
//
|
|
// The password parameter is only consulted for regular SSH servers.
|
|
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
|
if port < 1 || port > 65535 {
|
|
return fmt.Errorf("invalid port: %d", port)
|
|
}
|
|
|
|
cfg, cfgPath, cc := s.nb.authSnapshot()
|
|
if cc == nil {
|
|
return errors.New("netbird client not running")
|
|
}
|
|
if cfg == nil {
|
|
return errors.New("netbird config not loaded")
|
|
}
|
|
engine := cc.Engine()
|
|
if engine == nil {
|
|
return errors.New("netbird engine not available")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.gen++
|
|
gen := s.gen
|
|
s.mu.Unlock()
|
|
|
|
serverType := detectServerType(host, port)
|
|
log.Debugf("SSH server type: %s", serverType)
|
|
|
|
authMethods, hostKeyCallback, err := s.buildAuth(cfg, cfgPath, engine, serverType, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
clientConfig := &gossh.ClientConfig{
|
|
User: user,
|
|
Auth: authMethods,
|
|
HostKeyCallback: hostKeyCallback,
|
|
Timeout: sshDialTimeout,
|
|
}
|
|
err = s.dialAndHandshake(gen, host, port, clientConfig)
|
|
|
|
// An unknown host key is a prompt, not a failure: return the marker intact
|
|
// (rootCause would unwrap it) so Java can show the fingerprint and retry.
|
|
var unknownHost *errHostKeyUnknown
|
|
if errors.As(err, &unknownHost) {
|
|
return errors.New(unknownHost.Error())
|
|
}
|
|
|
|
// A regular server may still accept a password, so let the caller ask for
|
|
// one instead of failing. NetBird servers never use a password, so a
|
|
// failure there is genuine.
|
|
if err != nil && serverType != detection.ServerTypeNetBirdJWT &&
|
|
serverType != detection.ServerTypeNetBirdNoJWT && isAuthFailure(err) &&
|
|
passwordCouldHelp(err, password != "") {
|
|
return errPasswordRequired
|
|
}
|
|
if err != nil {
|
|
return rootCause(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartSession requests a PTY and starts an interactive shell. Output from
|
|
// the session is forwarded to the listener via OnData.
|
|
func (s *SSHClient) StartSession(cols, rows int) error {
|
|
err := s.startSession(cols, rows)
|
|
if err != nil {
|
|
log.Infof("SSH: start session failed: %v", err)
|
|
return rootCause(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Write sends data to the SSH session stdin.
|
|
func (s *SSHClient) Write(data []byte) error {
|
|
s.mu.Lock()
|
|
stdin := s.stdin
|
|
s.mu.Unlock()
|
|
if stdin == nil {
|
|
return errors.New("ssh session not started")
|
|
}
|
|
if _, err := stdin.Write(data); err != nil {
|
|
return fmt.Errorf("write stdin: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resize updates the PTY window size.
|
|
func (s *SSHClient) Resize(cols, rows int) error {
|
|
s.mu.Lock()
|
|
session := s.session
|
|
s.mu.Unlock()
|
|
if session == nil {
|
|
return errors.New("ssh session not started")
|
|
}
|
|
return session.WindowChange(rows, cols)
|
|
}
|
|
|
|
// Reset makes a closed client usable for another Connect: Close leaves the
|
|
// one-shot guard set, and clearing it lets the same client back a reconnect.
|
|
func (s *SSHClient) Reset() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.closed = false
|
|
}
|
|
|
|
// Close terminates the SSH session and underlying connection. Safe to call
|
|
// multiple times.
|
|
func (s *SSHClient) Close() error {
|
|
s.mu.Lock()
|
|
s.gen++
|
|
if s.dialCancel != nil {
|
|
s.dialCancel()
|
|
s.dialCancel = nil
|
|
}
|
|
sshClient := s.sshClient
|
|
session := s.session
|
|
stdin := s.stdin
|
|
s.sshClient = nil
|
|
s.session = nil
|
|
s.stdin = nil
|
|
notify := !s.closed
|
|
s.closed = true
|
|
listener := s.listener
|
|
s.mu.Unlock()
|
|
|
|
if stdin != nil {
|
|
if err := stdin.Close(); err != nil {
|
|
log.Debugf("ssh: stdin close: %v", err)
|
|
}
|
|
}
|
|
if session != nil {
|
|
if err := session.Close(); err != nil && !errors.Is(err, io.EOF) {
|
|
log.Debugf("ssh: session close: %v", err)
|
|
}
|
|
}
|
|
var firstErr error
|
|
if sshClient != nil {
|
|
if err := sshClient.Close(); err != nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
if notify && listener != nil {
|
|
listener.OnClose("closed by client")
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (s *SSHClient) startSession(cols, rows int) error {
|
|
log.Debugf("SSH: starting session %dx%d", cols, rows)
|
|
s.mu.Lock()
|
|
sshClient := s.sshClient
|
|
gen := s.gen
|
|
s.mu.Unlock()
|
|
|
|
if sshClient == nil {
|
|
return errors.New("ssh client not connected")
|
|
}
|
|
|
|
pty, err := nbssh.StartPTYSession(sshClient, cols, rows)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
if gen != s.gen {
|
|
s.mu.Unlock()
|
|
closeQuiet(pty.Session, "stale session")
|
|
return errClientClosed
|
|
}
|
|
s.session = pty.Session
|
|
s.stdin = pty.Stdin
|
|
s.mu.Unlock()
|
|
|
|
readerDone := make(chan string, 2)
|
|
go func() { readerDone <- s.readLoop(pty.Stdout, "stdout") }()
|
|
go func() { readerDone <- s.readLoop(pty.Stderr, "stderr") }()
|
|
go func() {
|
|
reason := <-readerDone
|
|
if second := <-readerDone; reason == "" {
|
|
reason = second
|
|
}
|
|
s.notifyClose(gen, reason)
|
|
}()
|
|
log.Debug("SSH: session started, shell running")
|
|
return nil
|
|
}
|
|
|
|
func (s *SSHClient) buildAuth(cfg *profilemanager.Config, cfgPath string, engine *internal.Engine,
|
|
serverType detection.ServerType, password string) ([]gossh.AuthMethod, gossh.HostKeyCallback, error) {
|
|
|
|
switch serverType {
|
|
case detection.ServerTypeNetBirdJWT:
|
|
token, err := s.requestJWTToken(cfg, cfgPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("jwt: %w", err)
|
|
}
|
|
auths := []gossh.AuthMethod{gossh.Password(token)}
|
|
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
|
|
|
|
case detection.ServerTypeNetBirdNoJWT:
|
|
if cfg.SSHKey == "" {
|
|
return nil, nil, errors.New("no NetBird SSH key available")
|
|
}
|
|
signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("parse netbird ssh key: %w", err)
|
|
}
|
|
auths := []gossh.AuthMethod{gossh.PublicKeys(signer)}
|
|
return auths, nbssh.CreateHostKeyCallback(nbssh.PeerKeyLookup(engine.GetPeerSSHKey)), nil
|
|
|
|
case detection.ServerTypeRegular:
|
|
var auths []gossh.AuthMethod
|
|
if cfg.SSHKey != "" {
|
|
if signer, err := gossh.ParsePrivateKey([]byte(cfg.SSHKey)); err == nil {
|
|
auths = append(auths, gossh.PublicKeys(signer))
|
|
} else {
|
|
log.Debugf("ssh: parse netbird key for regular auth: %v", err)
|
|
}
|
|
}
|
|
if password != "" {
|
|
pw := password
|
|
auths = append(auths, gossh.Password(pw))
|
|
auths = append(auths, gossh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
|
answers := make([]string, len(questions))
|
|
for i := range questions {
|
|
answers[i] = pw
|
|
}
|
|
return answers, nil
|
|
}))
|
|
}
|
|
if len(auths) == 0 {
|
|
// Nothing to offer at all: ask for a password rather than failing,
|
|
// so the caller can retry once the user supplies one.
|
|
return nil, nil, errPasswordRequired
|
|
}
|
|
callback, err := s.tofuHostKeyCallback()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return auths, callback, nil
|
|
|
|
default:
|
|
return nil, nil, fmt.Errorf("unsupported SSH server type: %v", serverType)
|
|
}
|
|
}
|
|
|
|
// tofuHostKeyCallback verifies a regular server's host key against the
|
|
// per-profile known-hosts store. An unknown host returns errHostKeyUnknown so
|
|
// Java can show the fingerprint and, once confirmed, retry with the key
|
|
// trusted; a changed key is rejected outright, as OpenSSH does. When the user
|
|
// has confirmed a fingerprint, the callback accepts exactly that key and
|
|
// appends it to the store.
|
|
func (s *SSHClient) tofuHostKeyCallback() (gossh.HostKeyCallback, error) {
|
|
s.mu.Lock()
|
|
configDir := s.knownHostsConfigDir
|
|
profileID := s.knownHostsProfile
|
|
trusted := s.trustHostKey
|
|
s.mu.Unlock()
|
|
|
|
if configDir == "" || profileID == "" {
|
|
return nil, errors.New("no known-hosts store configured for regular SSH")
|
|
}
|
|
|
|
store, err := openKnownHostsStore(configDir, profileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load known-hosts store: %w", err)
|
|
}
|
|
|
|
return func(hostname string, remote net.Addr, key gossh.PublicKey) error {
|
|
verdict, err := store.verify(hostname, remote, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if verdict == hostKeyMatched {
|
|
return nil
|
|
}
|
|
if verdict == hostKeyChanged {
|
|
return fmt.Errorf("SSH host key changed for %s (possible attack)", hostname)
|
|
}
|
|
|
|
fingerprint := gossh.FingerprintSHA256(key)
|
|
if trusted == "" {
|
|
return &errHostKeyUnknown{fingerprint: fingerprint}
|
|
}
|
|
if trusted != fingerprint {
|
|
return fmt.Errorf("SSH host key changed since it was confirmed for %s", hostname)
|
|
}
|
|
if err := store.append(hostname, remote, key); err != nil {
|
|
return fmt.Errorf("persist trusted host key: %w", err)
|
|
}
|
|
// The confirmation is spent: now that the key is stored, a later
|
|
// reconnect must verify against the file, not re-accept this fingerprint.
|
|
s.mu.Lock()
|
|
s.trustHostKey = ""
|
|
s.mu.Unlock()
|
|
return nil
|
|
}, nil
|
|
}
|
|
|
|
func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config, cfgPath string) (string, error) {
|
|
s.mu.Lock()
|
|
urlOpener := s.urlOpener
|
|
s.mu.Unlock()
|
|
if urlOpener == nil {
|
|
return "", errors.New("URL opener not configured for JWT auth")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
flow, err := auth.NewOAuthFlow(ctx, cfg, false, true, profileLoginHint(cfgPath))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create oauth flow: %w", err)
|
|
}
|
|
|
|
// The status callback covers the browser round-trip, which would
|
|
// otherwise leave the terminal blank.
|
|
tokenInfo, err := runOAuthFlow(ctx, flow, urlOpener, func() {
|
|
s.notifyStatus("Waiting for browser authentication...")
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
token := tokenInfo.GetTokenToUse()
|
|
if token == "" {
|
|
return "", errors.New("empty token returned by IdP")
|
|
}
|
|
|
|
// Tells the client the browser round-trip is over so it can dismiss the
|
|
// surface it opened, the same way the login and session-extend flows do.
|
|
// Without it the Custom Tab stays in front of the terminal even though the
|
|
// token has already been collected.
|
|
urlOpener.OnLoginSuccess()
|
|
|
|
return token, nil
|
|
}
|
|
|
|
func (s *SSHClient) dialAndHandshake(gen uint64, host string, port int, clientConfig *gossh.ClientConfig) error {
|
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
|
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
|
defer cancel()
|
|
|
|
s.mu.Lock()
|
|
if gen != s.gen {
|
|
s.mu.Unlock()
|
|
return errClientClosed
|
|
}
|
|
s.dialCancel = cancel
|
|
s.mu.Unlock()
|
|
|
|
var dialer net.Dialer
|
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return fmt.Errorf("dial %s: %w", addr, err)
|
|
}
|
|
|
|
client, err := nbssh.Handshake(ctx, conn, addr, clientConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
if gen != s.gen {
|
|
s.mu.Unlock()
|
|
closeQuiet(client, "stale ssh client")
|
|
return errClientClosed
|
|
}
|
|
s.sshClient = client
|
|
listener := s.listener
|
|
s.mu.Unlock()
|
|
|
|
if listener != nil {
|
|
listener.OnConnected()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SSHClient) readLoop(r io.Reader, name string) string {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := r.Read(buf)
|
|
if n > 0 {
|
|
s.mu.Lock()
|
|
listener := s.listener
|
|
s.mu.Unlock()
|
|
if listener != nil {
|
|
chunk := make([]byte, n)
|
|
copy(chunk, buf[:n])
|
|
listener.OnData(chunk)
|
|
}
|
|
}
|
|
if err != nil {
|
|
// EOF is a normal shell exit, so report it without a reason.
|
|
if errors.Is(err, io.EOF) {
|
|
return ""
|
|
}
|
|
log.Debugf("ssh %s read: %v", name, err)
|
|
return rootCause(err).Error()
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyStatus writes a progress line to the terminal through the normal
|
|
// output path, so long steps are visible while nothing else is arriving.
|
|
func (s *SSHClient) notifyStatus(text string) {
|
|
s.mu.Lock()
|
|
listener := s.listener
|
|
s.mu.Unlock()
|
|
if listener != nil {
|
|
listener.OnData([]byte("\r\n\x1b[33m" + text + "\x1b[0m\r\n"))
|
|
}
|
|
}
|
|
|
|
func (s *SSHClient) notifyClose(gen uint64, reason string) {
|
|
s.mu.Lock()
|
|
if gen != s.gen || s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.closed = true
|
|
listener := s.listener
|
|
s.mu.Unlock()
|
|
if listener != nil {
|
|
listener.OnClose(reason)
|
|
}
|
|
}
|
|
|
|
func closeQuiet(c io.Closer, label string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
if err := c.Close(); err != nil && !errors.Is(err, io.EOF) {
|
|
log.Debugf("ssh: close %s: %v", label, err)
|
|
}
|
|
}
|
|
|
|
func detectServerType(host string, port int) detection.ServerType {
|
|
ctx, cancel := context.WithTimeout(context.Background(), sshDetectionTimeout)
|
|
defer cancel()
|
|
|
|
dialer := &net.Dialer{}
|
|
serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port)
|
|
if err != nil {
|
|
log.Debugf("ssh: server detection failed: %v (assuming regular SSH)", err)
|
|
return detection.ServerTypeRegular
|
|
}
|
|
return serverType
|
|
}
|
|
|
|
// rootCause returns the innermost error of a %w chain, so the terminal shows
|
|
// "i/o timeout" rather than every layer that added context on the way up.
|
|
func rootCause(err error) error {
|
|
for {
|
|
// A joined error has no single root, so keep it as-is.
|
|
if _, ok := err.(interface{ Unwrap() []error }); ok {
|
|
return err
|
|
}
|
|
next := errors.Unwrap(err)
|
|
if next == nil {
|
|
return err
|
|
}
|
|
err = next
|
|
}
|
|
}
|
|
|
|
// isAuthFailure distinguishes credential rejection from dial, timeout and
|
|
// host-key errors, which retrying with a password would not fix.
|
|
func isAuthFailure(err error) bool {
|
|
if errors.Is(err, errPasswordRequired) {
|
|
return true
|
|
}
|
|
var partial *gossh.PartialSuccessError
|
|
if errors.As(err, &partial) {
|
|
return true
|
|
}
|
|
return strings.Contains(err.Error(), "unable to authenticate")
|
|
}
|
|
|
|
// passwordCouldHelp reports whether prompting for a password again can change
|
|
// the outcome. gossh lists a method under "attempted methods" only when the
|
|
// server offered it, so a supplied password that was never attempted means the
|
|
// server does not accept passwords and the real error should surface instead.
|
|
func passwordCouldHelp(err error, passwordOffered bool) bool {
|
|
if !passwordOffered {
|
|
return true
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "password") || strings.Contains(msg, "keyboard-interactive")
|
|
}
|