Skip to content

keepalive reads a TCP handshake from the tunnel as proof of connectivity #4

Open

Description

@ilylty

Summary

Commit ef134ec ("fix keepalive, use TCP instead of the previous HTTP", merged in PR #2) replaced the HTTP HEAD probe in keepAliveLoop with a bare net.DialTimeout. Under a TUN proxy client the dial completes against the proxy stack, so the probe returns "online" for targets that no network can reach. auth-nyist keepalive and keepOnline: true stop triggering re-authentication.

Environment

  • auth-nyist 2.0.0, HEAD c3e8428
  • Windows 10, Go 1.25.0
  • Proxy client: Hiddify (clash.meta v1.19.18), TUN enabled
    • tun.enable: true, tun.stack: gvisor, tun.auto-route: true, tun.dns-hijack: any:53
    • dns.enhanced-mode: fake-ip, dns.fake-ip-range: 198.18.0.1/16
    • find-process-mode: strict
  • Campus adapter WLAN, address 10.2.241.14, interface index 10
  • Host route: 0.0.0.0/0 through the Meta tunnel interface

The rule set sends 122.207.209.6/32 and auth.nyist.edu.cn to DIRECT through GeoIP cn. Traffic enters the TUN interface before the rule set applies.

Steps to reproduce

  1. Start the proxy client with TUN enabled.
  2. Run a probe that matches checkConnection in cli/main.go.
  3. Point the probe at targets that no network can reach.
func checkConnection(ip string, port int, timeout time.Duration) error {
	address := fmt.Sprintf("%s:%d", ip, port)
	conn, err := net.DialTimeout("tcp", address, timeout)
	if err != nil {
		var nErr net.Error
		if errors.As(err, &nErr) && nErr.Timeout() {
			return fmt.Errorf("%s timeout", address)
		}
		return fmt.Errorf("connect %s error: %v", address, err)
	}
	_ = conn.Close()
	return nil
}

Expected

The probe reports "offline" for a target that refuses or drops the connection. keepAliveLoop calls authUtil.

Actual

The probe reports "online" for each target. keepAliveLoop calls authUtil for no target.

Target                                | TCP dial (2s)      | HTTP HEAD (10s)
--------------------------------------------------------------------------------------
122.207.209.6:8080  (open port)       | online 1ms         | online 195ms
122.207.209.6:9999  (closed port)     | online 1ms         | offline (5.0s) EOF
192.0.2.1:80        (RFC 5737)        | online 1ms         | offline (884ms) EOF
10.255.255.1:443    (RFC 1918 sink)   | online 1ms         | offline (6.7s) EOF
1.2.3.4:9999        (arbitrary host)  | online 1ms         | offline (3.6s) EOF
no-such-host-918273.invalid:80        | online 21ms        | offline (672ms) EOF
auth.nyist.edu.cn:443                 | online 2ms         | online 51ms

The TCP probe returned "online" for 7 of 7 targets. Five of those targets accept no connection.

Control test

The same proxy instance reaches the same targets through its HTTP proxy port:

$ curl --proxy http://127.0.0.1:7890 http://122.207.209.6:8080/
302, total 0.010s

$ curl --proxy http://127.0.0.1:7890 http://122.207.209.6:9999/
502, total 5.001s

Through the TUN interface the same targets report connected=True in 0ms to 57ms.

The proxy reports the failure through the proxy port. The proxy reports success through the TUN interface. The entry point differs. The target does not.

Root cause

A TUN client terminates TCP in user space. The client cannot leave a handshake open while it reaches the upstream host, because the peer would retransmit. The client completes the handshake with the local peer and opens the upstream connection as a second step.

A connect() call that returns success carries one fact: the tunnel stack answered the SYN. It carries no fact about the upstream host.

When the upstream connection fails, the client sends RST or holds the connection:

  • RST: read() returns EOF. 192.0.2.1:80 returned EOF at 714ms.
  • Hold: read() blocks. 122.207.209.6:9999 held past a 3s read timeout.

A bare connect() observes neither case. The HTTP HEAD probe observed both, because it added an application round trip above the handshake.

This behavior follows from the "accept then connect upstream" model. mihomo, sing-box and clash-rs share the model. The TUN stack and the rule set do not change the outcome.

A rule change does not help. IP-CIDR,122.207.209.6/32,DIRECT and PROCESS-NAME,auth-nyist.exe,DIRECT apply after the packet leaves the TUN interface, so the local handshake completes.

Impact

  • auth-nyist keepalive: the loop calls authUtil for no target.
  • keepOnline: true: authUtil enters the loop and returns for no condition.
  • A session drop from the srun keepalive timer produces no log entry and no re-authentication.

Proposed fix

Option A: bind the probe socket to the campus adapter

auto-route: true installs the tunnel as the default route for every socket. A socket bound to the campus address selects the campus route.

iface, err := net.InterfaceByName(cfg.Adapter) // "WLAN"
if err != nil {
	return err
}
addrs, _ := iface.Addrs()
srcIP := firstIPv4(addrs)

d := net.Dialer{
	Timeout:   2 * time.Second,
	LocalAddr: &net.TCPAddr{IP: srcIP},
}
conn, err := d.Dial("tcp", "122.207.209.6:8080")

The socket leaves through the adapter, so connect() reports the upstream result. The probe keeps its current form.

Measured with the source address pinned, probe unchanged:

Target                       | bound to 10.2.241.14     | unbound (current)
---------------------------------------------------------------------------------
122.207.209.6:8080 (open)    | online 5ms               | online 1ms
122.207.209.6:9999 (closed)  | offline 2.0s timeout     | online 1ms
192.0.2.1:80 (unreachable)   | offline 2.0s timeout     | online 1ms

On Windows the interface index can be pinned in the socket:

const ipUnicastIf = 31 // IP_UNICAST_IF, not exported by the syscall package

Control: func(_, _ string, c syscall.RawConn) error {
	return c.Control(func(fd uintptr) {
		syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IP,
			ipUnicastIf, int(htonl(uint32(iface.Index))))
	})
}

IP_UNICAST_IF takes the index in network byte order. The host byte order value returns WSAEADDRNOTAVAIL ("The requested address is not valid in its context"), and the 16-bit swapped value returns WSAEINVAL ("An invalid argument was supplied"). htonl is the value that applies the option. Linux uses SO_BINDTODEVICE, so this part stays behind a platform guard.

The two bindings compose. LocalAddr covers Linux and macOS without a raw socket option, and the index covers the case where an adapter holds more than one address.

Option B: add an application round trip to the probe

Keep the TCP check and add a request and response above it. Send GET / to 122.207.209.6:8080 and accept a 302 with Location: /opac/search/simsearch. This restores the original intent: a reachable library server means a working campus session.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions