primeiro commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
he-dydns
|
||||||
@@ -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 ./...
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module git.aehoo.net/alphard/he-dydns
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user