diff options
| author | Mel <mel@rnrd.eu> | 2026-08-20 06:18:05 +0200 |
|---|---|---|
| committer | Mel <mel@rnrd.eu> | 2026-08-20 06:18:05 +0200 |
| commit | b52c860a7b1d3f57abc442bb8c1257f228d49536 (patch) | |
| tree | 524865405ba3b972d684ded62d7ab67547bb9040 | |
| download | ddnnss-b52c860a7b1d3f57abc442bb8c1257f228d49536.tar.zst ddnnss-b52c860a7b1d3f57abc442bb8c1257f228d49536.zip | |
ddnnss
Signed-off-by: Mel <mel@rnrd.eu>
| -rw-r--r-- | config.go | 53 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | main.go | 118 | ||||
| -rw-r--r-- | provider/dummy.go | 32 | ||||
| -rw-r--r-- | provider/provider.go | 104 | ||||
| -rw-r--r-- | source/file.go | 59 | ||||
| -rw-r--r-- | source/source.go | 91 | ||||
| -rw-r--r-- | source/static.go | 44 | ||||
| -rw-r--r-- | source/url.go | 49 |
9 files changed, 553 insertions, 0 deletions
diff --git a/config.go b/config.go new file mode 100644 index 0000000..9cd614d --- /dev/null +++ b/config.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + "git.rnrd.eu/ddnnss/provider" + "git.rnrd.eu/ddnnss/source" +) + +// a record target which we will try to update +// via the provider! +type Record struct { + Zone string `json:"zone"` + Name string `json:"name"` + Type provider.RecordType `json:"type"` + Provider string `json:"provider"` + Source string `json:"source"` +} + +type Config struct { + Records []Record `json:"records"` + Providers map[string]provider.Config `json:"providers"` + Sources map[string]source.Config `json:"sources"` +} + +func LoadConfig(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + for i, r := range cfg.Records { + switch r.Type { + case provider.TypeA, provider.TypeAAAA: + default: + return nil, fmt.Errorf("record %d: type must be A or AAAA", i) + } + if _, ok := cfg.Providers[r.Provider]; !ok { + return nil, fmt.Errorf("record %d: unknown provider %q", i, r.Provider) + } + if _, ok := cfg.Sources[r.Source]; !ok { + return nil, fmt.Errorf("record %d: unknown source %q", i, r.Source) + } + } + return &cfg, nil +} @@ -0,0 +1,3 @@ +module git.rnrd.eu/ddnnss + +go 1.23 @@ -0,0 +1,118 @@ +// dynamic domain no-name system service +// +// a simple dynamic dns record updating utility which fetches your +// ip address from a source, and lets a provider know of the fresh +// address. +package main + +import ( + "flag" + "fmt" + "log/slog" + "os" + + "git.rnrd.eu/ddnnss/provider" + "git.rnrd.eu/ddnnss/source" +) + +const version = "1.0.0" + +func main() { + configPath := flag.String("config", "/etc/ddnnss.json", "config file") + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + + if *showVersion { + fmt.Println("ddnnss", version) + return + } + + slog.Info("ddnnss!", slog.String("version", version)) + + cfg, err := LoadConfig(*configPath) + if err != nil { + slog.Error("could not load config", slog.String("error", err.Error())) + os.Exit(1) + } + + providers := map[string]provider.Provider{} + sources := map[string]source.Source{} + + slog.Info("starting new cycle", slog.Int("records", len(cfg.Records))) + + failed := false + for _, r := range cfg.Records { + fqdn := r.Name + "." + r.Zone + log := slog.With("record", fqdn, "type", r.Type) + + log.Info( + "ensuring record", + slog.String("provider", r.Provider), + slog.String("source", r.Source), + ) + + p, ok := providers[r.Provider] + if !ok { + p, err = provider.New(log, cfg.Providers[r.Provider]) + if err != nil { + log.Error("provider setup failed", "provider", r.Provider, "error", err) + failed = true + continue + } + providers[r.Provider] = p + } + + s, ok := sources[r.Source] + if !ok { + s, err = source.New(log, cfg.Sources[r.Source]) + if err != nil { + log.Error("source setup failed", "source", r.Source, "error", err) + failed = true + continue + } + sources[r.Source] = s + } + + family := source.IPv4 + if r.Type == provider.TypeAAAA { + family = source.IPv6 + } + if !s.Supports(family) { + log.Error("source cannot provide the needed address family", + "source", r.Source, "family", family) + failed = true + continue + } + + addr, err := s.Lookup(family) + if err != nil { + log.Error("address lookup failed", "source", r.Source, "error", err) + failed = true + continue + } + + prev, changed, err := p.Ensure(r.Zone, r.Name, r.Type, addr) + switch { + case err != nil: + log.Error("update failed", "error", err) + failed = true + case changed && prev == "": + log.Info("record created", "addr", addr) + case changed: + log.Info("record updated", "from", prev, "to", addr) + default: + log.Info("record unchanged", "addr", addr) + } + } + + slog.Info( + "cycle finished", + slog.Int("records", len(cfg.Records)), + slog.Bool("failed", failed), + ) + + if failed { + slog.Error("not all records were ensured successfully") + os.Exit(1) + } +} diff --git a/provider/dummy.go b/provider/dummy.go new file mode 100644 index 0000000..bec5907 --- /dev/null +++ b/provider/dummy.go @@ -0,0 +1,32 @@ +package provider + +import ( + "log/slog" + "net/netip" +) + +// a stand-in for a real provider. +// once asked to ensure a record is set, +// it will tell you what was requested, +// and not take any action. +// very useful for debugging! +type dummy struct { + log *slog.Logger +} + +var _ Provider = (*dummy)(nil) + +func newDummy(log *slog.Logger) (*dummy, error) { + return &dummy{log: log.With(slog.String("provider", "dummy"))}, nil +} + +func (d *dummy) Ensure(zone, name string, rtype RecordType, addr netip.Addr) (string, bool, error) { + d.log.Info( + "asked to ensure record, but nothing happened", + slog.String("zone", zone), + slog.String("name", name), + slog.String("record", string(rtype)), + slog.String("addr", addr.String()), + ) + return "", true, nil +} diff --git a/provider/provider.go b/provider/provider.go new file mode 100644 index 0000000..ee31ba8 --- /dev/null +++ b/provider/provider.go @@ -0,0 +1,104 @@ +package provider + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/netip" + "os" + "strings" + "time" +) + +type Kind string + +const ( + // only the generic dummy testing provider for now + KindDummy Kind = "dummy" +) + +type RecordType string + +const ( + TypeA RecordType = "A" + TypeAAAA RecordType = "AAAA" +) + +type Config struct { + Kind Kind `json:"kind"` + + // exactly one of these supplies the api key. + // both expand any $environment variables! + Key string `json:"key,omitempty"` + KeyFile string `json:"keyFile,omitempty"` +} + +func (c Config) key() (string, error) { + if (c.Key == "") == (c.KeyFile == "") { + return "", errors.New("exactly one of key or keyFile must be set") + } + + if c.Key != "" { + key := os.ExpandEnv(c.Key) + if key == "" { + return "", fmt.Errorf("key %q expanded to nothing", c.Key) + } + return key, nil + } + + // expand those environment variables + raw, err := os.ReadFile(os.ExpandEnv(c.KeyFile)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} + +type Provider interface { + // Ensure makes name.zone of the given type point at addr, + // creating the record when it doesn't exist yet. + // Returns the previous value (if any) and whether anything was changed. + Ensure(zone, name string, rtype RecordType, addr netip.Addr) (string, bool, error) +} + +func New(log *slog.Logger, cfg Config) (Provider, error) { + switch cfg.Kind { + case KindDummy: + return newDummy(log) + default: + return nil, fmt.Errorf("unknown provider kind %q", cfg.Kind) + } +} + +var client = http.Client{Timeout: 30 * time.Second} + +// generic request sending helper, since most providers will need it. +func request(method, url string, headers map[string]string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("%s %s: %s: %.200s", method, url, resp.Status, raw) + } + return raw, nil +} diff --git a/source/file.go b/source/file.go new file mode 100644 index 0000000..4ef0e7c --- /dev/null +++ b/source/file.go @@ -0,0 +1,59 @@ +package source + +import ( + "errors" + "fmt" + "log/slog" + "net/netip" + "os" +) + +// a source that reads the address from a file path. +// useful when your record has to be in-sync with +// some configured address on your machine. +type fileSource struct { + log *slog.Logger + path string + family Family +} + +var _ Source = (*fileSource)(nil) + +func newFile(log *slog.Logger, cfg Config) (*fileSource, error) { + log = log.With(slog.String("source", "file")) + + if _, err := os.Stat(cfg.Path); errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("cannot access file for source: %w", err) + } + + return &fileSource{log: log, path: cfg.Path, family: cfg.Family}, nil +} + +func (s *fileSource) Supports(f Family) bool { return f == s.family } + +func (s *fileSource) Lookup(Family) (netip.Addr, error) { + s.log.Info( + "will look up address from file source", + slog.String("path", s.path), + slog.String("family", string(s.family)), + ) + + raw, err := os.ReadFile(s.path) + if err != nil { + return netip.Addr{}, fmt.Errorf("file source read failed: %w", err) + } + content := string(raw) + + addr, err := parse(content, s.family) + if err != nil { + s.log.Error( + "source file does not contain address of expected family", + slog.String("path", s.path), + slog.String("expected", string(s.family)), + slog.String("content", content), + ) + return netip.Addr{}, fmt.Errorf("source file contains bad content: %w", err) + } + + return addr, nil +} diff --git a/source/source.go b/source/source.go new file mode 100644 index 0000000..6fe139c --- /dev/null +++ b/source/source.go @@ -0,0 +1,91 @@ +package source + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "net/netip" + "strings" + "time" +) + +type Kind string + +const ( + // only the simple generic sources for now! + KindStatic Kind = "static" + KindURL Kind = "url" + KindFile Kind = "file" +) + +type Family string + +const ( + IPv4 Family = "ipv4" + IPv6 Family = "ipv6" +) + +type Config struct { + Kind Kind `json:"kind"` + Family Family `json:"family,omitempty"` + + URL string `json:"url,omitempty"` + Path string `json:"path,omitempty"` + Value string `json:"value,omitempty"` +} + +type Source interface { + Supports(Family) bool + Lookup(Family) (netip.Addr, error) +} + +func New(log *slog.Logger, cfg Config) (Source, error) { + if cfg.Family != IPv4 && cfg.Family != IPv6 { + return nil, fmt.Errorf("%s source needs family set to ipv4 or ipv6", cfg.Kind) + } + + if cfg.Kind == KindStatic { + return newStatic(log, cfg) + } + + if cfg.Kind == KindURL { + return newURL(log, cfg) + } + + if cfg.Kind == KindFile { + return newFile(log, cfg) + } + + return nil, fmt.Errorf("unknown source kind %q", cfg.Kind) +} + +// parsing function for ipv4 and ipv6 strings +func parse(raw string, family Family) (netip.Addr, error) { + addr, err := netip.ParseAddr(strings.TrimSpace(raw)) + if err != nil { + return netip.Addr{}, err + } + if addr.Is6() != (family == IPv6) { + return netip.Addr{}, fmt.Errorf("%s is not an %s address", addr, family) + } + return addr, nil +} + +// generic http fetching helper since a lot of sources will need it. +func fetch(url string, family Family) (netip.Addr, error) { + client := http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(url) + if err != nil { + return netip.Addr{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return netip.Addr{}, fmt.Errorf("%s: %s", url, resp.Status) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, 256)) + if err != nil { + return netip.Addr{}, err + } + return parse(string(raw), family) +} diff --git a/source/static.go b/source/static.go new file mode 100644 index 0000000..d2645d6 --- /dev/null +++ b/source/static.go @@ -0,0 +1,44 @@ +package source + +import ( + "fmt" + "log/slog" + "net/netip" +) + +// a simple source that returns the configured address. +// useful when a record needs to have one specific value, +// that is known in advance, and for debugging. +type staticSource struct { + log *slog.Logger + addr netip.Addr +} + +var _ Source = (*staticSource)(nil) + +func newStatic(log *slog.Logger, cfg Config) (*staticSource, error) { + log = log.With("source", "static") + addr, err := parse(cfg.Value, cfg.Family) + if err != nil { + log.Error( + "static source can't be created from value", + slog.String("family", string(cfg.Family)), + slog.String("value", cfg.Value), + ) + return nil, fmt.Errorf("can't create static source from value: %w", err) + } + + return &staticSource{log: log, addr: addr}, nil +} + +func (s *staticSource) Supports(f Family) bool { + return (s.addr.Is4() && f == IPv4) || (s.addr.Is6() && f == IPv6) +} + +func (s *staticSource) Lookup(Family) (netip.Addr, error) { + s.log.Info( + "looked up address from static source", + slog.String("addr", s.addr.String()), + ) + return s.addr, nil +} diff --git a/source/url.go b/source/url.go new file mode 100644 index 0000000..067d48b --- /dev/null +++ b/source/url.go @@ -0,0 +1,49 @@ +package source + +import ( + "fmt" + "log/slog" + "net/netip" + nurl "net/url" +) + +// a simple source that fetches an address from a url. +// sends a http get request to url, and parses whatever it found there. +// can be used to fetch address values from generic "my ip" +// services which don't have direct support. +// note that the address does have to be returned in plain-text! +type urlSource struct { + log *slog.Logger + url *nurl.URL + family Family +} + +var _ Source = (*urlSource)(nil) + +func newURL(log *slog.Logger, cfg Config) (*urlSource, error) { + log = log.With("source", "url") + + url, err := nurl.ParseRequestURI(cfg.URL) + if err != nil { + return nil, fmt.Errorf("invalid url for source: %w", err) + } + + return &urlSource{log: log, url: url, family: cfg.Family}, nil +} + +func (s *urlSource) Supports(f Family) bool { return f == s.family } + +func (s *urlSource) Lookup(Family) (netip.Addr, error) { + s.log.Info( + "will look up address via fetching from url", + slog.String("url", s.url.String()), + slog.String("family", string(s.family)), + ) + + addr, err := fetch(s.url.String(), s.family) + if err != nil { + return netip.Addr{}, fmt.Errorf("failed to source address from url: %w", err) + } + + return addr, nil +} |
