commit 1a5a446b5c206c5285ed9c86b366741ac5cf99d5 Author: Glauber Ferreira Date: Wed Sep 16 00:18:01 2026 -0300 primeiro commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fa8574 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +he-dydns diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a6d99ad --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +BINARY := he-dydns +MODULE := git.aehoo.net/alphard/he-dydns +PREFIX := /usr/local + +export CGO_ENABLED := 0 + +.PHONY: build clean install uninstall fmt vet test + +build: + go build -o $(BINARY) $(MODULE) + +clean: + rm -f $(BINARY) + +install: build + install -Dm755 $(BINARY) $(DESTDIR)$(PREFIX)/bin/$(BINARY) + +uninstall: + rm -f $(DESTDIR)$(PREFIX)/bin/$(BINARY) + +fmt: + gofmt -w . + +vet: + go vet ./... + +test: + go test ./... diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..88a9632 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,17 @@ +# he-dydns configuration +# Copy to $HOME/.config/he-dydns/config.toml + +# Optional: watch this network interface for its IPv4/IPv6 addresses instead +# of guessing the public IP via an external service. +# interface = "eth0" + +# Optional: how often to update in daemon mode, in minutes. Default: 5. +# interval_minutes = 5 + +[[entries]] +hostname = "home.example.com" +token = "your-he-dyndns-token" + +[[entries]] +hostname = "home-v6.example.com" +token = "another-he-dyndns-token" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c47e5ed --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.aehoo.net/alphard/he-dydns + +go 1.24.4 + +require github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4a7d0dc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,63 @@ +// Package config loads he-dydns configuration from a TOML file. +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +const DefaultIntervalMinutes = 5 + +// Entry describes a single Hurricane Electric dynamic DNS hostname to keep updated. +type Entry struct { + Hostname string `toml:"hostname"` + Token string `toml:"token"` +} + +// Config is the top-level he-dydns configuration. +type Config struct { + // Interface, if set, restricts address discovery to this network + // interface instead of guessing the public IP via an external service. + Interface string `toml:"interface"` + // IntervalMinutes controls how often updates run in daemon mode. + IntervalMinutes int `toml:"interval_minutes"` + Entries []Entry `toml:"entries"` +} + +// DefaultPath returns $HOME/.config/he-dydns/config.toml. +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("determining home directory: %w", err) + } + return filepath.Join(home, ".config", "he-dydns", "config.toml"), nil +} + +// Load reads and validates the configuration file at path. +func Load(path string) (*Config, error) { + var cfg Config + if _, err := toml.DecodeFile(path, &cfg); err != nil { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + + if len(cfg.Entries) == 0 { + return nil, fmt.Errorf("config %s: no entries defined", path) + } + for i, e := range cfg.Entries { + if e.Hostname == "" { + return nil, fmt.Errorf("config %s: entries[%d] missing hostname", path, i) + } + if e.Token == "" { + return nil, fmt.Errorf("config %s: entries[%d] missing token", path, i) + } + } + + if cfg.IntervalMinutes <= 0 { + cfg.IntervalMinutes = DefaultIntervalMinutes + } + + return &cfg, nil +} diff --git a/internal/hedns/hedns.go b/internal/hedns/hedns.go new file mode 100644 index 0000000..7164bcd --- /dev/null +++ b/internal/hedns/hedns.go @@ -0,0 +1,61 @@ +// Package hedns implements the Hurricane Electric dynamic DNS update protocol. +package hedns + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + updateURL = "https://dyn.dns.he.net/nic/update" + requestTimeout = 15 * time.Second +) + +// Update pushes a single hostname -> ip mapping. HE picks the A or AAAA +// record based on the family of ip. +func Update(hostname, token string, ip net.IP) error { + client := http.Client{Timeout: requestTimeout} + + form := url.Values{ + "hostname": {hostname}, + "password": {token}, + "myip": {ip.String()}, + } + + resp, err := client.PostForm(updateURL, form) + if err != nil { + return fmt.Errorf("updating %s: %w", hostname, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return fmt.Errorf("updating %s: reading response: %w", hostname, err) + } + + return interpretResponse(hostname, strings.TrimSpace(string(body))) +} + +func interpretResponse(hostname, body string) error { + switch { + case strings.HasPrefix(body, "good"), strings.HasPrefix(body, "nochg"): + return nil + case body == "badauth": + return fmt.Errorf("updating %s: authentication failed (bad hostname/token)", hostname) + case body == "nohost": + return fmt.Errorf("updating %s: hostname not found on account", hostname) + case body == "notfqdn": + return fmt.Errorf("updating %s: hostname is not a valid fully-qualified domain name", hostname) + case body == "abuse": + return fmt.Errorf("updating %s: blocked for abuse", hostname) + case body == "": + return fmt.Errorf("updating %s: empty response from server", hostname) + default: + return fmt.Errorf("updating %s: unexpected response %q", hostname, body) + } +} diff --git a/internal/network/network.go b/internal/network/network.go new file mode 100644 index 0000000..ac86c39 --- /dev/null +++ b/internal/network/network.go @@ -0,0 +1,128 @@ +// Package network discovers the IPv4/IPv6 addresses to publish to dynamic DNS. +package network + +import ( + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +const httpTimeout = 10 * time.Second + +// PublicIPv4Services and PublicIPv6Services are queried in order; the first +// service that returns a usable address wins. +var ( + PublicIPv4Services = []string{ + "https://api.ipify.org", + "https://ipv4.icanhazip.com", + } + PublicIPv6Services = []string{ + "https://api6.ipify.org", + "https://ipv6.icanhazip.com", + } +) + +// Addresses holds the discovered addresses for a single update run. +type Addresses struct { + IPv4 net.IP + IPv6 net.IP +} + +// Empty reports whether no address was discovered at all. +func (a Addresses) Empty() bool { + return a.IPv4 == nil && a.IPv6 == nil +} + +// Discover finds the addresses to publish. If iface is non-empty its +// addresses are used; otherwise the public IP is guessed via external +// services, independently for IPv4 and IPv6. +func Discover(iface string) (Addresses, error) { + if iface != "" { + return fromInterface(iface) + } + return fromPublicServices(), nil +} + +func fromInterface(name string) (Addresses, error) { + ifi, err := net.InterfaceByName(name) + if err != nil { + return Addresses{}, fmt.Errorf("looking up interface %s: %w", name, err) + } + addrs, err := ifi.Addrs() + if err != nil { + return Addresses{}, fmt.Errorf("listing addresses on %s: %w", name, err) + } + + var out Addresses + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP + if !isUsable(ip) { + continue + } + if ip4 := ip.To4(); ip4 != nil { + if out.IPv4 == nil { + out.IPv4 = ip4 + } + } else if out.IPv6 == nil { + out.IPv6 = ip + } + } + return out, nil +} + +func isUsable(ip net.IP) bool { + return !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() +} + +func fromPublicServices() Addresses { + return Addresses{ + IPv4: queryFirst(PublicIPv4Services, false), + IPv6: queryFirst(PublicIPv6Services, true), + } +} + +func queryFirst(services []string, wantIPv6 bool) net.IP { + for _, url := range services { + ip, err := queryOne(url) + if err != nil { + continue + } + isV6 := ip.To4() == nil + if isV6 != wantIPv6 { + continue + } + return ip + } + return nil +} + +func queryOne(url string) (net.IP, error) { + client := http.Client{Timeout: httpTimeout} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: unexpected status %s", url, resp.Status) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 256)) + if err != nil { + return nil, err + } + + ip := net.ParseIP(strings.TrimSpace(string(body))) + if ip == nil { + return nil, fmt.Errorf("%s: could not parse IP from response", url) + } + return ip, nil +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..07f2145 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,59 @@ +// Package updater runs one dynamic DNS update pass across all configured entries. +package updater + +import ( + "fmt" + "log" + + "git.aehoo.net/alphard/he-dydns/internal/config" + "git.aehoo.net/alphard/he-dydns/internal/hedns" + "git.aehoo.net/alphard/he-dydns/internal/network" +) + +// Run discovers the current address(es) and updates every configured entry. +// It returns an error only when address discovery itself fails outright; +// per-entry update failures are logged and aggregated but do not stop other +// entries from being attempted. +func Run(cfg *config.Config) error { + addrs, err := network.Discover(cfg.Interface) + if err != nil { + return fmt.Errorf("discovering address: %w", err) + } + + if addrs.Empty() { + log.Printf("no address found (interface=%q); skipping update", cfg.Interface) + return nil + } + + if addrs.IPv4 != nil { + log.Printf("discovered IPv4 address %s", addrs.IPv4) + } + if addrs.IPv6 != nil { + log.Printf("discovered IPv6 address %s", addrs.IPv6) + } + + var failures int + for _, entry := range cfg.Entries { + if addrs.IPv4 != nil { + if err := hedns.Update(entry.Hostname, entry.Token, addrs.IPv4); err != nil { + log.Printf("error: %v", err) + failures++ + } else { + log.Printf("updated %s -> %s", entry.Hostname, addrs.IPv4) + } + } + if addrs.IPv6 != nil { + if err := hedns.Update(entry.Hostname, entry.Token, addrs.IPv6); err != nil { + log.Printf("error: %v", err) + failures++ + } else { + log.Printf("updated %s -> %s", entry.Hostname, addrs.IPv6) + } + } + } + + if failures > 0 { + return fmt.Errorf("%d update(s) failed", failures) + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b8e686c --- /dev/null +++ b/main.go @@ -0,0 +1,108 @@ +// Command he-dydns updates Hurricane Electric dynamic DNS entries. +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.aehoo.net/alphard/he-dydns/internal/config" + "git.aehoo.net/alphard/he-dydns/internal/updater" +) + +func main() { + log.SetFlags(log.LstdFlags) + + daemon := false + configPath := "" + + args := os.Args[1:] + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "daemon": + daemon = true + case arg == "-h" || arg == "--help" || arg == "help": + usage() + return + case arg == "--config" || arg == "-config": + i++ + if i >= len(args) { + fmt.Fprintf(os.Stderr, "%s requires a path argument\n", arg) + usage() + os.Exit(2) + } + configPath = args[i] + case strings.HasPrefix(arg, "--config="): + configPath = strings.TrimPrefix(arg, "--config=") + case strings.HasPrefix(arg, "-config="): + configPath = strings.TrimPrefix(arg, "-config=") + default: + fmt.Fprintf(os.Stderr, "unknown argument: %s\n", arg) + usage() + os.Exit(2) + } + } + + if configPath == "" { + var err error + configPath, err = config.DefaultPath() + if err != nil { + log.Fatalf("fatal: %v", err) + } + } + + cfg, err := config.Load(configPath) + if err != nil { + log.Fatalf("fatal: %v", err) + } + + runErr := updater.Run(cfg) + if runErr != nil { + log.Printf("update pass completed with errors: %v", runErr) + } + + if !daemon { + if runErr != nil { + os.Exit(1) + } + return + } + + interval := time.Duration(cfg.IntervalMinutes) * time.Minute + log.Printf("entering daemon mode, updating every %d minute(s)", cfg.IntervalMinutes) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + for { + select { + case <-ticker.C: + if err := updater.Run(cfg); err != nil { + log.Printf("update pass completed with errors: %v", err) + } + case sig := <-sigCh: + log.Printf("received %s, shutting down", sig) + return + } + } +} + +func usage() { + defaultPath := "~/.config/he-dydns/config.toml" + if p, err := config.DefaultPath(); err == nil { + defaultPath = p + } + + fmt.Fprintln(os.Stderr, "usage: he-dydns [--config path] [daemon]") + fmt.Fprintf(os.Stderr, " --config path load configuration from path instead of %s\n", defaultPath) + fmt.Fprintln(os.Stderr, " (no args) run one update pass and exit") + fmt.Fprintln(os.Stderr, " daemon run one update pass, then repeat on the configured interval") +}