primeiro commit

This commit is contained in:
2026-09-16 00:18:01 -03:00
commit 1a5a446b5c
10 changed files with 472 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
he-dydns
+28
View File
@@ -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 ./...
+17
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
module git.aehoo.net/alphard/he-dydns
go 1.24.4
require github.com/BurntSushi/toml v1.6.0 // indirect
+2
View File
@@ -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=
+63
View File
@@ -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
}
+61
View File
@@ -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)
}
}
+128
View File
@@ -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
}
+59
View File
@@ -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
}
+108
View File
@@ -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")
}