diff --git a/config.toml.example b/config.toml.example index 88a9632..f616d47 100644 --- a/config.toml.example +++ b/config.toml.example @@ -8,10 +8,16 @@ # Optional: how often to update in daemon mode, in minutes. Default: 5. # interval_minutes = 5 +# record_type selects which DNS record this entry updates: "A" (IPv4) or +# "AAAA" (IPv6). HE's dynamic DNS API cannot infer this from the address +# submitted, so it must be set explicitly. Defaults to "A" when omitted. + [[entries]] hostname = "home.example.com" token = "your-he-dyndns-token" +record_type = "A" [[entries]] -hostname = "home-v6.example.com" -token = "another-he-dyndns-token" +hostname = "home.example.com" +token = "your-he-dyndns-token" +record_type = "AAAA" diff --git a/internal/config/config.go b/internal/config/config.go index 4a7d0dc..ed3d33c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,16 +5,30 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/BurntSushi/toml" ) const DefaultIntervalMinutes = 5 +// RecordType identifies which DNS record family an entry updates. Hurricane +// Electric's dynamic DNS API cannot infer this from the submitted address +// alone, so it must be configured explicitly per entry. +type RecordType string + +const ( + RecordTypeA RecordType = "A" + RecordTypeAAAA RecordType = "AAAA" +) + // Entry describes a single Hurricane Electric dynamic DNS hostname to keep updated. type Entry struct { Hostname string `toml:"hostname"` Token string `toml:"token"` + // RecordType selects whether this entry updates the A (IPv4) or AAAA + // (IPv6) record. Defaults to A when omitted. + RecordType RecordType `toml:"record_type"` } // Config is the top-level he-dydns configuration. @@ -46,13 +60,25 @@ func Load(path string) (*Config, error) { if len(cfg.Entries) == 0 { return nil, fmt.Errorf("config %s: no entries defined", path) } - for i, e := range cfg.Entries { + for i := range cfg.Entries { + e := &cfg.Entries[i] if e.Hostname == "" { return nil, fmt.Errorf("config %s: entries[%d] missing hostname", path, i) } if e.Token == "" { return nil, fmt.Errorf("config %s: entries[%d] missing token", path, i) } + + switch RecordType(strings.ToUpper(string(e.RecordType))) { + case "": + e.RecordType = RecordTypeA + case RecordTypeA: + e.RecordType = RecordTypeA + case RecordTypeAAAA: + e.RecordType = RecordTypeAAAA + default: + return nil, fmt.Errorf("config %s: entries[%d] invalid record_type %q (must be \"A\" or \"AAAA\")", path, i, e.RecordType) + } } if cfg.IntervalMinutes <= 0 { diff --git a/internal/network/network.go b/internal/network/network.go index ac86c39..f1d9d45 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -2,6 +2,7 @@ package network import ( + "context" "fmt" "io" "net" @@ -83,14 +84,21 @@ func isUsable(ip net.IP) bool { func fromPublicServices() Addresses { return Addresses{ - IPv4: queryFirst(PublicIPv4Services, false), - IPv6: queryFirst(PublicIPv6Services, true), + IPv4: queryFirst(PublicIPv4Services, "tcp4", false), + IPv6: queryFirst(PublicIPv6Services, "tcp6", true), } } -func queryFirst(services []string, wantIPv6 bool) net.IP { +// 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(url) + ip, err := queryOne(client, url) if err != nil { continue } @@ -103,8 +111,19 @@ func queryFirst(services []string, wantIPv6 bool) net.IP { return nil } -func queryOne(url string) (net.IP, error) { - client := http.Client{Timeout: httpTimeout} +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 diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 07f2145..ece7c81 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -4,6 +4,7 @@ package updater import ( "fmt" "log" + "net" "git.aehoo.net/alphard/he-dydns/internal/config" "git.aehoo.net/alphard/he-dydns/internal/hedns" @@ -34,21 +35,24 @@ func Run(cfg *config.Config) error { var failures int for _, entry := range cfg.Entries { - if addrs.IPv4 != nil { - if err := hedns.Update(entry.Hostname, entry.Token, addrs.IPv4); err != nil { - log.Printf("error: %v", err) - failures++ - } else { - log.Printf("updated %s -> %s", entry.Hostname, addrs.IPv4) - } + var ip net.IP + switch entry.RecordType { + case config.RecordTypeAAAA: + ip = addrs.IPv6 + default: + ip = addrs.IPv4 } - if addrs.IPv6 != nil { - if err := hedns.Update(entry.Hostname, entry.Token, addrs.IPv6); err != nil { - log.Printf("error: %v", err) - failures++ - } else { - log.Printf("updated %s -> %s", entry.Hostname, addrs.IPv6) - } + + if ip == nil { + log.Printf("no %s address available for %s; skipping", entry.RecordType, entry.Hostname) + continue + } + + if err := hedns.Update(entry.Hostname, entry.Token, ip); err != nil { + log.Printf("error: %v", err) + failures++ + } else { + log.Printf("updated %s (%s) -> %s", entry.Hostname, entry.RecordType, ip) } }