mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 01:51:30 +02:00
[client] iOS SSH: gate JWT behind server detection, reuse jwtcache (#6759)
* feat(ios/ssh): add ConnectNetBirdPeer, JWT cache, and WireGuard dialer - Add ConnectNetBirdPeer() method that skips banner detection and authenticates directly via JWT (mirrors NetBird web dashboard flow) - Cache JWT token for 8 minutes to avoid OAuth re-auth on every reconnect (SSH servers reject tokens older than DefaultJWTMaxTokenAge = 10 min) - Bind SSH sockets to the WireGuard interface via IP_BOUND_IF (Darwin syscall 25) so connections to 100.x.x.x peers route through the tunnel in the iOS Network Extension process - Improve auth failure error message with actionable checklist - Add base64 JWT claims debug logging for auth troubleshooting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ios/ssh): never offer the JWT to an unverified SSH service Remove ConnectNetBirdPeer. It skipped banner detection and sent the JWT as a password to whatever sshd answered, under InsecureIgnoreHostKey. WireGuard authenticates the peer machine, not the SSH service listening on it, so that handed a valid bearer token to an unidentified listener — a plain OpenSSH + PAM setup may even log the password attempt. Connect is now the only entry point. Detection is what proves the listener is a NetBird SSH service before the token is offered, and the JWT path already verifies the host key against the management-plane registry via engineHostKeyVerifier. A peer whose NetBird SSH is disabled is served by plain sshd and authenticates by key or password like any other host, so it stays reachable without an OAuth round trip. Move the NetBird-specific auth-failure checklist into annotateAuthError, gated on serverType.RequiresJWT(), so a regular sshd rejection no longer tells the user to check dashboard SSH access. Drop base64DecodeJWT, which was left unused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,9 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -94,6 +96,12 @@ func (s *SSHClient) SetURLOpener(opener URLOpener) {
|
||||
// disabled (TOFU pending).
|
||||
//
|
||||
// The password parameter is only consulted for regular SSH servers.
|
||||
//
|
||||
// This is the only way to open a session, deliberately so: the JWT is a bearer
|
||||
// token, and detection is what proves the listener is a NetBird SSH service
|
||||
// before the token is offered to it. A peer whose NetBird SSH is disabled is
|
||||
// served by plain sshd and authenticates by key or password like any other
|
||||
// host, so it stays reachable without an OAuth round trip.
|
||||
func (s *SSHClient) Connect(host string, port int, user, password string) error {
|
||||
cfg, cc := s.nb.sshState()
|
||||
if cc == nil {
|
||||
@@ -107,7 +115,9 @@ func (s *SSHClient) Connect(host string, port int, user, password string) error
|
||||
return errors.New("netbird engine not available")
|
||||
}
|
||||
|
||||
serverType := detectServerType(host, port)
|
||||
wgDialer := makeWGDialer(cfg.WgIface, sshDialTimeout)
|
||||
|
||||
serverType := detectServerType(host, port, wgDialer)
|
||||
log.Infof("SSH server type for %s:%d: %s", host, port, serverType)
|
||||
|
||||
authMethods, hostKeyCallback, err := s.buildAuth(cfg, engine, serverType, password)
|
||||
@@ -121,7 +131,32 @@ func (s *SSHClient) Connect(host string, port int, user, password string) error
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
return s.dialAndHandshake(host, port, clientConfig)
|
||||
if err := s.dialAndHandshake(host, port, clientConfig, wgDialer); err != nil {
|
||||
return annotateAuthError(err, serverType, user)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotateAuthError adds NetBird-specific guidance to an authentication
|
||||
// failure, but only for a server that detection identified as NetBird-SSH.
|
||||
// There a rejection nearly always means the peer's dashboard SSH access is not
|
||||
// configured for this account, which the raw gossh error does not convey.
|
||||
func annotateAuthError(err error, serverType detection.ServerType, user string) error {
|
||||
if !serverType.RequiresJWT() {
|
||||
return err
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "no supported methods remain") &&
|
||||
!strings.Contains(msg, "unable to authenticate") {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("NetBird SSH authentication rejected.\n\n"+
|
||||
"Checklist:\n"+
|
||||
" 1. SSH is enabled for this peer in the NetBird dashboard\n"+
|
||||
" 2. Your account is listed under SSH access for this peer\n"+
|
||||
" 3. The OS username (%q) is mapped to your account\n\n"+
|
||||
"If SSH access is not configured, connect with a password instead.\n\n"+
|
||||
"Original: %w", user, err)
|
||||
}
|
||||
|
||||
// StartSession requests a PTY and starts an interactive shell. Output from
|
||||
@@ -340,14 +375,13 @@ func (s *SSHClient) requestJWTToken(cfg *profilemanager.Config) (string, error)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig) error {
|
||||
func (s *SSHClient) dialAndHandshake(host string, port int, clientConfig *gossh.ClientConfig, dialer *net.Dialer) error {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
log.Infof("SSH: connecting to %s as %s", addr, clientConfig.User)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialer net.Dialer
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
@@ -424,11 +458,10 @@ func (v *engineHostKeyVerifier) VerifySSHHostKey(peerAddress string, presented [
|
||||
return nbssh.VerifyHostKey(storedKey, presented, peerAddress)
|
||||
}
|
||||
|
||||
func detectServerType(host string, port int) detection.ServerType {
|
||||
func detectServerType(host string, port int, dialer *net.Dialer) 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 for %s:%d failed: %v (assuming regular SSH)", host, port, err)
|
||||
@@ -437,6 +470,38 @@ func detectServerType(host string, port int) detection.ServerType {
|
||||
return serverType
|
||||
}
|
||||
|
||||
// makeWGDialer returns a net.Dialer whose sockets are bound to the WireGuard
|
||||
// interface (wgIface, e.g. "utun100"). This is required in the iOS Network
|
||||
// Extension process, where the OS deliberately excludes the provider's own
|
||||
// traffic from the VPN tunnel to prevent routing loops. Without binding to
|
||||
// the WireGuard interface, TCP connections to NetBird peer IPs (100.x.x.x
|
||||
// CGNAT space) would be sent over the physical network and fail with
|
||||
// "network is unreachable". Falls back to an unbound dialer if the interface
|
||||
// cannot be found (e.g. tunnel not yet up).
|
||||
func makeWGDialer(wgIface string, timeout time.Duration) *net.Dialer {
|
||||
return &net.Dialer{
|
||||
Timeout: timeout,
|
||||
Control: func(network, address string, c syscall.RawConn) error {
|
||||
iface, err := net.InterfaceByName(wgIface)
|
||||
if err != nil {
|
||||
log.Debugf("ssh: WG interface %q not found, dialing without bind: %v", wgIface, err)
|
||||
return nil
|
||||
}
|
||||
var innerErr error
|
||||
if ctrlErr := c.Control(func(fd uintptr) {
|
||||
// IP_BOUND_IF (Darwin) = 25: binds the socket to a specific interface index.
|
||||
innerErr = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, 25, iface.Index)
|
||||
}); ctrlErr != nil {
|
||||
return ctrlErr
|
||||
}
|
||||
if innerErr != nil {
|
||||
log.Debugf("ssh: IP_BOUND_IF bind to %q failed: %v", wgIface, innerErr)
|
||||
}
|
||||
return innerErr
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func closeQuiet(c io.Closer, label string) {
|
||||
if c == nil {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user