Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f210de86 |
@@ -10,6 +10,3 @@ insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
name: check
|
||||
on: [push]
|
||||
# A new push to a branch cancels that branch's older run, queued or running;
|
||||
# runs on other branches, `next` and `main` among them, are left alone.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
# script/cibuild needs no token, so none is left in .git/config.
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: script/cibuild
|
||||
|
||||
-10
@@ -45,13 +45,3 @@ node_modules/
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# This repository's own entries, kept after the canonical content above.
|
||||
*.log
|
||||
*.out
|
||||
*.test
|
||||
bin/
|
||||
vendor/
|
||||
data/
|
||||
*.exe
|
||||
/dnswatcher
|
||||
|
||||
@@ -18,10 +18,6 @@ RUN golangci-lint run --config .golangci.yml ./...
|
||||
# it ends, because the first run already failed.
|
||||
# golang 1.25.7-trixie, 2026-10-06
|
||||
FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test
|
||||
# The file permission tests skip themselves as root, so the tests run as
|
||||
# an ordinary user, whose home directory holds Go's build cache.
|
||||
RUN useradd --create-home tester
|
||||
USER tester
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -99,11 +99,10 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
records, stored per nameserver. Their changes are notified as a hostname's
|
||||
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
|
||||
address change, in a message that starts `Domain:` where a hostname's starts
|
||||
`Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
|
||||
record change or inconsistency notification. A domain with no delegation of
|
||||
its own has these records asked at the servers of the zone it is in, as a
|
||||
hostname has. A domain that does not exist has none: they are not asked for,
|
||||
and those saved by an earlier check are removed without a notification.
|
||||
`Hostname:`. A domain with no delegation of its own has these records asked at
|
||||
the servers of the zone it is in, as a hostname has. A domain that does not
|
||||
exist has none: they are not asked for, and those saved by an earlier check
|
||||
are removed without a notification.
|
||||
|
||||
### DNS Hostname Monitoring (Subdomains)
|
||||
|
||||
@@ -137,14 +136,10 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
lower case, because names are case-insensitive and nameservers may answer in
|
||||
any letter case. TXT and CAA values keep their letter case; they are not
|
||||
lower-cased.
|
||||
- Any observable change in any nameserver's response triggers a notification,
|
||||
except a record change or an inconsistency for a domain or hostname listed in
|
||||
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`. This includes:
|
||||
- Any observable change in any nameserver's response triggers a notification.
|
||||
This includes:
|
||||
- **Record change**: A nameserver returns different records than it did on
|
||||
the previous check (additions, removals, value changes). For a domain or
|
||||
hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`, neither a
|
||||
record change nor an inconsistency is notified; its records are still
|
||||
checked and saved, and its other notifications are sent.
|
||||
the previous check (additions, removals, value changes).
|
||||
- **NS query failure**: A nameserver that previously responded becomes
|
||||
unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct
|
||||
from "responded with no records": a nameserver that answers NXDOMAIN or
|
||||
@@ -165,8 +160,7 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
failed on it, and answers differently is reported on the check where it
|
||||
answers. So is a pair that differs in a record type whose query to either
|
||||
nameserver failed on the previous check. If a pair agrees again and later
|
||||
disagrees, the alert is sent again. For a domain or hostname listed in
|
||||
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`, no inconsistency is notified.
|
||||
disagrees, the alert is sent again.
|
||||
- **CNAME address change**: The addresses at the end of a name's CNAME chain
|
||||
differ from those of the previous check. They are found when its
|
||||
nameservers answer with a CNAME and no address; a name that answers with
|
||||
@@ -192,13 +186,9 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
- Any change in port availability triggers a notification:
|
||||
- Port transitioned from open to closed (or vice versa).
|
||||
- New IP appeared (from DNS change): its port state is recorded without a
|
||||
port notification; the DNS change notification shows the new address. A
|
||||
domain or hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets
|
||||
no notification for an address added to its own A or AAAA records.
|
||||
port notification; the DNS change notification shows the new address.
|
||||
- IP disappeared (from DNS change) — noted in the DNS change notification;
|
||||
port state for that IP is removed. A domain or hostname listed in
|
||||
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no notification for an address
|
||||
removed from its own A or AAAA records. When none of a name's nameservers
|
||||
port state for that IP is removed. When none of a name's nameservers
|
||||
answered, its addresses are not known, so the port state saved for them is
|
||||
kept.
|
||||
|
||||
@@ -220,9 +210,7 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
|
||||
**Every observable state change produces a notification.** dnswatcher is
|
||||
designed as a real-time change feed — degradations, failures, recoveries, and
|
||||
routine changes are all reported equally. A domain or hostname listed in
|
||||
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
|
||||
notification.
|
||||
routine changes are all reported equally.
|
||||
|
||||
Supported notification backends:
|
||||
|
||||
@@ -421,24 +409,23 @@ following precedence (highest to lowest):
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| `PORT` | HTTP listen port; a port that cannot be bound, such as one already in use, stops startup | `8080` |
|
||||
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
|
||||
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
||||
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
||||
| `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` | Comma-separated names from `DNSWATCHER_TARGETS` for which no record change or inconsistency is notified; any other name stops startup | `""` |
|
||||
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
|
||||
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
|
||||
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
|
||||
| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` |
|
||||
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
|
||||
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
|
||||
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
|
||||
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
|
||||
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
|
||||
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
||||
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| `PORT` | HTTP listen port | `8080` |
|
||||
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
|
||||
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
||||
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
||||
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
|
||||
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
|
||||
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
|
||||
| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` |
|
||||
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
|
||||
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
|
||||
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
|
||||
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
|
||||
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
|
||||
| `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
|
||||
@@ -463,16 +450,6 @@ or empty variable (`DNSWATCHER_DNS_INTERVAL=`) means the default. If either is
|
||||
set to anything else, including a bare number or a zero or negative duration,
|
||||
dnswatcher refuses to start with an error naming the variable and the value.
|
||||
|
||||
**`PORT` and `DNSWATCHER_TLS_EXPIRY_WARNING`** take a whole number: 1 to 65535
|
||||
for `PORT`, and a number of days, 1 or more, for
|
||||
`DNSWATCHER_TLS_EXPIRY_WARNING`. `DNSWATCHER_DEBUG`,
|
||||
`DNSWATCHER_MAINTENANCE_MODE` and `DNSWATCHER_SEND_TEST_NOTIFICATION` take
|
||||
`true` or `false`; `1`, `t`, `T`, `TRUE` and `True` also mean true, and `0`,
|
||||
`f`, `F`, `FALSE` and `False` also mean false. An unset or empty variable means
|
||||
the default. Any other value, such as `PORT=http`,
|
||||
`DNSWATCHER_TLS_EXPIRY_WARNING=7d` or `DNSWATCHER_DEBUG=yes`, makes dnswatcher
|
||||
refuse to start with an error naming the variable and the value.
|
||||
|
||||
**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it
|
||||
is set, a panic in an HTTP request handler is sent to Sentry, and the request
|
||||
still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry:
|
||||
@@ -802,9 +779,7 @@ docker run -d \
|
||||
nameservers answered, the addresses an earlier check found at the end of
|
||||
the chain are used.
|
||||
4. **On change detection**: Send notifications to all configured endpoints,
|
||||
update in-memory state, persist to disk. A record change or inconsistency for
|
||||
a domain or hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` sends
|
||||
no notification, but the state is still updated and saved.
|
||||
update in-memory state, persist to disk.
|
||||
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
|
||||
and shutdown waits for that save before it goes on. Then it waits for
|
||||
in-flight notification deliveries to complete. Both waits share the fx
|
||||
|
||||
@@ -19,18 +19,9 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming
|
||||
the address, instead of running without the HTTP server (closes #266).
|
||||
- 2026-10-13: a bad `PORT`, `DNSWATCHER_TLS_EXPIRY_WARNING` or true/false
|
||||
setting stops startup instead of being read as 0 or false (closes #269).
|
||||
- 2026-10-13: a check saving the state while the dashboard or `/api/v1/status`
|
||||
is read cannot crash the process (closes #261).
|
||||
- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint
|
||||
and test are phases of the `Dockerfile`, every scripted build uncached (closes
|
||||
#257).
|
||||
- 2026-10-05: a name listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
|
||||
Record Change or Inconsistency notification; a name listed there that is not
|
||||
in `DNSWATCHER_TARGETS` stops startup (closes #255).
|
||||
- 2026-10-02: a nameserver whose query for one record type failed while the
|
||||
others answered with no records is `ok`, not `nodata` (closes #253).
|
||||
- 2026-10-02: a domain that does not exist is shown so, with no nameservers; no
|
||||
|
||||
+45
-191
@@ -5,8 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -25,9 +23,6 @@ const (
|
||||
defaultTLSExpiryWarning = 7
|
||||
)
|
||||
|
||||
// maxPort is the highest TCP port number.
|
||||
const maxPort = 65535
|
||||
|
||||
// ErrNoTargets is returned when no monitoring targets are configured.
|
||||
var ErrNoTargets = errors.New(
|
||||
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
|
||||
@@ -40,27 +35,6 @@ var ErrInvalidInterval = errors.New(
|
||||
"interval must be a positive duration such as 30m or 1h",
|
||||
)
|
||||
|
||||
// ErrNotInTargets is returned when DNSWATCHER_SKIP_RECORD_NOTIFICATIONS
|
||||
// lists a name that is not in DNSWATCHER_TARGETS.
|
||||
var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS")
|
||||
|
||||
// ErrInvalidPort is returned when PORT is set but is not a whole number
|
||||
// from 1 to 65535. An empty value counts as unset and means the default.
|
||||
var ErrInvalidPort = errors.New("port must be a whole number from 1 to 65535")
|
||||
|
||||
// ErrInvalidTLSExpiryWarning is returned when DNSWATCHER_TLS_EXPIRY_WARNING
|
||||
// is set but is not a whole number, 1 or more. An empty value counts as unset
|
||||
// and means the default.
|
||||
var ErrInvalidTLSExpiryWarning = errors.New(
|
||||
"TLS expiry warning must be a whole number of days, 1 or more",
|
||||
)
|
||||
|
||||
// ErrInvalidBool is returned when DNSWATCHER_DEBUG,
|
||||
// DNSWATCHER_MAINTENANCE_MODE or DNSWATCHER_SEND_TEST_NOTIFICATION is set
|
||||
// but is not a value strconv.ParseBool accepts. An empty value counts as
|
||||
// unset and means the default.
|
||||
var ErrInvalidBool = errors.New("value must be true or false")
|
||||
|
||||
// Params contains dependencies for Config.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -71,25 +45,24 @@ type Params struct {
|
||||
|
||||
// Config holds application configuration.
|
||||
type Config struct {
|
||||
Port int
|
||||
Debug bool
|
||||
DataDir string
|
||||
Domains []string
|
||||
Hostnames []string
|
||||
SkipRecordNotifications []string
|
||||
SlackWebhook string
|
||||
MattermostWebhook string
|
||||
NtfyTopic string
|
||||
DNSInterval time.Duration
|
||||
TLSInterval time.Duration
|
||||
TLSExpiryWarning int
|
||||
SentryDSN string
|
||||
MaintenanceMode bool
|
||||
MetricsUsername string
|
||||
MetricsPassword string
|
||||
SendTestNotification bool
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
Port int
|
||||
Debug bool
|
||||
DataDir string
|
||||
Domains []string
|
||||
Hostnames []string
|
||||
SlackWebhook string
|
||||
MattermostWebhook string
|
||||
NtfyTopic string
|
||||
DNSInterval time.Duration
|
||||
TLSInterval time.Duration
|
||||
TLSExpiryWarning int
|
||||
SentryDSN string
|
||||
MaintenanceMode bool
|
||||
MetricsUsername string
|
||||
MetricsPassword string
|
||||
SendTestNotification bool
|
||||
params *Params
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Config instance from environment and config files.
|
||||
@@ -130,7 +103,6 @@ func setupViper(name string) {
|
||||
viper.SetDefault("DEBUG", false)
|
||||
viper.SetDefault("DATA_DIR", "/var/lib/"+name)
|
||||
viper.SetDefault("TARGETS", "")
|
||||
viper.SetDefault("SKIP_RECORD_NOTIFICATIONS", "")
|
||||
viper.SetDefault("SLACK_WEBHOOK", "")
|
||||
viper.SetDefault("MATTERMOST_WEBHOOK", "")
|
||||
viper.SetDefault("NTFY_TOPIC", "")
|
||||
@@ -148,9 +120,16 @@ func buildConfig(
|
||||
log *slog.Logger,
|
||||
params *Params,
|
||||
) (*Config, error) {
|
||||
err := readConfigFile(log)
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
log.Error("config file malformed", "error", err)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"config file malformed: %w", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dnsInterval, err := parseInterval("DNS_INTERVAL")
|
||||
@@ -168,78 +147,30 @@ func buildConfig(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skipRecordNotifications, err := parseSkipRecordNotifications(
|
||||
domains, hostnames,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
port, err := parsePort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsExpiryWarning, err := parseTLSExpiryWarning()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debug, err := parseBool("DEBUG")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maintenanceMode, err := parseBool("MAINTENANCE_MODE")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sendTestNotification, err := parseBool("SEND_TEST_NOTIFICATION")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Port: port,
|
||||
Debug: debug,
|
||||
DataDir: viper.GetString("DATA_DIR"),
|
||||
Domains: domains,
|
||||
Hostnames: hostnames,
|
||||
SkipRecordNotifications: skipRecordNotifications,
|
||||
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
|
||||
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
|
||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||
DNSInterval: dnsInterval,
|
||||
TLSInterval: tlsInterval,
|
||||
TLSExpiryWarning: tlsExpiryWarning,
|
||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||
MaintenanceMode: maintenanceMode,
|
||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||
SendTestNotification: sendTestNotification,
|
||||
params: params,
|
||||
log: log,
|
||||
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
|
||||
}
|
||||
|
||||
// readConfigFile reads the config file, if there is one.
|
||||
func readConfigFile(log *slog.Logger) error {
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
var notFound viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
log.Error("config file malformed", "error", err)
|
||||
|
||||
return fmt.Errorf("config file malformed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseInterval reads the DNSWATCHER_-prefixed setting key as a duration. A
|
||||
// value that does not parse, or is zero or negative, is an error naming the
|
||||
// variable and the value; an unset variable has its default from setupViper.
|
||||
@@ -256,53 +187,6 @@ func parseInterval(key string) (time.Duration, error) {
|
||||
return interval, nil
|
||||
}
|
||||
|
||||
// parsePort reads PORT, which has no DNSWATCHER_ prefix, as a port number. A
|
||||
// value that is not a whole number from 1 to 65535 is an error naming the
|
||||
// variable and the value; an unset variable has its default from setupViper.
|
||||
func parsePort() (int, error) {
|
||||
value := viper.GetString("PORT")
|
||||
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > maxPort {
|
||||
return 0, fmt.Errorf("invalid PORT %q: %w", value, ErrInvalidPort)
|
||||
}
|
||||
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// parseTLSExpiryWarning reads DNSWATCHER_TLS_EXPIRY_WARNING as a number of
|
||||
// days. A value that is not a whole number, 1 or more, is an error naming the
|
||||
// variable and the value; an unset variable has its default from setupViper.
|
||||
func parseTLSExpiryWarning() (int, error) {
|
||||
value := viper.GetString("TLS_EXPIRY_WARNING")
|
||||
|
||||
days, err := strconv.Atoi(value)
|
||||
if err != nil || days < 1 {
|
||||
return 0, fmt.Errorf(
|
||||
"invalid DNSWATCHER_TLS_EXPIRY_WARNING %q: %w",
|
||||
value, ErrInvalidTLSExpiryWarning,
|
||||
)
|
||||
}
|
||||
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// parseBool reads the DNSWATCHER_-prefixed setting key as true or false. A
|
||||
// value strconv.ParseBool does not accept is an error naming the variable and
|
||||
// the value; an unset variable has its default from setupViper.
|
||||
func parseBool(key string) (bool, error) {
|
||||
value := viper.GetString(key)
|
||||
|
||||
result, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(
|
||||
"invalid DNSWATCHER_%s %q: %w", key, value, ErrInvalidBool,
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseAndValidateTargets() ([]string, []string, error) {
|
||||
domains, hostnames, err := ClassifyTargets(
|
||||
parseCSV(viper.GetString("TARGETS")),
|
||||
@@ -320,36 +204,6 @@ func parseAndValidateTargets() ([]string, []string, error) {
|
||||
return domains, hostnames, nil
|
||||
}
|
||||
|
||||
// parseSkipRecordNotifications reads DNSWATCHER_SKIP_RECORD_NOTIFICATIONS,
|
||||
// a comma-separated list of names from the targets. Each name is written
|
||||
// as ClassifyTargets writes a target, in lower case without a trailing
|
||||
// dot, and a name listed more than once is kept once. A name that is
|
||||
// not one of domains or hostnames is an error naming it.
|
||||
func parseSkipRecordNotifications(
|
||||
domains, hostnames []string,
|
||||
) ([]string, error) {
|
||||
value := viper.GetString("SKIP_RECORD_NOTIFICATIONS")
|
||||
|
||||
var names []string
|
||||
|
||||
for _, listed := range parseCSV(value) {
|
||||
name := strings.ToLower(strings.TrimSuffix(listed, "."))
|
||||
|
||||
if !slices.Contains(domains, name) && !slices.Contains(hostnames, name) {
|
||||
return nil, fmt.Errorf(
|
||||
"invalid DNSWATCHER_SKIP_RECORD_NOTIFICATIONS %q: %w",
|
||||
listed, ErrNotInTargets,
|
||||
)
|
||||
}
|
||||
|
||||
if !slices.Contains(names, name) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func parseCSV(input string) []string {
|
||||
if input == "" {
|
||||
return nil
|
||||
|
||||
@@ -57,7 +57,6 @@ func TestNew_DefaultValues(t *testing.T) {
|
||||
assert.Empty(t, cfg.MetricsUsername)
|
||||
assert.Empty(t, cfg.MetricsPassword)
|
||||
assert.False(t, cfg.SendTestNotification)
|
||||
assert.Empty(t, cfg.SkipRecordNotifications)
|
||||
}
|
||||
|
||||
func TestNew_EnvironmentOverrides(t *testing.T) {
|
||||
@@ -116,7 +115,7 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
|
||||
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an
|
||||
// TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an
|
||||
// unset or empty interval means the default.
|
||||
func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
|
||||
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
|
||||
@@ -144,107 +143,16 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_EmptyValueMeansDefault(t *testing.T) {
|
||||
func TestNew_EmptyIntervalMeansDefault(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DNS_INTERVAL", "")
|
||||
t.Setenv("DNSWATCHER_TLS_INTERVAL", "")
|
||||
t.Setenv("PORT", "")
|
||||
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "")
|
||||
t.Setenv("DNSWATCHER_DEBUG", "")
|
||||
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "")
|
||||
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "")
|
||||
|
||||
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)
|
||||
assert.Equal(t, 8080, cfg.Port)
|
||||
assert.Equal(t, 7, cfg.TLSExpiryWarning)
|
||||
assert.False(t, cfg.Debug)
|
||||
assert.False(t, cfg.MaintenanceMode)
|
||||
assert.False(t, cfg.SendTestNotification)
|
||||
}
|
||||
|
||||
// TestNew_InvalidNumberOrBoolStopsStartup checks values that must stop
|
||||
// startup; TestNew_EnvironmentOverrides, the two tests after this one and
|
||||
// TestNew_EmptyValueMeansDefault check values that must not.
|
||||
func TestNew_InvalidNumberOrBoolStopsStartup(t *testing.T) {
|
||||
notBools := []string{"yes", "on", "2", "tRUE"}
|
||||
tests := []struct {
|
||||
variable string
|
||||
values []string
|
||||
want error
|
||||
}{
|
||||
{
|
||||
variable: "PORT",
|
||||
values: []string{"notaport", "0", "-1", "65536", "8080.5"},
|
||||
want: config.ErrInvalidPort,
|
||||
},
|
||||
{
|
||||
variable: "DNSWATCHER_TLS_EXPIRY_WARNING",
|
||||
values: []string{"abc", "7d", "0", "-5"},
|
||||
want: config.ErrInvalidTLSExpiryWarning,
|
||||
},
|
||||
{
|
||||
variable: "DNSWATCHER_DEBUG",
|
||||
values: notBools,
|
||||
want: config.ErrInvalidBool,
|
||||
},
|
||||
{
|
||||
variable: "DNSWATCHER_MAINTENANCE_MODE",
|
||||
values: notBools,
|
||||
want: config.ErrInvalidBool,
|
||||
},
|
||||
{
|
||||
variable: "DNSWATCHER_SEND_TEST_NOTIFICATION",
|
||||
values: notBools,
|
||||
want: config.ErrInvalidBool,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, value := range tt.values {
|
||||
t.Run(tt.variable+"="+value, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv(tt.variable, value)
|
||||
|
||||
_, err := config.New(nil, newTestParams(t))
|
||||
require.ErrorIs(t, err, tt.want)
|
||||
require.ErrorContains(t, err, tt.variable)
|
||||
require.ErrorContains(t, err, strconv.Quote(value))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_LowestNumbersAndOtherSpellingsOfTrue(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("PORT", "1")
|
||||
t.Setenv("DNSWATCHER_TLS_EXPIRY_WARNING", "1")
|
||||
t.Setenv("DNSWATCHER_DEBUG", "1")
|
||||
t.Setenv("DNSWATCHER_MAINTENANCE_MODE", "T")
|
||||
t.Setenv("DNSWATCHER_SEND_TEST_NOTIFICATION", "True")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, cfg.Port)
|
||||
assert.Equal(t, 1, cfg.TLSExpiryWarning)
|
||||
assert.True(t, cfg.Debug)
|
||||
assert.True(t, cfg.MaintenanceMode)
|
||||
assert.True(t, cfg.SendTestNotification)
|
||||
}
|
||||
|
||||
func TestNew_HighestPort(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("PORT", "65535")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 65535, cfg.Port)
|
||||
}
|
||||
|
||||
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
|
||||
@@ -327,33 +235,6 @@ func TestNew_TargetsWithTrailingComma(t *testing.T) {
|
||||
"trailing comma should be ignored")
|
||||
}
|
||||
|
||||
func TestNew_SkipRecordNotifications(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.net,www.example.net,example.org")
|
||||
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
|
||||
" WWW.Example.net. , example.net,www.example.net")
|
||||
|
||||
cfg, err := config.New(nil, newTestParams(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
[]string{"www.example.net", "example.net"},
|
||||
cfg.SkipRecordNotifications,
|
||||
"names are written as targets are, each once",
|
||||
)
|
||||
}
|
||||
|
||||
func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.net")
|
||||
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
|
||||
"example.net,www.example.net")
|
||||
|
||||
_, err := config.New(nil, newTestParams(t))
|
||||
require.ErrorIs(t, err, config.ErrNotInTargets)
|
||||
require.ErrorContains(t, err, "DNSWATCHER_SKIP_RECORD_NOTIFICATIONS")
|
||||
require.ErrorContains(t, err, `"www.example.net"`)
|
||||
}
|
||||
|
||||
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
const RequestTimeout time.Duration = requestTimeout
|
||||
|
||||
// SetListenPort overrides the port Run binds. A test uses it to hand
|
||||
// Run an unbindable port, so that Run stores its http.Server and then
|
||||
// returns an error without listening.
|
||||
// Run an unbindable port so ListenAndServe fails immediately and Run
|
||||
// returns after storing its http.Server.
|
||||
func SetListenPort(s *Server, port int) {
|
||||
s.port = port
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -113,7 +112,7 @@ func New(
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
OnStart: func(_ context.Context) error {
|
||||
srv.startupTime = time.Now()
|
||||
|
||||
err := srv.enableSentry()
|
||||
@@ -121,7 +120,9 @@ func New(
|
||||
return err
|
||||
}
|
||||
|
||||
return srv.Run(ctx)
|
||||
go srv.Run()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return srv.Shutdown(ctx)
|
||||
@@ -148,10 +149,8 @@ func newHTTPServer(
|
||||
}
|
||||
}
|
||||
|
||||
// Run binds the listen port, then serves HTTP on it in a new goroutine
|
||||
// and returns. A port that cannot be bound is returned as an error, so
|
||||
// that startup stops instead of running without the HTTP server.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
// Run starts the HTTP server.
|
||||
func (s *Server) Run() {
|
||||
s.SetupRoutes()
|
||||
|
||||
listenAddr := fmt.Sprintf(":%d", s.port)
|
||||
@@ -159,21 +158,10 @@ func (s *Server) Run(ctx context.Context) error {
|
||||
|
||||
s.log.Info("http server starting", "addr", listenAddr)
|
||||
|
||||
lc := &net.ListenConfig{}
|
||||
|
||||
listener, err := lc.Listen(ctx, "tcp", listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting http server: %w", err)
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("http server error", "error", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := s.httpServer.Serve(listener)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("http server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server, then sends the error
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -60,13 +58,15 @@ func buildServer(t *testing.T) *server.Server {
|
||||
}
|
||||
|
||||
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
||||
// server actually serves — the one Run builds and serves on the port
|
||||
// it binds — carries every socket-level timeout, plus the two
|
||||
// server actually serves — the one Run builds and hands to
|
||||
// ListenAndServe — carries every socket-level timeout, plus the two
|
||||
// relationships the values must satisfy.
|
||||
//
|
||||
// Run is given an unbindable port: it builds and stores s.httpServer,
|
||||
// then the bind fails and Run returns the error without ever
|
||||
// listening. Nothing here measures elapsed time.
|
||||
// Run is driven to completion with an unbindable port: it builds and
|
||||
// stores s.httpServer, then ListenAndServe fails at once and Run
|
||||
// returns without ever listening. The assertions run in the same
|
||||
// goroutine after Run returns, so reading s.httpServer is free of any
|
||||
// data race. Nothing here measures elapsed time.
|
||||
//
|
||||
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
|
||||
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
||||
@@ -84,10 +84,7 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
|
||||
srv := buildServer(t)
|
||||
server.SetListenPort(srv, -1)
|
||||
|
||||
err := srv.Run(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("Run bound port -1")
|
||||
}
|
||||
srv.Run()
|
||||
|
||||
hs := server.HTTPServerOf(srv)
|
||||
if hs == nil {
|
||||
@@ -136,38 +133,3 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortInUseStopsStartup(t *testing.T) {
|
||||
// Bound on every address, as Run binds, so that Run's bind fails.
|
||||
lc := &net.ListenConfig{}
|
||||
|
||||
taken, err := lc.Listen(t.Context(), "tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("binding a port for the test: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = taken.Close() }()
|
||||
|
||||
_, port, err := net.SplitHostPort(taken.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("reading the bound port: %v", err)
|
||||
}
|
||||
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
|
||||
t.Setenv("PORT", port)
|
||||
|
||||
app := newServerApp(fx.Invoke(func(*server.Server) {}))
|
||||
|
||||
err = app.Start(t.Context())
|
||||
if err == nil {
|
||||
_ = app.Stop(t.Context())
|
||||
|
||||
t.Fatal("startup succeeded on a port that is already bound")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), ":"+port) {
|
||||
t.Errorf("startup error does not name port %s: %v", port, err)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-12
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -271,21 +270,12 @@ func (s *State) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSnapshot returns a copy of the current snapshot, with copies of
|
||||
// its maps, so that a caller can range over them while the state is
|
||||
// written. The entries in the maps are not copied: an entry saved in the
|
||||
// state must be replaced, never changed in place.
|
||||
// GetSnapshot returns a copy of the current snapshot.
|
||||
func (s *State) GetSnapshot() Snapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
snap := *s.snapshot
|
||||
snap.Domains = maps.Clone(snap.Domains)
|
||||
snap.Hostnames = maps.Clone(snap.Hostnames)
|
||||
snap.Ports = maps.Clone(snap.Ports)
|
||||
snap.Certificates = maps.Clone(snap.Certificates)
|
||||
|
||||
return snap
|
||||
return *s.snapshot
|
||||
}
|
||||
|
||||
// SetDomainState updates the state for a domain.
|
||||
|
||||
@@ -995,8 +995,7 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSnapshot_ReturnsCopy verifies that the maps in the snapshot
|
||||
// GetSnapshot returns are not the state's own.
|
||||
// TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy.
|
||||
func TestGetSnapshot_ReturnsCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1006,67 +1005,15 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
|
||||
|
||||
snap := s.GetSnapshot()
|
||||
|
||||
const added = "added.example.com"
|
||||
|
||||
snap.Domains[added] = &state.DomainState{}
|
||||
snap.Hostnames[added] = &state.HostnameState{}
|
||||
snap.Ports[added] = &state.PortState{}
|
||||
snap.Certificates[added] = &state.CertificateState{}
|
||||
|
||||
if _, ok := s.GetDomainState(added); ok {
|
||||
t.Error("the snapshot's Domains map is the state's own")
|
||||
// Mutate the returned snapshot's map.
|
||||
snap.Domains["mutated.example.com"] = &state.DomainState{
|
||||
Nameservers: []string{"ns1.mutated.com."},
|
||||
}
|
||||
|
||||
if _, ok := s.GetHostnameState(added); ok {
|
||||
t.Error("the snapshot's Hostnames map is the state's own")
|
||||
}
|
||||
|
||||
if _, ok := s.GetPortState(added); ok {
|
||||
t.Error("the snapshot's Ports map is the state's own")
|
||||
}
|
||||
|
||||
if _, ok := s.GetCertificateState(added); ok {
|
||||
t.Error("the snapshot's Certificates map is the state's own")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSnapshot_WhileWritten ranges over snapshots while another
|
||||
// goroutine writes the state, as the dashboard does while a check runs.
|
||||
// Under -race it fails when a snapshot's maps are the state's own.
|
||||
func TestGetSnapshot_WhileWritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := state.NewForTestWithDataDir(t.TempDir())
|
||||
|
||||
const writes = 1000
|
||||
|
||||
written := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(written)
|
||||
|
||||
for range writes {
|
||||
s.SetDomainState("example.com", &state.DomainState{})
|
||||
s.SetHostnameState(testHostname, &state.HostnameState{})
|
||||
s.SetPortState(testIPv4+":443", &state.PortState{})
|
||||
s.SetCertificateState(
|
||||
testIPv4+":443:"+testHostname, &state.CertificateState{},
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-written:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// json.Marshal ranges over each of the snapshot's maps.
|
||||
_, err := json.Marshal(s.GetSnapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The original domain data should still be accessible.
|
||||
_, ok := s.GetDomainState("example.com")
|
||||
if !ok {
|
||||
t.Error("original domain should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package watcher_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||
)
|
||||
|
||||
// TestSkipRecordNotificationsAlerts runs the hostname change detection
|
||||
// for two names on the same check: nsA's address changes, so that nsA
|
||||
// now differs from nsB, and nsC stops answering. The name in
|
||||
// SkipRecordNotifications gets only the NS Failure; the other name also
|
||||
// gets the Record Change and the Inconsistency.
|
||||
func TestSkipRecordNotificationsAlerts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const skipped = "skipped.example.net"
|
||||
|
||||
prev := saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||
nsC: answered(map[string][]string{"A": {ip1}}),
|
||||
})
|
||||
current := saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(map[string][]string{"A": {ip2}}),
|
||||
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||
nsC: failed(),
|
||||
})
|
||||
|
||||
cfg := &config.Config{
|
||||
Hostnames: []string{host, skipped},
|
||||
SkipRecordNotifications: []string{skipped},
|
||||
}
|
||||
|
||||
// The hostname change detection uses only the configuration and the
|
||||
// notifier.
|
||||
notifier := &mockNotifier{}
|
||||
w := watcher.NewForTest(cfg, nil, nil, nil, nil, notifier)
|
||||
|
||||
w.DetectHostnameChanges(t.Context(), host, prev, current)
|
||||
w.DetectHostnameChanges(t.Context(), skipped, prev, current)
|
||||
|
||||
sent := notifier.getNotifications()
|
||||
titles := make([]string, 0, len(sent))
|
||||
|
||||
for _, n := range sent {
|
||||
titles = append(titles, n.Title)
|
||||
}
|
||||
|
||||
slices.Sort(titles)
|
||||
|
||||
want := []string{
|
||||
"Inconsistency: " + host,
|
||||
"NS Failure: " + skipped,
|
||||
"NS Failure: " + host,
|
||||
"Record Change: " + host,
|
||||
}
|
||||
|
||||
if !slices.Equal(titles, want) {
|
||||
t.Errorf("sent %v, want %v", titles, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkipRecordNotificationsCheck checks testHost, which is in
|
||||
// SkipRecordNotifications, from a saved state in which every nameserver
|
||||
// live DNS lists answered with an address live DNS never returns. No
|
||||
// Record Change and no Inconsistency is sent, and the check still saves
|
||||
// what the nameservers answer.
|
||||
func TestSkipRecordNotificationsCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Hostnames = []string{testHost}
|
||||
cfg.SkipRecordNotifications = []string{testHost}
|
||||
|
||||
nameservers := lookupNameservers(t, testHost)
|
||||
|
||||
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
||||
byNameserver := make(map[string]*state.NameserverRecordState)
|
||||
for _, ns := range nameservers {
|
||||
byNameserver[ns] = answered(map[string][]string{"A": {oldIP}})
|
||||
}
|
||||
|
||||
deps.state.SetHostnameState(testHost, saved(byNameserver))
|
||||
})
|
||||
|
||||
for _, title := range []string{
|
||||
"Record Change: " + testHost,
|
||||
"Inconsistency: " + testHost,
|
||||
} {
|
||||
if n := countNotifications(deps, title); n != 0 {
|
||||
t.Errorf("sent %d %q, want 0", n, title)
|
||||
}
|
||||
}
|
||||
|
||||
// A nameserver whose query for A failed keeps oldIP, so the check
|
||||
// is that some address live DNS gave was saved.
|
||||
hs, _ := deps.state.GetHostnameState(testHost)
|
||||
fromLiveDNS := func(ip string) bool { return ip != oldIP }
|
||||
|
||||
if !slices.ContainsFunc(addresses(hs), fromLiveDNS) {
|
||||
t.Errorf("saved addresses %v, want those live DNS gave", addresses(hs))
|
||||
}
|
||||
}
|
||||
@@ -730,16 +730,12 @@ func (w *Watcher) detectCNAMEAddressChanges(
|
||||
// failed on either check has no records to compare. The records kept
|
||||
// for a record type whose query failed are compared too, but not those
|
||||
// of a type in UnknownTypes on either check, which the message leaves
|
||||
// out as well. Nothing is sent for a name in SkipRecordNotifications.
|
||||
// out as well.
|
||||
func (w *Watcher) detectRecordChanges(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
if slices.Contains(w.config.SkipRecordNotifications, hostname) {
|
||||
return
|
||||
}
|
||||
|
||||
for ns, cur := range current.RecordsByNameserver {
|
||||
prevNS, ok := prev.RecordsByNameserver[ns]
|
||||
if !ok || prevNS.Status != statusOK || cur.Status != statusOK {
|
||||
@@ -839,18 +835,11 @@ func (w *Watcher) detectNSFailures(
|
||||
}
|
||||
}
|
||||
|
||||
// detectInconsistencies notifies each pair of nameservers that newly
|
||||
// disagree (see newlyDisagreeingPairs). Nothing is sent for a name in
|
||||
// SkipRecordNotifications.
|
||||
func (w *Watcher) detectInconsistencies(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev, current *state.HostnameState,
|
||||
) {
|
||||
if slices.Contains(w.config.SkipRecordNotifications, hostname) {
|
||||
return
|
||||
}
|
||||
|
||||
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
||||
ns1, ns2 := pair[0], pair[1]
|
||||
state1 := current.RecordsByNameserver[ns1]
|
||||
|
||||
Reference in New Issue
Block a user