summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.go53
-rw-r--r--go.mod3
-rw-r--r--main.go118
-rw-r--r--provider/dummy.go32
-rw-r--r--provider/provider.go104
-rw-r--r--source/file.go59
-rw-r--r--source/source.go91
-rw-r--r--source/static.go44
-rw-r--r--source/url.go49
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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..5c4dcb9
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module git.rnrd.eu/ddnnss
+
+go 1.23
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..cc99e9e
--- /dev/null
+++ b/main.go
@@ -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
+}