Compare commits
1 Commits
fix/67-rea
...
fix/empty-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713a2b7332 |
@@ -15,6 +15,12 @@ import (
|
|||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoTargets is returned when DNSWATCHER_TARGETS is empty or unset.
|
||||||
|
var ErrNoTargets = errors.New(
|
||||||
|
"no targets configured: set DNSWATCHER_TARGETS to a comma-separated " +
|
||||||
|
"list of DNS names to monitor",
|
||||||
|
)
|
||||||
|
|
||||||
// Default configuration values.
|
// Default configuration values.
|
||||||
const (
|
const (
|
||||||
defaultPort = 8080
|
defaultPort = 8080
|
||||||
@@ -118,25 +124,9 @@ func buildConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsInterval, err := time.ParseDuration(
|
domains, hostnames, err := classifyAndValidateTargets()
|
||||||
viper.GetString("DNS_INTERVAL"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dnsInterval = defaultDNSInterval
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
tlsInterval, err := time.ParseDuration(
|
|
||||||
viper.GetString("TLS_INTERVAL"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
tlsInterval = defaultTLSInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
domains, hostnames, err := ClassifyTargets(
|
|
||||||
parseCSV(viper.GetString("TARGETS")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid targets configuration: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
@@ -148,8 +138,8 @@ func buildConfig(
|
|||||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||||
DNSInterval: dnsInterval,
|
DNSInterval: parseDurationOrDefault("DNS_INTERVAL", defaultDNSInterval),
|
||||||
TLSInterval: tlsInterval,
|
TLSInterval: parseDurationOrDefault("TLS_INTERVAL", defaultTLSInterval),
|
||||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
@@ -162,6 +152,32 @@ func buildConfig(
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classifyAndValidateTargets() ([]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 parseDurationOrDefault(key string, fallback time.Duration) time.Duration {
|
||||||
|
d, err := time.ParseDuration(viper.GetString(key))
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
func parseCSV(input string) []string {
|
func parseCSV(input string) []string {
|
||||||
if input == "" {
|
if input == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
87
internal/config/config_test.go
Normal file
87
internal/config/config_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewReturnsErrNoTargetsWhenEmpty(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() because t.Setenv modifies the process
|
||||||
|
// environment.
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "")
|
||||||
|
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
func() *globals.Globals {
|
||||||
|
return &globals.Globals{
|
||||||
|
Appname: "dnswatcher-test-empty",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&cfg),
|
||||||
|
fx.NopLogger,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := app.Err()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal(
|
||||||
|
"expected error when DNSWATCHER_TARGETS is empty, got nil",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, config.ErrNoTargets) {
|
||||||
|
t.Errorf("expected ErrNoTargets, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSucceedsWithTargets(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() because t.Setenv modifies the process
|
||||||
|
// environment.
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
|
// Prevent loading a local config file by changing to a temp dir.
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.Provide(
|
||||||
|
func() *globals.Globals {
|
||||||
|
return &globals.Globals{
|
||||||
|
Appname: "dnswatcher-test-ok",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&cfg),
|
||||||
|
fx.NopLogger,
|
||||||
|
)
|
||||||
|
|
||||||
|
err := app.Err()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected no error with valid targets, got: %v",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Domains) != 1 || cfg.Domains[0] != "example.com" {
|
||||||
|
t.Errorf(
|
||||||
|
"expected [example.com], got domains=%v",
|
||||||
|
cfg.Domains,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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,11 +8,9 @@ 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.
|
||||||
@@ -22,8 +20,6 @@ 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.
|
||||||
@@ -32,8 +28,6 @@ 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.
|
||||||
@@ -43,8 +37,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +44,7 @@ func (h *Handlers) respondJSON(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
_ *http.Request,
|
_ *http.Request,
|
||||||
data any,
|
data any,
|
||||||
status int, //nolint:unparam // general-purpose utility; status varies in future use
|
status int,
|
||||||
) {
|
) {
|
||||||
writer.Header().Set("Content-Type", "application/json")
|
writer.Header().Set("Content-Type", "application/json")
|
||||||
writer.WriteHeader(status)
|
writer.WriteHeader(status)
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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,8 +28,6 @@ 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