diff --git a/cmd/dyndns/dyndns.go b/cmd/dyndns/dyndns.go new file mode 100644 index 0000000..c1faf18 --- /dev/null +++ b/cmd/dyndns/dyndns.go @@ -0,0 +1,143 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Binary dyndns updates configured DNS records with the +// current public IPv4 address (of network interface uplink0). +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "time" + + "github.com/libdns/cloudflare" + "github.com/libdns/libdns" + "github.com/rtr7/router7/internal/dyndns" +) + +var update = dyndns.Update + +type DynDNSRecord struct { + // TODO: multiple providers support + Cloudflare struct { + APIToken string `json:"api_token"` + } `json:"cloudflare"` + Zone string `json:"zone"` + RecordName string `json:"record_name"` + // TODO: make RecordType customizable if non-A is ever desired + RecordTTLSeconds int `json:"record_ttl_seconds"` +} + +func getIPv4Address(ifname string) (string, error) { + iface, err := net.InterfaceByName(ifname) + if err != nil { + return "", err + } + addrs, err := iface.Addrs() + if err != nil { + return "", err + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok { + continue + } + if ipnet.IP.To4() == nil { + continue // not IPv4 + } + return ipnet.IP.String(), nil + } + return "", fmt.Errorf("no IPv4 address found on interface %s", ifname) +} + +func logic(ifname string, records []DynDNSRecord) error { + if len(records) == 0 { + return nil // exit early + } + + addr, err := getIPv4Address(ifname) + if err != nil { + return err + } + + for _, r := range records { + apiToken := r.Cloudflare.APIToken + if t, ok := os.LookupEnv("ROUTER7_CLOUDFLARE_API_KEY"); ok { + apiToken = t + } + provider := &cloudflare.Provider{ + APIToken: apiToken, + } + + ctx := context.Background() + record := libdns.Record{ + Name: r.RecordName, + Type: "A", + Value: addr, + TTL: time.Duration(r.RecordTTLSeconds) * time.Second, + } + if err := update(ctx, r.Zone, record, provider); err != nil { + return err + } + } + return nil +} + +func main() { + var ( + configFile = flag.String( + "config_file", + "/perm/dyndns.json", + "Path to the JSON configuration", + ) + + ifname = flag.String( + "interface_name", + "uplink0", + "Network interface name to take the first IPv4 address from", + ) + + oneoff = flag.Bool( + "oneoff", + false, + "run once (as opposed to continuously, in a loop)", + ) + ) + flag.Parse() + var config struct { + Records []DynDNSRecord `json:"records"` + } + b, err := ioutil.ReadFile(*configFile) + if err != nil { + log.Fatal(err) + } + if err := json.Unmarshal(b, &config); err != nil { + log.Fatal(err) + } + for { + if err := logic(*ifname, config.Records); err != nil { + log.Fatal(err) + } + if *oneoff { + break + } + time.Sleep(1 * time.Minute) + } +} diff --git a/cmd/dyndns/dyndns_test.go b/cmd/dyndns/dyndns_test.go new file mode 100644 index 0000000..8b68c70 --- /dev/null +++ b/cmd/dyndns/dyndns_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + "testing" + + "github.com/libdns/libdns" + "github.com/rtr7/router7/internal/dyndns" +) + +func TestLogic(t *testing.T) { + cfg := DynDNSRecord{ + Zone: "zekjur.net", + RecordName: "dyndns.zekjur.net", + RecordTTLSeconds: 300, // 5 minutes + } + update = func(ctx context.Context, zone string, record libdns.Record, _ dyndns.RecordGetterSetter) error { + if got, want := zone, cfg.Zone; got != want { + return fmt.Errorf("update(): unexpected zone: got %q, want %q", got, want) + } + if got, want := record.Name, cfg.RecordName; got != want { + return fmt.Errorf("update(): unexpected record name: got %q, want %q", got, want) + } + return nil + } + if err := logic("lo", []DynDNSRecord{cfg}); err != nil { + t.Fatalf("logic: %v", err) + } + +} diff --git a/go.mod b/go.mod index d878547..f740e1a 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,8 @@ require ( github.com/insomniacslk/dhcp v0.0.0-20200420235442-ed3125c2efe7 github.com/jpillora/backoff v1.0.0 github.com/krolaw/dhcp4 v0.0.0-20190909130307-a50d88189771 + github.com/libdns/cloudflare v0.0.0-20200506154110-16482ae4e806 + github.com/libdns/libdns v0.0.0-20200501023120-186724ffc821 github.com/mdlayher/ndp v0.0.0-20200509194142-8a50b5ef8b52 github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 github.com/miekg/dns v1.1.29 diff --git a/internal/dyndns/dyndns.go b/internal/dyndns/dyndns.go new file mode 100644 index 0000000..c4b4133 --- /dev/null +++ b/internal/dyndns/dyndns.go @@ -0,0 +1,66 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dyndns + +import ( + "context" + "log" + + "github.com/libdns/libdns" +) + +type RecordGetterSetter interface { + libdns.RecordGetter + libdns.RecordSetter +} + +// Update takes a record which should be updated +// within the specified zone. +func Update(ctx context.Context, zone string, record libdns.Record, provider RecordGetterSetter) error { + existing, err := provider.GetRecords(ctx, zone) + if err != nil { + return err + } + + var updated []libdns.Record + for _, rec := range existing { + if rec.Name != record.Name || rec.Type != record.Type { + continue + } + + if rec.Value == record.Value { + log.Printf("exit early: record up to date (%s)", record.Value) + return nil + } + + // TODO: it appears the cloudflare provider expects a non-empty ID to + // mean that a record should be created rather than updated. This behavior + // means that we clear the ID to force an update by the zone name. + // + // See: https://github.com/libdns/cloudflare/issues/1. + rec.ID = "" + rec.Value = record.Value + updated = append(updated, rec) + break + } + if len(updated) == 0 { + updated = []libdns.Record{record} + } + + if _, err := provider.SetRecords(ctx, zone, updated); err != nil { + return err + } + return nil +} diff --git a/internal/dyndns/dyndns_test.go b/internal/dyndns/dyndns_test.go new file mode 100644 index 0000000..bb69381 --- /dev/null +++ b/internal/dyndns/dyndns_test.go @@ -0,0 +1,86 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dyndns + +import ( + "context" + "log" + "testing" + "time" + + "github.com/libdns/libdns" +) + +func TestUpdate(t *testing.T) { + ctx, canc := context.WithCancel(context.Background()) + defer canc() + + const zone = "zekjur.net" + provider := &testProvider{ + getRecords: func(ctx context.Context, zone string) ([]libdns.Record, error) { + return []libdns.Record{ + { + ID: "rec1", + Name: "dyndns.zekjur.net", + Type: "A", + TTL: 5 * time.Minute, + Value: "127.0.0.3", + }, + + { + ID: "rec1", + Name: "unrelated.zekjur.net", + Type: "A", + TTL: 5 * time.Minute, + Value: "127.0.0.42", + }, + }, nil + }, + setRecords: func(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) { + log.Printf("setRecords(zone=%q): %+v", zone, recs) + // Don't care about return records? + return nil, nil + }, + } + update := libdns.Record{ + Name: "dyndns.zekjur.net", + Type: "A", + Value: "127.0.0.4", + TTL: 5 * time.Minute, + } + if err := Update(ctx, zone, update, provider); err != nil { + t.Fatalf("Update = %v", err) + } + + // TODO: add a test to verify setRecords is not called + // when no updates are necessary. +} + +var ( + _ RecordGetterSetter = &testProvider{} +) + +type testProvider struct { + getRecords func(ctx context.Context, zone string) ([]libdns.Record, error) + setRecords func(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) +} + +func (p *testProvider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { + return p.getRecords(ctx, zone) +} + +func (p *testProvider) SetRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) { + return p.setRecords(ctx, zone, recs) +}