148 lines
3.6 KiB
Go
148 lines
3.6 KiB
Go
// 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
|
|
}
|