Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions ios/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,42 @@ func initializeXpcConnection(h *http.HttpConnection) error {
return nil
}

// TunnelDialTimeout bounds TCP connects to tunnel/RSD endpoints. Without an
// explicit timeout, a dial to a dead-but-still-routed tunnel address (device
// rebooted or hung while the host-side TUN interface and route stayed up)
// blocks for the kernel's TCP SYN timeout (~135s on Linux) per operation. 15s
// is far above any healthy tunnel connect (sub-second) while still failing
// fast enough for staleness handling to react.
const TunnelDialTimeout = 15 * time.Second

// ErrDialTimeout marks a tunnel/RSD TCP connect that exceeded go-ios' dial
// timeout rather than failing outright. Callers can use errors.Is to treat the
// endpoint as stale: the route existed but the device never answered, which is
// the signature of a dead tunnel whose interface lingers.
var ErrDialTimeout = errors.New("dial timed out")

// DialTunnelTCP connects to a tunnel/RSD TCP endpoint (address in the form
// accepted by net.Dial, e.g. "[fd00::1]:1234") with TunnelDialTimeout.
func DialTunnelTCP(address string) (*net.TCPConn, error) {
return DialTunnelTCPWithTimeout(address, TunnelDialTimeout)
}

// DialTunnelTCPWithTimeout is DialTunnelTCP with a caller-chosen timeout.
// Timeout errors are wrapped in ErrDialTimeout so they stay distinguishable
// from refused/unreachable errors.
func DialTunnelTCPWithTimeout(address string, timeout time.Duration) (*net.TCPConn, error) {
d := net.Dialer{Timeout: timeout}
conn, err := d.Dial("tcp", address)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return nil, fmt.Errorf("%w after %v: %w", ErrDialTimeout, timeout, err)
}
return nil, err
}
return conn.(*net.TCPConn), nil
}

// ConnectTUNDevice creates a *net.TCPConn to the device at the given address and port.
// If the device is a userspaceTUN device provided by go-ios agent, it will connect to this
// automatically. Otherwise it will try a operating system level TUN device.
Expand All @@ -310,8 +346,7 @@ func ConnectTUNDevice(remoteIp string, port int, d DeviceEntry) (*net.TCPConn, e
return connectTUN(remoteIp, port)
}

addr, _ := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%s:%d", d.UserspaceTUNHost, d.UserspaceTUNPort))
conn, err := net.DialTCP("tcp", nil, addr)
conn, err := DialTunnelTCP(fmt.Sprintf("%s:%d", d.UserspaceTUNHost, d.UserspaceTUNPort))
if err != nil {
return nil, fmt.Errorf("ConnectUserSpaceTunnel: failed to dial: %w", err)
}
Expand All @@ -332,11 +367,7 @@ func ConnectTUNDevice(remoteIp string, port int, d DeviceEntry) (*net.TCPConn, e

// connect to a operating system level TUN device
func connectTUN(address string, port int) (*net.TCPConn, error) {
addr, err := net.ResolveTCPAddr("tcp6", fmt.Sprintf("[%s]:%d", address, port))
if err != nil {
return nil, fmt.Errorf("ConnectToHttp2WithAddr: failed to resolve address: %w", err)
}
conn, err := net.DialTCP("tcp", nil, addr)
conn, err := DialTunnelTCP(fmt.Sprintf("[%s]:%d", address, port))
if err != nil {
return nil, fmt.Errorf("ConnectToHttp2WithAddr: failed to dial: %w", err)
}
Expand Down
24 changes: 24 additions & 0 deletions ios/connect_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package ios

import (
"errors"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -57,3 +59,25 @@ func TestConnectToServiceFailsFastWhenServiceIsMissingFromRsd(t *testing.T) {
assert.Contains(t, err.Error(), "not available in RSD")
})
}

// A dial to a dead-but-still-routed tunnel address (device rebooted or hung
// while the host-side TUN route stayed up) must fail after go-ios' own dial
// timeout, not the kernel's ~135s SYN timeout, and the error must be
// classifiable as a timeout so staleness handling can key off it (issue #764).
// 192.0.2.1 (TEST-NET-1, RFC 5737) is reserved and never answers, mimicking the
// blackholed-SYN behavior of a dead tunnel.
func TestDialTunnelTCPWithTimeoutFailsFast(t *testing.T) {
const timeout = 250 * time.Millisecond
start := time.Now()
_, err := DialTunnelTCPWithTimeout("192.0.2.1:54321", timeout)
elapsed := time.Since(start)

require.Error(t, err)
assert.Less(t, elapsed, 5*time.Second, "dial must fail well under the kernel SYN timeout")
if !errors.Is(err, ErrDialTimeout) {
// Some environments answer TEST-NET-1 with a fast ICMP unreachable or a
// sandbox denial instead of blackholing the SYN; then this run cannot
// exercise the timeout classification, only the fast failure above.
t.Skipf("environment did not blackhole 192.0.2.1, got: %v", err)
}
}
Loading
Loading