02e3e0315d
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.
127 lines
3.6 KiB
Go
127 lines
3.6 KiB
Go
// 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)
|
|
}
|