WireGuard domain peer never sends its first handshake initiation (5 s dead window on every start)
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" }
}
sing-box run -c domain.json- 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 onlydestination;endpointstays the zero value.endpoint.go:395-397—GenerateIpcLines()emitsendpoint=only whenc.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-275 → send.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
- I am a sponsor
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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