├── .github ├── workflow-scripts │ └── check-gofmt.sh └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── addr.go ├── dns.go ├── go.mod ├── go.sum ├── main.go ├── netlink.go └── update.go /.github/workflow-scripts/check-gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ue 4 | 5 | fmt_list="$(gofmt -l "$@")" 6 | 7 | if [ -n "$fmt_list" ]; then 8 | echo "Check gofmt failed: " >&2 9 | 10 | for file in "$fmt_list"; do 11 | echo "::error file=${file},title=gofmt::gofmt check failed" 12 | echo "\t$file" >&2 13 | done 14 | exit 1 15 | fi 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Go build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'master' 7 | push: 8 | branches: 9 | - 'master' 10 | 11 | jobs: 12 | go-build: 13 | runs-on: ubuntu-20.04 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | 19 | - name: Setup Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.15 23 | cache: true 24 | 25 | - name: Download dependencies 26 | run: go mod download 27 | 28 | - name: Check gofmt 29 | run: .github/workflow-scripts/check-gofmt.sh . 30 | 31 | - name: Build 32 | run: go build -v 33 | 34 | - name: Test 35 | run: go test -v 36 | 37 | - name: Vet 38 | run: go vet 39 | 40 | - name: Upload 41 | uses: actions/upload-artifact@v3 42 | with: 43 | name: go-build 44 | path: ./go-nsupdate 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swo 2 | .*.swp 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Tero Marttila 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go build](https://github.com/SpComb/go-nsupdate/actions/workflows/build.yml/badge.svg)](https://github.com/SpComb/go-nsupdate/actions/workflows/build.yml) 2 | 3 | # go-nsupdate 4 | Update dynamic DNS records from netlink. 5 | 6 | `go-nsupdate` reads interface addresses from netlink, updating on `ip link up/down` and `ip addr add/del` events. 7 | 8 | The set of active interface IPv4/IPv6 addresses is used to send DNS `UPDATE` requests to the primary NS for a DNS zone. 9 | 10 | The DNS update requests are retried in the background (XXX: currently blocks for 10s on each query attempt). 11 | 12 | ## Install 13 | 14 | go get github.com/SpComb/go-nsupdate 15 | 16 | ## Usage 17 | 18 | Usage: 19 | go-nsupdate [OPTIONS] [Name] 20 | 21 | Application Options: 22 | -v, --verbose 23 | --watch Watch for interface changes 24 | -i, --interface=IFACE Use address from interface 25 | --interface-family=ipv4|ipv6|all Limit to interface addreses of given family 26 | --server=HOST[:PORT] Server for UPDATE query, default is discovered from zone SOA 27 | --timeout=DURATION Timeout for sever queries (default: 10s) 28 | --retry=DURATION Retry interval, increased for each retry attempt (default: 30s) 29 | --tsig-name=FQDN 30 | --tsig-secret=BASE-64 base64-encoded shared TSIG secret key [$TSIG_SECRET] 31 | --tsig-algorithm=hmac-{md5,sha1,sha256,sha512} 32 | --zone=FQDN Zone to update, default is derived from name 33 | --ttl=DURATION TTL for updated records (default: 60s) 34 | 35 | Help Options: 36 | -h, --help Show this help message 37 | 38 | Arguments: 39 | Name: DNS Name to update 40 | 41 | 42 | ## Example 43 | 44 | # Using a generated TSIG key: 45 | # TSIG_SECRET=$(python -c 'import os; print os.urandom(32).encode("base64")') 46 | 47 | TSIG_SECRET=... go-nsupdate --interface=vlan-wan --tsig-algorithm=hmac-sha256 yzzrt.dyn.qmsk.net --watch 48 | 2016/06/19 21:29:33 discover server=zovoweix.qmsk.net. 49 | 2016/06/19 21:29:33 using TSIG: yzzrt.dyn.qmsk.net (algo=hmac-sha256.) 50 | 2016/06/19 21:29:33 AddrSet iface=vlan-wan: up 2001:14ba:400:0:7:1449:a833:f11f 51 | 2016/06/19 21:29:33 update... 52 | 2016/06/19 21:29:33 update query: 53 | ;; opcode: UPDATE, status: NOERROR, id: 61616 54 | ;; flags:; QUERY: 1, ANSWER: 0, AUTHORITY: 2, ADDITIONAL: 1 55 | 56 | ;; QUESTION SECTION: 57 | ;dyn.qmsk.net. IN SOA 58 | 59 | ;; AUTHORITY SECTION: 60 | yzzrt.dyn.qmsk.net. 0 ANY ANY 61 | yzzrt.dyn.qmsk.net. 60 IN AAAA 2001:14ba:400:0:7:1449:a833:f11f 62 | 63 | ;; ADDITIONAL SECTION: 64 | 65 | ;; TSIG PSEUDOSECTION: 66 | yzzrt.dyn.qmsk.net. 0 ANY TSIG hmac-sha256. 20160619182933 300 0 61616 0 0 67 | 2016/06/19 21:29:33 update answer: 68 | ;; opcode: UPDATE, status: NOERROR, id: 61616 69 | ;; flags: qr; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1 70 | 71 | ;; QUESTION SECTION: 72 | ;dyn.qmsk.net. IN SOA 73 | 74 | ;; ADDITIONAL SECTION: 75 | 76 | ;; TSIG PSEUDOSECTION: 77 | yzzrt.dyn.qmsk.net. 0 ANY TSIG hmac-sha256. 20160619182933 300 32 E083433B2893B2036B24549E3537C6E17B858019B9862DC2EB9EDFB959D03232 61616 0 0 78 | 2016/06/19 21:46:34 AddrSet iface=vlan-wan: up 213.243.178.191 79 | 2016/06/19 21:46:34 addrs update... 80 | 2016/06/19 21:46:34 update... 81 | 2016/06/19 21:46:34 update query: 82 | ;; opcode: UPDATE, status: NOERROR, id: 30973 83 | ;; flags:; QUERY: 1, ANSWER: 0, AUTHORITY: 3, ADDITIONAL: 1 84 | 85 | ;; QUESTION SECTION: 86 | ;dyn.qmsk.net. IN SOA 87 | 88 | ;; AUTHORITY SECTION: 89 | yzzrt.dyn.qmsk.net. 0 ANY ANY 90 | yzzrt.dyn.qmsk.net. 60 IN AAAA 2001:14ba:400:0:7:1449:a833:f11f 91 | yzzrt.dyn.qmsk.net. 60 IN A 213.243.178.191 92 | 93 | ;; ADDITIONAL SECTION: 94 | 95 | ;; TSIG PSEUDOSECTION: 96 | yzzrt.dyn.qmsk.net. 0 ANY TSIG hmac-sha256. 20160619184634 300 0 30973 0 0 97 | 2016/06/19 21:46:35 update answer: 98 | ;; opcode: UPDATE, status: NOERROR, id: 30973 99 | ;; flags: qr; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1 100 | 101 | ;; QUESTION SECTION: 102 | ;dyn.qmsk.net. IN SOA 103 | 104 | ;; ADDITIONAL SECTION: 105 | 106 | ;; TSIG PSEUDOSECTION: 107 | yzzrt.dyn.qmsk.net. 0 ANY TSIG hmac-sha256. 20160619184635 300 32 1F7F1EB8A3D5213EAAA163AE78388D48911495A0F3E2870688F3338160905EC9 30973 0 108 | -------------------------------------------------------------------------------- /addr.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "net" 8 | 9 | "github.com/vishvananda/netlink" 10 | "github.com/vishvananda/netlink/nl" 11 | ) 12 | 13 | type AddrSet struct { 14 | linkAttrs netlink.LinkAttrs 15 | linkChan chan netlink.LinkUpdate 16 | addrChan chan netlink.AddrUpdate 17 | family Family 18 | 19 | addrs map[string]net.IP 20 | } 21 | 22 | func (addrs *AddrSet) String() string { 23 | return fmt.Sprintf("AddrSet iface=%v", addrs.linkAttrs.Name) 24 | } 25 | 26 | func (addrs *AddrSet) testFlag(flag net.Flags) bool { 27 | return addrs.linkAttrs.Flags&flag != 0 28 | } 29 | 30 | func (addrs *AddrSet) Up() bool { 31 | return addrs.testFlag(net.FlagUp) 32 | } 33 | 34 | func InterfaceAddrs(iface string, family Family) (*AddrSet, error) { 35 | var addrs AddrSet 36 | 37 | link, err := netlink.LinkByName(iface) 38 | if err != nil { 39 | return nil, fmt.Errorf("netlink.LinkByName %v: %v", iface, err) 40 | } else { 41 | addrs.linkAttrs = *link.Attrs() 42 | addrs.family = family 43 | } 44 | 45 | // list 46 | if addrList, err := netlink.AddrList(link, int(family)); err != nil { 47 | return nil, fmt.Errorf("netlink.AddrList %v: %v", link, err) 48 | } else { 49 | addrs.addrs = make(map[string]net.IP) 50 | 51 | for _, addr := range addrList { 52 | addrs.updateAddr(addr, true) 53 | } 54 | } 55 | 56 | // update 57 | addrs.linkChan = make(chan netlink.LinkUpdate) 58 | addrs.addrChan = make(chan netlink.AddrUpdate) 59 | 60 | if err := netlink.LinkSubscribe(addrs.linkChan, nil); err != nil { 61 | return nil, fmt.Errorf("netlink.LinkSubscribe: %v", err) 62 | } 63 | 64 | if err := netlink.AddrSubscribe(addrs.addrChan, nil); err != nil { 65 | return nil, fmt.Errorf("netlink.AddrSubscribe: %v", err) 66 | } 67 | 68 | return &addrs, nil 69 | } 70 | 71 | func (addrs *AddrSet) Read() error { 72 | for { 73 | select { 74 | case linkUpdate, ok := <-addrs.linkChan: 75 | if !ok { 76 | return io.EOF 77 | } 78 | 79 | linkAttrs := linkUpdate.Attrs() 80 | 81 | if linkAttrs.Index != addrs.linkAttrs.Index { 82 | continue 83 | } 84 | 85 | // update state 86 | addrs.updateLink(*linkAttrs) 87 | 88 | case addrUpdate, ok := <-addrs.addrChan: 89 | if !ok { 90 | return io.EOF 91 | } 92 | 93 | if addrUpdate.LinkIndex != addrs.linkAttrs.Index { 94 | continue 95 | } 96 | 97 | addrUpdateFamily := Family(nl.GetIPFamily(addrUpdate.LinkAddress.IP)) 98 | if addrs.family != netlink.FAMILY_ALL && addrUpdateFamily != addrs.family { 99 | continue 100 | } 101 | 102 | // XXX: scope and other filters? 103 | addrs.updateAddr(netlink.Addr{ 104 | IPNet: &addrUpdate.LinkAddress, 105 | Scope: addrUpdate.Scope}, addrUpdate.NewAddr) 106 | 107 | return nil 108 | } 109 | } 110 | } 111 | 112 | // Update state for address 113 | func (addrs *AddrSet) updateAddr(addr netlink.Addr, up bool) { 114 | if addr.Scope >= int(netlink.SCOPE_LINK) { 115 | return 116 | } 117 | 118 | ip := addr.IPNet.IP 119 | 120 | if up { 121 | log.Printf("%v: up %v", addrs, ip) 122 | 123 | addrs.addrs[ip.String()] = ip 124 | 125 | } else { 126 | log.Printf("%v: down %v", addrs, ip) 127 | 128 | delete(addrs.addrs, ip.String()) 129 | } 130 | } 131 | 132 | func (addrs *AddrSet) updateLink(linkAttrs netlink.LinkAttrs) { 133 | addrs.linkAttrs = linkAttrs 134 | 135 | if !addrs.Up() { 136 | log.Printf("%v: down", addrs) 137 | } 138 | } 139 | 140 | func (addrs *AddrSet) Each(visitFunc func(net.IP)) { 141 | if !addrs.Up() { 142 | // link down has no up addrs 143 | return 144 | } 145 | 146 | for _, ip := range addrs.addrs { 147 | visitFunc(ip) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /dns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/miekg/dns" 6 | "log" 7 | "net" 8 | "time" 9 | ) 10 | 11 | const TSIG_FUDGE_SECONDS = 300 12 | 13 | type TSIGAlgorithm string 14 | 15 | func (t *TSIGAlgorithm) UnmarshalFlag(value string) error { 16 | switch value { 17 | case "hmac-md5", "md5": 18 | *t = dns.HmacMD5 19 | case "hmac-sha1", "sha1": 20 | *t = dns.HmacSHA1 21 | case "hmac-sha256", "sha256": 22 | *t = dns.HmacSHA256 23 | case "hmac-sha512", "sha512": 24 | *t = dns.HmacSHA512 25 | default: 26 | return fmt.Errorf("Invalid --tsig-algorithm=%v", value) 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func query(query *dns.Msg) (*dns.Msg, error) { 33 | clientConfig, err := dns.ClientConfigFromFile("/etc/resolv.conf") 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | timeout := time.Duration(clientConfig.Timeout * int(time.Second)) 39 | 40 | var client = new(dns.Client) 41 | 42 | client.DialTimeout = timeout 43 | client.ReadTimeout = timeout 44 | client.WriteTimeout = timeout 45 | 46 | for _, server := range clientConfig.Servers { 47 | addr := net.JoinHostPort(server, "53") 48 | 49 | if answer, _, err := client.Exchange(query, addr); err != nil { 50 | log.Printf("query %v: %v", server, err) 51 | continue 52 | } else { 53 | return answer, nil 54 | } 55 | } 56 | 57 | return nil, fmt.Errorf("DNS query failed") 58 | } 59 | 60 | // Discover likely master NS for zone 61 | func discoverZoneServer(zone string) (string, error) { 62 | var q = new(dns.Msg) 63 | 64 | q.SetQuestion(zone, dns.TypeSOA) 65 | 66 | r, err := query(q) 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | for _, rr := range r.Answer { 72 | if soa, ok := rr.(*dns.SOA); ok { 73 | return soa.Ns, nil 74 | } 75 | } 76 | 77 | return "", fmt.Errorf("No SOA response") 78 | } 79 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/SpComb/go-nsupdate 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/jessevdk/go-flags v1.5.0 7 | github.com/miekg/dns v1.1.50 8 | github.com/vishvananda/netlink v1.1.0 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= 2 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 3 | github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 4 | github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 5 | github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= 6 | github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= 7 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k= 8 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= 9 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 10 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 11 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 12 | golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= 13 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 14 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 15 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 16 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 17 | golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 h1:4CSI6oo7cOjJKajidEljs9h+uP0rRZBPPPhcCbj5mw8= 18 | golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 19 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 21 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 23 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 30 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 31 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 33 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 34 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 35 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 36 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 37 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 38 | golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2 h1:BonxutuHCTL0rBDnZlKjpGIQFTjyUVTexFOdWkB6Fg0= 39 | golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 40 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 41 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 42 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 43 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 44 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/jessevdk/go-flags" 5 | "log" 6 | "os" 7 | "time" 8 | ) 9 | 10 | type Options struct { 11 | Verbose bool `long:"verbose" short:"v"` 12 | Watch bool `long:"watch" description:"Watch for interface changes"` 13 | 14 | // Netlink Interface 15 | Interface string `long:"interface" short:"i" value-name:"IFACE" description:"Use address from interface"` 16 | InterfaceFamily Family `long:"interface-family" value-name:"ipv4|ipv6|all" description:"Limit to interface addreses of given family"` 17 | 18 | // DNS Update 19 | Server string `long:"server" value-name:"HOST[:PORT]" description:"Server for UPDATE query, default is discovered from zone SOA"` 20 | Timeout time.Duration `long:"timeout" value-name:"DURATION" default:"10s" description:"Timeout for sever queries"` 21 | Retry time.Duration `long:"retry" value-name:"DURATION" default:"30s" description:"Retry interval, increased for each retry attempt"` 22 | TSIGName string `long:"tsig-name" value-name:"FQDN"` 23 | TSIGSecret string `long:"tsig-secret" value-name:"BASE-64" env:"TSIG_SECRET" description:"base64-encoded shared TSIG secret key"` 24 | TSIGAlgorithm TSIGAlgorithm `long:"tsig-algorithm" value-name:"hmac-{md5,sha1,sha256,sha512}" default:"hmac-sha1."` 25 | Zone string `long:"zone" value-name:"FQDN" description:"Zone to update, default is derived from name"` 26 | TTL time.Duration `long:"ttl" value-name:"DURATION" default:"60s" description:"TTL for updated records"` 27 | 28 | Args struct { 29 | Name string `value-name:"FQDN" description:"DNS Name to update"` 30 | } `positional-args:"yes"` 31 | } 32 | 33 | func main() { 34 | var options Options 35 | 36 | if _, err := flags.Parse(&options); err != nil { 37 | log.Fatalf("flags.Parse: %v", err) 38 | os.Exit(1) 39 | } 40 | 41 | var update = Update{ 42 | ttl: int(options.TTL.Seconds()), 43 | timeout: options.Timeout, 44 | retry: options.Retry, 45 | verbose: options.Verbose, 46 | } 47 | 48 | if err := update.Init(options.Args.Name, options.Zone, options.Server); err != nil { 49 | log.Fatalf("init: %v", err) 50 | } 51 | 52 | if options.TSIGSecret != "" { 53 | var name = options.TSIGName 54 | 55 | if name == "" { 56 | name = options.Args.Name 57 | } 58 | 59 | log.Printf("using TSIG: %v (algo=%v)", name, options.TSIGAlgorithm) 60 | 61 | update.InitTSIG(name, options.TSIGSecret, options.TSIGAlgorithm) 62 | } 63 | 64 | // addrs 65 | addrs, err := InterfaceAddrs(options.Interface, options.InterfaceFamily) 66 | if err != nil { 67 | log.Fatalf("addrs scan: %v", err) 68 | } 69 | 70 | // update 71 | update.Start() 72 | 73 | for { 74 | log.Printf("update...") 75 | 76 | if err := update.Update(addrs); err != nil { 77 | log.Fatalf("update: %v", err) 78 | } 79 | 80 | if !options.Watch { 81 | break 82 | } 83 | 84 | if err := addrs.Read(); err != nil { 85 | log.Fatalf("addrs read: %v", err) 86 | } else { 87 | log.Printf("addrs update...") 88 | } 89 | } 90 | 91 | log.Printf("wait...") 92 | 93 | if err := update.Done(); err != nil { 94 | log.Printf("update done: %v", err) 95 | } else { 96 | log.Printf("update done") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /netlink.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/vishvananda/netlink" 6 | ) 7 | 8 | // zero value is unspec=all 9 | type Family int 10 | 11 | func (f *Family) UnmarshalFlag(value string) error { 12 | switch value { 13 | case "unspec", "all": 14 | *f = netlink.FAMILY_ALL 15 | case "inet", "ipv4": 16 | *f = netlink.FAMILY_V4 17 | case "inet6", "ipv6": 18 | *f = netlink.FAMILY_V6 19 | default: 20 | return fmt.Errorf("Invalid --family=%v", value) 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/miekg/dns" 6 | "log" 7 | "net" 8 | "time" 9 | ) 10 | 11 | type updateState struct { 12 | updateZone string 13 | removeNames []dns.RR 14 | inserts []dns.RR 15 | } 16 | 17 | type Update struct { 18 | ttl int 19 | timeout time.Duration 20 | retry time.Duration 21 | verbose bool 22 | 23 | zone string 24 | name string 25 | 26 | tsig map[string]string 27 | tsigAlgo TSIGAlgorithm 28 | server string 29 | 30 | updateChan chan updateState 31 | doneChan chan error 32 | } 33 | 34 | func (u *Update) Init(name string, zone string, server string) error { 35 | if name == "" { 36 | return fmt.Errorf("Missing name") 37 | } else { 38 | u.name = dns.Fqdn(name) 39 | } 40 | 41 | if zone == "" { 42 | // guess 43 | if labels := dns.Split(u.name); len(labels) > 1 { 44 | u.zone = u.name[labels[1]:] 45 | } else { 46 | return fmt.Errorf("Missing zone") 47 | } 48 | } else { 49 | u.zone = dns.Fqdn(zone) 50 | } 51 | 52 | if server == "" { 53 | if server, err := discoverZoneServer(u.zone); err != nil { 54 | return fmt.Errorf("Failed to discver server") 55 | } else { 56 | log.Printf("discover server=%v", server) 57 | 58 | u.server = net.JoinHostPort(server, "53") 59 | } 60 | } else { 61 | if _, _, err := net.SplitHostPort(server); err == nil { 62 | u.server = server 63 | } else { 64 | u.server = net.JoinHostPort(server, "53") 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func (u *Update) InitTSIG(name string, secret string, algo TSIGAlgorithm) { 72 | u.tsig = map[string]string{dns.Fqdn(name): secret} 73 | u.tsigAlgo = algo 74 | } 75 | 76 | func (u *Update) buildAddr(ip net.IP) dns.RR { 77 | if ip4 := ip.To4(); ip4 != nil { 78 | return &dns.A{ 79 | Hdr: dns.RR_Header{Name: u.name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(u.ttl)}, 80 | A: ip4, 81 | } 82 | } 83 | 84 | if ip6 := ip.To16(); ip6 != nil { 85 | return &dns.AAAA{ 86 | Hdr: dns.RR_Header{Name: u.name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: uint32(u.ttl)}, 87 | AAAA: ip6, 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | func (u *Update) buildState(addrs *AddrSet) (state updateState, err error) { 94 | state.updateZone = u.zone 95 | state.removeNames = []dns.RR{ 96 | &dns.RR_Header{Name: u.name}, 97 | } 98 | 99 | addrs.Each(func(ip net.IP) { 100 | state.inserts = append(state.inserts, u.buildAddr(ip)) 101 | }) 102 | 103 | return 104 | } 105 | func (u *Update) buildQuery(state updateState) *dns.Msg { 106 | var msg = new(dns.Msg) 107 | 108 | msg.SetUpdate(state.updateZone) 109 | msg.RemoveName(state.removeNames) 110 | msg.Insert(state.inserts) 111 | 112 | if u.tsig != nil { 113 | for keyName, _ := range u.tsig { 114 | msg.SetTsig(keyName, string(u.tsigAlgo), TSIG_FUDGE_SECONDS, time.Now().Unix()) 115 | } 116 | } 117 | 118 | return msg 119 | } 120 | 121 | func (u *Update) query(msg *dns.Msg) (*dns.Msg, error) { 122 | var client = new(dns.Client) 123 | 124 | client.DialTimeout = u.timeout 125 | client.ReadTimeout = u.timeout 126 | client.WriteTimeout = u.timeout 127 | 128 | if u.tsig != nil { 129 | client.TsigSecret = u.tsig 130 | } 131 | 132 | msg, _, err := client.Exchange(msg, u.server) 133 | 134 | if err != nil { 135 | return msg, fmt.Errorf("dns:Client.Exchange ... %v: %v", u.server, err) 136 | } 137 | 138 | if msg.Rcode == dns.RcodeSuccess { 139 | return msg, nil 140 | } else { 141 | return msg, fmt.Errorf("rcode=%v", dns.RcodeToString[msg.Rcode]) 142 | } 143 | } 144 | 145 | func (u *Update) update(state updateState) error { 146 | q := u.buildQuery(state) 147 | 148 | if u.verbose { 149 | log.Printf("update query:\n%v", q) 150 | } else { 151 | log.Printf("update query...") 152 | } 153 | 154 | r, err := u.query(q) 155 | 156 | if err != nil { 157 | return err 158 | } 159 | 160 | if u.verbose { 161 | log.Printf("update answer:\n%v", r) 162 | } else { 163 | log.Printf("update answer") 164 | } 165 | 166 | return nil 167 | } 168 | 169 | func (u *Update) run() { 170 | var state updateState 171 | var retry = 0 172 | var retryTimer = time.NewTimer(time.Duration(0)) 173 | var updateChan = u.updateChan 174 | var updateError error 175 | 176 | defer func() { u.doneChan <- updateError }() 177 | 178 | for { 179 | select { 180 | case updateState, running := <-updateChan: 181 | if running { 182 | // Update() called 183 | state = updateState 184 | 185 | } else if retry > 0 { 186 | // Done() called, but still waiting for retry... 187 | updateChan = nil 188 | continue 189 | 190 | } else { 191 | // Done() called, no retrys or updates remaining 192 | return 193 | } 194 | 195 | case <-retryTimer.C: 196 | if retry == 0 { 197 | // spurious timer event.. 198 | continue 199 | } 200 | 201 | // trigger retry 202 | } 203 | 204 | if err := u.update(state); err != nil { 205 | log.Printf("update (retry=%v) error: %v", retry, err) 206 | 207 | updateError = err 208 | retry++ 209 | } else { 210 | // success 211 | updateError = nil 212 | retry = 0 213 | } 214 | 215 | if retry == 0 && updateChan == nil { 216 | // done, no more updates 217 | return 218 | 219 | } else if retry == 0 { 220 | // wait for next update 221 | retryTimer.Stop() 222 | 223 | } else { 224 | retryTimeout := time.Duration(retry * int(u.retry)) 225 | 226 | // wait for next retry 227 | // TODO: exponential backoff? 228 | retryTimer.Reset(retryTimeout) 229 | 230 | log.Printf("update retry in %v...", retryTimeout) 231 | } 232 | } 233 | } 234 | 235 | func (u *Update) Start() { 236 | u.updateChan = make(chan updateState) 237 | 238 | go u.run() 239 | } 240 | 241 | func (u *Update) Update(addrs *AddrSet) error { 242 | if state, err := u.buildState(addrs); err != nil { 243 | return err 244 | } else { 245 | u.updateChan <- state 246 | } 247 | 248 | return nil 249 | } 250 | 251 | func (u *Update) Done() error { 252 | u.doneChan = make(chan error) 253 | 254 | close(u.updateChan) 255 | 256 | return <-u.doneChan 257 | } 258 | --------------------------------------------------------------------------------