Files
he-dydns/internal/network/network.go
T
2026-09-16 00:18:01 -03:00

129 lines
2.7 KiB
Go

// Package network discovers the IPv4/IPv6 addresses to publish to dynamic DNS.
package network
import (
"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, false),
IPv6: queryFirst(PublicIPv6Services, true),
}
}
func queryFirst(services []string, wantIPv6 bool) net.IP {
for _, url := range services {
ip, err := queryOne(url)
if err != nil {
continue
}
isV6 := ip.To4() == nil
if isV6 != wantIPv6 {
continue
}
return ip
}
return nil
}
func queryOne(url string) (net.IP, error) {
client := http.Client{Timeout: httpTimeout}
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
}