Summary
On Windows, ssh to a host that resolves to more than one address does not try the remaining addresses when the first connect() is refused. For a dual-stack host whose service answers only on IPv4, ssh tries the AAAA (IPv6) address, the connect is refused, and instead of falling back to the A (IPv4) address it aborts with:
banner exchange: Connection to UNKNOWN port -1: Connection refused
The identical ssh invocation — same config, same host — works on Linux/WSL, where it falls back to IPv4.
Version
OpenSSH_for_Windows_10.0p2 (Win32-OpenSSH); reproduced against current latestw_all. Affects earlier releases too.
Repro
Any host that resolves to ≥2 addresses where the first-tried address refuses the port; easiest is a dual-stack host (A + AAAA) whose sshd / port-forward listens on IPv4 only:
ssh -vvv user@dual-stack-host
Windows (fails — stops at the IPv6 address, never tries IPv4):
debug1: Connecting to host [2a01:db8::1] port 64724.
debug1: Connection established. <-- the connect was actually refused
debug3: socketio_getpeername - ERROR:10057
debug1: getpeername failed: The socket is not connected
debug1: kex_exchange_identification: write: Connection refused
banner exchange: Connection to UNKNOWN port -1: Connection refused
Linux/WSL (works — detects the refusal and falls back):
debug1: Connecting to host [2a01:db8::1] port 64724.
debug1: connect to address 2a01:db8::1 port 64724: Connection refused
debug1: Connecting to host [198.51.100.10] port 64724.
debug1: Connection established.
Workaround: ssh -4 host (or AddressFamily inet).
Cause
ssh_connect_direct() (sshconnect.c) iterates the getaddrinfo() results and uses timeout_connect() (misc.c): non-blocking connect() → poll(POLLOUT) → getsockopt(SO_ERROR); a non-zero SO_ERROR means "this address failed, try the next one".
On Windows the asynchronous ConnectEx() failure is delivered through the overlapped completion and captured by socketio_finish_connect() / socketio_is_io_available() into the w32_io write_details.error / read_details.error. Once that completion is consumed, the underlying socket's SO_ERROR is left at 0. But socketio_getsockopt() (contrib/win32/win32compat/socketio.c) forwards SO_ERROR straight to the Winsock getsockopt(), which therefore returns 0 — so timeout_connect() sees optval == 0, treats the refused connect as a success, and the address loop stops at the first (failed) address. getpeername() on the unconnected socket then yields UNKNOWN / -1, and the first banner write returns the original WSAECONNREFUSED (10061).
The same getsockopt(SO_ERROR) idiom is used for forwarded-channel connects in channels.c, which is affected identically.
Fix
Surface the captured async-connect error for SO_ERROR in socketio_getsockopt() (return the stored write_details.error / read_details.error, mapped through errno_from_WSAError()), so the standard non-blocking connect() + getsockopt(SO_ERROR) idiom — and thus the multi-address (IPv6 → IPv4) fallback — behaves as it does on POSIX.
Implemented, built, and verified on Windows x64: the failing repro above now detects the refused IPv6 connect and falls back to IPv4, matching Linux. Includes a win32compat regression test. PR to follow.
Summary
On Windows,
sshto a host that resolves to more than one address does not try the remaining addresses when the firstconnect()is refused. For a dual-stack host whose service answers only on IPv4,sshtries theAAAA(IPv6) address, the connect is refused, and instead of falling back to theA(IPv4) address it aborts with:The identical
sshinvocation — same config, same host — works on Linux/WSL, where it falls back to IPv4.Version
OpenSSH_for_Windows_10.0p2(Win32-OpenSSH); reproduced against currentlatestw_all. Affects earlier releases too.Repro
Any host that resolves to ≥2 addresses where the first-tried address refuses the port; easiest is a dual-stack host (
A+AAAA) whose sshd / port-forward listens on IPv4 only:Windows (fails — stops at the IPv6 address, never tries IPv4):
Linux/WSL (works — detects the refusal and falls back):
Workaround:
ssh -4 host(orAddressFamily inet).Cause
ssh_connect_direct()(sshconnect.c) iterates thegetaddrinfo()results and usestimeout_connect()(misc.c): non-blockingconnect()→poll(POLLOUT)→getsockopt(SO_ERROR); a non-zeroSO_ERRORmeans "this address failed, try the next one".On Windows the asynchronous
ConnectEx()failure is delivered through the overlapped completion and captured bysocketio_finish_connect()/socketio_is_io_available()into thew32_iowrite_details.error/read_details.error. Once that completion is consumed, the underlying socket'sSO_ERRORis left at 0. Butsocketio_getsockopt()(contrib/win32/win32compat/socketio.c) forwardsSO_ERRORstraight to the Winsockgetsockopt(), which therefore returns 0 — sotimeout_connect()seesoptval == 0, treats the refused connect as a success, and the address loop stops at the first (failed) address.getpeername()on the unconnected socket then yieldsUNKNOWN/-1, and the first banner write returns the originalWSAECONNREFUSED(10061).The same
getsockopt(SO_ERROR)idiom is used for forwarded-channel connects inchannels.c, which is affected identically.Fix
Surface the captured async-connect error for
SO_ERRORinsocketio_getsockopt()(return the storedwrite_details.error/read_details.error, mapped througherrno_from_WSAError()), so the standard non-blockingconnect()+getsockopt(SO_ERROR)idiom — and thus the multi-address (IPv6 → IPv4) fallback — behaves as it does on POSIX.Implemented, built, and verified on Windows x64: the failing repro above now detects the refused IPv6 connect and falls back to IPv4, matching Linux. Includes a
win32compatregression test. PR to follow.