diff --git a/.gitignore b/.gitignore index 1f859f1..59c9353 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,9 @@ dist/ # Test binary, built with `go test -c` *.test -# Local binary +# Local testing /scaleway-ddns +/config.yml # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/cmd/scaleway-ddns/dns.go b/cmd/scaleway-ddns/dns.go deleted file mode 100644 index b65c2b3..0000000 --- a/cmd/scaleway-ddns/dns.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "github.com/aerialls/scaleway-ddns/config" - "github.com/aerialls/scaleway-ddns/ip" - "github.com/aerialls/scaleway-ddns/scaleway" -) - -// UpdateDNSRecordFromCurrentIP updates the DNS record on Scaleway DNS based -// on the current IP for IPv4 or IPv6 -func UpdateDNSRecordFromCurrentIP( - dns *scaleway.DNS, - domain config.DomainConfig, - cfg config.IPConfig, - recordType string, - dryRun bool, -) error { - if !cfg.Enabled { - logger.Debugf("skipping %s update, disabled in the configuration", recordType) - return nil - } - - scalewayIP, err := dns.GetRecord(domain.Name, domain.Record, recordType) - if err != nil { - return err - } - - currentIP, err := ip.GetPublicIP(cfg.URL) - if err != nil { - return err - } - - logger.Debugf("current IP registered in Scaleway is %s", scalewayIP) - logger.Debugf("current public IP is %s", currentIP) - - if scalewayIP == currentIP { - logger.Debug("both IPs are identical, nothing to do") - return nil - } - - logger.Infof( - "updating %s record from %s to %s", recordType, scalewayIP, currentIP, - ) - - if dryRun { - logger.Info("running in dry-run mode, doing nothing") - return nil - } - - err = dns.UpdateRecord( - domain.Name, - domain.Record, - domain.TTL, - currentIP, - recordType, - ) - - if err != nil { - return err - } - - return nil -} diff --git a/cmd/scaleway-ddns/main.go b/cmd/scaleway-ddns/main.go index 029964c..7c5c00d 100644 --- a/cmd/scaleway-ddns/main.go +++ b/cmd/scaleway-ddns/main.go @@ -3,9 +3,10 @@ package main import ( "fmt" "os" - "time" "github.com/aerialls/scaleway-ddns/config" + "github.com/aerialls/scaleway-ddns/ddns" + "github.com/aerialls/scaleway-ddns/notifier" "github.com/aerialls/scaleway-ddns/scaleway" "github.com/sirupsen/logrus" @@ -41,39 +42,20 @@ var rootCmd = &cobra.Command{ logger.Fatal(err) } - ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) - - for { - select { - case <-ticker.C: - logger.Debugf( - "updating A/AAAA records for %s.%s", - cfg.DomainConfig.Record, - cfg.DomainConfig.Name, - ) - - recordTypes := map[string]config.IPConfig{ - "A": cfg.IPv4Config, - "AAAA": cfg.IPv6Config, - } - - for recordType, recordCfg := range recordTypes { - err := UpdateDNSRecordFromCurrentIP( - dns, - cfg.DomainConfig, - recordCfg, - recordType, - dryRun, - ) - - if err != nil { - logger.WithError(err).Errorf( - "unable to update %s record", recordType, - ) - } - } - } + // Create a container to store all objects in one place + container := config.NewContainer(logger, cfg, dns) + + if cfg.TelegramConfig.Enabled { + tgCfg := cfg.TelegramConfig + container.AddNotifier(notifier.NewTelegram( + tgCfg.Token, + tgCfg.ChatID, + tgCfg.Template, + )) } + + updater := ddns.NewDynamicDNSUpdater(container, dryRun) + updater.Start() }, } diff --git a/config/config.go b/config/config.go index 25eb7d4..5134cac 100644 --- a/config/config.go +++ b/config/config.go @@ -7,72 +7,6 @@ import ( "gopkg.in/yaml.v2" ) -const ( - // IntervalMinValue is the lowest possible value between two updates (in sec) - IntervalMinValue = 60 -) - -// Config struct for the configuration file -type Config struct { - Interval uint `yaml:"interval"` - IPv4Config IPConfig `yaml:"ipv4"` - IPv6Config IPConfig `yaml:"ipv6"` - ScalewayConfig ScalewayConfig `yaml:"scaleway"` - DomainConfig DomainConfig `yaml:"domain"` -} - -// IPConfig struct for the required configuration for IPv4 or IPv6 -type IPConfig struct { - URL string `yaml:"url"` - Enabled bool `yaml:"enabled"` -} - -// ScalewayConfig struct for the required configuration to use the Scaleway API -type ScalewayConfig struct { - OrganizationID string `yaml:"organization_id"` - AccessKey string `yaml:"access_key"` - SecretKey string `yaml:"secret_key"` -} - -// DomainConfig struct for the domain parameters -type DomainConfig struct { - Name string `yaml:"name"` - Record string `yaml:"record"` - TTL uint32 `yaml:"ttl"` -} - -var ( - // DefaultIPv4Config is the default configuration for IPv4 - DefaultIPv4Config = IPConfig{ - URL: "https://api-ipv4.ip.sb/ip", - Enabled: true, - } - - // DefaultIPv6Config is the default configuration for IPv6 - DefaultIPv6Config = IPConfig{ - URL: "https://api-ipv6.ip.sb/ip", - Enabled: false, - } - - // DefaultScalewayConfig is the default configuration to use the Scaleway API - DefaultScalewayConfig = ScalewayConfig{} - - // DefaultDomainConfig is the default domain configuration for common parameters - DefaultDomainConfig = DomainConfig{ - Record: "ddns", - TTL: 60, - } - - // DefaultConfig is the global default configuration. - DefaultConfig = Config{ - Interval: 300, - DomainConfig: DefaultDomainConfig, - IPv4Config: DefaultIPv4Config, - IPv6Config: DefaultIPv6Config, - ScalewayConfig: DefaultScalewayConfig, - } -) - // NewConfig returns a new config object if the file exists func NewConfig(path string) (*Config, error) { data, err := ioutil.ReadFile(path) @@ -112,5 +46,12 @@ func (c *Config) validate() error { ) } + tgCfg := c.TelegramConfig + if tgCfg.Enabled && (tgCfg.Token == "" || tgCfg.ChatID == 0) { + return fmt.Errorf( + "token and chat_id are required for the Telegram notifier", + ) + } + return nil } diff --git a/config/container.go b/config/container.go new file mode 100644 index 0000000..bf1c586 --- /dev/null +++ b/config/container.go @@ -0,0 +1,41 @@ +package config + +import ( + "github.com/aerialls/scaleway-ddns/scaleway" + + "github.com/sirupsen/logrus" +) + +// Notifier interface to represent any notifier +type Notifier interface { + Notify(domain string, record string, previousIP string, newIP string) error +} + +// Container structure to hold global objects +type Container struct { + Logger *logrus.Logger + Config *Config + DNS *scaleway.DNS + Notifiers []Notifier +} + +// NewContainer returns a new container instance +func NewContainer( + logger *logrus.Logger, + config *Config, + dns *scaleway.DNS, + +) *Container { + return &Container{ + Config: config, + Logger: logger, + DNS: dns, + Notifiers: []Notifier{}, + } +} + +// AddNotifier adds a new notifier into the container +func (c *Container) AddNotifier(notifier Notifier) { + c.Logger.Debugf("New notifier %T added", notifier) + c.Notifiers = append(c.Notifiers, notifier) +} diff --git a/config/data.go b/config/data.go new file mode 100644 index 0000000..b44d359 --- /dev/null +++ b/config/data.go @@ -0,0 +1,83 @@ +package config + +const ( + // IntervalMinValue is the lowest possible value between two updates (in sec) + IntervalMinValue = 60 +) + +// Config struct for the configuration file +type Config struct { + Interval uint `yaml:"interval"` + IPv4Config IPConfig `yaml:"ipv4"` + IPv6Config IPConfig `yaml:"ipv6"` + ScalewayConfig ScalewayConfig `yaml:"scaleway"` + DomainConfig DomainConfig `yaml:"domain"` + TelegramConfig TelegramConfig `yaml:"telegram"` +} + +// IPConfig struct for the required configuration for IPv4 or IPv6 +type IPConfig struct { + URL string `yaml:"url"` + Enabled bool `yaml:"enabled"` +} + +// ScalewayConfig struct for the required configuration to use the Scaleway API +type ScalewayConfig struct { + OrganizationID string `yaml:"organization_id"` + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` +} + +// DomainConfig struct for the domain parameters +type DomainConfig struct { + Name string `yaml:"name"` + Record string `yaml:"record"` + TTL uint32 `yaml:"ttl"` +} + +// TelegramConfig struct for Telegram notifications after updates +type TelegramConfig struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + ChatID int64 `yaml:"chat_id"` + Template string `yaml:"template"` +} + +var ( + // DefaultIPv4Config is the default configuration for IPv4 + DefaultIPv4Config = IPConfig{ + URL: "https://api-ipv4.ip.sb/ip", + Enabled: true, + } + + // DefaultIPv6Config is the default configuration for IPv6 + DefaultIPv6Config = IPConfig{ + URL: "https://api-ipv6.ip.sb/ip", + Enabled: false, + } + + // DefaultScalewayConfig is the default configuration to use the Scaleway API + DefaultScalewayConfig = ScalewayConfig{} + + // DefaultDomainConfig is the default domain configuration for common parameters + DefaultDomainConfig = DomainConfig{ + Record: "ddns", + TTL: 60, + } + + // DefaultTelegramConfig is the default configuration to use Telegram notifications + DefaultTelegramConfig = TelegramConfig{ + Enabled: false, + Template: "DNS record *{{ .Record }}.{{ .Domain }}* has been updated from *{{ .PreviousIP }}* to *{{ .NewIP }}*", + } + + // DefaultConfig is the global default configuration. + DefaultConfig = Config{ + Interval: 300, + DomainConfig: DefaultDomainConfig, + IPv4Config: DefaultIPv4Config, + IPv6Config: DefaultIPv6Config, + ScalewayConfig: DefaultScalewayConfig, + TelegramConfig: DefaultTelegramConfig, + } +) diff --git a/ddns/ddns.go b/ddns/ddns.go new file mode 100644 index 0000000..cd516a7 --- /dev/null +++ b/ddns/ddns.go @@ -0,0 +1,149 @@ +package ddns + +import ( + "time" + + "github.com/aerialls/scaleway-ddns/config" + "github.com/aerialls/scaleway-ddns/ip" +) + +// DynamicDNSUpdater struct +type DynamicDNSUpdater struct { + container *config.Container + dryRun bool +} + +// NewDynamicDNSUpdater returns a new DynamicDNSUpdate +func NewDynamicDNSUpdater( + container *config.Container, + dryRun bool, +) *DynamicDNSUpdater { + return &DynamicDNSUpdater{ + container: container, + dryRun: dryRun, + } +} + +// Start launches the ticker to update DNS records every interval +func (d *DynamicDNSUpdater) Start() { + cfg := d.container.Config + logger := d.container.Logger + + ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) + + for { + select { + case <-ticker.C: + logger.Debugf( + "updating A/AAAA records for %s.%s", + cfg.DomainConfig.Record, + cfg.DomainConfig.Name, + ) + + recordTypes := map[string]config.IPConfig{ + "A": cfg.IPv4Config, + "AAAA": cfg.IPv6Config, + } + + for recordType, recordCfg := range recordTypes { + err := d.UpdateRecord( + cfg.DomainConfig, + recordCfg, + recordType, + d.dryRun, + ) + + if err != nil { + logger.WithError(err).Errorf( + "unable to update %s record", recordType, + ) + } + } + } + } +} + +// UpdateRecord updates the DNS record on Scaleway DNS based +// on the current IPv4 or IPv6 +func (d *DynamicDNSUpdater) UpdateRecord( + domain config.DomainConfig, + cfg config.IPConfig, + recordType string, + dryRun bool, +) error { + logger := d.container.Logger + dns := d.container.DNS + + if !cfg.Enabled { + logger.Debugf("skipping %s update, disabled in the configuration", recordType) + return nil + } + + scalewayIP, err := dns.GetRecord(domain.Name, domain.Record, recordType) + if err != nil { + return err + } + + if scalewayIP == "" { + scalewayIP = "(empty)" + } + + currentIP, err := ip.GetPublicIP(cfg.URL) + if err != nil { + return err + } + + logger.Debugf( + "current IP state (scaleway=%s, current=%s)", + scalewayIP, + currentIP, + ) + + if scalewayIP == currentIP { + logger.Debug("both IPs are identical, nothing to do") + return nil + } + + logger.Infof( + "updating %s record from %s to %s", + recordType, + scalewayIP, + currentIP, + ) + + if dryRun { + logger.Info("running in dry-run mode, doing nothing") + return nil + } + + err = dns.UpdateRecord( + domain.Name, + domain.Record, + domain.TTL, + currentIP, + recordType, + ) + + if err != nil { + return err + } + + notifiers := d.container.Notifiers + for _, notifier := range notifiers { + err := notifier.Notify( + domain.Name, + domain.Record, + scalewayIP, + currentIP, + ) + + if err != nil { + logger.WithError(err).Errorf( + "unable to notify the IP change with notifier %T", + notifier, + ) + } + } + + return nil +} diff --git a/go.mod b/go.mod index 76a0590..2e67d07 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/aerialls/scaleway-ddns go 1.14 require ( + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/scaleway/scaleway-sdk-go v1.0.0-beta.6 github.com/sirupsen/logrus v1.6.0 github.com/spf13/cobra v1.0.0 github.com/stretchr/testify v1.6.1 + github.com/technoweenie/multipartstreamer v1.0.1 // indirect gopkg.in/yaml.v2 v2.3.0 ) diff --git a/go.sum b/go.sum index dfcf81e..43b692f 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,9 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-telegram-bot-api/telegram-bot-api v1.0.0 h1:HXVtsZ+yINQeyyhPFAUU4yKmeN+iFhJ87jXZOC016gs= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -99,6 +102,8 @@ github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= diff --git a/notifier/telegram.go b/notifier/telegram.go new file mode 100644 index 0000000..95fc500 --- /dev/null +++ b/notifier/telegram.go @@ -0,0 +1,75 @@ +package notifier + +import ( + "bytes" + templating "text/template" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" +) + +// Telegram struct to be able to notify with Telegram messages +type Telegram struct { + token string + chatID int64 + template string +} + +// TelegramMessageData holds information for the Telegram template message +type TelegramMessageData struct { + Domain string + Record string + PreviousIP string + NewIP string +} + +// NewTelegram returns a new Telegram notifier +func NewTelegram( + token string, + chatID int64, + template string, +) *Telegram { + return &Telegram{ + token: token, + chatID: chatID, + template: template, + } +} + +// Notify launches a new message on Telegram when the IP has changed +func (t *Telegram) Notify(domain string, record string, previousIP string, newIP string) error { + bot, err := tgbotapi.NewBotAPI(t.token) + if err != nil { + return err + } + + template, err := templating.New("telegram").Parse(t.template) + if err != nil { + return err + } + + if previousIP == "" { + previousIP = "(empty)" + } + + var message bytes.Buffer + err = template.Execute(&message, &TelegramMessageData{ + Domain: domain, + Record: record, + PreviousIP: previousIP, + NewIP: newIP, + }) + + if err != nil { + return err + } + + msg := tgbotapi.NewMessage(t.chatID, message.String()) + msg.ParseMode = "markdown" + + _, err = bot.Send(msg) + if err != nil { + return err + } + + return nil +} diff --git a/scaleway-ddns.yml b/scaleway-ddns.yml index 48318f5..24534a5 100644 --- a/scaleway-ddns.yml +++ b/scaleway-ddns.yml @@ -9,3 +9,8 @@ domain: ttl: 60 interval: 300 + +telegram: + enabled: true + token: __TELEGRAM_TOKEN__ + chat_id: 98873332