SagerNet / SagerNet/sing-box

WireGuard domain peer never sends its first handshake initiation (5 s dead window on every start)

Open
#4,539 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Go
Stars
38.2k
Forks
4.6k
Avg merge
19d 15h
Merged PRs (30d)
1

Description

Operating system

Linux

System version

Ubuntu 24.04 (aarch64), Raspberry Pi 4. Reproduced identically on macOS 26 (arm64), so it is not platform-specific.

Installation type

Original sing-box Command Line

Version
sing-box version 1.14.1

Also present on testing (6e0520ec) and v1.15.0-alpha.5.

Description

A WireGuard peer configured with a domain name can never send its first handshake initiation, because when it is started it has neither a static endpoint nor a resolver:

ERROR endpoint/wireguard[wg-ep]: peer(...) - failed to send handshake initiation: no known endpoint for peer

wireguard-go returns early without touching DNS, so the tunnel stays down until the retry timer fires — RekeyTimeout (5 s) plus up to 334 ms of jitter. The same config with a literal address handshakes at T+0 and never logs the error.

In a transparent-gateway/router deployment every flow routed to that endpoint is blackholed for that window, on every start, reload and boot. This is a regression; 1.13.x is not affected.

Reproduction

No remote server, no TUN, no graphical client. persistent_keepalive_interval makes the first handshake fire at process start with no traffic at all, and the hosts DNS type keeps resolution entirely offline.

domain.json:

{
  "log": { "level": "debug", "timestamp": true },
  "dns": { "servers": [ { "type": "hosts", "tag": "hosts",
    "predefined": { "wg-peer.test": ["127.0.0.1"] } } ] },
  "endpoints": [ {
    "type": "wireguard", "tag": "wg-ep", "system": false,
    "address": ["172.16.0.2/32"],
    "private_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
    "peers": [ {
      "address": "wg-peer.test", "port": 51820,
      "public_key": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
      "allowed_ips": ["0.0.0.0/0"], "persistent_keepalive_interval": 25
    } ]
  } ],
  "outbounds": [ { "type": "direct", "tag": "direct" } ],
  "route": { "final": "direct" }
}
  1. sing-box run -c domain.json
  2. Repeat with "address": "127.0.0.1" as the control.

Nothing needs to answer on 127.0.0.1:51820: the failure happens before a packet is sent.

Logs

Domain peer — no uapi: updating endpoint line, the error lands in the same second the peer starts, and the first DNS lookup for the peer FQDN only happens at the retry, 5 s later:

01:36:49 DEBUG peer(AQID…eHyA) - sending handshake initiation
01:36:49 ERROR peer(AQID…eHyA) - failed to send handshake initiation: no known endpoint for peer
01:36:49 INFO sing-box started (0.00s)
01:36:54 DEBUG peer(AQID…eHyA) - handshake did not complete after 5 seconds, retrying (try 2)
01:36:54 DEBUG peer(AQID…eHyA) - sending handshake initiation
01:36:54 DEBUG dns: lookup domain wg-peer.test
01:36:54 INFO dns: lookup succeed for wg-peer.test: 127.0.0.1

Literal address peer, same build — uapi: updating endpoint present, handshake sent at T+0, error string never logged:

01:37:03 DEBUG peer(AQID…eHyA) - uapi: updating endpoint
01:37:03 DEBUG peer(AQID…eHyA) - sending handshake initiation
01:37:03 INFO sing-box started (0.00s)
Root cause
  • transport/wireguard/endpoint.go:67-71 — a domain peer stores only destination; endpoint stays the zero value.
  • endpoint.go:395-397GenerateIpcLines() emits endpoint= only when c.endpoint.IsValid(), so the IPC config carries none.
  • The resolver is a per-peer callback and the peer only exists after IpcSet (endpoint.go:224), with the resolver loop at :229-259. The peer therefore starts with neither an endpoint nor a resolver.

wireguard-go then fails without consulting DNS (2da8d81dcd5e): device/peer.go:428-434 returns nil when resolver == nil, send.go:196-220 falls back to SendBuffers, peer.go:184-187 returns no known endpoint for peer, and timers.go:194 arms the 5.000–5.334 s retry.

Attaching the resolver immediately after IpcSet would not help either: handlePostConfig() runs inside IpcSet (device/uapi.go:258-275send.go:574-583) and can attempt the handshake before sing-box regains control. The address must already be present in the config handed to IpcSet.

Regression: 1.13.x declared Start(resolve bool), resolved domain peers up front and therefore put endpoint= in the IPC config. 1e2846a1 (2026-08-10, first released in 1.14.0) replaced it with Start(postStart bool) and dropped that loop. The racing resolver it added is a real improvement for endpoint freshness; only the initial endpoint was lost.

Fix

Resolve domain peers in Start() after the bind is constructed and before the reserved-address loop, so the config handed to IpcSet carries endpoint= as it already does for a literal address. Leaving SetSinglePeerMode() and the client-bind isConnect shortcut untouched keeps this a pure latency fix, and a resolution failure stays non-fatal since the resolver still re-resolves on every handshake initiation.

Patch (~15 lines) — not opened as a PR because PR creation is currently restricted for this account

e.resolvePeerEndpoints() is called just above the if isUDPListener || len(e.peers) > 1 reserved-address block:

func (e *Endpoint) resolvePeerEndpoints() {
	for peerIndex := range e.peers {
		peer := &e.peers[peerIndex]
		if peer.endpoint.IsValid() || !peer.destination.IsDomain() {
			continue
		}
		addresses, err := e.options.ResolvePeer(peer.destination.Fqdn)
		if err != nil {
			e.options.Logger.Warn(E.Cause(err, "resolve endpoint domain for peer[", peerIndex, "]: ", peer.destination))
			continue
		}
		for _, address := range addresses {
			if address.IsValid() {
				peer.endpoint = netip.AddrPortFrom(address, peer.destination.Port)
				break
			}
		}
		if !peer.endpoint.IsValid() {
			e.options.Logger.Warn("no endpoint address for peer[", peerIndex, "]: ", peer.destination)
		}
	}
}

With it, a domain peer logs uapi: updating endpoint, sends its handshake in the same second sing-box starts, and never logs no known endpoint for peer.

Related

#4366 chases stale-FQDN refresh after a DDNS change; its trigger fires at the retry, so it cannot help this startup case. Its rewrite of ResolvePeer to a slice-returning form is also already obsolete — endpoint_options.go:36 declares func(domain string) ([]netip.Addr, error) on testing.

Supporter
Integrity requirements
  • I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
  • I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
  • I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
  • I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in transport/wireguard/endpoint.go, especially Endpoint.Start, resolvePeerEndpoints, and GenerateIpcLines, then read endpoint_options.go's ResolvePeer signature. Run the supplied domain.json reproduction with sing-box run and compare it with the literal-address control; done means the domain peer has an endpoint in the initial IPC configuration and sends its first handshake without the startup error or 5-second delay.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.