#4539·sing-box

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

Author: myleshkCreated Sep 16, 2026Updated Sep 16, 2026

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

bash
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:

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:

bash
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:

bash
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:

go
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.