package main import ( "context" "encoding/json" "fmt" "io/ioutil" "net/http" "os" "path" "regexp" "time" "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/dghubble/sling" "github.com/matryer/try" "github.com/urfave/cli" "gopkg.in/gomail.v2" ) type Configuration struct { Username string `json:"name"` Email string `json:"email"` GandiKey string `json:"gandikey"` Domains []string `json:"domains"` RouterLogin string `json:"router_login"` RouterPassword string `json:"router_password"` RouterStatusUrl string `json:"router_status_url"` WanIPRegex string `json:"wan_ip_regex"` MailSettings *struct { Sender string `json:"sender"` To []string `json:"to"` SmtpHost string `json:"smtp_host"` SmtpLogin string `json:"smtp_login"` SmtpPassword string `json:"smtp_password"` SmtpPort int `json:"smtp_port"` } `json:"mail_settings"` } type DnsResponse struct { RecordType string `json:"rrset_type"` Ttl int `json:"rrset_ttl"` Name string `json:"rrset_name"` Values []string `json:"rrset_values"` } var ( configuration Configuration configPath string base *sling.Sling ) const ( GandiApiUrl = "https://dns.api.gandi.net/api/v5/" GandiARecordUrl = "domains/%s/records/@/A" ) var log = sloghuman.Make(os.Stdout) func main() { app := cli.NewApp() app.Name = "dnsupdater" app.Usage = "Automatically update dns" app.Before = before app.Action = start app.Flags = []cli.Flag{ cli.StringFlag{ Name: "config, C", Usage: "path to config file", Value: "/config", EnvVar: "CONFIG_PATH", }, } if err := app.Run(os.Args); err != nil { log.Fatal(context.Background(), "", slog.Error(err)) } } func before(c *cli.Context) error { configPath = path.Join(c.String("config"), "conf.json") file, err := os.Open(configPath) if err != nil { return fmt.Errorf("error while opening config file: %v", err) } defer file.Close() decoder := json.NewDecoder(file) configuration = Configuration{} err = decoder.Decode(&configuration) if err != nil { return err } base = sling.New().Client(&http.Client{ Timeout: 30 * time.Second, }).Base(GandiApiUrl). Set("X-Api-Key", configuration.GandiKey) return nil } func start(*cli.Context) error { ctx := context.Background() if err := update(ctx); err != nil { SendStatusMail(ctx, fmt.Sprintf("Error during update process : %s", err)) return err } return nil } func update(ctx context.Context) error { newIp, err := GetOutboundIP() if err != nil { return fmt.Errorf("getting outbound ip: %v", err) } updatedDomains := "" for _, domain := range configuration.Domains { currentIp, err := GetCurrentIp(ctx, domain) if err != nil { return fmt.Errorf("getting current ip: %v", err) } if currentIp == newIp { continue } log.Info(ctx, "ip has changed", slog.F("domain", domain), slog.F("current ip", currentIp), slog.F("new ip", newIp)) err = SetCurrentIp(newIp, domain) if err != nil { return fmt.Errorf("setting current ip: %v", err) } updatedDomains += fmt.Sprintf("\t- %s\n", domain) } if updatedDomains != "" { SendStatusMail(ctx, fmt.Sprintf(`Home IP has changed : %s Dns updated successfully for domains %s`, newIp, updatedDomains)) } return nil } func SendStatusMail(ctx context.Context, messageText string) { settings := configuration.MailSettings if settings == nil { log.Warn(ctx, "no smtp settings defined > status mail not sent") return } d := gomail.NewDialer(settings.SmtpHost, settings.SmtpPort, settings.SmtpLogin, settings.SmtpPassword) s, err := d.Dial() if err != nil { log.Error(ctx, "dialing smtp server", slog.Error(err)) return } m := gomail.NewMessage() for _, r := range settings.To { m.SetHeader("From", settings.Sender) m.SetAddressHeader("To", r, "") m.SetHeader("Subject", "DnsUpdater Status") m.SetBody("text/plain", messageText) if err := gomail.Send(s, m); err != nil { log.Warn(ctx, "could not send email", slog.F("recipient", r), slog.Error(err)) } m.Reset() } } func GetCurrentIp(ctx context.Context, domain string) (string, error) { var value string err := try.Do(func(attempt int) (bool, error) { var err error value, err = func(domain string) (string, error) { dnsresponse := DnsResponse{} resp, err := base.New().Get(fmt.Sprintf(GandiARecordUrl, domain)). ReceiveSuccess(&dnsresponse) if err != nil { return "", fmt.Errorf("requesting gandi dns info: %v", err) } if resp.StatusCode < 200 || resp.StatusCode > 299 { return "", fmt.Errorf("requesting gandi dns info, no 2xx http status code: %d", resp.StatusCode) } if len(dnsresponse.Values) == 0 { return "", fmt.Errorf("no values in dnsresponse") } return dnsresponse.Values[0], nil }(domain) if err != nil { log.Warn(ctx, "getting current ip from gandi", slog.Error(err)) time.Sleep(30 * time.Second) } return attempt < 5, err }) if err != nil { return "", xerrors.Errorf("getting current ip from gandi: %w", err) } return value, nil } func SetCurrentIp(newip string, domain string) error { resp, err := base.New().Put(fmt.Sprintf(GandiARecordUrl, domain)). BodyJSON(struct { TTL int `json:"rrset_ttl"` Values []string `json:"rrset_values"` }{ TTL: 600, Values: []string{newip}, }).ReceiveSuccess(nil) if err != nil { return xerrors.Errorf("setting gandi dns info: %w", err) } if resp.StatusCode < 200 || resp.StatusCode > 299 { return xerrors.Errorf("setting gandi dns info, no 2xx http status code: %d", resp.StatusCode) } return nil } func GetOutboundIP() (string, error) { url := configuration.RouterStatusUrl if url == "" { url = "http://192.168.1.1/Status_Internet.live.asp" } req, err := http.NewRequest("GET", url, nil) if err != nil { return "", fmt.Errorf("creating http request: %v", err) } if configuration.RouterLogin != "" && configuration.RouterPassword != "" { req.SetBasicAuth(configuration.RouterLogin, configuration.RouterPassword) } res, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("executing http request: %v", err) } defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) if err != nil { return "", fmt.Errorf("reading response body: %v", err) } wanipregex := configuration.WanIPRegex if wanipregex == "" { wanipregex = "wan_ipaddr::([0-9.]*)}" } r := regexp.MustCompile(wanipregex) matches := r.FindStringSubmatch(string(body)) if len(matches) < 2 { return "", fmt.Errorf("unable to find WAN IP with regex %s in %s", wanipregex, string(body)) } return matches[1], nil }