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()
}