primeiro commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user