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
+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
}