Release 2.6.1: resolve AWG endpoint hostnames to IP before connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navis
2026-07-29 15:42:55 +03:00
co-authored by Cursor
parent 99bbac20e2
commit 4ecfb9bed7
23 changed files with 233 additions and 34 deletions
+117 -1
View File
@@ -1,6 +1,7 @@
package awg
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
@@ -9,6 +10,7 @@ import (
"net/netip"
"strconv"
"strings"
"time"
)
// Conf is a parsed AmneziaWG / WireGuard client configuration (AWG 2.0 fields included).
@@ -444,7 +446,117 @@ func HostPort(raw string) (host, port string, err error) {
return h, p, nil
}
// lookupIP is overridable in tests.
var lookupIP = defaultLookupIP
func defaultLookupIP(host string) ([]net.IP, error) {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
try := func(r *net.Resolver) ([]net.IP, error) {
addrs, err := r.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
out := make([]net.IP, 0, len(addrs))
for _, a := range addrs {
if a.IP != nil {
out = append(out, a.IP)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("нет IP-адресов")
}
return out, nil
}
// PreferGo avoids flaky Windows WinDNS "No such host" for some A/AAAA shapes.
if ips, err := try(&net.Resolver{PreferGo: true, Dial: resolverDial}); err == nil {
return ips, nil
}
if ips, err := try(net.DefaultResolver); err == nil {
return ips, nil
}
// Last resort: classic LookupIP (system).
return net.LookupIP(host)
}
func resolverDial(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 3 * time.Second}
var last error
for _, dns := range []string{"1.1.1.1:53", "8.8.8.8:53", "9.9.9.9:53"} {
c, err := d.DialContext(ctx, network, dns)
if err == nil {
return c, nil
}
last = err
}
return nil, last
}
// ResolveEndpoint turns "hostname:port" into "ip:port" for amneziawg-go IpcSet
// (which fails on bare hostnames with "No such host is known"). Prefers IPv4.
func ResolveEndpoint(endpoint string) (string, error) {
return resolveEndpointWith(endpoint, lookupIP)
}
// ResolveEndpoint rewrites c.Endpoint to a resolved IP:port (IPv4 preferred).
func (c *Conf) ResolveEndpoint() error {
resolved, err := ResolveEndpoint(c.Endpoint)
if err != nil {
return err
}
c.Endpoint = resolved
return nil
}
func resolveEndpointWith(endpoint string, lookup func(string) ([]net.IP, error)) (string, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return "", fmt.Errorf("awg: нет Endpoint")
}
host, port, err := net.SplitHostPort(endpoint)
if err != nil {
host = trimBrackets(endpoint)
port = "51820"
} else {
host = trimBrackets(host)
}
if host == "" {
return "", fmt.Errorf("awg: нет Endpoint")
}
if ip := net.ParseIP(host); ip != nil {
return net.JoinHostPort(ip.String(), port), nil
}
ips, err := lookup(host)
if err != nil {
return "", fmt.Errorf("awg: не удалось разрешить DNS для Endpoint %q: %v", host, err)
}
var v4, v6 net.IP
for _, ip := range ips {
if ip == nil {
continue
}
if ip4 := ip.To4(); ip4 != nil {
v4 = ip4
break
}
if v6 == nil {
v6 = ip
}
}
chosen := v4
if chosen == nil {
chosen = v6
}
if chosen == nil {
return "", fmt.Errorf("awg: не удалось разрешить DNS для Endpoint %q: нет IP-адресов", host)
}
return net.JoinHostPort(chosen.String(), port), nil
}
// ToIPC builds the amneziawg-go UAPI configuration string.
// Endpoint hostnames are resolved to IP first (IPv4 preferred).
func (c Conf) ToIPC() (string, error) {
priv, err := decodeKey(c.PrivateKey)
if err != nil {
@@ -454,6 +566,10 @@ func (c Conf) ToIPC() (string, error) {
if err != nil {
return "", fmt.Errorf("public key: %w", err)
}
endpoint, err := ResolveEndpoint(c.Endpoint)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString("private_key=")
b.WriteString(hex.EncodeToString(priv))
@@ -477,7 +593,7 @@ func (c Conf) ToIPC() (string, error) {
b.WriteString("\npublic_key=")
b.WriteString(hex.EncodeToString(pub))
b.WriteString("\nendpoint=")
b.WriteString(c.Endpoint)
b.WriteString(endpoint)
for _, ip := range c.AllowedIPs {
b.WriteString("\nallowed_ip=")
b.WriteString(strings.TrimSpace(ip))
+76
View File
@@ -2,6 +2,7 @@ package awg
import (
"encoding/base64"
"net"
"testing"
)
@@ -139,6 +140,81 @@ func TestDecodeKeyURLSafe(t *testing.T) {
}
}
func TestResolveEndpointPrefersIPv4(t *testing.T) {
got, err := resolveEndpointWith("peer.example:41421", func(host string) ([]net.IP, error) {
if host != "peer.example" {
t.Fatalf("host %q", host)
}
return []net.IP{
net.ParseIP("2001:db8::1"),
net.ParseIP("203.0.113.44"),
}, nil
})
if err != nil {
t.Fatal(err)
}
if got != "203.0.113.44:41421" {
t.Fatalf("got %q", got)
}
}
func TestResolveEndpointAlreadyIP(t *testing.T) {
got, err := resolveEndpointWith("203.0.113.10:51820", func(string) ([]net.IP, error) {
t.Fatal("lookup should not run for IP")
return nil, nil
})
if err != nil || got != "203.0.113.10:51820" {
t.Fatalf("%q %v", got, err)
}
}
func TestResolveEndpointDNSError(t *testing.T) {
_, err := resolveEndpointWith("missing.invalid:41421", func(string) ([]net.IP, error) {
return nil, &net.DNSError{Err: "no such host", Name: "missing.invalid", IsNotFound: true}
})
if err == nil || !stringsContains(err.Error(), "не удалось разрешить DNS") {
t.Fatalf("expected Russian DNS error, got %v", err)
}
}
func TestToIPCResolvesEndpoint(t *testing.T) {
old := lookupIP
lookupIP = func(host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("198.51.100.9")}, nil
}
defer func() { lookupIP = old }()
c := Conf{
PrivateKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
PublicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=",
Address: []string{"10.8.0.2/32"},
Endpoint: "peer.example:41421",
}
ipc, err := c.ToIPC()
if err != nil {
t.Fatal(err)
}
if !stringsContains(ipc, "endpoint=198.51.100.9:41421") {
t.Fatalf("ipc missing resolved endpoint: %s", ipc)
}
}
func TestConfResolveEndpointMutates(t *testing.T) {
old := lookupIP
lookupIP = func(string) ([]net.IP, error) {
return []net.IP{net.ParseIP("198.51.100.1")}, nil
}
defer func() { lookupIP = old }()
c := Conf{Endpoint: "host.example:41421"}
if err := c.ResolveEndpoint(); err != nil {
t.Fatal(err)
}
if c.Endpoint != "198.51.100.1:41421" {
t.Fatalf("endpoint %q", c.Endpoint)
}
}
func containsAll(s string, parts ...string) bool {
for _, p := range parts {
if !stringsContains(s, p) {
+4
View File
@@ -46,6 +46,10 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, _ string) er
if err != nil {
return err
}
// amneziawg-go IpcSet does not resolve hostnames reliably (Windows WSAHOST_NOT_FOUND).
if err := cfg.ResolveEndpoint(); err != nil {
return err
}
ipc, err := cfg.ToIPC()
if err != nil {
return err