Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhancement: added support for multiple subdomains #29

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ docker run \
-e ZONE_NAME=example.com \
-e API_TOKEN=my-secret-api-token \
-e RECORD_TYPE=A \
-e RECORD_NAME_VPN=vpn \
-e RECORD_NAME_VPN_TTL=600 \
-e RECORD_NAME_WEBSITE=www \
-e RECORD_NAME_WEBSITE_TTL=1337 \
kutzilla/hetzner-ddns
```

Expand All @@ -39,9 +43,11 @@ docker run kutzilla/hetzner-ddns example.com my-secret-api-token A

## Optional Parameters

* `-e RECORD_NAME` - The name of the DNS-record that DDNS updates should be applied to. This could be `sub` if you like to update the subdomain `sub.example.com` of `example.com`. The default value is `@`.
* `-e RECORD_NAME` - The name of the DNS-record that DDNS updates should be applied to. This could be `sub` if you like to update the subdomain `sub.example.com` of `example.com`. The default value is `@` If you want to update multiple Records you can use a pattern. The pattern which can be used is `RECORD_NAME_<NAME>` e.g. `RECORD_NAME_EXAMPLE`, `RECORD_NAME` will be ignored the multi domain approach is used.
* `-e CRON_EXPRESSION` - The cron expression of the DDNS update interval. The default is every 5 minutes - `*/5 * * * *`.
* `-e TTL` - The TTL (Time To Live) value specifies how long the record is valid before the nameservers are prompted to reload the zone file. The default is `86400`.
* `-e RECORD_NAME_<NAME>_TTL` - In case you have multiple records, you can specify the TTL per record referenced by `<NAME>` for example `RECORD_NAME_EXAMPLE_TTL`, if no TTL is specified the TTL will be `86400`. The `TTL` argument will be ignored if this parameter is used.


## Build

Expand Down
6 changes: 2 additions & 4 deletions cmd/hetzner-ddns/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@ func main() {
}

ddnsParameter := ddns.Parameter{
ZoneName: dynDnsConf.DnsConf.ZoneName,
RecordName: dynDnsConf.RecordConf.RecordName,
RecordType: dynDnsConf.RecordConf.RecordType,
TTL: dynDnsConf.RecordConf.TTL,
ZoneName: dynDnsConf.DnsConf.ZoneName,
Records: ddns.ConvertFromConfig(dynDnsConf.RecordConf),
}

ddnsService := ddns.Service{
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.17
require (
github.com/namsral/flag v1.7.4-pre
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.8.3
github.com/stretchr/testify v1.8.4
)

require (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
80 changes: 63 additions & 17 deletions pkg/conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package conf

import (
"fmt"
"log"
"os"
"strings"

"github.com/namsral/flag"
)
Expand All @@ -20,9 +22,11 @@ const (
DescRecordType = "The record type of your zone. If your zone uses an IPv4 address use `A`. Use `AAAA` if it uses an IPv6 address."
DescRecordName = "The name of the DNS-record that DDNS updates should be applied to. This could be `sub` if you like to update the subdomain `sub.example.com` of `example.com`. The default value is `@`"
DescCronExpression = "The cron expression of the DDNS update interval. The default is every 5 minutes - `*/5 * * * *`"
DescTimeToLive = "Time to live of the recourd"
DescTimeToLive = "Time to live of the record"

DefaultRecordKey = "default"
DefaultRecordName = "@"
DefaultRecordType = "A"
DefaultCronExpression = "*/5 * * * *"
DefaultTimeToLive = 86400

Expand All @@ -31,9 +35,11 @@ const (
IPv6RecordType = "AAAA"
)

type RecordConfig map[string]*RecordConf

type DynDnsConf struct {
DnsConf DnsConf
RecordConf RecordConf
RecordConf map[string]*RecordConf
ProviderConf ProviderConf
CronConf CronConf
}
Expand Down Expand Up @@ -65,20 +71,61 @@ func (e *ArgumentMissingError) Error() string {
return "The mandatory argument " + e.argumentName + " is missing"
}

func setupRecordConfig(records RecordConfig) {
useDefaultConfig := true
envPrefix := fmt.Sprintf("%s_", EnvRecordName)

for _, envRecord := range os.Environ() {
if strings.HasPrefix(envRecord, envPrefix) {
useDefaultConfig = false
envKey := strings.Split(envRecord, "=")[0]

if strings.HasSuffix(envKey, "_TTL") {
continue
}

if _, exists := records[envKey]; exists {
continue
}

var record = &RecordConf{
RecordType: DefaultRecordType, // assume its ipv4, fix later after arg parse if not
}
records[envKey] = record

flag.StringVar(&record.RecordName, envKey, DefaultRecordName, DescRecordName)
flag.IntVar(&record.TTL, fmt.Sprintf("%s_TTL", envKey), DefaultTimeToLive, DescTimeToLive)
}
}

if useDefaultConfig {
var record = &RecordConf{
RecordType: DefaultRecordType,
}
flag.StringVar(&record.RecordName, EnvRecordName, DefaultRecordName, DescRecordName)
flag.IntVar(&record.TTL, EnvTimeToLive, DefaultTimeToLive, DescTimeToLive)
records[DefaultRecordKey] = record
}
}

func setRecordType(records RecordConfig, recordType string) {
for _, record := range records {
record.RecordType = recordType
}
}

func Read() DynDnsConf {
// Mandatory flags
var zoneName, apiToken, recordType string
flag.StringVar(&zoneName, EnvZoneName, zoneName, DescZoneName)
flag.StringVar(&apiToken, EnvApiToken, apiToken, DescApiToken)
flag.StringVar(&recordType, EnvRecordType, recordType, DescRecordType)

// Optional flags
var recordName = DefaultRecordName
flag.StringVar(&recordName, EnvRecordName, recordName, DescRecordName)
records := make(map[string]*RecordConf)
setupRecordConfig(records)

var cronExpression = DefaultCronExpression
flag.StringVar(&cronExpression, EnvCronExpression, cronExpression, DescCronExpression)
var ttl = DefaultTimeToLive
flag.IntVar(&ttl, EnvTimeToLive, ttl, DescTimeToLive)

// Parse flags
flag.Parse()
Expand All @@ -87,15 +134,12 @@ func Read() DynDnsConf {
var ipVersion = IPv4
if recordType == IPv6RecordType {
ipVersion = IPv6
setRecordType(records, recordType)
}

dynDnsConf := DynDnsConf{
DnsConf: DnsConf{ApiToken: apiToken, ZoneName: zoneName},
RecordConf: RecordConf{
RecordType: recordType,
RecordName: recordName,
TTL: ttl,
},
DnsConf: DnsConf{ApiToken: apiToken, ZoneName: zoneName},
RecordConf: records,
ProviderConf: ProviderConf{
IpVersion: ipVersion,
},
Expand All @@ -104,7 +148,7 @@ func Read() DynDnsConf {

validatedConf, err := validate(dynDnsConf)
if err != nil {
fmt.Println(err.Error())
log.Println(err.Error())
os.Exit(1)
}

Expand All @@ -131,9 +175,11 @@ func validate(dynDnsConf DynDnsConf) (DynDnsConf, error) {
}

// Check record type
if dynDnsConf.RecordConf.RecordType == "" {
return dynDnsConf, &ArgumentMissingError{
argumentName: EnvRecordType,
for _, record := range dynDnsConf.RecordConf {
if record.RecordType == "" {
return dynDnsConf, &ArgumentMissingError{
argumentName: EnvRecordType,
}
}
}

Expand Down
25 changes: 18 additions & 7 deletions pkg/conf/conf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,34 @@ import (
"github.com/stretchr/testify/assert"
)

func TestReadOptionalEnvs(t *testing.T) {
func TestReadMultipleDomains(t *testing.T) {
assert := assert.New(t)

os.Setenv(EnvZoneName, "example.com")
os.Setenv(EnvApiToken, "abcdefghi1234567890")
os.Setenv(EnvRecordType, "A")
os.Setenv(EnvRecordName, "www")
os.Setenv(EnvTimeToLive, "43200")

os.Setenv("RECORD_NAME_TEST1", "test1")
os.Setenv("RECORD_NAME_TEST1_TTL", "4711")

os.Setenv("RECORD_NAME_TEST2", "test2")
os.Setenv("RECORD_NAME_TEST2_TTL", "1337")

os.Setenv(EnvCronExpression, "*/10 * * * *")

dynDnsConf := Read()
// Check optional args
assert.NotEqual(DefaultRecordName, dynDnsConf.RecordConf.RecordName)
assert.Equal("www", dynDnsConf.RecordConf.RecordName)
assert.NotEqual(DefaultRecordName, dynDnsConf.RecordConf["RECORD_NAME_TEST1"].RecordName)
assert.Equal("test1", dynDnsConf.RecordConf["RECORD_NAME_TEST1"].RecordName)

assert.NotEqual(DefaultTimeToLive, dynDnsConf.RecordConf["RECORD_NAME_TEST1"].TTL)
assert.Equal(4711, dynDnsConf.RecordConf["RECORD_NAME_TEST1"].TTL)

assert.NotEqual(DefaultRecordName, dynDnsConf.RecordConf["RECORD_NAME_TEST2"].RecordName)
assert.Equal("test2", dynDnsConf.RecordConf["RECORD_NAME_TEST2"].RecordName)

assert.NotEqual(DefaultTimeToLive, dynDnsConf.RecordConf.TTL)
assert.Equal(43200, dynDnsConf.RecordConf.TTL)
assert.NotEqual(DefaultTimeToLive, dynDnsConf.RecordConf["RECORD_NAME_TEST2"].TTL)
assert.Equal(1337, dynDnsConf.RecordConf["RECORD_NAME_TEST2"].TTL)

assert.NotEqual(DefaultCronExpression, dynDnsConf.CronConf.CronExpression)
assert.Equal("*/10 * * * *", dynDnsConf.CronConf.CronExpression)
Expand Down
86 changes: 53 additions & 33 deletions pkg/ddns/ddns.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package ddns

import (
"fmt"
"log"
"matthias-kutz.com/hetzner-ddns/pkg/conf"

"matthias-kutz.com/hetzner-ddns/pkg/dns"
"matthias-kutz.com/hetzner-ddns/pkg/ip"
Expand All @@ -14,64 +15,83 @@ type Service struct {
}

type Parameter struct {
ZoneName string
ZoneName string
Records []Record
}

type Record struct {
RecordName string
RecordType string
TTL int
}

func ConvertFromConfig(config conf.RecordConfig) []Record {
var recordList []Record

for _, recordConf := range config {
recordList = append(recordList, Record{
RecordName: recordConf.RecordName,
RecordType: recordConf.RecordType,
TTL: recordConf.TTL,
})
}

return recordList
}

func (service Service) Run() {

// Check if online
if !service.IpProvider.IsOnline() {
fmt.Println("No connection to IP provider")
log.Println("No connection to IP provider")
return
}

// Request IP from ip provider
ip, err := service.IpProvider.Request()
if err != nil {
fmt.Println(err)
log.Println(err)
return
}

// Request zones from dns provider
zone, err := service.DnsProvider.RequestZone(service.Parameter.ZoneName)
if err != nil {
fmt.Println(err)
log.Println(err)
return
}

// Request record from dns provider
record, err := service.DnsProvider.RequestRecord(zone, service.Parameter.RecordName, service.Parameter.RecordType)
if err != nil {
fmt.Println(err)
return
}
for _, dnsRecord := range service.Parameter.Records {
// Request record from dns provider
record, err := service.DnsProvider.RequestRecord(zone, dnsRecord.RecordName, dnsRecord.RecordType)
if err != nil {
log.Println(err)
continue
}

// Create record with IP from ip provider to update record at the dns provider
updateRecord := dns.Record{
Id: record.Id,
ZoneId: zone.Id,
Name: service.Parameter.RecordName,
Type: service.Parameter.RecordType,
TTL: service.Parameter.TTL,
Value: ip.Value,
}
// Create record with IP from ip provider to update record at the dns provider
updateRecord := dns.Record{
Id: record.Id,
ZoneId: zone.Id,
Name: dnsRecord.RecordName,
Type: dnsRecord.RecordType,
TTL: dnsRecord.TTL,
Value: ip.Value,
}

// Check if record value has to be updated
if ip.Value == record.Value {
fmt.Println("No DNS update required for", service.Parameter.ZoneName, "with IP", ip.Value)
return
}
// Check if record value has to be updated
if ip.Value == record.Value {
//log.Printf("no update required for %s.%s [%s]\n", dnsRecord.RecordName, service.Parameter.ZoneName, ip.Value)
continue
}

fmt.Println("DNS update required for", service.Parameter.ZoneName, "with current IP", record.Value, "to IP", ip.Value)
// Update the record at the dns provider
updateRecord, err = service.DnsProvider.UpdateZoneRecord(zone, updateRecord)
if err != nil {
fmt.Println(err)
return
log.Printf("updating %s.%s [%s] to %s\n", dnsRecord.RecordName, service.Parameter.ZoneName, record.Value, ip.Value)
// Update the record at the dns provider
updateRecord, err = service.DnsProvider.UpdateZoneRecord(zone, updateRecord)
if err != nil {
log.Println(err)
continue
}
log.Printf("updated %s.%s [%s]\n", dnsRecord.RecordName, service.Parameter.ZoneName, updateRecord.Value)
}
fmt.Println("Updated DNS for", service.Parameter.ZoneName, "from IP", record.Value, "to IP", updateRecord.Value)

}
11 changes: 8 additions & 3 deletions pkg/ddns/ddns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ func TestRun(t *testing.T) {
IpProvider: ipProvider,
DnsProvider: dnsProvider,
Parameter: Parameter{
ZoneName: "example.com",
RecordName: "@",
RecordType: "A",
ZoneName: "example.com",
Records: []Record{
{
RecordName: "@",
RecordType: "A",
TTL: 86400,
},
},
},
}

Expand Down
Loading