Compare commits
5 Commits
fix/67-rea
...
069bf61028
| Author | SHA1 | Date | |
|---|---|---|---|
| 069bf61028 | |||
| d6130e5892 | |||
|
|
e8cd0705a7 | ||
| 0a74971ade | |||
| e882e7d237 |
14
README.md
14
README.md
@@ -52,10 +52,6 @@ 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
|
||||||
|
|
||||||
@@ -136,8 +132,6 @@ 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -210,6 +204,12 @@ 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,8 +319,6 @@ 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,6 +23,11 @@ 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
|
||||||
@@ -132,11 +137,9 @@ func buildConfig(
|
|||||||
tlsInterval = defaultTLSInterval
|
tlsInterval = defaultTLSInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
domains, hostnames, err := ClassifyTargets(
|
domains, hostnames, err := parseAndValidateTargets()
|
||||||
parseCSV(viper.GetString("TARGETS")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid targets configuration: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
@@ -162,6 +165,23 @@ 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
|
||||||
|
|||||||
260
internal/config/config_test.go
Normal file
260
internal/config/config_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestParams creates config.Params suitable for testing
|
||||||
|
// without requiring the fx dependency injection framework.
|
||||||
|
func newTestParams(t *testing.T) config.Params {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
g := &globals.Globals{
|
||||||
|
Appname: "dnswatcher",
|
||||||
|
Version: "test",
|
||||||
|
Buildarch: "amd64",
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := logger.New(nil, logger.Params{Globals: g})
|
||||||
|
require.NoError(t, err, "failed to create logger")
|
||||||
|
|
||||||
|
return config.Params{
|
||||||
|
Globals: g,
|
||||||
|
Logger: l,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These tests exercise viper global state and MUST NOT use
|
||||||
|
// t.Parallel(). Each test resets viper for isolation.
|
||||||
|
|
||||||
|
func TestNew_DefaultValues(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
|
assert.False(t, cfg.Debug)
|
||||||
|
assert.Equal(t, "./data", cfg.DataDir)
|
||||||
|
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
||||||
|
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
||||||
|
assert.Equal(t, 7, cfg.TLSExpiryWarning)
|
||||||
|
assert.False(t, cfg.MaintenanceMode)
|
||||||
|
assert.Empty(t, cfg.SlackWebhook)
|
||||||
|
assert.Empty(t, cfg.MattermostWebhook)
|
||||||
|
assert.Empty(t, cfg.NtfyTopic)
|
||||||
|
assert.Empty(t, cfg.SentryDSN)
|
||||||
|
assert.Empty(t, cfg.MetricsUsername)
|
||||||
|
assert.Empty(t, cfg.MetricsPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_EnvironmentOverrides(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("PORT", "9090")
|
||||||
|
t.Setenv("DNSWATCHER_DEBUG", "true")
|
||||||
|
t.Setenv("DNSWATCHER_DATA_DIR", "/tmp/test-data")
|
||||||
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "30m")
|
||||||
|
t.Setenv("DNSWATCHER_TLS_INTERVAL", "6h")
|
||||||
|
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "14")
|
||||||
|
t.Setenv("DNSWATCHER_SLACK_WEBHOOK", "https://hooks.slack.com/t")
|
||||||
|
t.Setenv("DNSWATCHER_MATTERMOST_WEBHOOK", "https://mm.test/hooks/t")
|
||||||
|
t.Setenv("DNSWATCHER_NTFY_TOPIC", "https://ntfy.sh/test")
|
||||||
|
t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1")
|
||||||
|
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "true")
|
||||||
|
t.Setenv("DNSWATCHER_METRICS_USERNAME", "admin")
|
||||||
|
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "secret")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 9090, cfg.Port)
|
||||||
|
assert.True(t, cfg.Debug)
|
||||||
|
assert.Equal(t, "/tmp/test-data", cfg.DataDir)
|
||||||
|
assert.Equal(t, 30*time.Minute, cfg.DNSInterval)
|
||||||
|
assert.Equal(t, 6*time.Hour, cfg.TLSInterval)
|
||||||
|
assert.Equal(t, 14, cfg.TLSExpiryWarning)
|
||||||
|
assert.Equal(t, "https://hooks.slack.com/t", cfg.SlackWebhook)
|
||||||
|
assert.Equal(t, "https://mm.test/hooks/t", cfg.MattermostWebhook)
|
||||||
|
assert.Equal(t, "https://ntfy.sh/test", cfg.NtfyTopic)
|
||||||
|
assert.Equal(t, "https://sentry.test/1", cfg.SentryDSN)
|
||||||
|
assert.True(t, cfg.MaintenanceMode)
|
||||||
|
assert.Equal(t, "admin", cfg.MetricsUsername)
|
||||||
|
assert.Equal(t, "secret", cfg.MetricsPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_NoTargetsError(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "")
|
||||||
|
|
||||||
|
_, err := config.New(nil, newTestParams(t))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, config.ErrNoTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", " , , ")
|
||||||
|
|
||||||
|
_, err := config.New(nil, newTestParams(t))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, config.ErrNoTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_InvalidDNSInterval_FallsBackToDefault(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "banana")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, time.Hour, cfg.DNSInterval,
|
||||||
|
"invalid DNS interval should fall back to 1h default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_InvalidTLSInterval_FallsBackToDefault(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_TLS_INTERVAL", "notaduration")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 12*time.Hour, cfg.TLSInterval,
|
||||||
|
"invalid TLS interval should fall back to 12h default")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_BothIntervalsInvalid(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "xyz")
|
||||||
|
t.Setenv("DNSWATCHER_TLS_INTERVAL", "abc")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
||||||
|
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_DEBUG", "true")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, cfg.Debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_PortEnvNotPrefixed(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("PORT", "3000")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 3000, cfg.Port,
|
||||||
|
"PORT env should work without DNSWATCHER_ prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_TargetClassification(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS",
|
||||||
|
"example.com,www.example.com,api.example.com,example.org")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// example.com and example.org are apex domains
|
||||||
|
assert.Len(t, cfg.Domains, 2)
|
||||||
|
// www.example.com and api.example.com are hostnames
|
||||||
|
assert.Len(t, cfg.Hostnames, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_InvalidTargetPublicSuffix(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "co.uk")
|
||||||
|
|
||||||
|
_, err := config.New(nil, newTestParams(t))
|
||||||
|
require.Error(t, err, "public suffix should be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_EmptyAppnameDefaultsToDnswatcher(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
|
||||||
|
g := &globals.Globals{Appname: "", Version: "test"}
|
||||||
|
|
||||||
|
l, err := logger.New(nil, logger.Params{Globals: g})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg, err := config.New(
|
||||||
|
nil, config.Params{Globals: g, Logger: l},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 8080, cfg.Port,
|
||||||
|
"defaults should load when appname is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_TargetsWithWhitespace(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", " example.com , www.example.com ")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
|
||||||
|
"whitespace around targets should be trimmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_TargetsWithTrailingComma(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com,www.example.com,")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(cfg.Domains)+len(cfg.Hostnames),
|
||||||
|
"trailing comma should be ignored")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "5s")
|
||||||
|
|
||||||
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 5*time.Second, cfg.DNSInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dataDir string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"default", "./data", "./data/state.json"},
|
||||||
|
{"absolute", "/var/lib/dw", "/var/lib/dw/state.json"},
|
||||||
|
{"nested", "/opt/app/data", "/opt/app/data/state.json"},
|
||||||
|
{"empty", "", "/state.json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &config.Config{DataDir: tt.dataDir}
|
||||||
|
assert.Equal(t, tt.want, cfg.StatePath())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/config/export_test.go
Normal file
6
internal/config/export_test.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// ParseCSVForTest exports parseCSV for use in external tests.
|
||||||
|
func ParseCSVForTest(input string) []string {
|
||||||
|
return parseCSV(input)
|
||||||
|
}
|
||||||
44
internal/config/parsecsv_test.go
Normal file
44
internal/config/parsecsv_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCSV(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"empty string", "", nil},
|
||||||
|
{"single value", "a", []string{"a"}},
|
||||||
|
{"multiple values", "a,b,c", []string{"a", "b", "c"}},
|
||||||
|
{"whitespace trimmed", " a , b ", []string{"a", "b"}},
|
||||||
|
{"trailing comma", "a,b,", []string{"a", "b"}},
|
||||||
|
{"leading comma", ",a,b", []string{"a", "b"}},
|
||||||
|
{"consecutive commas", "a,,b", []string{"a", "b"}},
|
||||||
|
{"all empty segments", ",,,", nil},
|
||||||
|
{"whitespace only", " , , ", nil},
|
||||||
|
{"tabs", "\ta\t,\tb\t", []string{"a", "b"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := config.ParseCSVForTest(tt.input)
|
||||||
|
require.Len(t, got, len(tt.want))
|
||||||
|
|
||||||
|
for i, w := range tt.want {
|
||||||
|
assert.Equal(t, w, got[i])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
1130
internal/notify/delivery_test.go
Normal file
1130
internal/notify/delivery_test.go
Normal file
File diff suppressed because it is too large
Load Diff
75
internal/notify/export_test.go
Normal file
75
internal/notify/export_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NtfyPriority exports ntfyPriority for testing.
|
||||||
|
func NtfyPriority(priority string) string {
|
||||||
|
return ntfyPriority(priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlackColor exports slackColor for testing.
|
||||||
|
func SlackColor(priority string) string {
|
||||||
|
return slackColor(priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRequestForTest exports newRequest for testing.
|
||||||
|
func NewRequestForTest(
|
||||||
|
ctx context.Context,
|
||||||
|
method string,
|
||||||
|
target *url.URL,
|
||||||
|
body io.Reader,
|
||||||
|
) *http.Request {
|
||||||
|
return newRequest(ctx, method, target, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestService creates a Service suitable for unit testing.
|
||||||
|
// It discards log output and uses the given transport.
|
||||||
|
func NewTestService(transport http.RoundTripper) *Service {
|
||||||
|
return &Service{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
transport: transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNtfyURL sets the ntfy URL on a Service for testing.
|
||||||
|
func (svc *Service) SetNtfyURL(u *url.URL) {
|
||||||
|
svc.ntfyURL = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSlackWebhookURL sets the Slack webhook URL on a
|
||||||
|
// Service for testing.
|
||||||
|
func (svc *Service) SetSlackWebhookURL(u *url.URL) {
|
||||||
|
svc.slackWebhookURL = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMattermostWebhookURL sets the Mattermost webhook URL on
|
||||||
|
// a Service for testing.
|
||||||
|
func (svc *Service) SetMattermostWebhookURL(u *url.URL) {
|
||||||
|
svc.mattermostWebhookURL = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNtfy exports sendNtfy for testing.
|
||||||
|
func (svc *Service) SendNtfy(
|
||||||
|
ctx context.Context,
|
||||||
|
topicURL *url.URL,
|
||||||
|
title, message, priority string,
|
||||||
|
) error {
|
||||||
|
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSlack exports sendSlack for testing.
|
||||||
|
func (svc *Service) SendSlack(
|
||||||
|
ctx context.Context,
|
||||||
|
webhookURL *url.URL,
|
||||||
|
title, message, priority string,
|
||||||
|
) error {
|
||||||
|
return svc.sendSlack(
|
||||||
|
ctx, webhookURL, title, message, priority,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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