diff --git a/README.md b/README.md index 09bc959..0d371d2 100644 --- a/README.md +++ b/README.md @@ -5,33 +5,86 @@ Dynamic DNS app for Caddy This is a simple Caddy app that keeps your DNS pointed to your machine; especially useful if your IP address is not static. -It simply queries a service (an "IP source") for your public IP address every so often and if it changes, it updates the DNS records with your configured provider. +It simply queries a service (an "IP source") for your public IP address every so often and if it changes, it updates the DNS records with your configured provider. It supports multiple IPs, including IPv4 and IPv6, as well as redundant IP sources. -The IP source and DNS providers are modular. In addition to this app module, you'll need to plug in [a DNS provider module from caddy-dns](https://github.com/caddy-dns). +IP sources and DNS providers are modular. This app comes with IP source modules. However, you'll need to plug in [a DNS provider module from caddy-dns](https://github.com/caddy-dns) so that your DNS records can be updated. -Example Caddy config: +Example minimal Caddy config: ```json { "apps": { "dynamic_dns": { - "domain": "example.com", + "domains": { + "example.com": ["@"] + }, "dns_provider": { "name": "cloudflare", - "api_token": "topsecret", + "api_token": "topsecret" } } } } ``` -Example Caddyfile config, via [global options](https://caddyserver.com/docs/caddyfile/options): +This updates DNS records for `example.com` via Cloudflare's API. (Notice how the DNS zone is separate from record names/subdomains.) + +Equivalent Caddyfile config ([global options](https://caddyserver.com/docs/caddyfile/options)): + +``` +{ + dynamic_dns { + provider cloudflare {env.CLOUDFLARE_API_TOKEN} + domains { + example.com + } + } +} +``` + +Here's a more filled-out JSON config: + +```json +{ + "apps": { + "dynamic_dns": { + "ip_sources": [ + { + "source": "upnp" + }, + { + "source": "simple_http", + "endpoints": ["https://icanhazip.com"] + } + ], + "domains": { + "example.com": ["@", "www"], + "example.net": ["subdomain"] + }, + "dns_provider": { + "name": "cloudflare", + "api_token": "topsecret" + }, + "check_interval": "5m" + } + } +} +``` + + +This config prefers to get the IP address locally via UPnP (if edge router has UPnP enabled, of course), but if that fails, will fall back to querying `icanhazip.com` for the IP address. It then updates records for `example.com`, `www.example.com`, and `subdomain.example.net`. Notice how the zones and subdomains are separate; this eliminates ambiguity since we don't have to try to be clever and figure out the zone via recursive, authoritative DNS lookups. We also check every 5 minutes instead of 30 minutes (default). + +Equivalent Caddyfile (there is not currently a way to customize IP sources via Caddyfile; PRs welcomed!): + ``` { dynamic_dns { - domain example.com provider cloudflare {env.CLOUDFLARE_API_TOKEN} + domains { + example.com @ www + example.net subdomain + } check_interval 5m } } -``` \ No newline at end of file +``` diff --git a/caddyfile.go b/caddyfile.go index e8b8f9d..e67efa0 100644 --- a/caddyfile.go +++ b/caddyfile.go @@ -15,10 +15,14 @@ func init() { // Syntax: // // dynamic_dns { -// domain +// domains { +// +// } // check_interval // provider ... // } +// +// If are omitted after , then "@" will be assumed. func parseApp(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { app := new(App) @@ -30,9 +34,17 @@ func parseApp(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { // handle the block for d.NextBlock(0) { switch d.Val() { - case "domain": - if !d.Args(&app.Domain) { - return nil, d.ArgErr() + case "domains": + for d.NextBlock(0) { + zone := d.Val() + if zone == "" { + return nil, d.ArgErr() + } + names := d.RemainingArgs() + if len(names) == 0 { + names = []string{"@"} + } + app.Domains[zone] = names } case "check_interval": @@ -57,9 +69,6 @@ func parseApp(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { } app.DNSProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, nil) - // TODO: Implement this once there's actually more than one source - // case "ip_source": - default: return nil, d.ArgErr() } diff --git a/dynamicdns.go b/dynamicdns.go index 409acb0..90eddd2 100644 --- a/dynamicdns.go +++ b/dynamicdns.go @@ -10,31 +10,38 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/libdns/libdns" "go.uber.org/zap" - "golang.org/x/net/publicsuffix" ) func init() { caddy.RegisterModule(App{}) } -// App is a Caddy app that keeps your DNS records updated. +// App is a Caddy app that keeps your DNS records updated with the public +// IP address of your instance. It updates A and AAAA records. type App struct { - // The source from which to get the server's public IP address. - IPSourceRaw json.RawMessage `json:"ip_source,omitempty" caddy:"namespace=dynamic_dns.ip_sources inline_key=source"` + // The sources from which to get the server's public IP address. + // Multiple sources can be specified for redundancy. + // Default: simple_http + IPSourcesRaw []json.RawMessage `json:"ip_sources,omitempty" caddy:"namespace=dynamic_dns.ip_sources inline_key=source"` // The configuration for the DNS provider with which the DNS // records will be updated. DNSProviderRaw json.RawMessage `json:"dns_provider,omitempty" caddy:"namespace=dns.providers inline_key=name"` - // The domain name for which to update DNS records. - Domain string `json:"domain,omitempty"` + // The record names, keyed by DNS zone, for which to update the A/AAAA records. + // Record names are relative to the zone. The zone is usually your registered + // domain name. To refer to the zone itself, use the record name of "@". + // + // For example, assuming your zone is example.com, and you want to update A/AAAA + // records for "example.com" and "www.example.com" so that they resolve to this + // Caddy instance, configure like so: `"example.com": ["@", "www"]` + Domains map[string][]string `json:"domains,omitempty"` - // How frequently to check the public IP address. Default: 10m + // How frequently to check the public IP address. Default: 30m CheckInterval caddy.Duration `json:"check_interval,omitempty"` - ipSource IPSource + ipSources []IPSource dnsProvider libdns.RecordSetter - eTLDplus1 string // TODO: a better way to get the zone from the domain - recursive DNS lookups until SOA record, like lego does maybe? ctx caddy.Context logger *zap.Logger @@ -53,16 +60,6 @@ func (a *App) Provision(ctx caddy.Context) error { a.ctx = ctx a.logger = ctx.Logger(a) - // parse the domain name - if a.Domain == "" { - return fmt.Errorf("domain is required") - } - eTLDplus1, err := publicsuffix.EffectiveTLDPlusOne(a.Domain) - if err != nil { - return err - } - a.eTLDplus1 = eTLDplus1 - // set up the DNS provider module if len(a.DNSProviderRaw) == 0 { return fmt.Errorf("a DNS provider is required") @@ -74,20 +71,26 @@ func (a *App) Provision(ctx caddy.Context) error { a.dnsProvider = val.(libdns.RecordSetter) // set up the IP source module or use a default - if a.IPSourceRaw != nil { - val, err := ctx.LoadModule(a, "IPSourceRaw") + if a.IPSourcesRaw != nil { + vals, err := ctx.LoadModule(a, "IPSourcesRaw") if err != nil { return fmt.Errorf("loading IP source module: %v", err) } - a.ipSource = val.(IPSource) + for _, val := range vals.([]interface{}) { + a.ipSources = append(a.ipSources, val.(IPSource)) + } } - if a.ipSource == nil { - a.ipSource = Ipify{} + if len(a.ipSources) == 0 { + var sh SimpleHTTP + if err = sh.Provision(ctx); err != nil { + return err + } + a.ipSources = []IPSource{sh} } // make sure a check interval is set if a.CheckInterval == 0 { - a.CheckInterval = caddy.Duration(10 * time.Minute) + a.CheckInterval = caddy.Duration(defaultCheckInterval) } if time.Duration(a.CheckInterval) < time.Second { return fmt.Errorf("check interval must be at least 1 second") @@ -125,86 +128,186 @@ func (a App) checkerLoop() { } } -// checkIPAndUpdateDNS checks the public IP address and, -// if it is different from the last IP, it updates DNS -// records accordingly. +// checkIPAndUpdateDNS checks public IP addresses and, for any IP addresses +// that are different from before, it updates DNS records accordingly. func (a App) checkIPAndUpdateDNS() { - lastIPMu.Lock() - defer lastIPMu.Unlock() + a.logger.Debug("beginning IP address check") + + lastIPsMu.Lock() + defer lastIPsMu.Unlock() + + var err error + + // if we don't know current IPs for this domain, look them up from DNS + if lastIPs == nil { + lastIPs, err = a.lookupCurrentIPsFromDNS() + if err != nil { + // not the end of the world, but might be an extra initial API hit with the DNS provider + a.logger.Error("unable to lookup current IPs from DNS records", zap.Error(err)) + } + } + + // look up current address(es) from first successful IP source + var currentIPs []net.IP + for _, ipSrc := range a.ipSources { + currentIPs, err = ipSrc.GetIPs(a.ctx) + if len(currentIPs) == 0 { + err = fmt.Errorf("no IP addresses returned") + } + if err == nil { + break + } + a.logger.Error("looking up IP address", + zap.String("ip_source", ipSrc.(caddy.Module).CaddyModule().ID.Name()), + zap.Error(err)) + } + + // make sure the source returns tidy info; duplicates are wasteful + currentIPs = removeDuplicateIPs(currentIPs) + + // do a simple diff of current and previous IPs to make DNS records to update + updatedRecsByZone := make(map[string][]libdns.Record) + for _, ip := range currentIPs { + if ipListContains(lastIPs, ip) { + continue // IP is not different; no update needed + } + + a.logger.Info("different IP address", zap.String("new_ip", ip.String())) + + for zone, domains := range a.Domains { + for _, domain := range domains { + updatedRecsByZone[zone] = append(updatedRecsByZone[zone], libdns.Record{ + Type: recordType(ip), + Name: domain, + Value: ip.String(), + TTL: time.Duration(a.CheckInterval), + }) + } + } + } + + if len(updatedRecsByZone) == 0 { + a.logger.Debug("no IP address change; no update needed") + return + } + + for zone, records := range updatedRecsByZone { + for _, rec := range records { + a.logger.Info("updating DNS record", + zap.String("zone", zone), + zap.String("type", rec.Type), + zap.String("name", rec.Name), + zap.String("value", rec.Value), + zap.Duration("ttl", rec.TTL), + ) + } + _, err = a.dnsProvider.SetRecords(a.ctx, zone, records) + if err != nil { + a.logger.Error("failed setting DNS record(s) with new IP address(es)", + zap.String("zone", zone), + zap.Error(err), + ) + } + } + + a.logger.Info("finished updating DNS") + + lastIPs = currentIPs +} + +// lookupCurrentIPsFromDNS looks up the current IP addresses +// from DNS records. +func (a App) lookupCurrentIPsFromDNS() ([]net.IP, error) { + // avoid duplicates + currentIPs := make(map[string]net.IP) - // if we don't know the current IP for this domain, try to get it - if lastIP == nil { - if recordGetter, ok := a.dnsProvider.(libdns.RecordGetter); ok { - recs, err := recordGetter.GetRecords(a.ctx, a.eTLDplus1) + if recordGetter, ok := a.dnsProvider.(libdns.RecordGetter); ok { + for zone, names := range a.Domains { + recs, err := recordGetter.GetRecords(a.ctx, zone) if err == nil { for _, r := range recs { - if r.Type == "A" && r.Name == a.Domain { - lastIP = net.ParseIP(r.Value) - break + if r.Type != recordTypeA && r.Type != recordTypeAAAA { + continue + } + if !stringListContains(names, r.Name) { + continue + } + ip := net.ParseIP(r.Value) + if ip != nil { + currentIPs[ip.String()] = ip + } else { + a.logger.Error("invalid IP address found in current DNS record", zap.String("A", r.Value)) } } } else { - a.logger.Error("unable to get current records", zap.Error(err)) + return nil, err } } } - ip, err := a.ipSource.GetIPv4() - if err != nil { - a.logger.Error("checking IP address", zap.Error(err)) - return - } - if ip.Equal(lastIP) { - return - } - - a.logger.Info("IP address changed", - zap.String("last_ip", lastIP.String()), - zap.String("new_ip", ip.String()), - ) - err = a.updateDNS(ip) - if err != nil { - a.logger.Error("updating DNS record(s) with new IP address", zap.Error(err)) - return + // convert into a slice + ips := make([]net.IP, 0, len(currentIPs)) + for _, ip := range currentIPs { + ips = append(ips, ip) } - lastIP = ip + return ips, nil } -func (a App) updateDNS(ipv4 net.IP) error { - recordA := libdns.Record{ - Type: "A", - Name: a.Domain, - Value: ipv4.String(), - TTL: time.Duration(a.CheckInterval), +// recordType returns the DNS record type associated with the version of ip. +func recordType(ip net.IP) string { + if ip.To4() == nil { + return recordTypeAAAA } + return recordTypeA +} - a.logger.Info("updating DNS record", - zap.String("type", recordA.Type), - zap.String("name", recordA.Name), - zap.String("value", recordA.Value), - zap.Duration("ttl", recordA.TTL), - ) - - _, err := a.dnsProvider.SetRecords(a.ctx, a.eTLDplus1, []libdns.Record{recordA}) - if err != nil { - return err +// removeDuplicateIPs returns ips without duplicates. +func removeDuplicateIPs(ips []net.IP) []net.IP { + var clean []net.IP + for _, ip := range ips { + if !ipListContains(clean, ip) { + clean = append(clean, ip) + } } + return clean +} - a.logger.Info("finished updating DNS") +// ipListContains returns true if list contains ip; false otherwise. +func ipListContains(list []net.IP, ip net.IP) bool { + for _, ipInList := range list { + if ipInList.Equal(ip) { + return true + } + } + return false +} - return nil +func stringListContains(list []string, s string) bool { + for _, val := range list { + if val == s { + return true + } + } + return false } -// Remember what the last IP is so that we +// Remember what the last IPs are so that we // don't try to update DNS records every -// time a new config is loaded; the IP is -// unlikely to change that often. +// time a new config is loaded; the IPs are +// unlikely to change very often. var ( - lastIP net.IP - lastIPMu sync.Mutex + lastIPs []net.IP + lastIPsMu sync.Mutex ) +const ( + recordTypeA = "A" + recordTypeAAAA = "AAAA" +) + +const defaultCheckInterval = 30 * time.Minute + // Interface guards var ( _ caddy.Provisioner = (*App)(nil) diff --git a/go.mod b/go.mod index a76f4cc..f9fe998 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,13 @@ module github.com/mholt/caddy-dynamicdns go 1.15 require ( - github.com/caddyserver/caddy/v2 v2.4.0-beta.1 - github.com/libdns/libdns v0.1.0 + github.com/NebulousLabs/go-upnp v0.0.0-20181203152547-b32978b8ccbf + github.com/caddyserver/caddy/v2 v2.4.0-beta.1.0.20210227022758-ec309c6d52fd + github.com/libdns/libdns v0.2.0 + gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 // indirect + gitlab.com/NebulousLabs/go-upnp v0.0.0-20181011194642-3a71999ed0d3 // indirect go.uber.org/zap v1.16.0 - golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d + golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect + golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb // indirect + honnef.co/go/tools v0.0.1-2020.1.3 // indirect ) diff --git a/go.sum b/go.sum index b5018fc..cd2073a 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/Masterminds/sprig/v3 v3.1.0 h1:j7GpgZ7PdFqNsmncycTHsLmVPf5/3wJtlgW9TN github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZE/rU8pmrbihA= github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/NebulousLabs/go-upnp v0.0.0-20181203152547-b32978b8ccbf h1:1UP+tqdgLAKwt6NpefYq/SdyFaelU8MXOThESt6Od1U= +github.com/NebulousLabs/go-upnp v0.0.0-20181203152547-b32978b8ccbf/go.mod h1:GbuBk21JqF+driLX3XtJYNZjGa45YDoa9IqCTzNSfEc= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OpenPeeDeeP/depguard v1.0.0/go.mod h1:7/4sitnI9YlQgTLLk734QlzXT8DuHVnAyztLplQjk+o= @@ -94,10 +96,10 @@ github.com/bombsimon/wsl/v2 v2.0.0/go.mod h1:mf25kr/SqFEPhhcxW1+7pxzGlW+hIl/hYTK github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -github.com/caddyserver/caddy/v2 v2.4.0-beta.1 h1:Ed/tIaN3p6z8M3pEiXWJL/T8JmCqV62FrSJCHKquW/I= -github.com/caddyserver/caddy/v2 v2.4.0-beta.1/go.mod h1:PvH/751lpEjuG7/6+u4nagQLz0jOZ91yJQGUtoEily4= -github.com/caddyserver/certmagic v0.12.1-0.20210211020017-ebb8d8b435b4 h1:YPHanayqEADEHFxGui7lqQ0tx4rypJaD2y4Y7Tip/ks= -github.com/caddyserver/certmagic v0.12.1-0.20210211020017-ebb8d8b435b4/go.mod h1:CUPfwomVXGCyV77EQbR3v7H4tGJ4pX16HATeR55rqws= +github.com/caddyserver/caddy/v2 v2.4.0-beta.1.0.20210227022758-ec309c6d52fd h1:Fvxh1kW7soG+k+0oG17Tn1+LYsYowXMHwtTIGUuDDc8= +github.com/caddyserver/caddy/v2 v2.4.0-beta.1.0.20210227022758-ec309c6d52fd/go.mod h1:zb1rFV34Xyb61KKxYipnHObLGdf4Rwv3kbj5eyzafnY= +github.com/caddyserver/certmagic v0.12.1-0.20210224184602-7550222c4a6a h1:gcqCMCPuHlQPY+XZzOilNouNeRpH40UhMfSp0tmulxI= +github.com/caddyserver/certmagic v0.12.1-0.20210224184602-7550222c4a6a/go.mod h1:dNOzF4iOB7H9E51xTooMB90vs+2XNVtpnx0liQNsQY4= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -408,8 +410,8 @@ github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+ github.com/letsencrypt/pkcs11key v2.0.1-0.20170608213348-396559074696+incompatible/go.mod h1:iGYXKqDXt0cpBthCHdr9ZdsQwyGlYFh/+8xa4WzIQ34= github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/libdns/libdns v0.1.0 h1:0ctCOrVJsVzj53mop1angHp/pE3hmAhP7KiHvR0HD04= -github.com/libdns/libdns v0.1.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= +github.com/libdns/libdns v0.2.0 h1:ewg3ByWrdUrxrje8ChPVMBNcotg7H9LQYg+u5De2RzI= +github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= @@ -736,6 +738,10 @@ github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54t github.com/zmap/zcertificate v0.0.0-20190521191901-30e388164f71/go.mod h1:gIZi1KPgkZNUQzPZXsZrNnUnxy05nTc0+tmlqvIkhRw= github.com/zmap/zcrypto v0.0.0-20190329181646-dff83107394d/go.mod h1:ix3q2kpLy0ibAuFXlr7qOhPKwFRRSjuynGuTR8EUPCk= github.com/zmap/zlint v0.0.0-20190516161541-9047d02cf65a/go.mod h1:xwLbce0UzBXp44sIAL1cii+hoK8j4AxRKlymZA2AIcY= +gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 h1:dizWJqTWjwyD8KGcMOwgrkqu1JIkofYgKkmDeNE7oAs= +gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40/go.mod h1:rOnSnoRyxMI3fe/7KIbVcsHRGxe30OONv8dEgo+vCfA= +gitlab.com/NebulousLabs/go-upnp v0.0.0-20181011194642-3a71999ed0d3 h1:qXqiXDgeQxspR3reot1pWme00CX1pXbxesdzND+EjbU= +gitlab.com/NebulousLabs/go-upnp v0.0.0-20181011194642-3a71999ed0d3/go.mod h1:sleOmkovWsDEQVYXmOJhx69qheoMTmCuPYyiCFCihlg= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= @@ -807,13 +813,16 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20170915142106-8351a756f30f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -843,13 +852,13 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20191002035440-2ec189313ef0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d h1:1aflnvSoWWLI2k/dMUAl5lvU1YO4Mb4hz0gh+1rjcxU= -golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -911,10 +920,8 @@ golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915090833-1cbadb444a80/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -963,12 +970,16 @@ golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113232020-e2727e816f5a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200106190116-7be0a674c9fc h1:MR2F33ipDGog0C4eMhU6u9o3q6c3dvYis2aG6Jl12Wg= golang.org/x/tools v0.0.0-20200106190116-7be0a674c9fc/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb h1:iKlO7ROJc6SttHKlxzwGytRtBUqX4VARrNTgP2YLX5M= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= @@ -1079,8 +1090,9 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3 h1:sXmLre5bzIR6ypkjXCDI3jHPssRhc8KD/Ome589sc3U= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= howett.net/plist v0.0.0-20181124034731-591f970eefbb h1:jhnBjNi9UFpfpl8YZhA9CrOqpnJdvzuiHsl/dnxl11M= howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed/go.mod h1:Xkxe497xwlCKkIaQYRfC7CSLworTXY9RMqwhhCm+8Nc= diff --git a/ipsource.go b/ipsource.go index c85e4a1..24091c1 100644 --- a/ipsource.go +++ b/ipsource.go @@ -1,51 +1,138 @@ package dynamicdns import ( + "context" "fmt" "io" "io/ioutil" "net" "net/http" "strings" + "time" + upnp "github.com/NebulousLabs/go-upnp" "github.com/caddyserver/caddy/v2" + "go.uber.org/zap" ) func init() { - caddy.RegisterModule(Ipify{}) + caddy.RegisterModule(SimpleHTTP{}) + caddy.RegisterModule(UPnP{}) } // IPSource is a type that can get IP addresses. -// TODO: IPv6? type IPSource interface { - GetIPv4() (net.IP, error) + GetIPs(context.Context) ([]net.IP, error) } -// Ipify gets IP addresses from ipify.org. -// (TODO: api6.ipify.org for IPv6) -type Ipify struct{} +// SimpleHTTP is an IP source that looks up the public IP addresses by +// making HTTP(S) requests to the specified endpoints; it will try each +// endpoint with IPv4 and IPv6 until at least one returns a valid value. +// It is OK if an endpoint doesn't support both IP versions; returning +// a single valid IP address is sufficient. +// +// The endpoints must return HTTP status 200 and the response body must +// contain only the IP address in plain text. +type SimpleHTTP struct { + // The list of endpoints to query. If empty, a default list will + // be used: + // + // - https://api.ipify.org + // - https://myip.addr.space + // - https://ifconfig.me + // - https://icanhazip.com + // - https://ident.me + // - https://bot.whatismyipaddress.com + // - https://ipecho.net/plain + Endpoints []string `json:"endpoints,omitempty"` + + logger *zap.Logger +} // CaddyModule returns the Caddy module information. -func (Ipify) CaddyModule() caddy.ModuleInfo { +func (SimpleHTTP) CaddyModule() caddy.ModuleInfo { return caddy.ModuleInfo{ - ID: "dynamic_dns.ip_sources.ipify", - New: func() caddy.Module { return new(Ipify) }, + ID: "dynamic_dns.ip_sources.simple_http", + New: func() caddy.Module { return new(SimpleHTTP) }, + } +} + +// Provision sets up the module. +func (sh *SimpleHTTP) Provision(ctx caddy.Context) error { + sh.logger = ctx.Logger(sh) + if len(sh.Endpoints) == 0 { + sh.Endpoints = defaultHTTPIPServices } + return nil } -// GetIPv4 gets the public IPv4 address of this machine. -func (Ipify) GetIPv4() (net.IP, error) { - resp, err := http.Get("https://api.ipify.org") +// GetIPs gets the public addresses of this machine. +func (sh SimpleHTTP) GetIPs(ctx context.Context) ([]net.IP, error) { + ipv4Client := sh.makeClient("tcp4") + ipv6Client := sh.makeClient("tcp6") + + var ips []net.IP + for _, endpoint := range sh.Endpoints { + ipv4, err := sh.lookupIP(ctx, ipv4Client, endpoint) + if err != nil { + sh.logger.Warn("IPv4 lookup failed", + zap.String("endpoint", endpoint), + zap.Error(err)) + } else if !ipListContains(ips, ipv4) { + ips = append(ips, ipv4) + } + + ipv6, err := sh.lookupIP(ctx, ipv6Client, endpoint) + if err != nil { + sh.logger.Warn("IPv6 lookup failed", + zap.String("endpoint", endpoint), + zap.Error(err)) + } else if !ipListContains(ips, ipv6) { + ips = append(ips, ipv6) + } + + // use first successful service + if len(ips) > 0 { + break + } + } + + return ips, nil +} + +// makeClient makes an HTTP client that forces use of the specified network type (e.g. "tcp6"). +func (SimpleHTTP) makeClient(network string) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second} + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: func(ctx context.Context, _, address string) (net.Conn, error) { + return dialer.DialContext(ctx, network, address) + }, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } +} + +func (SimpleHTTP) lookupIP(ctx context.Context, client *http.Client, endpoint string) (net.IP, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, fmt.Errorf("server response was: %d %s", resp.StatusCode, resp.Status) + return nil, fmt.Errorf("%s: server response was: %d %s", endpoint, resp.StatusCode, resp.Status) } - ipASCII, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1024)) + ipASCII, err := ioutil.ReadAll(io.LimitReader(resp.Body, 256)) if err != nil { return nil, err } @@ -53,11 +140,56 @@ func (Ipify) GetIPv4() (net.IP, error) { ip := net.ParseIP(ipStr) if ip == nil { - return nil, fmt.Errorf("invalid IP address: %s", ipStr) + return nil, fmt.Errorf("%s: invalid IP address: %s", endpoint, ipStr) } return ip, nil } -// Interface guard -var _ IPSource = (*Ipify)(nil) +var defaultHTTPIPServices = []string{ + "https://api.ipify.org", + "https://myip.addr.space", + "https://ifconfig.me", + "https://icanhazip.com", + "https://ident.me", + "https://bot.whatismyipaddress.com", + "https://ipecho.net/plain", +} + +// UPnP gets the IP address from UPnP device. +type UPnP struct{} + +// CaddyModule returns the Caddy module information. +func (UPnP) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "dynamic_dns.ip_sources.upnp", + New: func() caddy.Module { return new(UPnP) }, + } +} + +// GetIPs gets the public address(es) of this machine. +func (UPnP) GetIPs(ctx context.Context) ([]net.IP, error) { + d, err := upnp.DiscoverCtx(ctx) + if err != nil { + return nil, err + } + + ipStr, err := d.ExternalIP() + if err != nil { + return nil, err + } + + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, fmt.Errorf("invalid IP: %s", ipStr) + } + + return []net.IP{ip}, nil +} + +// Interface guards +var ( + _ IPSource = (*SimpleHTTP)(nil) + _ caddy.Provisioner = (*SimpleHTTP)(nil) + _ IPSource = (*UPnP)(nil) +)