Skip to content

Fix STUN test when server address is an IP literal - #4350

Open
swedenborg33 wants to merge 247 commits into
SagerNet:testingfrom
swedenborg33:fix-stun-ip-literal
Open

Fix STUN test when server address is an IP literal#4350
swedenborg33 wants to merge 247 commits into
SagerNet:testingfrom
swedenborg33:fix-stun-ip-literal

Conversation

@swedenborg33

@swedenborg33 swedenborg33 commented Jul 26, 2026

Copy link
Copy Markdown

Problem

stun.Run assigns two different types to serverAddr depending on which branch
it takes (common/stun/stun.go):

if options.Dialer != nil {
    packetConn, err = options.Dialer.ListenPacket(ctx, serverSocksaddr)
    serverAddr = serverSocksaddr            // M.Socksaddr
} else {
    serverUDPAddr, _ := net.ResolveUDPAddr("udp", serverSocksaddr.String())
    packetConn, err = net.ListenPacket("udp", "")
    serverAddr = serverUDPAddr              // *net.UDPAddr
}

serverAddr is then passed to conn.WriteTo(request, addr) in roundTrip.

When the server is given as a domain, ListenPacketWithDestination resolves it
and returns a conn wrapped by N.ListenSerial, which accepts M.Socksaddr. When the
server is given as an IP literal, the raw packet conn is returned and the type
mismatch surfaces:

  • net.UDPConn.WriteTo rejects the address: write udp4 ...: invalid argument.
    The STUN test simply does not work.
  • gonet.UDPConn.WriteTo (gVisor, used by WireGuard and Tailscale endpoints)
    type-asserts to *net.UDPAddr and panics, taking down the whole instance:
panic: interface conversion: net.Addr is metadata.Socksaddr, not *net.UDPAddr

gvisor/pkg/tcpip/adapters/gonet.(*UDPConn).WriteTo(...)  gonet.go:665
sing-box/common/stun.roundTrip(...)                      common/stun/stun.go:342
sing-box/common/stun.Run(...)                            common/stun/stun.go:452

The default server (stun.voipgate.com:3478) is a hostname, which is why the
domain-only path has been exercised and the defect stayed hidden.

Originally observed on the iOS graphical client (1.14.0-beta.2) via
Tools -> STUN Test with a WireGuard endpoint selected and an IP address typed into
the Server field; the extension crashed with SIGABRT.

Fix

Convert to *net.UDPAddr when the server is already an IP. The domain case is left
untouched, since that path goes through the wrapper and works today.

Alternatives considered: wrapping the raw packet conn returned by
Endpoint.ListenPacketWithDestination for the IP branch, so that it accepts
M.Socksaddr the way the domain branch already does via N.ListenSerial; or making
gonet.UDPConn.WriteTo return an error instead of panicking. Both are broader
changes affecting other callers, so this keeps the fix at the site that produces the
wrong type.

Reproduction

createDialer in cmd/sing-box/cmd_tools.go falls back to
instance.Outbound().Default() when -o is omitted, so options.Dialer is never
nil on the command line. The shortest reproduction therefore needs no special
configuration at all — any config, any outbound:

sing-box tools stun -s 185.125.180.70:3478
# binding request: send STUN request: write udp4 ...: invalid argument

For the panic, an endpoint is required (no TUN, no remote server, no graphical
client; the peer never has to answer, because the failure happens on the first
write):

{
  "endpoints": [
    {
      "type": "wireguard",
      "tag": "wg-ep",
      "system": false,
      "address": ["10.0.0.2/32"],
      "private_key": "<generated>",
      "peers": [
        {
          "address": "127.0.0.1",
          "port": 51820,
          "public_key": "<generated>",
          "allowed_ips": ["0.0.0.0/0"]
        }
      ]
    }
  ],
  "outbounds": [{ "type": "direct", "tag": "direct" }]
}
sing-box tools stun -o wg-ep -s 185.125.180.70:3478   # panic

Note: reaching the endpoint case from the command line requires one local change —
createPreStartedClient in cmd/sing-box/cmd_tools.go calls instance.PreStart(),
while Endpoint.started is only set at StartStatePostStart, so every tools
command against a WireGuard or Tailscale endpoint currently fails with
WireGuard is not ready yet. Replacing PreStart() with Start() locally makes the
panic reproducible on the CLI. That is a separate concern and is not part of this
change.

Verification

Built from v1.14.0-beta.2 with -tags with_gvisor,with_wireguard, go1.26.

outbound server before after
direct IP literal invalid argument external address, latency, NAT mapping and filtering
direct hostname works works
wg-ep IP literal panic fails cleanly (i/o timeout, peer is a stub)
wg-ep hostname i/o timeout i/o timeout

The two direct rows return the same NAT classification, so the IP path now behaves
identically to the hostname path.

Not covered by the above: IPv6 server literals, and outbounds other than direct
(VLESS, Shadowsocks, Hysteria2 and so on) whose packet conns are wrapped
differently. The wg-ep row after the fix shows a clean failure rather than a
successful test, because the peer in the minimal config is a stub.

nekohasekai and others added 16 commits July 23, 2026 13:17
Since b0c6762, every rule inside a referenced rule-set was evaluated
as if merged into the outer rule, which required tracking per-branch
group states and let outer rules and rule-set rules satisfy each
other's grouped conditions in both directions.

Restrict merging to the only designed case: a rule-set containing
exactly one non-inverted default rule is merged into the outer rule as
before. Any other rule-set now matches as an ordinary condition of the
outer rule: it matches when any of its rules matches on its own, and
its rules no longer exchange grouped match state with the outer rule
in either direction. Multiple referenced rule-sets keep OR semantics.

Flat address rule-sets such as generated geosite/geoip sets contain a
single default rule, so their behavior is unchanged. The group-state
set machinery is replaced by a single required/satisfied mask pair.

Also update the route and DNS rule docs.
DHCP option 119/15 and resolved link domains are not rooted, so appending
them to the query name produced a non-FQDN name, failing to pack with
"domain must be fully qualified".
stun.Run assigned M.Socksaddr to serverAddr in the dialer branch, which
net.UDPConn.WriteTo rejects with EINVAL and gonet.UDPConn.WriteTo
panics on.
@nekohasekai
nekohasekai force-pushed the testing branch 14 times, most recently from 067b00f to 4f7f894 Compare July 29, 2026 13:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants