Compare commits
9 Commits
fix/empty-
...
feature/62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bd6c3228 | ||
| b64db3e10f | |||
| 65180ad661 | |||
| 1076543c23 | |||
| 1843d09eb3 | |||
| c5bf16055e | |||
| d6130e5892 | |||
| 0a74971ade | |||
| e882e7d237 |
3
Makefile
3
Makefile
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
BINARY := dnswatcher
|
BINARY := dnswatcher
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
BUILDARCH := $(shell go env GOARCH)
|
LDFLAGS := -X main.Version=$(VERSION)
|
||||||
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|
||||||
|
|
||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
|
|||||||
54
README.md
54
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
|
||||||
|
|
||||||
@@ -128,16 +124,42 @@ includes:
|
|||||||
- State is written atomically (write to temp file, then rename) to prevent
|
- State is written atomically (write to temp file, then rename) to prevent
|
||||||
corruption.
|
corruption.
|
||||||
|
|
||||||
|
### Web Dashboard
|
||||||
|
|
||||||
|
dnswatcher includes an unauthenticated, read-only web dashboard at the
|
||||||
|
root URL (`/`). It displays:
|
||||||
|
|
||||||
|
- **Summary counts** for monitored domains, hostnames, ports, and
|
||||||
|
certificates.
|
||||||
|
- **Domains** with their discovered nameservers.
|
||||||
|
- **Hostnames** with per-nameserver DNS records and status.
|
||||||
|
- **Ports** with open/closed state and associated hostnames.
|
||||||
|
- **TLS certificates** with CN, issuer, expiry, and status.
|
||||||
|
- **Recent alerts** (last 100 notifications sent since the process
|
||||||
|
started), displayed in reverse chronological order.
|
||||||
|
|
||||||
|
Every data point shows its age (e.g. "5m ago") so you can tell at a
|
||||||
|
glance how fresh the information is. The page auto-refreshes every 30
|
||||||
|
seconds.
|
||||||
|
|
||||||
|
The dashboard intentionally does not expose any configuration details
|
||||||
|
such as webhook URLs, notification endpoints, or API tokens.
|
||||||
|
|
||||||
|
All assets (CSS) are embedded in the binary and served from the
|
||||||
|
application itself. The dashboard makes zero external HTTP requests —
|
||||||
|
no CDN dependencies or third-party resources are loaded at runtime.
|
||||||
|
|
||||||
### HTTP API
|
### HTTP API
|
||||||
|
|
||||||
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|---------------------------------------|--------------------------------|
|
|---------------------------------------|--------------------------------|
|
||||||
| `GET /health` | Health check (JSON) |
|
| `GET /` | Web dashboard (HTML) |
|
||||||
|
| `GET /s/...` | Static assets (embedded CSS) |
|
||||||
|
| `GET /.well-known/healthcheck` | Health check (JSON) |
|
||||||
|
| `GET /health` | Health check (JSON, legacy) |
|
||||||
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -149,7 +171,7 @@ cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
|
|||||||
|
|
||||||
internal/
|
internal/
|
||||||
config/config.go Viper-based configuration
|
config/config.go Viper-based configuration
|
||||||
globals/globals.go Build-time variables (version, arch)
|
globals/globals.go Build-time variables (version)
|
||||||
logger/logger.go slog structured logging (TTY detection)
|
logger/logger.go slog structured logging (TTY detection)
|
||||||
healthcheck/healthcheck.go Health check service
|
healthcheck/healthcheck.go Health check service
|
||||||
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
|
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
|
||||||
@@ -209,6 +231,13 @@ the following precedence (highest to lowest):
|
|||||||
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
|
||||||
| `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_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
||||||
|
|
||||||
|
**`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`
|
||||||
|
|
||||||
@@ -220,6 +249,7 @@ DNSWATCHER_TARGETS=example.com,example.org,www.example.com,api.example.com,mail.
|
|||||||
DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
DNSWATCHER_SLACK_WEBHOOK=https://hooks.slack.com/services/T.../B.../xxx
|
||||||
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
DNSWATCHER_MATTERMOST_WEBHOOK=https://mattermost.example.com/hooks/xxx
|
||||||
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-dns-alerts
|
||||||
|
DNSWATCHER_SEND_TEST_NOTIFICATION=true
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -319,8 +349,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) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -337,11 +365,10 @@ make clean # Remove build artifacts
|
|||||||
|
|
||||||
### Build-Time Variables
|
### Build-Time Variables
|
||||||
|
|
||||||
Version and architecture are injected via `-ldflags`:
|
Version is injected via `-ldflags`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go build -ldflags "-X main.Version=$(git describe --tags --always) \
|
go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher
|
||||||
-X main.Buildarch=$(go env GOARCH)" ./cmd/dnswatcher
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -355,6 +382,7 @@ docker run -d \
|
|||||||
-v dnswatcher-data:/var/lib/dnswatcher \
|
-v dnswatcher-data:/var/lib/dnswatcher \
|
||||||
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
-e DNSWATCHER_TARGETS=example.com,www.example.com \
|
||||||
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
-e DNSWATCHER_NTFY_TOPIC=https://ntfy.sh/my-alerts \
|
||||||
|
-e DNSWATCHER_SEND_TEST_NOTIFICATION=true \
|
||||||
dnswatcher
|
dnswatcher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -25,15 +25,13 @@ import (
|
|||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // build-time variables
|
//nolint:gochecknoglobals // build-time variables
|
||||||
var (
|
var (
|
||||||
Appname = "dnswatcher"
|
Appname = "dnswatcher"
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
globals.SetAppname(Appname)
|
globals.SetAppname(Appname)
|
||||||
globals.SetVersion(Version)
|
globals.SetVersion(Version)
|
||||||
globals.SetBuildarch(Buildarch)
|
|
||||||
|
|
||||||
fx.New(
|
fx.New(
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,23 +38,24 @@ type Params struct {
|
|||||||
|
|
||||||
// Config holds application configuration.
|
// Config holds application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
Debug bool
|
Debug bool
|
||||||
DataDir string
|
DataDir string
|
||||||
Domains []string
|
Domains []string
|
||||||
Hostnames []string
|
Hostnames []string
|
||||||
SlackWebhook string
|
SlackWebhook string
|
||||||
MattermostWebhook string
|
MattermostWebhook string
|
||||||
NtfyTopic string
|
NtfyTopic string
|
||||||
DNSInterval time.Duration
|
DNSInterval time.Duration
|
||||||
TLSInterval time.Duration
|
TLSInterval time.Duration
|
||||||
TLSExpiryWarning int
|
TLSExpiryWarning int
|
||||||
SentryDSN string
|
SentryDSN string
|
||||||
MaintenanceMode bool
|
MaintenanceMode bool
|
||||||
MetricsUsername string
|
MetricsUsername string
|
||||||
MetricsPassword string
|
MetricsPassword string
|
||||||
params *Params
|
SendTestNotification bool
|
||||||
log *slog.Logger
|
params *Params
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Config instance from environment and config files.
|
// New creates a new Config instance from environment and config files.
|
||||||
@@ -100,6 +106,7 @@ func setupViper(name string) {
|
|||||||
viper.SetDefault("MAINTENANCE_MODE", false)
|
viper.SetDefault("MAINTENANCE_MODE", false)
|
||||||
viper.SetDefault("METRICS_USERNAME", "")
|
viper.SetDefault("METRICS_USERNAME", "")
|
||||||
viper.SetDefault("METRICS_PASSWORD", "")
|
viper.SetDefault("METRICS_PASSWORD", "")
|
||||||
|
viper.SetDefault("SEND_TEST_NOTIFICATION", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildConfig(
|
func buildConfig(
|
||||||
@@ -132,34 +139,50 @@ func buildConfig(
|
|||||||
tlsInterval = defaultTLSInterval
|
tlsInterval = defaultTLSInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
domains, hostnames, err := parseAndValidateTargets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Port: viper.GetInt("PORT"),
|
||||||
|
Debug: viper.GetBool("DEBUG"),
|
||||||
|
DataDir: viper.GetString("DATA_DIR"),
|
||||||
|
Domains: domains,
|
||||||
|
Hostnames: hostnames,
|
||||||
|
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||||
|
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||||
|
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||||
|
DNSInterval: dnsInterval,
|
||||||
|
TLSInterval: tlsInterval,
|
||||||
|
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
||||||
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
|
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
||||||
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
|
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
|
||||||
|
params: params,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAndValidateTargets() ([]string, []string, error) {
|
||||||
domains, hostnames, err := ClassifyTargets(
|
domains, hostnames, err := ClassifyTargets(
|
||||||
parseCSV(viper.GetString("TARGETS")),
|
parseCSV(viper.GetString("TARGETS")),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid targets configuration: %w", err)
|
return nil, nil, fmt.Errorf(
|
||||||
|
"invalid targets configuration: %w", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
if len(domains) == 0 && len(hostnames) == 0 {
|
||||||
Port: viper.GetInt("PORT"),
|
return nil, nil, ErrNoTargets
|
||||||
Debug: viper.GetBool("DEBUG"),
|
|
||||||
DataDir: viper.GetString("DATA_DIR"),
|
|
||||||
Domains: domains,
|
|
||||||
Hostnames: hostnames,
|
|
||||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
|
||||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
|
||||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
|
||||||
DNSInterval: dnsInterval,
|
|
||||||
TLSInterval: tlsInterval,
|
|
||||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
|
||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
|
||||||
params: params,
|
|
||||||
log: log,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return domains, hostnames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseCSV(input string) []string {
|
func parseCSV(input string) []string {
|
||||||
|
|||||||
262
internal/config/config_test.go
Normal file
262
internal/config/config_test.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert.False(t, cfg.SendTestNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "true")
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert.True(t, cfg.SendTestNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,15 @@ import (
|
|||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // Required for ldflags injection at build time
|
//nolint:gochecknoglobals // Required for ldflags injection at build time
|
||||||
var (
|
var (
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
appname string
|
appname string
|
||||||
version string
|
version string
|
||||||
buildarch string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Globals holds build-time variables for dependency injection.
|
// Globals holds build-time variables for dependency injection.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Globals instance from package-level variables.
|
// New creates a new Globals instance from package-level variables.
|
||||||
@@ -31,9 +29,8 @@ func New(_ fx.Lifecycle) (*Globals, error) {
|
|||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
||||||
return &Globals{
|
return &Globals{
|
||||||
Appname: appname,
|
Appname: appname,
|
||||||
Version: version,
|
Version: version,
|
||||||
Buildarch: buildarch,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +49,3 @@ func SetVersion(ver string) {
|
|||||||
|
|
||||||
version = ver
|
version = ver
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBuildarch sets the build architecture.
|
|
||||||
func SetBuildarch(arch string) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
buildarch = arch
|
|
||||||
}
|
|
||||||
|
|||||||
151
internal/handlers/dashboard.go
Normal file
151
internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/dashboard.html
|
||||||
|
var dashboardFS embed.FS
|
||||||
|
|
||||||
|
// Time unit constants for relative time calculations.
|
||||||
|
const (
|
||||||
|
secondsPerMinute = 60
|
||||||
|
minutesPerHour = 60
|
||||||
|
hoursPerDay = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDashboardTemplate parses the embedded dashboard HTML
|
||||||
|
// template with helper functions.
|
||||||
|
func newDashboardTemplate() *template.Template {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"relTime": relTime,
|
||||||
|
"joinStrings": joinStrings,
|
||||||
|
"formatRecords": formatRecords,
|
||||||
|
"expiryDays": expiryDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.Must(
|
||||||
|
template.New("dashboard.html").
|
||||||
|
Funcs(funcs).
|
||||||
|
ParseFS(dashboardFS, "templates/dashboard.html"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashboardData is the data passed to the dashboard template.
|
||||||
|
type dashboardData struct {
|
||||||
|
Snapshot state.Snapshot
|
||||||
|
Alerts []notify.AlertEntry
|
||||||
|
StateAge string
|
||||||
|
GeneratedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDashboard returns the dashboard page handler.
|
||||||
|
func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||||
|
tmpl := newDashboardTemplate()
|
||||||
|
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
) {
|
||||||
|
snap := h.state.GetSnapshot()
|
||||||
|
alerts := h.notifyHistory.Recent()
|
||||||
|
|
||||||
|
data := dashboardData{
|
||||||
|
Snapshot: snap,
|
||||||
|
Alerts: alerts,
|
||||||
|
StateAge: relTime(snap.LastUpdated),
|
||||||
|
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Header().Set(
|
||||||
|
"Content-Type", "text/html; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := tmpl.Execute(writer, data)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error(
|
||||||
|
"dashboard template error",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relTime returns a human-readable relative time string such
|
||||||
|
// as "2 minutes ago" or "never" for zero times.
|
||||||
|
func relTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
|
||||||
|
d := time.Since(t)
|
||||||
|
if d < 0 {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds := int(math.Round(d.Seconds()))
|
||||||
|
if seconds < secondsPerMinute {
|
||||||
|
return fmt.Sprintf("%ds ago", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := seconds / secondsPerMinute
|
||||||
|
if minutes < minutesPerHour {
|
||||||
|
return fmt.Sprintf("%dm ago", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
hours := minutes / minutesPerHour
|
||||||
|
if hours < hoursPerDay {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%dh %dm ago", hours, minutes%minutesPerHour,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
days := hours / hoursPerDay
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%dd %dh ago", days, hours%hoursPerDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinStrings joins a string slice with a separator.
|
||||||
|
func joinStrings(items []string, sep string) string {
|
||||||
|
return strings.Join(items, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRecords formats a map of record type → values into a
|
||||||
|
// compact display string.
|
||||||
|
func formatRecords(records map[string][]string) string {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
for rtype, values := range records {
|
||||||
|
for _, v := range values {
|
||||||
|
parts = append(parts, rtype+": "+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiryDays returns the number of days until the given time,
|
||||||
|
// rounded down. Returns 0 if already expired.
|
||||||
|
func expiryDays(t time.Time) int {
|
||||||
|
d := time.Until(t).Hours() / hoursPerDay
|
||||||
|
if d < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(d)
|
||||||
|
}
|
||||||
80
internal/handlers/dashboard_test.go
Normal file
80
internal/handlers/dashboard_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRelTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dur time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"zero", 0, "never"},
|
||||||
|
{"seconds", 30 * time.Second, "30s ago"},
|
||||||
|
{"minutes", 5 * time.Minute, "5m ago"},
|
||||||
|
{"hours", 2*time.Hour + 15*time.Minute, "2h 15m ago"},
|
||||||
|
{"days", 48*time.Hour + 3*time.Hour, "2d 3h ago"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var input time.Time
|
||||||
|
if tt.dur > 0 {
|
||||||
|
input = time.Now().Add(-tt.dur)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := handlers.RelTime(input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf(
|
||||||
|
"RelTime(%v) = %q, want %q",
|
||||||
|
tt.dur, got, tt.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryDays(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 10 days from now.
|
||||||
|
future := time.Now().Add(10 * 24 * time.Hour)
|
||||||
|
|
||||||
|
days := handlers.ExpiryDays(future)
|
||||||
|
if days < 9 || days > 10 {
|
||||||
|
t.Errorf("expected ~10 days, got %d", days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already expired.
|
||||||
|
past := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
days = handlers.ExpiryDays(past)
|
||||||
|
if days != 0 {
|
||||||
|
t.Errorf("expected 0 for expired, got %d", days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRecords(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := handlers.FormatRecords(nil)
|
||||||
|
if got != "-" {
|
||||||
|
t.Errorf("expected -, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = handlers.FormatRecords(map[string][]string{
|
||||||
|
"A": {"1.2.3.4"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if got != "A: 1.2.3.4" {
|
||||||
|
t.Errorf("unexpected format: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/handlers/export_test.go
Normal file
18
internal/handlers/export_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RelTime exports relTime for testing.
|
||||||
|
func RelTime(t time.Time) string {
|
||||||
|
return relTime(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiryDays exports expiryDays for testing.
|
||||||
|
func ExpiryDays(t time.Time) int {
|
||||||
|
return expiryDays(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRecords exports formatRecords for testing.
|
||||||
|
func FormatRecords(records map[string][]string) string {
|
||||||
|
return formatRecords(records)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"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/notify"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Handlers.
|
// Params contains dependencies for Handlers.
|
||||||
@@ -20,23 +22,29 @@ type Params struct {
|
|||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
|
State *state.State
|
||||||
|
Notify *notify.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
params *Params
|
params *Params
|
||||||
globals *globals.Globals
|
globals *globals.Globals
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
|
state *state.State
|
||||||
|
notifyHistory *notify.AlertHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
globals: params.Globals,
|
globals: params.Globals,
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
|
state: params.State,
|
||||||
|
notifyHistory: params.Notify.History(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,217 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// statusDomainInfo holds status information for a monitored domain.
|
||||||
|
type statusDomainInfo struct {
|
||||||
|
Nameservers []string `json:"nameservers"`
|
||||||
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusHostnameNSInfo holds per-nameserver status for a hostname.
|
||||||
|
type statusHostnameNSInfo struct {
|
||||||
|
Records map[string][]string `json:"records"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusHostnameInfo holds status information for a monitored hostname.
|
||||||
|
type statusHostnameInfo struct {
|
||||||
|
Nameservers map[string]*statusHostnameNSInfo `json:"nameservers"`
|
||||||
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusPortInfo holds status information for a monitored port.
|
||||||
|
type statusPortInfo struct {
|
||||||
|
Open bool `json:"open"`
|
||||||
|
Hostnames []string `json:"hostnames"`
|
||||||
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCertificateInfo holds status information for a TLS certificate.
|
||||||
|
type statusCertificateInfo struct {
|
||||||
|
CommonName string `json:"commonName"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
NotAfter time.Time `json:"notAfter"`
|
||||||
|
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCounts holds summary counts of monitored resources.
|
||||||
|
type statusCounts struct {
|
||||||
|
Domains int `json:"domains"`
|
||||||
|
Hostnames int `json:"hostnames"`
|
||||||
|
Ports int `json:"ports"`
|
||||||
|
PortsOpen int `json:"portsOpen"`
|
||||||
|
Certificates int `json:"certificates"`
|
||||||
|
CertsOK int `json:"certificatesOk"`
|
||||||
|
CertsError int `json:"certificatesError"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusResponse is the full /api/v1/status response.
|
||||||
|
type statusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastUpdated time.Time `json:"lastUpdated"`
|
||||||
|
Counts statusCounts `json:"counts"`
|
||||||
|
Domains map[string]*statusDomainInfo `json:"domains"`
|
||||||
|
Hostnames map[string]*statusHostnameInfo `json:"hostnames"`
|
||||||
|
Ports map[string]*statusPortInfo `json:"ports"`
|
||||||
|
Certificates map[string]*statusCertificateInfo `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
// HandleStatus returns the monitoring status handler.
|
// HandleStatus returns the monitoring status handler.
|
||||||
func (h *Handlers) HandleStatus() http.HandlerFunc {
|
func (h *Handlers) HandleStatus() http.HandlerFunc {
|
||||||
type response struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(
|
return func(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
snap := h.state.GetSnapshot()
|
||||||
|
|
||||||
|
resp := buildStatusResponse(snap)
|
||||||
|
|
||||||
h.respondJSON(
|
h.respondJSON(
|
||||||
writer, request,
|
writer, request,
|
||||||
&response{Status: "ok"},
|
resp,
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildStatusResponse constructs the full status response from
|
||||||
|
// the current monitoring snapshot.
|
||||||
|
func buildStatusResponse(
|
||||||
|
snap state.Snapshot,
|
||||||
|
) *statusResponse {
|
||||||
|
resp := &statusResponse{
|
||||||
|
Status: "ok",
|
||||||
|
LastUpdated: snap.LastUpdated,
|
||||||
|
Domains: make(map[string]*statusDomainInfo),
|
||||||
|
Hostnames: make(map[string]*statusHostnameInfo),
|
||||||
|
Ports: make(map[string]*statusPortInfo),
|
||||||
|
Certificates: make(map[string]*statusCertificateInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDomains(snap, resp)
|
||||||
|
buildHostnames(snap, resp)
|
||||||
|
buildPorts(snap, resp)
|
||||||
|
buildCertificates(snap, resp)
|
||||||
|
buildCounts(resp)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDomains(
|
||||||
|
snap state.Snapshot,
|
||||||
|
resp *statusResponse,
|
||||||
|
) {
|
||||||
|
for name, ds := range snap.Domains {
|
||||||
|
ns := make([]string, len(ds.Nameservers))
|
||||||
|
copy(ns, ds.Nameservers)
|
||||||
|
sort.Strings(ns)
|
||||||
|
|
||||||
|
resp.Domains[name] = &statusDomainInfo{
|
||||||
|
Nameservers: ns,
|
||||||
|
LastChecked: ds.LastChecked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHostnames(
|
||||||
|
snap state.Snapshot,
|
||||||
|
resp *statusResponse,
|
||||||
|
) {
|
||||||
|
for name, hs := range snap.Hostnames {
|
||||||
|
info := &statusHostnameInfo{
|
||||||
|
Nameservers: make(map[string]*statusHostnameNSInfo),
|
||||||
|
LastChecked: hs.LastChecked,
|
||||||
|
}
|
||||||
|
|
||||||
|
for ns, nsState := range hs.RecordsByNameserver {
|
||||||
|
recs := make(map[string][]string, len(nsState.Records))
|
||||||
|
for rtype, vals := range nsState.Records {
|
||||||
|
copied := make([]string, len(vals))
|
||||||
|
copy(copied, vals)
|
||||||
|
recs[rtype] = copied
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Nameservers[ns] = &statusHostnameNSInfo{
|
||||||
|
Records: recs,
|
||||||
|
Status: nsState.Status,
|
||||||
|
LastChecked: nsState.LastChecked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Hostnames[name] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPorts(
|
||||||
|
snap state.Snapshot,
|
||||||
|
resp *statusResponse,
|
||||||
|
) {
|
||||||
|
for key, ps := range snap.Ports {
|
||||||
|
hostnames := make([]string, len(ps.Hostnames))
|
||||||
|
copy(hostnames, ps.Hostnames)
|
||||||
|
sort.Strings(hostnames)
|
||||||
|
|
||||||
|
resp.Ports[key] = &statusPortInfo{
|
||||||
|
Open: ps.Open,
|
||||||
|
Hostnames: hostnames,
|
||||||
|
LastChecked: ps.LastChecked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCertificates(
|
||||||
|
snap state.Snapshot,
|
||||||
|
resp *statusResponse,
|
||||||
|
) {
|
||||||
|
for key, cs := range snap.Certificates {
|
||||||
|
sans := make([]string, len(cs.SubjectAlternativeNames))
|
||||||
|
copy(sans, cs.SubjectAlternativeNames)
|
||||||
|
|
||||||
|
resp.Certificates[key] = &statusCertificateInfo{
|
||||||
|
CommonName: cs.CommonName,
|
||||||
|
Issuer: cs.Issuer,
|
||||||
|
NotAfter: cs.NotAfter,
|
||||||
|
SubjectAlternativeNames: sans,
|
||||||
|
Status: cs.Status,
|
||||||
|
LastChecked: cs.LastChecked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCounts(resp *statusResponse) {
|
||||||
|
var portsOpen, certsOK, certsError int
|
||||||
|
|
||||||
|
for _, ps := range resp.Ports {
|
||||||
|
if ps.Open {
|
||||||
|
portsOpen++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cs := range resp.Certificates {
|
||||||
|
switch cs.Status {
|
||||||
|
case "ok":
|
||||||
|
certsOK++
|
||||||
|
case "error":
|
||||||
|
certsError++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Counts = statusCounts{
|
||||||
|
Domains: len(resp.Domains),
|
||||||
|
Hostnames: len(resp.Hostnames),
|
||||||
|
Ports: len(resp.Ports),
|
||||||
|
PortsOpen: portsOpen,
|
||||||
|
Certificates: len(resp.Certificates),
|
||||||
|
CertsOK: certsOK,
|
||||||
|
CertsError: certsError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
370
internal/handlers/templates/dashboard.html
Normal file
370
internal/handlers/templates/dashboard.html
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="bg-slate-950">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="refresh" content="30" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>dnswatcher</title>
|
||||||
|
<link rel="stylesheet" href="/s/css/tailwind.min.css" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="bg-surface-950 text-slate-300 font-mono text-sm min-h-screen antialiased"
|
||||||
|
>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
{{/* ---- Header ---- */}}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-teal-400 tracking-tight">
|
||||||
|
dnswatcher
|
||||||
|
</h1>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">
|
||||||
|
state updated {{ .StateAge }} · page generated
|
||||||
|
{{ .GeneratedAt }} UTC · auto-refresh 30s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Summary bar ---- */}}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8"
|
||||||
|
>
|
||||||
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||||
|
Domains
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||||
|
{{ len .Snapshot.Domains }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||||
|
Hostnames
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||||
|
{{ len .Snapshot.Hostnames }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||||
|
Ports
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||||
|
{{ len .Snapshot.Ports }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||||
|
<div class="text-xs text-slate-500 uppercase tracking-wider">
|
||||||
|
Certificates
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||||
|
{{ len .Snapshot.Certificates }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ---- Domains ---- */}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||||
|
>
|
||||||
|
Domains
|
||||||
|
</h2>
|
||||||
|
{{ if .Snapshot.Domains }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-slate-500 uppercase tracking-wider">
|
||||||
|
<th class="py-2 px-3">Domain</th>
|
||||||
|
<th class="py-2 px-3">Nameservers</th>
|
||||||
|
<th class="py-2 px-3">Checked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{{ range $name, $ds := .Snapshot.Domains }}
|
||||||
|
<tr class="hover:bg-surface-800/50">
|
||||||
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||||
|
{{ $name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ joinStrings $ds.Nameservers ", " }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
|
{{ relTime $ds.LastChecked }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-slate-600 italic text-xs">
|
||||||
|
No domains configured.
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ---- Hostnames ---- */}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||||
|
>
|
||||||
|
Hostnames
|
||||||
|
</h2>
|
||||||
|
{{ if .Snapshot.Hostnames }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-slate-500 uppercase tracking-wider">
|
||||||
|
<th class="py-2 px-3">Hostname</th>
|
||||||
|
<th class="py-2 px-3">NS</th>
|
||||||
|
<th class="py-2 px-3">Status</th>
|
||||||
|
<th class="py-2 px-3">Records</th>
|
||||||
|
<th class="py-2 px-3">Checked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{{ range $name, $hs := .Snapshot.Hostnames }}
|
||||||
|
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
||||||
|
<tr class="hover:bg-surface-800/50">
|
||||||
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||||
|
{{ $name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ $ns }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
{{ if eq $nsr.Status "ok" }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||||
|
>ok</span
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||||
|
>{{ $nsr.Status }}</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||||
|
>
|
||||||
|
{{ formatRecords $nsr.Records }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
|
{{ relTime $nsr.LastChecked }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-slate-600 italic text-xs">
|
||||||
|
No hostnames configured.
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ---- Ports ---- */}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||||
|
>
|
||||||
|
Ports
|
||||||
|
</h2>
|
||||||
|
{{ if .Snapshot.Ports }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-slate-500 uppercase tracking-wider">
|
||||||
|
<th class="py-2 px-3">Address</th>
|
||||||
|
<th class="py-2 px-3">State</th>
|
||||||
|
<th class="py-2 px-3">Hostnames</th>
|
||||||
|
<th class="py-2 px-3">Checked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{{ range $key, $ps := .Snapshot.Ports }}
|
||||||
|
<tr class="hover:bg-surface-800/50">
|
||||||
|
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||||
|
{{ $key }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
{{ if $ps.Open }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||||
|
>open</span
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||||
|
>closed</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ joinStrings $ps.Hostnames ", " }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
|
{{ relTime $ps.LastChecked }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-slate-600 italic text-xs">
|
||||||
|
No port data yet.
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ---- Certificates ---- */}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||||
|
>
|
||||||
|
Certificates
|
||||||
|
</h2>
|
||||||
|
{{ if .Snapshot.Certificates }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-slate-500 uppercase tracking-wider">
|
||||||
|
<th class="py-2 px-3">Endpoint</th>
|
||||||
|
<th class="py-2 px-3">Status</th>
|
||||||
|
<th class="py-2 px-3">CN</th>
|
||||||
|
<th class="py-2 px-3">Issuer</th>
|
||||||
|
<th class="py-2 px-3">Expires</th>
|
||||||
|
<th class="py-2 px-3">Checked</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{{ range $key, $cs := .Snapshot.Certificates }}
|
||||||
|
<tr class="hover:bg-surface-800/50">
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ $key }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
{{ if eq $cs.Status "ok" }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||||
|
>ok</span
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||||
|
>{{ $cs.Status }}</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-200">
|
||||||
|
{{ $cs.CommonName }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ $cs.Issuer }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 whitespace-nowrap">
|
||||||
|
{{ if not $cs.NotAfter.IsZero }}
|
||||||
|
{{ $days := expiryDays $cs.NotAfter }}
|
||||||
|
{{ if lt $days 7 }}
|
||||||
|
<span class="text-red-400 font-medium"
|
||||||
|
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||||
|
({{ $days }}d)</span
|
||||||
|
>
|
||||||
|
{{ else if lt $days 30 }}
|
||||||
|
<span class="text-amber-400"
|
||||||
|
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||||
|
({{ $days }}d)</span
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-slate-400"
|
||||||
|
>{{ $cs.NotAfter.Format "2006-01-02" }}
|
||||||
|
({{ $days }}d)</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
|
{{ relTime $cs.LastChecked }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-slate-600 italic text-xs">
|
||||||
|
No certificate data yet.
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ---- Recent Alerts ---- */}}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2
|
||||||
|
class="text-sm font-semibold text-teal-300 uppercase tracking-wider mb-3 border-b border-slate-700/50 pb-2"
|
||||||
|
>
|
||||||
|
Recent Alerts ({{ len .Alerts }})
|
||||||
|
</h2>
|
||||||
|
{{ if .Alerts }}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{{ range .Alerts }}
|
||||||
|
<div
|
||||||
|
class="bg-surface-800 border rounded-lg px-4 py-3 {{ if eq .Priority "error" }}border-red-700/40{{ else if eq .Priority "warning" }}border-amber-700/40{{ else if eq .Priority "success" }}border-teal-700/40{{ else }}border-blue-700/40{{ end }}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-1">
|
||||||
|
{{ if eq .Priority "error" }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||||
|
>error</span
|
||||||
|
>
|
||||||
|
{{ else if eq .Priority "warning" }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-amber-900/50 text-amber-400 border border-amber-700/30"
|
||||||
|
>warning</span
|
||||||
|
>
|
||||||
|
{{ else if eq .Priority "success" }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||||
|
>success</span
|
||||||
|
>
|
||||||
|
{{ else }}
|
||||||
|
<span
|
||||||
|
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-900/50 text-blue-400 border border-blue-700/30"
|
||||||
|
>info</span
|
||||||
|
>
|
||||||
|
{{ end }}
|
||||||
|
<span class="text-slate-200 text-xs font-medium">
|
||||||
|
{{ .Title }}
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-600 text-[11px] ml-auto whitespace-nowrap">
|
||||||
|
{{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC
|
||||||
|
({{ relTime .Timestamp }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-slate-400 text-xs whitespace-pre-line pl-0.5"
|
||||||
|
>
|
||||||
|
{{ .Message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<p class="text-slate-600 italic text-xs">
|
||||||
|
No alerts recorded since last restart.
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{/* ---- Footer ---- */}}
|
||||||
|
<div
|
||||||
|
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
||||||
|
>
|
||||||
|
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
||||||
|
{{ len .Snapshot.Hostnames }} hostnames
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -78,6 +78,5 @@ func (l *Logger) Identify() {
|
|||||||
l.log.Info("starting",
|
l.log.Info("starting",
|
||||||
"appname", l.params.Globals.Appname,
|
"appname", l.params.Globals.Appname,
|
||||||
"version", l.params.Globals.Version,
|
"version", l.params.Globals.Version,
|
||||||
"buildarch", l.params.Globals.Buildarch,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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
105
internal/notify/export_test.go
Normal file
105
internal/notify/export_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
history: NewAlertHistory(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRetryConfig overrides the retry configuration for
|
||||||
|
// testing.
|
||||||
|
func (svc *Service) SetRetryConfig(cfg RetryConfig) {
|
||||||
|
svc.retryConfig = cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSleepFunc overrides the sleep function so tests can
|
||||||
|
// eliminate real delays.
|
||||||
|
func (svc *Service) SetSleepFunc(
|
||||||
|
fn func(time.Duration) <-chan time.Time,
|
||||||
|
) {
|
||||||
|
svc.sleepFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliverWithRetry exports deliverWithRetry for testing.
|
||||||
|
func (svc *Service) DeliverWithRetry(
|
||||||
|
ctx context.Context,
|
||||||
|
endpoint string,
|
||||||
|
fn func(context.Context) error,
|
||||||
|
) error {
|
||||||
|
return svc.deliverWithRetry(ctx, endpoint, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackoffDuration exports RetryConfig.backoff for testing.
|
||||||
|
func (rc RetryConfig) BackoffDuration(attempt int) time.Duration {
|
||||||
|
return rc.defaults().backoff(attempt)
|
||||||
|
}
|
||||||
62
internal/notify/history.go
Normal file
62
internal/notify/history.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAlertHistory is the maximum number of alerts to retain.
|
||||||
|
const maxAlertHistory = 100
|
||||||
|
|
||||||
|
// AlertEntry represents a single notification that was sent.
|
||||||
|
type AlertEntry struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
Priority string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertHistory is a thread-safe ring buffer that stores
|
||||||
|
// the most recent alerts.
|
||||||
|
type AlertHistory struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries [maxAlertHistory]AlertEntry
|
||||||
|
count int
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertHistory creates a new empty AlertHistory.
|
||||||
|
func NewAlertHistory() *AlertHistory {
|
||||||
|
return &AlertHistory{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add records a new alert entry in the ring buffer.
|
||||||
|
func (h *AlertHistory) Add(entry AlertEntry) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
h.entries[h.index] = entry
|
||||||
|
h.index = (h.index + 1) % maxAlertHistory
|
||||||
|
|
||||||
|
if h.count < maxAlertHistory {
|
||||||
|
h.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent returns the stored alerts in reverse chronological
|
||||||
|
// order (newest first). Returns at most maxAlertHistory entries.
|
||||||
|
func (h *AlertHistory) Recent() []AlertEntry {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]AlertEntry, h.count)
|
||||||
|
|
||||||
|
for i := range h.count {
|
||||||
|
// Walk backwards from the most recent entry.
|
||||||
|
idx := (h.index - 1 - i + maxAlertHistory) %
|
||||||
|
maxAlertHistory
|
||||||
|
result[i] = h.entries[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
88
internal/notify/history_test.go
Normal file
88
internal/notify/history_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package notify_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertHistoryEmpty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := notify.NewAlertHistory()
|
||||||
|
|
||||||
|
entries := h.Recent()
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertHistoryAddAndRecent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := notify.NewAlertHistory()
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
h.Add(notify.AlertEntry{
|
||||||
|
Timestamp: now.Add(-2 * time.Minute),
|
||||||
|
Title: "first",
|
||||||
|
Message: "msg1",
|
||||||
|
Priority: "info",
|
||||||
|
})
|
||||||
|
|
||||||
|
h.Add(notify.AlertEntry{
|
||||||
|
Timestamp: now.Add(-1 * time.Minute),
|
||||||
|
Title: "second",
|
||||||
|
Message: "msg2",
|
||||||
|
Priority: "warning",
|
||||||
|
})
|
||||||
|
|
||||||
|
entries := h.Recent()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first.
|
||||||
|
if entries[0].Title != "second" {
|
||||||
|
t.Errorf(
|
||||||
|
"expected newest first, got %q", entries[0].Title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries[1].Title != "first" {
|
||||||
|
t.Errorf(
|
||||||
|
"expected oldest second, got %q", entries[1].Title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertHistoryOverflow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := notify.NewAlertHistory()
|
||||||
|
|
||||||
|
const totalEntries = 110
|
||||||
|
|
||||||
|
// Fill beyond capacity.
|
||||||
|
for i := range totalEntries {
|
||||||
|
h.Add(notify.AlertEntry{
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Title: "alert",
|
||||||
|
Message: "msg",
|
||||||
|
Priority: string(rune('0' + i%10)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := h.Recent()
|
||||||
|
|
||||||
|
const maxHistory = 100
|
||||||
|
|
||||||
|
if len(entries) != maxHistory {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected %d entries, got %d",
|
||||||
|
maxHistory, len(entries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,6 +112,9 @@ type Service struct {
|
|||||||
ntfyURL *url.URL
|
ntfyURL *url.URL
|
||||||
slackWebhookURL *url.URL
|
slackWebhookURL *url.URL
|
||||||
mattermostWebhookURL *url.URL
|
mattermostWebhookURL *url.URL
|
||||||
|
history *AlertHistory
|
||||||
|
retryConfig RetryConfig
|
||||||
|
sleepFn func(time.Duration) <-chan time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new notify Service.
|
// New creates a new notify Service.
|
||||||
@@ -123,6 +126,7 @@ func New(
|
|||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
transport: http.DefaultTransport,
|
transport: http.DefaultTransport,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
|
history: NewAlertHistory(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.Config.NtfyTopic != "" {
|
if params.Config.NtfyTopic != "" {
|
||||||
@@ -167,65 +171,117 @@ func New(
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History returns the alert history for reading recent alerts.
|
||||||
|
func (svc *Service) History() *AlertHistory {
|
||||||
|
return svc.history
|
||||||
|
}
|
||||||
|
|
||||||
// SendNotification sends a notification to all configured
|
// SendNotification sends a notification to all configured
|
||||||
// endpoints.
|
// endpoints and records it in the alert history.
|
||||||
func (svc *Service) SendNotification(
|
func (svc *Service) SendNotification(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) {
|
) {
|
||||||
if svc.ntfyURL != nil {
|
svc.history.Add(AlertEntry{
|
||||||
go func() {
|
Timestamp: time.Now().UTC(),
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Priority: priority,
|
||||||
|
})
|
||||||
|
|
||||||
err := svc.sendNtfy(
|
svc.dispatchNtfy(ctx, title, message, priority)
|
||||||
notifyCtx,
|
svc.dispatchSlack(ctx, title, message, priority)
|
||||||
svc.ntfyURL,
|
svc.dispatchMattermost(ctx, title, message, priority)
|
||||||
title, message, priority,
|
}
|
||||||
)
|
|
||||||
if err != nil {
|
func (svc *Service) dispatchNtfy(
|
||||||
svc.log.Error(
|
ctx context.Context,
|
||||||
"failed to send ntfy notification",
|
title, message, priority string,
|
||||||
"error", err,
|
) {
|
||||||
)
|
if svc.ntfyURL == nil {
|
||||||
}
|
return
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.slackWebhookURL != nil {
|
go func() {
|
||||||
go func() {
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
|
||||||
|
|
||||||
err := svc.sendSlack(
|
err := svc.deliverWithRetry(
|
||||||
notifyCtx,
|
notifyCtx, "ntfy",
|
||||||
svc.slackWebhookURL,
|
func(c context.Context) error {
|
||||||
title, message, priority,
|
return svc.sendNtfy(
|
||||||
)
|
c, svc.ntfyURL,
|
||||||
if err != nil {
|
title, message, priority,
|
||||||
svc.log.Error(
|
|
||||||
"failed to send slack notification",
|
|
||||||
"error", err,
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}()
|
)
|
||||||
|
if err != nil {
|
||||||
|
svc.log.Error(
|
||||||
|
"failed to send ntfy notification "+
|
||||||
|
"after retries",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) dispatchSlack(
|
||||||
|
ctx context.Context,
|
||||||
|
title, message, priority string,
|
||||||
|
) {
|
||||||
|
if svc.slackWebhookURL == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.mattermostWebhookURL != nil {
|
go func() {
|
||||||
go func() {
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
notifyCtx := context.WithoutCancel(ctx)
|
|
||||||
|
|
||||||
err := svc.sendSlack(
|
err := svc.deliverWithRetry(
|
||||||
notifyCtx,
|
notifyCtx, "slack",
|
||||||
svc.mattermostWebhookURL,
|
func(c context.Context) error {
|
||||||
title, message, priority,
|
return svc.sendSlack(
|
||||||
)
|
c, svc.slackWebhookURL,
|
||||||
if err != nil {
|
title, message, priority,
|
||||||
svc.log.Error(
|
|
||||||
"failed to send mattermost notification",
|
|
||||||
"error", err,
|
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}()
|
)
|
||||||
|
if err != nil {
|
||||||
|
svc.log.Error(
|
||||||
|
"failed to send slack notification "+
|
||||||
|
"after retries",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) dispatchMattermost(
|
||||||
|
ctx context.Context,
|
||||||
|
title, message, priority string,
|
||||||
|
) {
|
||||||
|
if svc.mattermostWebhookURL == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
notifyCtx := context.WithoutCancel(ctx)
|
||||||
|
|
||||||
|
err := svc.deliverWithRetry(
|
||||||
|
notifyCtx, "mattermost",
|
||||||
|
func(c context.Context) error {
|
||||||
|
return svc.sendSlack(
|
||||||
|
c, svc.mattermostWebhookURL,
|
||||||
|
title, message, priority,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
svc.log.Error(
|
||||||
|
"failed to send mattermost notification "+
|
||||||
|
"after retries",
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *Service) sendNtfy(
|
func (svc *Service) sendNtfy(
|
||||||
|
|||||||
139
internal/notify/retry.go
Normal file
139
internal/notify/retry.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Retry defaults.
|
||||||
|
const (
|
||||||
|
// DefaultMaxRetries is the number of additional attempts
|
||||||
|
// after the first failure.
|
||||||
|
DefaultMaxRetries = 3
|
||||||
|
|
||||||
|
// DefaultBaseDelay is the initial delay before the first
|
||||||
|
// retry attempt.
|
||||||
|
DefaultBaseDelay = 1 * time.Second
|
||||||
|
|
||||||
|
// DefaultMaxDelay caps the computed backoff delay.
|
||||||
|
DefaultMaxDelay = 10 * time.Second
|
||||||
|
|
||||||
|
// backoffMultiplier is the exponential growth factor.
|
||||||
|
backoffMultiplier = 2
|
||||||
|
|
||||||
|
// jitterFraction controls the ±random spread applied
|
||||||
|
// to each delay (0.25 = ±25%).
|
||||||
|
jitterFraction = 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
// RetryConfig holds tuning knobs for the retry loop.
|
||||||
|
// Zero values fall back to the package defaults above.
|
||||||
|
type RetryConfig struct {
|
||||||
|
MaxRetries int
|
||||||
|
BaseDelay time.Duration
|
||||||
|
MaxDelay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaults returns a copy with zero fields replaced by
|
||||||
|
// package defaults.
|
||||||
|
func (rc RetryConfig) defaults() RetryConfig {
|
||||||
|
if rc.MaxRetries <= 0 {
|
||||||
|
rc.MaxRetries = DefaultMaxRetries
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.BaseDelay <= 0 {
|
||||||
|
rc.BaseDelay = DefaultBaseDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
if rc.MaxDelay <= 0 {
|
||||||
|
rc.MaxDelay = DefaultMaxDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
return rc
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoff computes the delay for attempt n (0-indexed) with
|
||||||
|
// jitter. The raw delay is BaseDelay * 2^n, capped at
|
||||||
|
// MaxDelay, then randomised by ±jitterFraction.
|
||||||
|
func (rc RetryConfig) backoff(attempt int) time.Duration {
|
||||||
|
raw := float64(rc.BaseDelay) *
|
||||||
|
math.Pow(backoffMultiplier, float64(attempt))
|
||||||
|
|
||||||
|
if raw > float64(rc.MaxDelay) {
|
||||||
|
raw = float64(rc.MaxDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply jitter: uniform in [raw*(1-j), raw*(1+j)].
|
||||||
|
lo := raw * (1 - jitterFraction)
|
||||||
|
hi := raw * (1 + jitterFraction)
|
||||||
|
|
||||||
|
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand
|
||||||
|
|
||||||
|
return time.Duration(jittered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWithRetry calls fn, retrying on error with
|
||||||
|
// exponential backoff. It logs every failed attempt and
|
||||||
|
// returns the last error if all attempts are exhausted.
|
||||||
|
func (svc *Service) deliverWithRetry(
|
||||||
|
ctx context.Context,
|
||||||
|
endpoint string,
|
||||||
|
fn func(context.Context) error,
|
||||||
|
) error {
|
||||||
|
cfg := svc.retryConfig.defaults()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
// attempt 0 is the initial call; attempts 1..MaxRetries
|
||||||
|
// are retries.
|
||||||
|
for attempt := range cfg.MaxRetries + 1 {
|
||||||
|
lastErr = fn(ctx)
|
||||||
|
if lastErr == nil {
|
||||||
|
if attempt > 0 {
|
||||||
|
svc.log.Info(
|
||||||
|
"notification delivered after retry",
|
||||||
|
"endpoint", endpoint,
|
||||||
|
"attempt", attempt+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last attempt — don't sleep, just return.
|
||||||
|
if attempt == cfg.MaxRetries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := cfg.backoff(attempt)
|
||||||
|
|
||||||
|
svc.log.Warn(
|
||||||
|
"notification delivery failed, retrying",
|
||||||
|
"endpoint", endpoint,
|
||||||
|
"attempt", attempt+1,
|
||||||
|
"maxAttempts", cfg.MaxRetries+1,
|
||||||
|
"retryIn", delay,
|
||||||
|
"error", lastErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-svc.sleepFunc(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepFunc returns a channel that closes after d.
|
||||||
|
// It is a field-level indirection so tests can override it.
|
||||||
|
func (svc *Service) sleepFunc(d time.Duration) <-chan time.Time {
|
||||||
|
if svc.sleepFn != nil {
|
||||||
|
return svc.sleepFn(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.After(d)
|
||||||
|
}
|
||||||
493
internal/notify/retry_test.go
Normal file
493
internal/notify/retry_test.go
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
package notify_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Static test errors (err113).
|
||||||
|
var (
|
||||||
|
errTransient = errors.New("transient failure")
|
||||||
|
errPermanent = errors.New("permanent failure")
|
||||||
|
errFail = errors.New("fail")
|
||||||
|
)
|
||||||
|
|
||||||
|
// instantSleep returns a closed channel immediately, removing
|
||||||
|
// real delays from tests.
|
||||||
|
func instantSleep(_ time.Duration) <-chan time.Time {
|
||||||
|
ch := make(chan time.Time, 1)
|
||||||
|
ch <- time.Now()
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── backoff calculation ───────────────────────────────────
|
||||||
|
|
||||||
|
func TestBackoffDurationIncreases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := notify.RetryConfig{
|
||||||
|
MaxRetries: 5,
|
||||||
|
BaseDelay: 1 * time.Second,
|
||||||
|
MaxDelay: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := time.Duration(0)
|
||||||
|
|
||||||
|
// With jitter the exact value varies, but the trend
|
||||||
|
// should be increasing for the first few attempts.
|
||||||
|
for attempt := range 4 {
|
||||||
|
d := cfg.BackoffDuration(attempt)
|
||||||
|
if d <= 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"attempt %d: backoff must be positive, got %v",
|
||||||
|
attempt, d,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow jitter to occasionally flatten a step, but
|
||||||
|
// the midpoint (no-jitter) should be strictly higher.
|
||||||
|
midpoint := cfg.BaseDelay * (1 << attempt)
|
||||||
|
if attempt > 0 && midpoint <= prev {
|
||||||
|
t.Fatalf(
|
||||||
|
"midpoint should grow: attempt %d midpoint=%v prev=%v",
|
||||||
|
attempt, midpoint, prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = midpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackoffDurationCappedAtMax(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := notify.RetryConfig{
|
||||||
|
MaxRetries: 5,
|
||||||
|
BaseDelay: 1 * time.Second,
|
||||||
|
MaxDelay: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 10 would be 1024s without capping.
|
||||||
|
d := cfg.BackoffDuration(10)
|
||||||
|
|
||||||
|
// With ±25% jitter on a 5s cap: max is 6.25s.
|
||||||
|
const maxWithJitter = 5*time.Second +
|
||||||
|
5*time.Second/4 +
|
||||||
|
time.Millisecond // rounding margin
|
||||||
|
|
||||||
|
if d > maxWithJitter {
|
||||||
|
t.Errorf(
|
||||||
|
"backoff %v exceeds max+jitter %v",
|
||||||
|
d, maxWithJitter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── deliverWithRetry ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDeliverWithRetrySucceedsFirstAttempt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := notify.NewTestService(http.DefaultTransport)
|
||||||
|
svc.SetSleepFunc(instantSleep)
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
|
||||||
|
err := svc.DeliverWithRetry(
|
||||||
|
context.Background(), "test",
|
||||||
|
func(_ context.Context) error {
|
||||||
|
calls.Add(1)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 call, got %d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverWithRetryRetriesOnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := notify.NewTestService(http.DefaultTransport)
|
||||||
|
svc.SetSleepFunc(instantSleep)
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 3,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: 10 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
|
||||||
|
// Fail twice, then succeed on the third attempt.
|
||||||
|
err := svc.DeliverWithRetry(
|
||||||
|
context.Background(), "test",
|
||||||
|
func(_ context.Context) error {
|
||||||
|
n := calls.Add(1)
|
||||||
|
if n <= 2 {
|
||||||
|
return errTransient
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success after retries: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if calls.Load() != 3 {
|
||||||
|
t.Errorf("expected 3 calls, got %d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverWithRetryExhaustsAttempts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := notify.NewTestService(http.DefaultTransport)
|
||||||
|
svc.SetSleepFunc(instantSleep)
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 2,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: 10 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
|
||||||
|
err := svc.DeliverWithRetry(
|
||||||
|
context.Background(), "test",
|
||||||
|
func(_ context.Context) error {
|
||||||
|
calls.Add(1)
|
||||||
|
|
||||||
|
return errPermanent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when all retries exhausted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, errPermanent) {
|
||||||
|
t.Errorf("expected permanent failure, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 initial + 2 retries = 3 total.
|
||||||
|
if calls.Load() != 3 {
|
||||||
|
t.Errorf("expected 3 calls, got %d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverWithRetryRespectsContextCancellation(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := notify.NewTestService(http.DefaultTransport)
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 5,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: 10 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use a blocking sleep so the context cancellation is
|
||||||
|
// the only way out.
|
||||||
|
svc.SetSleepFunc(func(_ time.Duration) <-chan time.Time {
|
||||||
|
return make(chan time.Time) // never fires
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
done <- svc.DeliverWithRetry(
|
||||||
|
ctx, "test",
|
||||||
|
func(_ context.Context) error {
|
||||||
|
return errFail
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the first failure + retry sleep to be
|
||||||
|
// entered, then cancel.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf(
|
||||||
|
"expected context.Canceled, got: %v", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("deliverWithRetry did not return after cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── integration: SendNotification with retry ──────────────
|
||||||
|
|
||||||
|
func TestSendNotificationRetriesTransientFailure(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts int
|
||||||
|
)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
attempts++
|
||||||
|
n := attempts
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if n <= 2 {
|
||||||
|
w.WriteHeader(
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := newRetryTestService(srv.URL, "ntfy")
|
||||||
|
|
||||||
|
svc.SendNotification(
|
||||||
|
context.Background(),
|
||||||
|
"Retry Test", "body", "warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
waitForCondition(t, func() bool {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
return attempts >= 3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRetryTestService creates a test service with instant
|
||||||
|
// sleep and low retry delays for the named endpoint.
|
||||||
|
func newRetryTestService(
|
||||||
|
rawURL, endpoint string,
|
||||||
|
) *notify.Service {
|
||||||
|
svc := notify.NewTestService(http.DefaultTransport)
|
||||||
|
svc.SetSleepFunc(instantSleep)
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 3,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: 10 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
u, _ := url.Parse(rawURL)
|
||||||
|
|
||||||
|
switch endpoint {
|
||||||
|
case "ntfy":
|
||||||
|
svc.SetNtfyURL(u)
|
||||||
|
case "slack":
|
||||||
|
svc.SetSlackWebhookURL(u)
|
||||||
|
case "mattermost":
|
||||||
|
svc.SetMattermostWebhookURL(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendNotificationAllEndpointsRetrySetup(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := newEndpointRetryResult()
|
||||||
|
ntfySrv, slackSrv, mmSrv := newRetryServers(result)
|
||||||
|
|
||||||
|
defer ntfySrv.Close()
|
||||||
|
defer slackSrv.Close()
|
||||||
|
defer mmSrv.Close()
|
||||||
|
|
||||||
|
svc := buildAllEndpointRetryService(
|
||||||
|
ntfySrv.URL, slackSrv.URL, mmSrv.URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
svc.SendNotification(
|
||||||
|
context.Background(),
|
||||||
|
"Multi-Retry", "testing", "error",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertAllEndpointsRetried(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointRetryResult tracks per-endpoint retry state.
|
||||||
|
type endpointRetryResult struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ntfyAttempts int
|
||||||
|
slackAttempts int
|
||||||
|
mmAttempts int
|
||||||
|
ntfyOK bool
|
||||||
|
slackOK bool
|
||||||
|
mmOK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEndpointRetryResult() *endpointRetryResult {
|
||||||
|
return &endpointRetryResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRetryServers(
|
||||||
|
r *endpointRetryResult,
|
||||||
|
) (*httptest.Server, *httptest.Server, *httptest.Server) {
|
||||||
|
mk := func(
|
||||||
|
attempts *int, ok *bool,
|
||||||
|
) *httptest.Server {
|
||||||
|
return httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
r.mu.Lock()
|
||||||
|
*attempts++
|
||||||
|
n := *attempts
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if n == 1 {
|
||||||
|
w.WriteHeader(
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
*ok = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mk(&r.ntfyAttempts, &r.ntfyOK),
|
||||||
|
mk(&r.slackAttempts, &r.slackOK),
|
||||||
|
mk(&r.mmAttempts, &r.mmOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAllEndpointRetryService(
|
||||||
|
ntfyURL, slackURL, mmURL string,
|
||||||
|
) *notify.Service {
|
||||||
|
svc := notify.NewTestService(http.DefaultTransport)
|
||||||
|
svc.SetSleepFunc(instantSleep)
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 3,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: 10 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
nu, _ := url.Parse(ntfyURL)
|
||||||
|
su, _ := url.Parse(slackURL)
|
||||||
|
mu, _ := url.Parse(mmURL)
|
||||||
|
|
||||||
|
svc.SetNtfyURL(nu)
|
||||||
|
svc.SetSlackWebhookURL(su)
|
||||||
|
svc.SetMattermostWebhookURL(mu)
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAllEndpointsRetried(
|
||||||
|
t *testing.T,
|
||||||
|
r *endpointRetryResult,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
waitForCondition(t, func() bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
return r.ntfyOK && r.slackOK && r.mmOK
|
||||||
|
})
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.ntfyAttempts < 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"ntfy: expected >= 2 attempts, got %d",
|
||||||
|
r.ntfyAttempts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.slackAttempts < 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"slack: expected >= 2 attempts, got %d",
|
||||||
|
r.slackAttempts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.mmAttempts < 2 {
|
||||||
|
t.Errorf(
|
||||||
|
"mattermost: expected >= 2 attempts, got %d",
|
||||||
|
r.mmAttempts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendNotificationPermanentFailureLogsError(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts int
|
||||||
|
)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
attempts++
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
w.WriteHeader(
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
svc := newRetryTestService(srv.URL, "slack")
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 2,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: 10 * time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
svc.SendNotification(
|
||||||
|
context.Background(),
|
||||||
|
"Permanent Fail", "body", "error",
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1 initial + 2 retries = 3 total.
|
||||||
|
waitForCondition(t, func() bool {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
return attempts >= 3
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/static"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requestTimeout is the maximum duration for handling a request.
|
// requestTimeout is the maximum duration for handling a request.
|
||||||
@@ -22,7 +25,25 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(chimw.Timeout(requestTimeout))
|
s.router.Use(chimw.Timeout(requestTimeout))
|
||||||
|
|
||||||
// Health check
|
// Dashboard (read-only web UI)
|
||||||
|
s.router.Get("/", s.handlers.HandleDashboard())
|
||||||
|
|
||||||
|
// Static assets (embedded CSS/JS)
|
||||||
|
s.router.Mount(
|
||||||
|
"/s",
|
||||||
|
http.StripPrefix(
|
||||||
|
"/s",
|
||||||
|
http.FileServer(http.FS(static.Static)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Health check (standard well-known path)
|
||||||
|
s.router.Get(
|
||||||
|
"/.well-known/healthcheck",
|
||||||
|
s.handlers.HandleHealthCheck(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Legacy health check (keep for backward compatibility)
|
||||||
s.router.Get("/health", s.handlers.HandleHealthCheck())
|
s.router.Get("/health", s.handlers.HandleHealthCheck())
|
||||||
|
|
||||||
// API v1 routes
|
// API v1 routes
|
||||||
|
|||||||
1302
internal/state/state_test.go
Normal file
1302
internal/state/state_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,3 +20,19 @@ func NewForTest() *State {
|
|||||||
config: &config.Config{DataDir: ""},
|
config: &config.Config{DataDir: ""},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewForTestWithDataDir creates a State backed by the given directory
|
||||||
|
// for tests that need file persistence.
|
||||||
|
func NewForTestWithDataDir(dataDir string) *State {
|
||||||
|
return &State{
|
||||||
|
log: slog.Default(),
|
||||||
|
snapshot: &Snapshot{
|
||||||
|
Version: stateVersion,
|
||||||
|
Domains: make(map[string]*DomainState),
|
||||||
|
Hostnames: make(map[string]*HostnameState),
|
||||||
|
Ports: make(map[string]*PortState),
|
||||||
|
Certificates: make(map[string]*CertificateState),
|
||||||
|
},
|
||||||
|
config: &config.Config{DataDir: dataDir},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ func (w *Watcher) Run(ctx context.Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
w.RunOnce(ctx)
|
w.RunOnce(ctx)
|
||||||
|
w.maybeSendTestNotification(ctx)
|
||||||
|
|
||||||
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
||||||
tlsTicker := time.NewTicker(w.config.TLSInterval)
|
tlsTicker := time.NewTicker(w.config.TLSInterval)
|
||||||
@@ -854,6 +855,38 @@ func (w *Watcher) saveState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeSendTestNotification sends a startup status notification
|
||||||
|
// after the first full scan completes, if SEND_TEST_NOTIFICATION
|
||||||
|
// is enabled. The message is clearly informational ("all ok")
|
||||||
|
// and not an error or anomaly alert.
|
||||||
|
func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
||||||
|
if !w.config.SendTestNotification {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := w.state.GetSnapshot()
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"dnswatcher has started and completed its initial scan.\n"+
|
||||||
|
"Monitoring %d domain(s) and %d hostname(s).\n"+
|
||||||
|
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
|
||||||
|
"All notification channels are working.",
|
||||||
|
len(snap.Domains),
|
||||||
|
len(snap.Hostnames),
|
||||||
|
len(snap.Ports),
|
||||||
|
len(snap.Certificates),
|
||||||
|
)
|
||||||
|
|
||||||
|
w.log.Info("sending startup test notification")
|
||||||
|
|
||||||
|
w.notify.SendNotification(
|
||||||
|
ctx,
|
||||||
|
"✅ dnswatcher startup complete",
|
||||||
|
msg,
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Utility functions ---
|
// --- Utility functions ---
|
||||||
|
|
||||||
func toSet(items []string) map[string]bool {
|
func toSet(items []string) map[string]bool {
|
||||||
|
|||||||
@@ -756,6 +756,117 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendTestNotification_Enabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{"example.com"}
|
||||||
|
cfg.Hostnames = []string{"www.example.com"}
|
||||||
|
cfg.SendTestNotification = true
|
||||||
|
|
||||||
|
w, deps := newTestWatcher(t, cfg)
|
||||||
|
setupBaselineMocks(deps)
|
||||||
|
|
||||||
|
w.RunOnce(t.Context())
|
||||||
|
|
||||||
|
// RunOnce does not send the test notification — it is
|
||||||
|
// sent by Run after RunOnce completes. Call the exported
|
||||||
|
// RunOnce then check that no test notification was sent
|
||||||
|
// (only Run triggers it). We test the full path via Run.
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
if len(notifications) != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"RunOnce should not send test notification, got %d",
|
||||||
|
len(notifications),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendTestNotification_ViaRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{"example.com"}
|
||||||
|
cfg.Hostnames = []string{"www.example.com"}
|
||||||
|
cfg.SendTestNotification = true
|
||||||
|
cfg.DNSInterval = 24 * time.Hour
|
||||||
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
|
w, deps := newTestWatcher(t, cfg)
|
||||||
|
setupBaselineMocks(deps)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
w.Run(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the initial scan and test notification.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
<-done
|
||||||
|
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, n := range notifications {
|
||||||
|
if n.Priority == "success" &&
|
||||||
|
n.Title == "✅ dnswatcher startup complete" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Errorf(
|
||||||
|
"expected startup test notification, got: %v",
|
||||||
|
notifications,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendTestNotification_Disabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{"example.com"}
|
||||||
|
cfg.Hostnames = []string{"www.example.com"}
|
||||||
|
cfg.SendTestNotification = false
|
||||||
|
cfg.DNSInterval = 24 * time.Hour
|
||||||
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
|
w, deps := newTestWatcher(t, cfg)
|
||||||
|
setupBaselineMocks(deps)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
w.Run(ctx)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
<-done
|
||||||
|
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
|
||||||
|
for _, n := range notifications {
|
||||||
|
if n.Title == "✅ dnswatcher startup complete" {
|
||||||
|
t.Error(
|
||||||
|
"test notification should not be sent when disabled",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNSFailureAndRecovery(t *testing.T) {
|
func TestNSFailureAndRecovery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
1
static/css/tailwind.min.css
vendored
Normal file
1
static/css/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
10
static/static.go
Normal file
10
static/static.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Package static provides embedded static assets.
|
||||||
|
package static
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// Static contains the embedded static assets (CSS, JS) served
|
||||||
|
// at the /s/ URL prefix.
|
||||||
|
//
|
||||||
|
//go:embed css
|
||||||
|
var Static embed.FS
|
||||||
Reference in New Issue
Block a user