Compare commits
1 Commits
673302c130
...
fix/67-rea
| Author | SHA1 | Date | |
|---|---|---|---|
| 83643f84ab |
14
README.md
14
README.md
@@ -52,6 +52,10 @@ without requiring an external database.
|
|||||||
responding again.
|
responding again.
|
||||||
- **Inconsistency detected**: Two nameservers that previously agreed
|
- **Inconsistency detected**: Two nameservers that previously agreed
|
||||||
now return different record sets for the same hostname.
|
now return different record sets for the same hostname.
|
||||||
|
- **Inconsistency resolved**: Nameservers that previously disagreed
|
||||||
|
are now back in agreement.
|
||||||
|
- **Empty response**: A nameserver that previously returned records
|
||||||
|
now returns an authoritative empty response (NODATA/NXDOMAIN).
|
||||||
|
|
||||||
### TCP Port Monitoring
|
### TCP Port Monitoring
|
||||||
|
|
||||||
@@ -132,6 +136,8 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
|||||||
|---------------------------------------|--------------------------------|
|
|---------------------------------------|--------------------------------|
|
||||||
| `GET /health` | Health check (JSON) |
|
| `GET /health` | Health check (JSON) |
|
||||||
| `GET /api/v1/status` | Current monitoring state |
|
| `GET /api/v1/status` | Current monitoring state |
|
||||||
|
| `GET /api/v1/domains` | Configured domains and status |
|
||||||
|
| `GET /api/v1/hostnames` | Configured hostnames and status|
|
||||||
| `GET /metrics` | Prometheus metrics (optional) |
|
| `GET /metrics` | Prometheus metrics (optional) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -204,12 +210,6 @@ the following precedence (highest to lowest):
|
|||||||
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
|
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
|
||||||
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
||||||
|
|
||||||
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
|
||||||
monitoring targets are configured. A monitoring daemon with nothing to monitor
|
|
||||||
is a misconfiguration, so dnswatcher fails fast with a clear error message
|
|
||||||
rather than running silently. Set `DNSWATCHER_TARGETS` to a comma-separated
|
|
||||||
list of DNS names before starting.
|
|
||||||
|
|
||||||
### Example `.env`
|
### Example `.env`
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -319,6 +319,8 @@ tracks reachability:
|
|||||||
|-------------|-------------------------------------------------|
|
|-------------|-------------------------------------------------|
|
||||||
| `ok` | Query succeeded, records are current |
|
| `ok` | Query succeeded, records are current |
|
||||||
| `error` | Query failed (timeout, SERVFAIL, network error) |
|
| `error` | Query failed (timeout, SERVFAIL, network error) |
|
||||||
|
| `nxdomain` | Authoritative NXDOMAIN response |
|
||||||
|
| `nodata` | Authoritative empty response (NODATA) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ const (
|
|||||||
defaultTLSExpiryWarning = 7
|
defaultTLSExpiryWarning = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNoTargets is returned when no monitoring targets are configured.
|
|
||||||
var ErrNoTargets = errors.New(
|
|
||||||
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Params contains dependencies for Config.
|
// Params contains dependencies for Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -137,9 +132,11 @@ func buildConfig(
|
|||||||
tlsInterval = defaultTLSInterval
|
tlsInterval = defaultTLSInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
domains, hostnames, err := parseAndValidateTargets()
|
domains, hostnames, err := ClassifyTargets(
|
||||||
|
parseCSV(viper.GetString("TARGETS")),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("invalid targets configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
@@ -165,23 +162,6 @@ func buildConfig(
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAndValidateTargets() ([]string, []string, error) {
|
|
||||||
domains, hostnames, err := ClassifyTargets(
|
|
||||||
parseCSV(viper.GetString("TARGETS")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"invalid targets configuration: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(domains) == 0 && len(hostnames) == 0 {
|
|
||||||
return nil, nil, ErrNoTargets
|
|
||||||
}
|
|
||||||
|
|
||||||
return domains, hostnames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCSV(input string) []string {
|
func parseCSV(input string) []string {
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
60
internal/handlers/domains.go
Normal file
60
internal/handlers/domains.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainResponse represents a single domain in the API response.
|
||||||
|
type domainResponse struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Nameservers []string `json:"nameservers,omitempty"`
|
||||||
|
LastChecked string `json:"lastChecked,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainsResponse is the top-level response for GET /api/v1/domains.
|
||||||
|
type domainsResponse struct {
|
||||||
|
Domains []domainResponse `json:"domains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDomains returns the configured domains and their status.
|
||||||
|
func (h *Handlers) HandleDomains() http.HandlerFunc {
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
configured := h.config.Domains
|
||||||
|
snapshot := h.state.GetSnapshot()
|
||||||
|
|
||||||
|
domains := make(
|
||||||
|
[]domainResponse, 0, len(configured),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, domain := range configured {
|
||||||
|
dr := domainResponse{
|
||||||
|
Domain: domain,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
ds, ok := snapshot.Domains[domain]
|
||||||
|
if ok {
|
||||||
|
dr.Nameservers = ds.Nameservers
|
||||||
|
dr.Status = "ok"
|
||||||
|
|
||||||
|
if !ds.LastChecked.IsZero() {
|
||||||
|
dr.LastChecked = ds.LastChecked.
|
||||||
|
Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domains = append(domains, dr)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respondJSON(
|
||||||
|
writer, request,
|
||||||
|
&domainsResponse{Domains: domains},
|
||||||
|
http.StatusOK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Handlers.
|
// Params contains dependencies for Handlers.
|
||||||
@@ -20,6 +22,8 @@ type Params struct {
|
|||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
State *state.State
|
||||||
|
Config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
@@ -28,6 +32,8 @@ type Handlers struct {
|
|||||||
params *Params
|
params *Params
|
||||||
globals *globals.Globals
|
globals *globals.Globals
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
|
state *state.State
|
||||||
|
config *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -37,6 +43,8 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
|||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
globals: params.Globals,
|
globals: params.Globals,
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
|
state: params.State,
|
||||||
|
config: params.Config,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +52,7 @@ func (h *Handlers) respondJSON(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
data any,
|
data any,
|
||||||
status int,
|
status int, //nolint:unparam // general-purpose utility; status varies in future use
|
||||||
) {
|
) {
|
||||||
writer.Header().Set("Content-Type", "application/json")
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
writer.WriteHeader(status)
|
writer.WriteHeader(status)
|
||||||
|
|||||||
120
internal/handlers/hostnames.go
Normal file
120
internal/handlers/hostnames.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nameserverRecordResponse represents one nameserver's records
|
||||||
|
// in the API response.
|
||||||
|
type nameserverRecordResponse struct {
|
||||||
|
Nameserver string `json:"nameserver"`
|
||||||
|
Records map[string][]string `json:"records"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
LastChecked string `json:"lastChecked,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostnameResponse represents a single hostname in the API response.
|
||||||
|
type hostnameResponse struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Nameservers []nameserverRecordResponse `json:"nameservers,omitempty"`
|
||||||
|
LastChecked string `json:"lastChecked,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostnamesResponse is the top-level response for
|
||||||
|
// GET /api/v1/hostnames.
|
||||||
|
type hostnamesResponse struct {
|
||||||
|
Hostnames []hostnameResponse `json:"hostnames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHostnames returns the configured hostnames and their status.
|
||||||
|
func (h *Handlers) HandleHostnames() http.HandlerFunc {
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
configured := h.config.Hostnames
|
||||||
|
snapshot := h.state.GetSnapshot()
|
||||||
|
|
||||||
|
hostnames := make(
|
||||||
|
[]hostnameResponse, 0, len(configured),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, hostname := range configured {
|
||||||
|
hr := hostnameResponse{
|
||||||
|
Hostname: hostname,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
hs, ok := snapshot.Hostnames[hostname]
|
||||||
|
if ok {
|
||||||
|
hr.Status = "ok"
|
||||||
|
|
||||||
|
if !hs.LastChecked.IsZero() {
|
||||||
|
hr.LastChecked = hs.LastChecked.
|
||||||
|
Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
hr.Nameservers = buildNameserverRecords(
|
||||||
|
hs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostnames = append(hostnames, hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.respondJSON(
|
||||||
|
writer, request,
|
||||||
|
&hostnamesResponse{Hostnames: hostnames},
|
||||||
|
http.StatusOK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNameserverRecords converts the per-nameserver state map
|
||||||
|
// into a sorted slice for deterministic JSON output.
|
||||||
|
func buildNameserverRecords(
|
||||||
|
hs *state.HostnameState,
|
||||||
|
) []nameserverRecordResponse {
|
||||||
|
if hs.RecordsByNameserver == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nsNames := make(
|
||||||
|
[]string, 0, len(hs.RecordsByNameserver),
|
||||||
|
)
|
||||||
|
for ns := range hs.RecordsByNameserver {
|
||||||
|
nsNames = append(nsNames, ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(nsNames)
|
||||||
|
|
||||||
|
records := make(
|
||||||
|
[]nameserverRecordResponse, 0, len(nsNames),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, ns := range nsNames {
|
||||||
|
nsr := hs.RecordsByNameserver[ns]
|
||||||
|
|
||||||
|
entry := nameserverRecordResponse{
|
||||||
|
Nameserver: ns,
|
||||||
|
Records: nsr.Records,
|
||||||
|
Status: nsr.Status,
|
||||||
|
Error: nsr.Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !nsr.LastChecked.IsZero() {
|
||||||
|
entry.LastChecked = nsr.LastChecked.
|
||||||
|
Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ func (s *Server) SetupRoutes() {
|
|||||||
// API v1 routes
|
// API v1 routes
|
||||||
s.router.Route("/api/v1", func(r chi.Router) {
|
s.router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Get("/status", s.handlers.HandleStatus())
|
r.Get("/status", s.handlers.HandleStatus())
|
||||||
|
r.Get("/domains", s.handlers.HandleDomains())
|
||||||
|
r.Get("/hostnames", s.handlers.HandleHostnames())
|
||||||
})
|
})
|
||||||
|
|
||||||
// Metrics endpoint (optional, with basic auth)
|
// Metrics endpoint (optional, with basic auth)
|
||||||
|
|||||||
Reference in New Issue
Block a user