// Package network discovers the IPv4/IPv6 addresses to publish to dynamic DNS. package network import ( "context" "fmt" "io" "net" "net/http" "strings" "time" ) const httpTimeout = 10 * time.Second // PublicIPv4Services and PublicIPv6Services are queried in order; the first // service that returns a usable address wins. var ( PublicIPv4Services = []string{ "https://api.ipify.org", "https://ipv4.icanhazip.com", } PublicIPv6Services = []string{ "https://api6.ipify.org", "https://ipv6.icanhazip.com", } ) // Addresses holds the discovered addresses for a single update run. type Addresses struct { IPv4 net.IP IPv6 net.IP } // Empty reports whether no address was discovered at all. func (a Addresses) Empty() bool { return a.IPv4 == nil && a.IPv6 == nil } // Discover finds the addresses to publish. If iface is non-empty its // addresses are used; otherwise the public IP is guessed via external // services, independently for IPv4 and IPv6. func Discover(iface string) (Addresses, error) { if iface != "" { return fromInterface(iface) } return fromPublicServices(), nil } func fromInterface(name string) (Addresses, error) { ifi, err := net.InterfaceByName(name) if err != nil { return Addresses{}, fmt.Errorf("looking up interface %s: %w", name, err) } addrs, err := ifi.Addrs() if err != nil { return Addresses{}, fmt.Errorf("listing addresses on %s: %w", name, err) } var out Addresses for _, a := range addrs { ipNet, ok := a.(*net.IPNet) if !ok { continue } ip := ipNet.IP if !isUsable(ip) { continue } if ip4 := ip.To4(); ip4 != nil { if out.IPv4 == nil { out.IPv4 = ip4 } } else if out.IPv6 == nil { out.IPv6 = ip } } return out, nil } func isUsable(ip net.IP) bool { return !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() } func fromPublicServices() Addresses { return Addresses{ IPv4: queryFirst(PublicIPv4Services, "tcp4", false), IPv6: queryFirst(PublicIPv6Services, "tcp6", true), } } // queryFirst tries each service in order over a connection forced to the // given network ("tcp4" or "tcp6"). Forcing the dial family avoids relying // on the target hostname having only an A or only an AAAA record: some of // these services answer on both families, and letting the OS pick (e.g. via // Happy Eyeballs) could silently return the wrong address family. The // resulting address family is still double-checked as a second safeguard. func queryFirst(services []string, network string, wantIPv6 bool) net.IP { client := httpClientForNetwork(network) for _, url := range services { ip, err := queryOne(client, url) if err != nil { continue } isV6 := ip.To4() == nil if isV6 != wantIPv6 { continue } return ip } return nil } func httpClientForNetwork(network string) *http.Client { dialer := net.Dialer{Timeout: httpTimeout} return &http.Client{ Timeout: httpTimeout, Transport: &http.Transport{ DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { return dialer.DialContext(ctx, network, addr) }, }, } } func queryOne(client *http.Client, url string) (net.IP, error) { resp, err := client.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: unexpected status %s", url, resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) if err != nil { return nil, err } ip := net.ParseIP(strings.TrimSpace(string(body))) if ip == nil { return nil, fmt.Errorf("%s: could not parse IP from response", url) } return ip, nil }