Files
he-dydns/internal/network/network.go
T

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
}