dnsupdater/main.go

261 lines
6.6 KiB
Go

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
}