Initial implementation: dns-updater daemon
RFC2136 dynamic-DNS updater. Watches a records file (inotify) and new interface addresses and pushes TSIG-signed updates to BIND per zone, sending only the delta. Native miekg/dns (structured per-zone RCODEs), local status API + facter fact, systemd unit, nfpm RPM, Woodpecker CI. Replaces the puppet dns-update shell script; keeps the same records-file and TSIG-key contract.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
// Package api exposes the daemon's last-reconcile status over a local HTTP
|
||||
// endpoint (a unix socket by default) so puppet's facter — or a health check —
|
||||
// can see whether records are actually live on the server, without re-querying
|
||||
// DNS itself. It reports results and health, not the desired record set (puppet
|
||||
// already owns that, it writes the records file).
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ZoneStatus is the outcome of the last update for one zone.
|
||||
type ZoneStatus struct {
|
||||
Zone string `json:"zone"`
|
||||
Adds int `json:"adds"`
|
||||
Deletes int `json:"deletes"`
|
||||
Rcode int `json:"rcode"`
|
||||
RcodeText string `json:"rcode_text"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Status is the daemon's current view, serialised to JSON.
|
||||
type Status struct {
|
||||
Version string `json:"version"`
|
||||
Server string `json:"server"`
|
||||
RecordsFile string `json:"records_file"`
|
||||
Healthy bool `json:"healthy"`
|
||||
ManagedRecords int `json:"managed_records"`
|
||||
LastReconcile time.Time `json:"last_reconcile"`
|
||||
LastChange time.Time `json:"last_change,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Zones []ZoneStatus `json:"zones"`
|
||||
}
|
||||
|
||||
// Store holds the latest Status behind a mutex.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
s Status
|
||||
}
|
||||
|
||||
// NewStore seeds a Store with static fields.
|
||||
func NewStore(version, server, recordsFile string) *Store {
|
||||
return &Store{s: Status{Version: version, Server: server, RecordsFile: recordsFile}}
|
||||
}
|
||||
|
||||
// Set replaces the dynamic portion of the status.
|
||||
func (st *Store) Set(s Status) {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
// preserve static identity fields
|
||||
s.Version, s.Server, s.RecordsFile = st.s.Version, st.s.Server, st.s.RecordsFile
|
||||
st.s = s
|
||||
}
|
||||
|
||||
// Get returns a copy of the current status.
|
||||
func (st *Store) Get() Status {
|
||||
st.mu.RLock()
|
||||
defer st.mu.RUnlock()
|
||||
return st.s
|
||||
}
|
||||
|
||||
// Serve starts an HTTP server on addr. If addr contains a '/', it is treated as
|
||||
// a unix socket path; otherwise as a TCP address. It returns once ctx is done.
|
||||
func Serve(ctx context.Context, addr string, store *Store) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, store.Get())
|
||||
})
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
s := store.Get()
|
||||
code := http.StatusOK
|
||||
if !s.Healthy {
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
writeJSON(w, code, map[string]any{"healthy": s.Healthy, "last_error": s.LastError})
|
||||
})
|
||||
|
||||
ln, err := listen(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
}()
|
||||
err = srv.Serve(ln)
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func listen(addr string) (net.Listener, error) {
|
||||
if strings.Contains(addr, "/") {
|
||||
if err := os.MkdirAll(filepath.Dir(addr), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Remove(addr) // clear a stale socket from an unclean exit
|
||||
ln, err := net.Listen("unix", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Chmod(addr, 0o660)
|
||||
return ln, nil
|
||||
}
|
||||
return net.Listen("tcp", addr)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user