package main import ( "bytes" "encoding/json" "fmt" "io" "os" "go.mlcdf.fr/sally/build" ) type discordClient struct { WebhookURL string } var _ io.Writer = (*discordClient)(nil) // Webhook is the webhook object sent to discord type Webhook struct { Username string `json:"username"` AvatarURL string `json:"avatar_url"` Content string `json:"content"` Embeds []Embed `json:"embeds"` } // Embed is the embed object type Embed struct { Author Author `json:"author"` Title string `json:"title"` URL string `json:"url"` Description string `json:"description"` Color int64 `json:"color"` Fields []Field `json:"fields"` Thumbnail Image `json:"thumbnail"` Image Image `json:"image"` Footer Footer `json:"footer"` TimeStamp string `json:"timestamp"` } // Author is the author object type Author struct { Name string `json:"name"` URL string `json:"url"` IconURL string `json:"icon_url"` } // Field is the field object inside an embed type Field struct { Name string `json:"name"` Value string `json:"value"` Inline bool `json:"inline,omitempty"` } // Footer is the footer of the embed type Footer struct { Text string `json:"text"` IconURL string `json:"icon_url"` } // Image is an image possibly contained inside the embed type Image struct { URL string `json:"url"` } func (c *discordClient) postInfo(webhook *Webhook) error { webhook.Embeds[0].Color = 2201331 return c.post(webhook) } func (c *discordClient) postError(webhook *Webhook) error { webhook.Embeds[0].Color = 15092300 return c.post(webhook) } func (c *discordClient) postSuccess(webhook *Webhook) error { webhook.Embeds[0].Color = 5747840 return c.post(webhook) } func (c *discordClient) post(webhook *Webhook) error { payload, err := json.Marshal(webhook) if err != nil { return fmt.Errorf("failed to marshal webhook payload: %s", err) } res, err := defaultHTTP.Post(c.WebhookURL, "application/json", bytes.NewReader(payload)) if err != nil { return fmt.Errorf("failed to post to webhook: %s", err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return fmt.Errorf("failed read body response : %s", err) } if res.StatusCode >= 400 { return fmt.Errorf("failed to post to webhook: reason=%s status=%s", body, res.Status) } return nil } func (c *discordClient) Write(p []byte) (n int, err error) { hostname, err := os.Hostname() if err != nil { hostname = "(unknown)" } w := &Webhook{ Username: build.String(), Embeds: []Embed{{Footer: Footer{Text: hostname}}}, } w.Embeds[0] = Embed{ Description: string(p), } return len(p), c.postError(w) }
package main import ( "fmt" "io" "log" "net" "github.com/pkg/errors" ) // DynDNS holds all the required dependencies type DynDNS struct { gandiClient *gandiClient discordClient *discordClient } type IPAddrs struct { V4 *net.IP `json:"IPAddress"` V6 *net.IP `json:"IPv6Address"` } func (ipAddrs *IPAddrs) String() string { str := "[" if ipAddrs.V4 != nil { str += ipAddrs.V4.String() } if ipAddrs.V6 != nil { str += " " + ipAddrs.V6.String() } str += "]" return str } func (ipAddrs *IPAddrs) values() []*net.IP { values := make([]*net.IP, 0, 2) if ipAddrs.V4 != nil { values = append(values, ipAddrs.V4) } if ipAddrs.V6 != nil { values = append(values, ipAddrs.V6) } return values } // resolveIPs finds the current IP(s) addresses pointu func (d *DynDNS) resolveIPs() (*IPAddrs, error) { res, err := defaultHTTP.Get("https://api64.ipify.org") if err != nil { return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return nil, err } ip := net.ParseIP(string(body)) if ip == nil { return nil, fmt.Errorf("failed to parse ip: %s", body) } if ip.To4() != nil { // if ipv4 return here because there are not IPv6 return &IPAddrs{V4: &ip}, nil } res, err = defaultHTTP.Get("https://api.ipify.org") if err != nil { return nil, err } defer res.Body.Close() body, err = io.ReadAll(res.Body) if err != nil { return nil, err } ip2 := net.ParseIP(string(body)) if ip2 == nil { return nil, fmt.Errorf("failed to parse ip: %s", body) } return &IPAddrs{V6: &ip, V4: &ip2}, nil } // execute check the current IPs, and the one defines in the DNS records. // If necessary, it updates the DNS records and notify Discord. func (dyndns *DynDNS) execute(domain string, record string, ttl int, alwaysNotify bool) error { resolvedIPs, err := dyndns.resolveIPs() if err != nil { return err } log.Printf("Current dynamic IP(s): %s\n", resolvedIPs) dnsRecords, err := dyndns.gandiClient.get(domain, record) if err != nil { return err } needUpdate := dyndns.matchIPs(resolvedIPs, dnsRecords) if !needUpdate { log.Println("IP address(es) match - no further action") if alwaysNotify { err := dyndns.discordClient.postInfo(&Webhook{ Embeds: []Embed{ { Title: fmt.Sprintf("IP address(es) match for record %s.%s - no further action", record, domain), Description: "To disable notifications when nothing happens, remove the `--always-notify` flag", }, }, }) return errors.Wrap(err, "failed to send message to discord") } return nil } err = dyndns.gandiClient.put(domain, record, []*net.IP{resolvedIPs.V4, resolvedIPs.V6}, ttl) if err != nil { return err } log.Printf("DNS record for %s.%s updated\n", record, domain) err = dyndns.notifyDiscord(domain, record, resolvedIPs.values()) return err } func (dyndns *DynDNS) notifyDiscord(domain string, record string, ips []*net.IP) error { fields := make([]Field, 0, len(ips)) for _, ip := range ips { field := &Field{Inline: true, Value: ip.String()} if ip.To4() != nil { field.Name = "v4" } else { field.Name = "v6" } fields = append(fields, *field) } err := dyndns.discordClient.postSuccess(&Webhook{ Embeds: []Embed{ { Title: fmt.Sprintf("DNS record for %s.%s updated with the new IP adresses", record, domain), Description: fmt.Sprintf("See [Gandi Live DNS](https://admin.gandi.net/domain/%s/records)", domain), Fields: fields, }, }, }) return errors.Wrap(err, "failed to post success message to Discord") } func (dyndns *DynDNS) matchIPs(resolvedIPs *IPAddrs, dnsRecords []*domainRecord) bool { ipsFromDNS := make([]*net.IP, 0, 2) var foundIPV4 bool var foundIPV6 bool for _, records := range dnsRecords { for _, rrsetValue := range records.RrsetValues { ipsFromDNS = append(ipsFromDNS, rrsetValue) if resolvedIPs.V4 != nil && rrsetValue.Equal(*resolvedIPs.V4) { foundIPV4 = true } else if resolvedIPs.V6 != nil && rrsetValue.Equal(*resolvedIPs.V6) { foundIPV6 = true } } } log.Printf("IP(s) from DNS: %s", ipsFromDNS) return !foundIPV4 || !foundIPV6 }
package main import ( "bytes" "encoding/json" "fmt" "io" "net" "net/http" "github.com/pkg/errors" ) type gandiClient struct { Token string } // domainRecord represents a DNS Record type domainRecord struct { RrsetType string `json:"rrset_type,omitempty"` RrsetTTL int `json:"rrset_ttl,omitempty"` RrsetName string `json:"rrset_name,omitempty"` RrsetHref string `json:"rrset_href,omitempty"` RrsetValues []*net.IP `json:"rrset_values,omitempty"` } func (c *gandiClient) get(domain string, record string) ([]*domainRecord, error) { url := fmt.Sprintf("https://api.gandi.net/v5/livedns/domains/%s/records/%s", domain, record) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "ApiKey "+c.Token) req.Header.Set("Content-type", "application/json") res, err := defaultHTTP.Do(req) if err != nil { return nil, err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return nil, err } records := make([]*domainRecord, 0) err = json.Unmarshal(body, &records) if err != nil { return nil, errors.Wrapf(err, "failed to get %s/records/%s response=%s", domain, record, body) } return records, nil } func rrsetType(ip *net.IP) string { if xx := ip.To4(); xx == nil { return "AAAA" } return "A" } func (c *gandiClient) put(domain string, name string, ips []*net.IP, ttl int) error { record := struct { Items []*domainRecord `json:"items"` }{Items: make([]*domainRecord, 0, 2)} for _, ip := range ips { item := &domainRecord{RrsetTTL: ttl, RrsetValues: []*net.IP{ip}, RrsetType: rrsetType(ip)} record.Items = append(record.Items, item) } payload, err := json.Marshal(record) if err != nil { return err } url := fmt.Sprintf("https://api.gandi.net/v5/livedns/domains/%s/records/%s", domain, name) req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "ApiKey "+c.Token) req.Header.Set("Content-type", "application/json") res, err := defaultHTTP.Do(req) if err != nil { return err } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { return err } if res.StatusCode >= 400 { return fmt.Errorf("failed to perform PUT status=%d response=%s", res.StatusCode, body) } return nil }
package main import ( "net/http" "time" "go.mlcdf.fr/dyndns/tests/smockertest" ) var defaultHTTP = &http.Client{Timeout: 20 * time.Second} func init() { if isTest == "true" { defaultHTTP.Transport = &smockertest.RedirectTransport{} } }
package main import ( "flag" "fmt" "io" "log" "os" "go.mlcdf.fr/sally/build" ) const usage = `Usage: dyndns --domain [DOMAIN] --record [RECORD] Options: --ttl Time to live in seconds. Defaults to 3600 --always-notify Always notify the Discord channel (even when nothing changes) -V, --version Print version Examples: export DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/xxx' export GANDI_TOKEN='foobar' dyndns --domain example.com --record "*.pi" How to create a Discord webhook: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks How to generate your Gandi token: https://docs.gandi.net/en/domain_names/advanced_users/api.html ` type exitCode int const ( exitOK exitCode = 0 exitError exitCode = 1 ) var ( // Injected from linker flags like `go build -ldflags "-X main.version=$VERSION" -X ...` isTest = "false" ) func main() { code := int(mainRun()) os.Exit(code) } func mainRun() exitCode { log.SetFlags(0) flag.Usage = func() { fmt.Fprint(os.Stderr, usage) } if len(os.Args) == 1 { flag.Usage() return exitOK } var ( versionFlag bool domainFlag string recordFlag string ttlFlag int = 3600 alwaysNotifyFlag bool ) flag.StringVar(&domainFlag, "domain", domainFlag, "") flag.StringVar(&recordFlag, "record", recordFlag, "") flag.IntVar(&ttlFlag, "ttl", ttlFlag, "Time to live. Defaults to 3600.") flag.BoolVar(&versionFlag, "version", versionFlag, "print the version") flag.BoolVar(&versionFlag, "V", versionFlag, "print the version") flag.BoolVar(&alwaysNotifyFlag, "always-notify", alwaysNotifyFlag, "") flag.Parse() if versionFlag { fmt.Fprintln(os.Stdout, "dyndns "+build.String()) return exitOK } webhook := os.Getenv("DISCORD_WEBHOOK_URL") if webhook == "" { log.Println("error: required environment variable DISCORD_WEBHOOK_URL is empty or missing") return exitError } discordClient := &discordClient{webhook} logErr := log.New(io.MultiWriter(os.Stderr, discordClient), "", 0) if domainFlag == "" { logErr.Println("error: required flag --domain is missing") return exitError } if recordFlag == "" { logErr.Println("error: required flag --record is missing") return exitError } token := os.Getenv("GANDI_TOKEN") if token == "" { log.Println("error: required environment variable GANDI_TOKEN is empty or missing") return exitError } gandiClient := &gandiClient{token} dyn := &DynDNS{ gandiClient, discordClient, } err := dyn.execute(domainFlag, recordFlag, ttlFlag, alwaysNotifyFlag) if err != nil { logErr.Printf("error: %v", err) return exitError } return exitOK }
package smockertest import ( "fmt" "net/http" "os" ) // RedirectTransport implement a Roundtrip that redirects requests to a running smocker instance type RedirectTransport struct{} var _ http.RoundTripper = &RedirectTransport{} func (s *RedirectTransport) RoundTrip(r *http.Request) (*http.Response, error) { r.URL.Scheme = "http" r.URL.Host = fmt.Sprintf("localhost:%d", port) return http.DefaultTransport.RoundTrip(r) } // PushMock send the mockfile to the smocker server func PushMock(filepath string) error { f, err := os.Open(filepath) if err != nil { return err } url := fmt.Sprintf("http://localhost:%d/mocks?reset=true", adminPort) res, err := http.Post(url, "content-type: application/x-yaml", f) if err != nil { return err } if res.StatusCode != 200 { return fmt.Errorf("error %d while performing POST %s", res.StatusCode, url) } return err }
package smockertest import ( "bytes" "errors" "fmt" "log" "os/exec" "strings" ) const ( image = "thiht/smocker:0.18.2" port = 8080 adminPort = 8081 ) type containerID struct { id string } func MustStart() *containerID { container, err := Start() if err != nil { log.Fatalln(err) } return container } func Start() (*containerID, error) { cmd := exec.Command( "docker", "run", "-d", "-p", fmt.Sprintf("%d:%d", port, port), "-p", fmt.Sprintf("%d:%d", adminPort, adminPort), "--name", "dyndns-smocker", image, ) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("%v%v", stderr.String(), err) } id := strings.TrimSpace(stdout.String()) if id == "" { return nil, errors.New("unexpected empty output from `docker run`") } return &containerID{id}, nil } func (c containerID) Nuke() { if err := c.Kill(); err != nil { log.Fatalln(err) } if err := c.Remove(); err != nil { log.Fatalln(err) } } func (c containerID) Kill() error { return exec.Command("docker", "kill", c.id).Run() } func (c containerID) Remove() error { return exec.Command("docker", "rm", c.id).Run() }