Compare commits
18
Commits
b9c6d43722
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adc8131191 | ||
|
|
06ae357165 | ||
|
|
f6b7aefcbf | ||
|
|
db3b09c5d8 | ||
|
|
caa3aa3c12 | ||
|
|
dee69c4780 | ||
|
|
5e2c456e85 | ||
|
|
b79726acc6 | ||
|
|
aaccabad50 | ||
|
|
6b580a5a0f | ||
|
|
cb89e4f8cf | ||
|
|
9e6871817a | ||
|
|
3592302615 | ||
|
|
79761c29e1 | ||
|
|
b9686c6d7b | ||
|
|
5617c92fb3 | ||
|
|
2ae6110414 | ||
|
|
39e7bc9067 |
@@ -78,8 +78,9 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
|
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
|
||||||
not existing (see Web Dashboard and HTTP API). A domain that exists but has no
|
not existing (see Web Dashboard and HTTP API). A domain that exists but has no
|
||||||
delegation of its own, such as `octocat.github.io`, has no nameservers either.
|
delegation of its own, such as `octocat.github.io`, has no nameservers either.
|
||||||
When the parent zone's servers do not answer, the check fails and the set from
|
When the parent zone's servers do not answer, or the lookup of the domain's
|
||||||
the previous check is kept.
|
own records (below) fails, the check fails and the set from the previous check
|
||||||
|
is kept.
|
||||||
- Any change triggers a notification:
|
- Any change triggers a notification:
|
||||||
- NS added to or removed from that set. A domain that had nameservers on the
|
- NS added to or removed from that set. A domain that had nameservers on the
|
||||||
previous check and no longer exists gets one with all of them removed.
|
previous check and no longer exists gets one with all of them removed.
|
||||||
@@ -99,11 +100,18 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
records, stored per nameserver. Their changes are notified as a hostname's
|
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
|
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
|
address change, in a message that starts `Domain:` where a hostname's starts
|
||||||
`Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
|
`Hostname:`. A nameserver removed from the domain's delegation gets only the
|
||||||
record change or inconsistency notification. A domain with no delegation of
|
NS change notification above. A domain listed in
|
||||||
its own has these records asked at the servers of the zone it is in, as a
|
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
|
||||||
hostname has. A domain that does not exist has none: they are not asked for,
|
notification. A domain with no delegation of its own has these records asked
|
||||||
and those saved by an earlier check are removed without a notification.
|
at the servers of the zone it is in, as a hostname has. When none of those
|
||||||
|
servers answers, the lookup of its NS record set fails, and they are saved as
|
||||||
|
failed and notified as a hostname's are (see below). A nameserver the domain's
|
||||||
|
records were asked at on the previous check but not on this one gets a
|
||||||
|
hostname's NS change notification (see below), unless it was in the domain's
|
||||||
|
delegation on the previous check, as when that delegation is withdrawn. 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)
|
### DNS Hostname Monitoring (Subdomains)
|
||||||
|
|
||||||
@@ -115,22 +123,29 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
trace moves from a name to its parent only when the servers asked answer that
|
trace moves from a name to its parent only when the servers asked answer that
|
||||||
the name has no delegation of its own, or does not exist. When they do not
|
the name has no delegation of its own, or does not exist. When they do not
|
||||||
answer, the check fails and the hostname's records from the previous check are
|
answer, the check fails and the hostname's records from the previous check are
|
||||||
kept.
|
kept, unless they are the nameservers the previous check saved for the
|
||||||
|
hostname. Each of those is then saved as failed, with the reason, as a
|
||||||
|
domain's nameservers are when none of them answers, so it gets an NS query
|
||||||
|
failure notification, and an NS recovery notification when it answers again
|
||||||
|
(see below). A trace that gets no answer before it reaches them, as when the
|
||||||
|
host running dnswatcher loses its network, fails the check without a
|
||||||
|
notification.
|
||||||
- Queries **each** authoritative nameserver independently for **all** record
|
- Queries **each** authoritative nameserver independently for **all** record
|
||||||
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||||
- Each record type is a query of its own. When a nameserver answers some types
|
- Each record type is a query of its own. When a nameserver answers some types
|
||||||
but the query for another gets no usable reply (no reply after two tries, an
|
but the query for another gets no usable reply at any of its addresses (no
|
||||||
error reply such as SERVFAIL, a referral, or a reply too large for UDP whose
|
reply after two tries, an error reply such as SERVFAIL, a referral, or a reply
|
||||||
retry over TCP fails), the failure is logged with the reason, and the type is
|
too large for UDP whose retry over TCP fails), the failure is logged with the
|
||||||
listed in the nameserver's `failedTypes` and keeps the records saved for the
|
reason, and the type is listed in the nameserver's `failedTypes` and keeps the
|
||||||
nameserver by the previous check. On that check those records are not compared
|
records saved for the nameserver by the previous check. On that check those
|
||||||
with the other nameservers', so no record change or inconsistency is reported
|
records are not compared with the other nameservers', so no record change or
|
||||||
for the type; on the next check they are compared with the nameserver's answer
|
inconsistency is reported for the type; on the next check they are compared
|
||||||
as usual. When the previous check did not know the type's records either,
|
with the nameserver's answer as usual. When the previous check did not know
|
||||||
because the nameserver was new or failing then or the type was already listed
|
the type's records either, because the nameserver was new or failing then or
|
||||||
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
|
the type was already listed in `unknownTypes`, the type is also listed in
|
||||||
every comparison until it answers. A nameserver none of whose queries got a
|
`unknownTypes` and left out of every comparison until it answers. A nameserver
|
||||||
usable reply has failed (see NS query failure below).
|
none of whose queries got a usable reply has failed (see NS query failure
|
||||||
|
below).
|
||||||
- Stores results **per nameserver**. The state for a hostname is not a merged
|
- Stores results **per nameserver**. The state for a hostname is not a merged
|
||||||
view — it is a map from nameserver to record set.
|
view — it is a map from nameserver to record set.
|
||||||
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
|
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
|
||||||
@@ -156,6 +171,10 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
- **NS recovery**: A previously-unreachable nameserver starts responding
|
- **NS recovery**: A previously-unreachable nameserver starts responding
|
||||||
again. Its records are not compared with those from before it failed, so a
|
again. Its records are not compared with those from before it failed, so a
|
||||||
change made while it was failing is not reported as a record change.
|
change made while it was failing is not reported as a record change.
|
||||||
|
- **NS change**: A nameserver of the zone the hostname is in on the previous
|
||||||
|
check is not one now, whether or not it answered. One notification lists
|
||||||
|
every such nameserver on a `Removed:` line. Nothing is sent for a
|
||||||
|
nameserver that joins the zone.
|
||||||
- **Inconsistency detected**: Two nameservers return different record sets
|
- **Inconsistency detected**: Two nameservers return different record sets
|
||||||
for the same hostname and did not already differ on the previous check.
|
for the same hostname and did not already differ on the previous check.
|
||||||
Every pair of nameservers is compared. The alert is sent once for each
|
Every pair of nameservers is compared. The alert is sent once for each
|
||||||
@@ -213,8 +232,14 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
|
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
|
||||||
and new CN and issuer).
|
and new CN and issuer).
|
||||||
- TLS connection failure to a previously-reachable IP:443 (handshake error,
|
- TLS connection failure to a previously-reachable IP:443 (handshake error,
|
||||||
timeout, connection refused after previously succeeding).
|
timeout, or connection refused when port 443 closed after the port check
|
||||||
|
that found it open).
|
||||||
- TLS recovery: a previously-failing IP:443 now completes a handshake again.
|
- TLS recovery: a previously-failing IP:443 now completes a handshake again.
|
||||||
|
- A port check that finds port 443 closed removes the certificate entries for
|
||||||
|
that IP address, without a notification of its own: the port change
|
||||||
|
notification reports the closed port. When the port opens again, the next TLS
|
||||||
|
check saves the certificate as new, without a certificate change or recovery
|
||||||
|
notification.
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
@@ -237,7 +262,9 @@ includes:
|
|||||||
|
|
||||||
- **DNS record changes**: Which hostname or domain, which nameserver, what
|
- **DNS record changes**: Which hostname or domain, which nameserver, what
|
||||||
record type, old values, new values.
|
record type, old values, new values.
|
||||||
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
- **DNS NS changes**: Which domain, which nameservers were added to or removed
|
||||||
|
from its delegation. For a hostname, or a domain with no delegation of its
|
||||||
|
own, which nameservers left the zone it is in.
|
||||||
- **NS address changes**: Which domain, which nameserver, its old and new
|
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||||
addresses.
|
addresses.
|
||||||
- **CNAME address changes**: Which hostname or domain, the old and new addresses
|
- **CNAME address changes**: Which hostname or domain, the old and new addresses
|
||||||
@@ -259,7 +286,7 @@ includes:
|
|||||||
|
|
||||||
Each endpoint is sent each notification on its own, in the background. A
|
Each endpoint is sent each notification on its own, in the background. A
|
||||||
delivery that fails (a network error, no reply within 10 seconds, or an HTTP
|
delivery that fails (a network error, no reply within 10 seconds, or an HTTP
|
||||||
status of 400 or more) is retried up to 5 times: the first retry after about 1
|
status other than 2xx) is retried up to 5 times: the first retry after about 1
|
||||||
second, each wait after that twice as long up to 60 seconds, every wait varied
|
second, each wait after that twice as long up to 60 seconds, every wait varied
|
||||||
at random by up to 25%. A delivery still failing after that is logged and
|
at random by up to 25%. A delivery still failing after that is logged and
|
||||||
dropped.
|
dropped.
|
||||||
@@ -282,8 +309,10 @@ clears them.
|
|||||||
port entry left with no name goes, so the dashboard and `/api/v1/status` no
|
port entry left with no name goes, so the dashboard and `/api/v1/status` no
|
||||||
longer list or count it. The first check's port checks remove the port entries
|
longer list or count it. The first check's port checks remove the port entries
|
||||||
of addresses no configured name has.
|
of addresses no configured name has.
|
||||||
- Each port check also removes the certificate entries for an address their name
|
- Each port check also removes, without a notification, the certificate entries
|
||||||
no longer resolves to, except while none of the name's nameservers answer.
|
for an address whose port 443 it found closed, and those for an address their
|
||||||
|
name no longer resolves to, except while none of the name's nameservers
|
||||||
|
answer.
|
||||||
|
|
||||||
### Web Dashboard
|
### Web Dashboard
|
||||||
|
|
||||||
@@ -423,7 +452,7 @@ following precedence (highest to lowest):
|
|||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||||
| `PORT` | HTTP listen port | `8080` |
|
| `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_DEBUG` | Enable debug logging | `false` |
|
||||||
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
|
||||||
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
|
||||||
@@ -437,7 +466,7 @@ following precedence (highest to lowest):
|
|||||||
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
|
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
|
||||||
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
|
| `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_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
|
||||||
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
|
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics; required when `DNSWATCHER_METRICS_USERNAME` is set, and startup stops without it | `""` |
|
||||||
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
|
||||||
|
|
||||||
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
|
||||||
@@ -463,6 +492,16 @@ 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,
|
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.
|
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
|
**`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
|
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:
|
still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry:
|
||||||
@@ -511,6 +550,11 @@ name's nameservers and in the lookup of a nameserver's own address. Such a
|
|||||||
lookup can need others in turn; lookups go at most three deep, one inside
|
lookup can need others in turn; lookups go at most three deep, one inside
|
||||||
another, so delegations that point at each other still end.
|
another, so delegations that point at each other still end.
|
||||||
|
|
||||||
|
In step 4 each record type is asked at the IPv4 addresses the nameserver's name
|
||||||
|
resolves to, one at a time, until one gives a usable reply; the next type is
|
||||||
|
asked first at that address. A type's query fails only when none of them gives
|
||||||
|
one, and the nameserver has failed only when that is so for every type.
|
||||||
|
|
||||||
This approach ensures:
|
This approach ensures:
|
||||||
|
|
||||||
- Independence from any upstream resolver's cache or filtering.
|
- Independence from any upstream resolver's cache or filtering.
|
||||||
@@ -636,10 +680,20 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
|
|||||||
earlier check saved one. A state file without it loads, and the first check
|
earlier check saved one. A state file without it loads, and the first check
|
||||||
after that saves it without a notification.
|
after that saves it without a notification.
|
||||||
|
|
||||||
|
A port entry's key is its address and port, and a certificate entry's key its
|
||||||
|
address, port 443 and hostname, joined by colons. An IPv6 address is written in
|
||||||
|
brackets in these keys, as in `[2001:db8::1]:443` and
|
||||||
|
`[2001:db8::1]:443:www.example.com`. `/api/v1/status` and the dashboard show the
|
||||||
|
same keys.
|
||||||
|
|
||||||
A port entry's `hostnames` lists every name that resolves to its address,
|
A port entry's `hostnames` lists every name that resolves to its address,
|
||||||
domains included. A port entry in the older format, with one `hostname` instead
|
domains included. A port entry in the older format, with one `hostname` instead
|
||||||
of the `hostnames` list, loads as a list of that one name.
|
of the `hostnames` list, loads as a list of that one name.
|
||||||
|
|
||||||
|
A state file that leaves out any of `domains`, `hostnames`, `ports` and
|
||||||
|
`certificates`, or holds `null` for one, loads with that section empty. A file
|
||||||
|
holding `{}` loads as empty state.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
@@ -856,6 +910,7 @@ internal/
|
|||||||
templates/dashboard.html Dashboard template (embedded)
|
templates/dashboard.html Dashboard template (embedded)
|
||||||
status.go /api/v1/status
|
status.go /api/v1/status
|
||||||
healthcheck.go Health check handler
|
healthcheck.go Health check handler
|
||||||
|
static.go Static assets under /s/
|
||||||
server/
|
server/
|
||||||
server.go HTTP server lifecycle
|
server.go HTTP server lifecycle
|
||||||
routes.go Route definitions
|
routes.go Route definitions
|
||||||
|
|||||||
+9
-7
@@ -19,16 +19,18 @@ works correctly in production.
|
|||||||
### Constraints
|
### Constraints
|
||||||
|
|
||||||
- Tests hit real DNS infrastructure and require network access
|
- Tests hit real DNS infrastructure and require network access
|
||||||
- Test duration depends on network conditions; timeout tuning keeps the suite
|
- Test duration depends on network conditions. `make test` must finish in under
|
||||||
within the 60-second target
|
60 seconds, the hard cap in `REPO_POLICIES.md`; under 20 seconds is the target
|
||||||
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) plus
|
- Each query to a nameserver has a 2-second timeout, and a query over UDP that
|
||||||
processing margin
|
gets no reply is sent once more (`queryTimeoutDuration` and `maxRetries` in
|
||||||
- Root server fan-out is limited to reduce parallel query load
|
`internal/resolver/iterative.go`)
|
||||||
|
- In the walk to a name's nameservers, the servers of each zone are asked one at
|
||||||
|
a time in a random order, as the README's DNS Resolution Strategy describes
|
||||||
- Live lookups that expect an answer go through `internal/livednstest`, which
|
- Live lookups that expect an answer go through `internal/livednstest`, which
|
||||||
limits how many run at once in a test binary and retries a lookup that got
|
limits how many run at once in a test binary and retries a lookup that got
|
||||||
none
|
none
|
||||||
- Flaky failures from transient network issues are acceptable and should be
|
- A flaky live test is a robustness problem, fixed with retries with backoff,
|
||||||
investigated as potential resolver bugs, not papered over with mocks or skip
|
several independent nameservers or longer timeouts, never with mocks or skip
|
||||||
flags
|
flags
|
||||||
|
|
||||||
### What NOT to do
|
### What NOT to do
|
||||||
|
|||||||
@@ -10,15 +10,56 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked
|
pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0
|
||||||
on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-15: a query its caller's context ends before it is sent records no
|
||||||
|
reason about the nameserver, and a try the context's deadline cuts short is a
|
||||||
|
timeout, so a nameserver whose only failures were timeouts is `timeout`, not a
|
||||||
|
network error or `ok` (closes #298).
|
||||||
|
- 2026-10-15: the resolver no longer says its query timeout grows on each try;
|
||||||
|
every try waits the same 2 seconds, and the unused `timeoutMultiplier` is gone
|
||||||
|
(closes #296).
|
||||||
|
- 2026-10-15: `TESTING.md` Constraints give the resolver's 2-second query
|
||||||
|
timeout and second try, say a zone's servers are asked one at a time in a
|
||||||
|
random order, give the 60-second cap and 20-second target for `make test`, and
|
||||||
|
say a flaky live test is fixed with robustness, not with mocks (closes #289).
|
||||||
|
- 2026-10-15: a request under `/s/` that names a directory, such as `/s/` or
|
||||||
|
`/s/css`, answers 404 instead of a listing of its files (closes #286).
|
||||||
|
- 2026-10-15: a notification endpoint that answers with a redirect, or any
|
||||||
|
status other than 2xx, is a failed delivery, retried and logged (closes #287).
|
||||||
|
- 2026-10-15: a nameserver is asked at each of its IPv4 addresses in turn, so it
|
||||||
|
has failed only when none of them gives a usable reply (closes #285).
|
||||||
|
- 2026-10-15: the log names a notification endpoint (`slack`, `mattermost`,
|
||||||
|
`ntfy`) and never shows its URL's path or query (closes #288).
|
||||||
|
- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering
|
||||||
|
gets an NS Failure for each, then an NS Recovery (closes #284).
|
||||||
|
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
|
||||||
|
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
|
||||||
|
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
|
||||||
|
Status says nothing about git tags (closes #281).
|
||||||
|
- 2026-10-13: a port check that finds port 443 closed removes that address's
|
||||||
|
certificate entries, so none is left shown as `ok` (closes #265).
|
||||||
|
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
|
||||||
|
that section empty; the first check no longer crashes on it (closes #267).
|
||||||
|
- 2026-10-13: a stop before the first scan completes sends no startup
|
||||||
|
notification; a scan that ran to the end still sends it (closes #271).
|
||||||
|
- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change;
|
||||||
|
one leaving a hostname's zone sends NS Change, not NS Failure (closes #262).
|
||||||
|
- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without
|
||||||
|
`DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268).
|
||||||
|
- 2026-10-13: a save syncs the state file to disk before renaming it into place,
|
||||||
|
so a crash cannot leave it empty and stop the next start (closes #264).
|
||||||
|
- 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`
|
- 2026-10-13: a check saving the state while the dashboard or `/api/v1/status`
|
||||||
is read cannot crash the process (closes #261).
|
is read cannot crash the process (closes #261).
|
||||||
- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint
|
- 2026-10-06: canonical files re-fetched from `sneak/prompts` at `dd4027b`; lint
|
||||||
@@ -185,6 +226,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- 1.0 readiness: run it with a real config and read the logs:
|
- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59
|
||||||
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
|
||||||
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
|
||||||
|
|||||||
+126
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ const (
|
|||||||
defaultTLSExpiryWarning = 7
|
defaultTLSExpiryWarning = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxPort is the highest TCP port number.
|
||||||
|
const maxPort = 65535
|
||||||
|
|
||||||
// ErrNoTargets is returned when no monitoring targets are configured.
|
// ErrNoTargets is returned when no monitoring targets are configured.
|
||||||
var ErrNoTargets = errors.New(
|
var ErrNoTargets = errors.New(
|
||||||
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
|
"no monitoring targets configured: set DNSWATCHER_TARGETS environment variable",
|
||||||
@@ -40,6 +44,30 @@ var ErrInvalidInterval = errors.New(
|
|||||||
// lists a name that is not in DNSWATCHER_TARGETS.
|
// lists a name that is not in DNSWATCHER_TARGETS.
|
||||||
var ErrNotInTargets = errors.New("name 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")
|
||||||
|
|
||||||
|
// ErrNoMetricsPassword is returned when DNSWATCHER_METRICS_USERNAME is set and
|
||||||
|
// DNSWATCHER_METRICS_PASSWORD is empty. Basic Auth on /metrics would otherwise
|
||||||
|
// let in anyone who sends the username with an empty password.
|
||||||
|
var ErrNoMetricsPassword = errors.New(
|
||||||
|
"DNSWATCHER_METRICS_PASSWORD must be set when DNSWATCHER_METRICS_USERNAME is set",
|
||||||
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Config.
|
// Params contains dependencies for Config.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -87,6 +115,10 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.MetricsUsername != "" && cfg.MetricsPassword == "" {
|
||||||
|
return nil, ErrNoMetricsPassword
|
||||||
|
}
|
||||||
|
|
||||||
configureDebugLogging(cfg, params)
|
configureDebugLogging(cfg, params)
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -127,16 +159,9 @@ func buildConfig(
|
|||||||
log *slog.Logger,
|
log *slog.Logger,
|
||||||
params *Params,
|
params *Params,
|
||||||
) (*Config, error) {
|
) (*Config, error) {
|
||||||
err := viper.ReadInConfig()
|
err := readConfigFile(log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var notFound viper.ConfigFileNotFoundError
|
return nil, err
|
||||||
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")
|
dnsInterval, err := parseInterval("DNS_INTERVAL")
|
||||||
@@ -161,9 +186,34 @@ func buildConfig(
|
|||||||
return nil, err
|
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{
|
cfg := &Config{
|
||||||
Port: viper.GetInt("PORT"),
|
Port: port,
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: debug,
|
||||||
DataDir: viper.GetString("DATA_DIR"),
|
DataDir: viper.GetString("DATA_DIR"),
|
||||||
Domains: domains,
|
Domains: domains,
|
||||||
Hostnames: hostnames,
|
Hostnames: hostnames,
|
||||||
@@ -173,12 +223,12 @@ func buildConfig(
|
|||||||
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
NtfyTopic: viper.GetString("NTFY_TOPIC"),
|
||||||
DNSInterval: dnsInterval,
|
DNSInterval: dnsInterval,
|
||||||
TLSInterval: tlsInterval,
|
TLSInterval: tlsInterval,
|
||||||
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
|
TLSExpiryWarning: tlsExpiryWarning,
|
||||||
SentryDSN: viper.GetString("SENTRY_DSN"),
|
SentryDSN: viper.GetString("SENTRY_DSN"),
|
||||||
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
|
MaintenanceMode: maintenanceMode,
|
||||||
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
MetricsUsername: viper.GetString("METRICS_USERNAME"),
|
||||||
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
|
||||||
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
|
SendTestNotification: sendTestNotification,
|
||||||
params: params,
|
params: params,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
@@ -186,6 +236,21 @@ func buildConfig(
|
|||||||
return cfg, nil
|
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
|
// 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
|
// 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.
|
// variable and the value; an unset variable has its default from setupViper.
|
||||||
@@ -202,6 +267,53 @@ func parseInterval(key string) (time.Duration, error) {
|
|||||||
return interval, nil
|
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) {
|
func parseAndValidateTargets() ([]string, []string, error) {
|
||||||
domains, hostnames, err := ClassifyTargets(
|
domains, hostnames, err := ClassifyTargets(
|
||||||
parseCSV(viper.GetString("TARGETS")),
|
parseCSV(viper.GetString("TARGETS")),
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func TestNew_OnlyEmptyCSVSegments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
|
// TestNew_InvalidIntervalStopsStartup checks values that must stop startup;
|
||||||
// TestNew_DefaultValues and TestNew_EmptyIntervalMeansDefault check that an
|
// TestNew_DefaultValues and TestNew_EmptyValueMeansDefault check that an
|
||||||
// unset or empty interval means the default.
|
// unset or empty interval means the default.
|
||||||
func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
|
func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
|
||||||
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
|
variables := []string{"DNSWATCHER_DNS_INTERVAL", "DNSWATCHER_TLS_INTERVAL"}
|
||||||
@@ -144,16 +144,107 @@ func TestNew_InvalidIntervalStopsStartup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_EmptyIntervalMeansDefault(t *testing.T) {
|
func TestNew_EmptyValueMeansDefault(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
t.Setenv("DNSWATCHER_DNS_INTERVAL", "")
|
t.Setenv("DNSWATCHER_DNS_INTERVAL", "")
|
||||||
t.Setenv("DNSWATCHER_TLS_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))
|
cfg, err := config.New(nil, newTestParams(t))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
assert.Equal(t, time.Hour, cfg.DNSInterval)
|
||||||
assert.Equal(t, 12*time.Hour, cfg.TLSInterval)
|
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) {
|
func TestNew_DebugEnablesDebugLogging(t *testing.T) {
|
||||||
@@ -263,6 +354,24 @@ func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, `"www.example.net"`)
|
require.ErrorContains(t, err, `"www.example.net"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNew_MetricsUsernameWithoutPasswordStopsStartup checks an unset and an
|
||||||
|
// empty password; TestNew_EnvironmentOverrides checks a username with one.
|
||||||
|
func TestNew_MetricsUsernameWithoutPasswordStopsStartup(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
t.Setenv("DNSWATCHER_METRICS_USERNAME", "prom")
|
||||||
|
|
||||||
|
_, err := config.New(nil, newTestParams(t))
|
||||||
|
require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password unset")
|
||||||
|
require.ErrorContains(t, err, "DNSWATCHER_METRICS_PASSWORD")
|
||||||
|
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "")
|
||||||
|
|
||||||
|
_, err = config.New(nil, newTestParams(t))
|
||||||
|
require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password empty")
|
||||||
|
}
|
||||||
|
|
||||||
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
|
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleStatic returns the handler for the embedded static assets. It
|
||||||
|
// is mounted under /s, with that prefix stripped from the path. A
|
||||||
|
// request that names a directory answers 404: http.FileServer would
|
||||||
|
// answer it with a listing of the directory, or with a redirect to
|
||||||
|
// that listing when the trailing slash is missing.
|
||||||
|
func (h *Handlers) HandleStatic() http.HandlerFunc {
|
||||||
|
files := http.FileServer(http.FS(static.Static))
|
||||||
|
|
||||||
|
return func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
// fs.Stat refuses a name that is empty or ends in a slash,
|
||||||
|
// so /s/ and /s/css/ answer 404 here along with /s/css.
|
||||||
|
name := strings.TrimPrefix(request.URL.Path, "/")
|
||||||
|
|
||||||
|
info, err := fs.Stat(static.Static, name)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files.ServeHTTP(writer, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -38,6 +40,13 @@ const (
|
|||||||
prioUrgent = "urgent"
|
prioUrgent = "urgent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Endpoint names, as the log gives them, used across multiple tests.
|
||||||
|
const (
|
||||||
|
endpointNtfy = "ntfy"
|
||||||
|
endpointSlack = "slack"
|
||||||
|
endpointMattermost = "mattermost"
|
||||||
|
)
|
||||||
|
|
||||||
// testHost is the hostname used in request construction tests.
|
// testHost is the hostname used in request construction tests.
|
||||||
const testHost = "example.com"
|
const testHost = "example.com"
|
||||||
|
|
||||||
@@ -1094,6 +1103,182 @@ func TestSendNotificationErrorNamesEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── webhook URLs kept out of the log ──────────────────────
|
||||||
|
|
||||||
|
// webhookPathAndQuery follows the server's address in every
|
||||||
|
// endpoint's URL in the tests below. A webhook URL's path or query
|
||||||
|
// lets anyone post to the channel or topic, so neither word marked
|
||||||
|
// secret in it may reach the log.
|
||||||
|
const webhookPathAndQuery = "/hooks/secret-in-path?token=secret-in-query"
|
||||||
|
|
||||||
|
// assertNoWebhookSecret fails the test if text holds either word
|
||||||
|
// marked secret in webhookPathAndQuery.
|
||||||
|
func assertNoWebhookSecret(t *testing.T, text string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, secret := range []string{"secret-in-path", "secret-in-query"} {
|
||||||
|
if strings.Contains(text, secret) {
|
||||||
|
t.Errorf("%q from the webhook URL is in:\n%s", secret, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logRecord is the part of a JSON log line the tests below read.
|
||||||
|
type logRecord struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLog decodes each line of a JSON log.
|
||||||
|
func parseLog(t *testing.T, logs string) []logRecord {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var records []logRecord
|
||||||
|
|
||||||
|
for line := range strings.Lines(logs) {
|
||||||
|
var record logRecord
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(line), &record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding log line %q: %v", line, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendToEachEndpoint sends one notification with the given title to
|
||||||
|
// ntfy, Slack and Mattermost, each at baseURL followed by
|
||||||
|
// webhookPathAndQuery, and retries a failed delivery once. When
|
||||||
|
// every delivery has finished it returns the JSON log written,
|
||||||
|
// debug lines included.
|
||||||
|
func sendToEachEndpoint(t *testing.T, baseURL, title string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
target, err := url.Parse(baseURL + webhookPathAndQuery)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing webhook URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc, logs := newLoggingService(http.DefaultTransport)
|
||||||
|
svc.SetNtfyURL(target)
|
||||||
|
svc.SetSlackWebhookURL(target)
|
||||||
|
svc.SetMattermostWebhookURL(target)
|
||||||
|
svc.SetSleepFunc(instantSleep)
|
||||||
|
svc.SetRetryConfig(notify.RetryConfig{
|
||||||
|
MaxRetries: 1,
|
||||||
|
BaseDelay: time.Millisecond,
|
||||||
|
MaxDelay: time.Millisecond,
|
||||||
|
})
|
||||||
|
|
||||||
|
svc.SendNotification(context.Background(), title, "m", prioInfo)
|
||||||
|
|
||||||
|
waitForCondition(t, func() bool {
|
||||||
|
return svc.OutstandingDeliveries() == 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return logs.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertEachEndpointRetriedThenFailed fails the test unless, for each
|
||||||
|
// of ntfy, Slack and Mattermost, the lines of logs whose error
|
||||||
|
// contains reason are the retry line and then the final failure line.
|
||||||
|
func assertEachEndpointRetriedThenFailed(
|
||||||
|
t *testing.T, logs, reason string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// For each endpoint, the messages of its lines whose error
|
||||||
|
// contains reason, in the order they were logged.
|
||||||
|
got := map[string][]string{}
|
||||||
|
|
||||||
|
for _, record := range parseLog(t, logs) {
|
||||||
|
if strings.Contains(record.Error, reason) {
|
||||||
|
got[record.Endpoint] = append(
|
||||||
|
got[record.Endpoint], record.Msg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{
|
||||||
|
"notification delivery failed, retrying",
|
||||||
|
"failed to send notification after retries",
|
||||||
|
}
|
||||||
|
want := map[string][]string{
|
||||||
|
endpointNtfy: lines,
|
||||||
|
endpointSlack: lines,
|
||||||
|
endpointMattermost: lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !maps.EqualFunc(got, want, slices.Equal) {
|
||||||
|
t.Errorf("lines whose error contains %q = %v, want %v",
|
||||||
|
reason, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendNotificationDebugLogOmitsWebhookURL checks that, with debug
|
||||||
|
// logging on, a delivery to each endpoint logs the endpoint's name and
|
||||||
|
// the title, and no part of the URL's path or query.
|
||||||
|
func TestSendNotificationDebugLogOmitsWebhookURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const title = "Port Change"
|
||||||
|
|
||||||
|
srv := httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
logs := sendToEachEndpoint(t, srv.URL, title)
|
||||||
|
|
||||||
|
assertNoWebhookSecret(t, logs)
|
||||||
|
|
||||||
|
got := map[string]string{}
|
||||||
|
|
||||||
|
for _, record := range parseLog(t, logs) {
|
||||||
|
if record.Level == slog.LevelDebug.String() {
|
||||||
|
got[record.Endpoint] = record.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
endpointNtfy: title,
|
||||||
|
endpointSlack: title,
|
||||||
|
endpointMattermost: title,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("debug lines' endpoint and title = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendNotificationErrorLogOmitsWebhookURL checks that a delivery
|
||||||
|
// to each endpoint that cannot connect logs the transport's error,
|
||||||
|
// which names the refused connection, on its retry line and on its
|
||||||
|
// final failure line, and that no line holds any part of the URL's
|
||||||
|
// path or query.
|
||||||
|
func TestSendNotificationErrorLogOmitsWebhookURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Closed before anything is sent, so every attempt fails to
|
||||||
|
// connect.
|
||||||
|
srv := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
srv.Close()
|
||||||
|
|
||||||
|
logs := sendToEachEndpoint(t, srv.URL, "t")
|
||||||
|
|
||||||
|
assertNoWebhookSecret(t, logs)
|
||||||
|
assertEachEndpointRetriedThenFailed(t, logs, "connection refused")
|
||||||
|
}
|
||||||
|
|
||||||
// ── SlackPayload JSON marshaling ──────────────────────────
|
// ── SlackPayload JSON marshaling ──────────────────────────
|
||||||
|
|
||||||
func TestSlackPayloadJSON(t *testing.T) {
|
func TestSlackPayloadJSON(t *testing.T) {
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ func (svc *Service) SendNtfy(
|
|||||||
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
return svc.sendNtfy(ctx, topicURL, title, message, priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendSlack exports sendSlack for testing.
|
// SendSlack exports sendSlack for testing. It sends as the
|
||||||
|
// "slack" endpoint.
|
||||||
func (svc *Service) SendSlack(
|
func (svc *Service) SendSlack(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookURL *url.URL,
|
webhookURL *url.URL,
|
||||||
@@ -89,7 +90,7 @@ func (svc *Service) SendSlack(
|
|||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
return svc.sendSlack(
|
return svc.sendSlack(
|
||||||
ctx, webhookURL, failed, title, message, priority,
|
ctx, webhookURL, "slack", failed, title, message, priority,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-13
@@ -25,9 +25,6 @@ import (
|
|||||||
// HTTP client timeout.
|
// HTTP client timeout.
|
||||||
const httpClientTimeout = 10 * time.Second
|
const httpClientTimeout = 10 * time.Second
|
||||||
|
|
||||||
// HTTP status code thresholds.
|
|
||||||
const httpStatusClientError = 400
|
|
||||||
|
|
||||||
// Sentinel errors for notification failures.
|
// Sentinel errors for notification failures.
|
||||||
var (
|
var (
|
||||||
// ErrNtfyFailed indicates the ntfy request failed.
|
// ErrNtfyFailed indicates the ntfy request failed.
|
||||||
@@ -56,6 +53,15 @@ func IsAllowedScheme(scheme string) bool {
|
|||||||
func ValidateWebhookURL(raw string) (*url.URL, error) {
|
func ValidateWebhookURL(raw string) (*url.URL, error) {
|
||||||
u, err := url.ParseRequestURI(raw)
|
u, err := url.ParseRequestURI(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// A *url.Error quotes the whole URL, and this error is
|
||||||
|
// logged at startup. A webhook URL's path or query lets
|
||||||
|
// anyone post to the channel or topic, so keep only the
|
||||||
|
// reason.
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) {
|
||||||
|
err = urlErr.Err
|
||||||
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +283,7 @@ func (svc *Service) dispatchSlack(
|
|||||||
|
|
||||||
svc.dispatch(ctx, "slack", func(c context.Context) error {
|
svc.dispatch(ctx, "slack", func(c context.Context) error {
|
||||||
return svc.sendSlack(
|
return svc.sendSlack(
|
||||||
c, svc.slackWebhookURL, ErrSlackFailed,
|
c, svc.slackWebhookURL, "slack", ErrSlackFailed,
|
||||||
title, message, priority,
|
title, message, priority,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -295,8 +301,8 @@ func (svc *Service) dispatchMattermost(
|
|||||||
ctx, "mattermost",
|
ctx, "mattermost",
|
||||||
func(c context.Context) error {
|
func(c context.Context) error {
|
||||||
return svc.sendSlack(
|
return svc.sendSlack(
|
||||||
c, svc.mattermostWebhookURL, ErrMattermostFailed,
|
c, svc.mattermostWebhookURL, "mattermost",
|
||||||
title, message, priority,
|
ErrMattermostFailed, title, message, priority,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -307,9 +313,11 @@ func (svc *Service) sendNtfy(
|
|||||||
topicURL *url.URL,
|
topicURL *url.URL,
|
||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
|
// The topic URL is left out of the log: it lets anyone who has
|
||||||
|
// it read and post to the topic.
|
||||||
svc.log.Debug(
|
svc.log.Debug(
|
||||||
"sending ntfy notification",
|
"sending ntfy notification",
|
||||||
"topic", topicURL.String(),
|
"endpoint", "ntfy",
|
||||||
"title", title,
|
"title", title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -333,7 +341,10 @@ func (svc *Service) sendNtfy(
|
|||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode >= httpStatusClientError {
|
// Only a 2xx reply means ntfy took the notification. A
|
||||||
|
// redirect is not followed, so it is a failure too.
|
||||||
|
if resp.StatusCode < http.StatusOK ||
|
||||||
|
resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: status %d",
|
"%w: status %d",
|
||||||
ErrNtfyFailed, resp.StatusCode,
|
ErrNtfyFailed, resp.StatusCode,
|
||||||
@@ -372,12 +383,15 @@ type SlackAttachment struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendSlack posts to a Slack or Mattermost incoming webhook, which
|
// sendSlack posts to a Slack or Mattermost incoming webhook, which
|
||||||
// take the same payload. An HTTP error status is returned wrapped in
|
// take the same payload. endpoint, "slack" or "mattermost", names
|
||||||
// failed, ErrSlackFailed or ErrMattermostFailed, so the error names
|
// the webhook in the log in place of its URL, which lets anyone who
|
||||||
// the endpoint.
|
// has it post to the channel. A reply status other than 2xx is
|
||||||
|
// returned wrapped in failed, ErrSlackFailed or ErrMattermostFailed,
|
||||||
|
// so the error names the endpoint.
|
||||||
func (svc *Service) sendSlack(
|
func (svc *Service) sendSlack(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
webhookURL *url.URL,
|
webhookURL *url.URL,
|
||||||
|
endpoint string,
|
||||||
failed error,
|
failed error,
|
||||||
title, message, priority string,
|
title, message, priority string,
|
||||||
) error {
|
) error {
|
||||||
@@ -388,7 +402,7 @@ func (svc *Service) sendSlack(
|
|||||||
|
|
||||||
svc.log.Debug(
|
svc.log.Debug(
|
||||||
"sending webhook notification",
|
"sending webhook notification",
|
||||||
"url", webhookURL.String(),
|
"endpoint", endpoint,
|
||||||
"title", title,
|
"title", title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -423,7 +437,10 @@ func (svc *Service) sendSlack(
|
|||||||
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
if resp.StatusCode >= httpStatusClientError {
|
// Only a 2xx reply means the webhook took the notification. A
|
||||||
|
// redirect is not followed, so it is a failure too.
|
||||||
|
if resp.StatusCode < http.StatusOK ||
|
||||||
|
resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: status %d",
|
"%w: status %d",
|
||||||
failed, resp.StatusCode,
|
failed, resp.StatusCode,
|
||||||
|
|||||||
@@ -79,6 +79,23 @@ func TestValidateWebhookURLInvalid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestValidateWebhookURLErrorOmitsURL checks that the error for a
|
||||||
|
// webhook URL that does not parse, here because a carriage return
|
||||||
|
// was left at its end, leaves out the URL's path and query: the
|
||||||
|
// error is logged at startup.
|
||||||
|
func TestValidateWebhookURLErrorOmitsURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := notify.ValidateWebhookURL(
|
||||||
|
"https://hooks.example.com" + webhookPathAndQuery + "\r",
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a URL ending in a carriage return")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoWebhookSecret(t, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsAllowedScheme(t *testing.T) {
|
func TestIsAllowedScheme(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -536,3 +536,33 @@ func TestSendNotificationPermanentFailureLogsError(
|
|||||||
return attempts >= 3
|
return attempts >= 3
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSendNotificationRedirectFails checks that a 302 reply is a
|
||||||
|
// failed delivery at each endpoint: the server is sent the first
|
||||||
|
// attempt and the retry, and each endpoint logs a retry line and
|
||||||
|
// then the final failure line, both naming the 302.
|
||||||
|
func TestSendNotificationRedirectFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var requests atomic.Int64
|
||||||
|
|
||||||
|
srv := httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
http.Redirect(w, r, "/moved", http.StatusFound)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
logs := sendToEachEndpoint(t, srv.URL, "t")
|
||||||
|
|
||||||
|
// ntfy, Slack and Mattermost each send once and retry once.
|
||||||
|
const wantRequests = 3 * 2
|
||||||
|
|
||||||
|
if sent := requests.Load(); sent != wantRequests {
|
||||||
|
t.Errorf("server got %d requests, want %d", sent, wantRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEachEndpointRetriedThenFailed(t, logs, "status 302")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ package resolver
|
|||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
|
// NoAnswerError is returned when the walk to a name's nameservers
|
||||||
|
// reached a zone through a referral and none of that zone's
|
||||||
|
// nameservers, Nameservers as the referral names them, answered. Err
|
||||||
|
// says why they did not.
|
||||||
|
type NoAnswerError struct {
|
||||||
|
Nameservers []string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NoAnswerError) Error() string {
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NoAnswerError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
// Sentinel errors returned by the resolver.
|
// Sentinel errors returned by the resolver.
|
||||||
var (
|
var (
|
||||||
// ErrNoNameservers is returned when no authoritative NS
|
// ErrNoNameservers is returned when no authoritative NS
|
||||||
@@ -19,6 +36,10 @@ var (
|
|||||||
// so whether the name has addresses is unknown.
|
// so whether the name has addresses is unknown.
|
||||||
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
ErrNoNameserverAnswered = errors.New("no nameserver answered")
|
||||||
|
|
||||||
|
// ErrNoNameserverAddress is returned when a nameserver is to be
|
||||||
|
// asked and no address for it was given.
|
||||||
|
ErrNoNameserverAddress = errors.New("nameserver has no address")
|
||||||
|
|
||||||
// ErrUnusableReply is returned when a server replied with an
|
// ErrUnusableReply is returned when a server replied with an
|
||||||
// error such as SERVFAIL, or with a referral that leads no
|
// error such as SERVFAIL, or with a referral that leads no
|
||||||
// closer to the name asked about.
|
// closer to the name asked about.
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
|
|||||||
func (r *Resolver) FollowDelegation(
|
func (r *Resolver) FollowDelegation(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
|
zone string,
|
||||||
|
nameservers []string,
|
||||||
servers []string,
|
servers []string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
return r.followDelegation(ctx, domain, servers)
|
return r.followDelegation(ctx, domain, zone, nameservers, servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
|
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
|
||||||
@@ -94,6 +96,17 @@ func (r *Resolver) QueryServers(
|
|||||||
return r.queryServers(ctx, servers, zone, name, qtype)
|
return r.queryServers(ctx, servers, zone, name, qtype)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryTypes exports queryTypes for testing.
|
||||||
|
func (r *Resolver) QueryTypes(
|
||||||
|
ctx context.Context,
|
||||||
|
nsHostname string,
|
||||||
|
nsIPs []string,
|
||||||
|
hostname string,
|
||||||
|
qtypes []uint16,
|
||||||
|
) (*NameserverResponse, error) {
|
||||||
|
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
|
||||||
|
}
|
||||||
|
|
||||||
// QueryEachNS exports queryEachNS for testing.
|
// QueryEachNS exports queryEachNS for testing.
|
||||||
func (r *Resolver) QueryEachNS(
|
func (r *Resolver) QueryEachNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const (
|
|||||||
queryTimeoutDuration = 2 * time.Second
|
queryTimeoutDuration = 2 * time.Second
|
||||||
maxRetries = 2
|
maxRetries = 2
|
||||||
maxDelegation = 20
|
maxDelegation = 20
|
||||||
timeoutMultiplier = 2
|
|
||||||
|
|
||||||
// maxLookupDepth is how many lookups of nameserver addresses may be
|
// maxLookupDepth is how many lookups of nameserver addresses may be
|
||||||
// under way one inside another. Looking up a nameserver's address
|
// under way one inside another. Looking up a nameserver's address
|
||||||
@@ -62,19 +61,11 @@ func checkCtx(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) exchangeWithTimeout(
|
// tryExchange sends msg to addr over UDP, up to maxRetries times, until
|
||||||
ctx context.Context,
|
// a reply comes. Every try waits the same queryTimeoutDuration, or less
|
||||||
msg *dns.Msg,
|
// when ctx's deadline comes first. When ctx has ended before a further
|
||||||
addr string,
|
// try, it returns the error of the try already made, such as a timeout,
|
||||||
attempt int,
|
// or ErrContextCanceled when none was made.
|
||||||
) (*dns.Msg, error) {
|
|
||||||
_ = attempt // timeout escalation handled by client config
|
|
||||||
|
|
||||||
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
|
|
||||||
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) tryExchange(
|
func (r *Resolver) tryExchange(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg *dns.Msg,
|
msg *dns.Msg,
|
||||||
@@ -84,14 +75,16 @@ func (r *Resolver) tryExchange(
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
for attempt := range maxRetries {
|
for range maxRetries {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
|
if err == nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = r.exchangeWithTimeout(
|
return nil, err
|
||||||
ctx, msg, addr, attempt,
|
}
|
||||||
)
|
|
||||||
|
resp, _, err = r.client.ExchangeContext(ctx, msg, addr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -204,20 +197,24 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
// followDelegation follows referrals from servers, the root servers, to
|
// followDelegation follows referrals to domain from servers, the
|
||||||
// domain and returns the NS set of domain's delegation. When the servers
|
// addresses of the servers of zone, and returns the NS set of domain's
|
||||||
// of the zone domain is in answer that domain does not exist, the error
|
// delegation. nameservers are the names a referral gave the servers of
|
||||||
// is ErrNXDomain. When they answer that it has no delegation of its own,
|
// zone; the walk to a name's nameservers starts at the root servers, the
|
||||||
|
// servers of zone ".", which no referral names. When the servers of the
|
||||||
|
// zone domain is in answer that domain does not exist, the error is
|
||||||
|
// ErrNXDomain. When they answer that it has no delegation of its own,
|
||||||
// because it is not the zone's apex, the set is empty and there is no
|
// because it is not the zone's apex, the set is empty and there is no
|
||||||
// error. Any other error means that no such answer came.
|
// error. Any other error means that no such answer came; it is a
|
||||||
|
// *NoAnswerError when the servers that did not answer are those of a
|
||||||
|
// zone a referral named.
|
||||||
func (r *Resolver) followDelegation(
|
func (r *Resolver) followDelegation(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
|
zone string,
|
||||||
|
nameservers []string,
|
||||||
servers []string,
|
servers []string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
// servers are the root servers, the servers of zone ".".
|
|
||||||
zone := "."
|
|
||||||
|
|
||||||
var withoutAddresses []string
|
var withoutAddresses []string
|
||||||
|
|
||||||
for range maxDelegation {
|
for range maxDelegation {
|
||||||
@@ -228,6 +225,12 @@ func (r *Resolver) followDelegation(
|
|||||||
resp, err := r.queryZone(
|
resp, err := r.queryZone(
|
||||||
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
|
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A query that ctx cut short says nothing about the servers.
|
||||||
|
if err != nil && len(nameservers) > 0 && checkCtx(ctx) == nil {
|
||||||
|
return nil, &NoAnswerError{Nameservers: nameservers, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -256,6 +259,7 @@ func (r *Resolver) followDelegation(
|
|||||||
return r.resolveNSIterative(ctx, domain)
|
return r.resolveNSIterative(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nameservers = authNS
|
||||||
servers, withoutAddresses = referralNameservers(resp)
|
servers, withoutAddresses = referralNameservers(resp)
|
||||||
zone = referralZone(resp)
|
zone = referralZone(resp)
|
||||||
}
|
}
|
||||||
@@ -626,7 +630,8 @@ func (r *Resolver) resolveARecord(
|
|||||||
// them. When the servers asked answer that the name has no delegation
|
// them. When the servers asked answer that the name has no delegation
|
||||||
// of its own, or does not exist, it tries each parent name in turn, so
|
// of its own, or does not exist, it tries each parent name in turn, so
|
||||||
// it returns the nameservers of the zone the name is in. When they do
|
// it returns the nameservers of the zone the name is in. When they do
|
||||||
// not answer, it returns the error and tries no parent name.
|
// not answer, it returns the error and tries no parent name; when a
|
||||||
|
// referral named them, the error is a *NoAnswerError naming them.
|
||||||
func (r *Resolver) FindAuthoritativeNameservers(
|
func (r *Resolver) FindAuthoritativeNameservers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
@@ -655,7 +660,7 @@ func (r *Resolver) findAuthoritativeNameservers(
|
|||||||
|
|
||||||
candidate := strings.Join(labels[i:], ".") + "."
|
candidate := strings.Join(labels[i:], ".") + "."
|
||||||
|
|
||||||
nsNames, err := r.followDelegation(ctx, candidate, servers)
|
nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers)
|
||||||
if err != nil && !errors.Is(err, ErrNXDomain) {
|
if err != nil && !errors.Is(err, ErrNXDomain) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -696,8 +701,9 @@ func (r *Resolver) QueryNameserver(
|
|||||||
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
return r.queryNameserver(ctx, nsHostname, hostname, recordTypes())
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryNameserver queries a specific nameserver for the record types
|
// queryNameserver queries a specific nameserver, at the IPv4 addresses
|
||||||
// in qtypes and builds a NameserverResponse.
|
// its name resolves to, for the record types in qtypes and builds a
|
||||||
|
// NameserverResponse.
|
||||||
func (r *Resolver) queryNameserver(
|
func (r *Resolver) queryNameserver(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsHostname string,
|
nsHostname string,
|
||||||
@@ -715,7 +721,7 @@ func (r *Resolver) queryNameserver(
|
|||||||
|
|
||||||
hostname = dns.Fqdn(hostname)
|
hostname = dns.Fqdn(hostname)
|
||||||
|
|
||||||
return r.queryTypes(ctx, nsHostname, nsIPs[0], hostname, qtypes)
|
return r.queryTypes(ctx, nsHostname, nsIPs, hostname, qtypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryNameserverIP queries a nameserver by its IP address directly,
|
// QueryNameserverIP queries a nameserver by its IP address directly,
|
||||||
@@ -732,23 +738,33 @@ func (r *Resolver) QueryNameserverIP(
|
|||||||
|
|
||||||
hostname = dns.Fqdn(hostname)
|
hostname = dns.Fqdn(hostname)
|
||||||
|
|
||||||
return r.queryTypes(ctx, nsHostname, nsIP, hostname, recordTypes())
|
return r.queryTypes(
|
||||||
|
ctx, nsHostname, []string{nsIP}, hostname, recordTypes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryTypes asks the nameserver nsHostname, at its addresses nsIPs,
|
||||||
|
// about hostname's records of each type in qtypes and builds a
|
||||||
|
// NameserverResponse. With no address there is nothing to ask, and it
|
||||||
|
// returns ErrNoNameserverAddress.
|
||||||
func (r *Resolver) queryTypes(
|
func (r *Resolver) queryTypes(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsHostname string,
|
nsHostname string,
|
||||||
nsIP string,
|
nsIPs []string,
|
||||||
hostname string,
|
hostname string,
|
||||||
qtypes []uint16,
|
qtypes []uint16,
|
||||||
) (*NameserverResponse, error) {
|
) (*NameserverResponse, error) {
|
||||||
|
if len(nsIPs) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrNoNameserverAddress, nsHostname)
|
||||||
|
}
|
||||||
|
|
||||||
resp := &NameserverResponse{
|
resp := &NameserverResponse{
|
||||||
Nameserver: nsHostname,
|
Nameserver: nsHostname,
|
||||||
Records: make(map[string][]string),
|
Records: make(map[string][]string),
|
||||||
Status: StatusOK,
|
Status: StatusOK,
|
||||||
}
|
}
|
||||||
|
|
||||||
state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
|
state := r.queryEachType(ctx, nsIPs, hostname, qtypes, resp)
|
||||||
classifyResponse(resp, state)
|
classifyResponse(resp, state)
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
@@ -766,28 +782,37 @@ type queryState struct {
|
|||||||
answered bool
|
answered bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryEachType asks the nameserver at nsIP about hostname once for each
|
// queryEachType asks the nameserver about hostname's records of each type
|
||||||
// record type in qtypes, and lists in resp.FailedTypes the types whose
|
// in qtypes, at its addresses nsIPs in turn until one gives a usable
|
||||||
// query got no usable reply, logging each with the reason unless ctx was
|
// reply, and lists in resp.FailedTypes the types that got none at any of
|
||||||
// cancelled: shutdown cancels it, and a query it cut short did not fail.
|
// them, logging each with the reasons unless ctx was cancelled: shutdown
|
||||||
|
// cancels it, and a query it cut short did not fail.
|
||||||
func (r *Resolver) queryEachType(
|
func (r *Resolver) queryEachType(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsIP string,
|
nsIPs []string,
|
||||||
hostname string,
|
hostname string,
|
||||||
qtypes []uint16,
|
qtypes []uint16,
|
||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
) queryState {
|
) queryState {
|
||||||
var state queryState
|
var state queryState
|
||||||
|
|
||||||
|
// Reordered below; the caller's list is left as it was.
|
||||||
|
nsIPs = slices.Clone(nsIPs)
|
||||||
|
|
||||||
for _, qtype := range qtypes {
|
for _, qtype := range qtypes {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
|
answeredAt, err := r.querySingleType(
|
||||||
|
ctx, nsIPs, hostname, qtype, resp, &state,
|
||||||
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
state.answered = true
|
state.answered = true
|
||||||
|
|
||||||
|
// The next type is asked first at the address that answered.
|
||||||
|
nsIPs[0], nsIPs[answeredAt] = nsIPs[answeredAt], nsIPs[0]
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,20 +846,34 @@ func (r *Resolver) queryEachType(
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
// querySingleType asks the nameserver at nsIP about hostname's records
|
// querySingleType asks the nameserver about hostname's records of type
|
||||||
// of type qtype. It returns nil when the nameserver answered: with
|
// qtype at its addresses nsIPs, of which there is at least one, one at a
|
||||||
// records, with none, or with NXDOMAIN; otherwise it returns why not.
|
// time, until one answers: with records, with none, or with NXDOMAIN.
|
||||||
|
// It returns the index in nsIPs of the address that answered; when none
|
||||||
|
// did, it returns why each did not, each reason naming its address.
|
||||||
func (r *Resolver) querySingleType(
|
func (r *Resolver) querySingleType(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsIP string,
|
nsIPs []string,
|
||||||
hostname string,
|
hostname string,
|
||||||
qtype uint16,
|
qtype uint16,
|
||||||
resp *NameserverResponse,
|
resp *NameserverResponse,
|
||||||
state *queryState,
|
state *queryState,
|
||||||
) error {
|
) (int, error) {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for i, nsIP := range nsIPs {
|
||||||
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
|
||||||
if err != nil {
|
|
||||||
switch {
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
err = readReply(msg, resp, state)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("query %s @%s: %w", hostname, nsIP, err)
|
||||||
|
}
|
||||||
|
case errors.Is(err, ErrContextCanceled):
|
||||||
|
// The caller's context ended before the query was sent.
|
||||||
|
// That says nothing about the nameserver, so no reason is
|
||||||
|
// recorded.
|
||||||
case isTimeout(err):
|
case isTimeout(err):
|
||||||
state.gotTimeout = true
|
state.gotTimeout = true
|
||||||
case errors.Is(err, ErrRefused):
|
case errors.Is(err, ErrRefused):
|
||||||
@@ -843,10 +882,14 @@ func (r *Resolver) querySingleType(
|
|||||||
state.netErr = err
|
state.netErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
if err == nil {
|
||||||
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return readReply(msg, resp, state)
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readReply adds to resp the records in msg, a nameserver's reply to a
|
// readReply adds to resp the records in msg, a nameserver's reply to a
|
||||||
@@ -940,17 +983,15 @@ func isTimeout(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// classifyResponse sets the nameserver's status. One that answered no
|
// classifyResponse sets the nameserver's status. One that answered no
|
||||||
// record type has failed, and Error says why; one that answered some has
|
// record type has failed, and Error says why; it says all queries timed
|
||||||
// the status of those answers. It has no data only when every type
|
// out only when every query, at every address, did. One that answered
|
||||||
|
// some has the status of those answers. It has no data only when every type
|
||||||
// answered with no records: a type in FailedTypes may have records, so a
|
// answered with no records: a type in FailedTypes may have records, so a
|
||||||
// nameserver with one stays ok.
|
// nameserver with one stays ok.
|
||||||
func classifyResponse(resp *NameserverResponse, state queryState) {
|
func classifyResponse(resp *NameserverResponse, state queryState) {
|
||||||
switch {
|
switch {
|
||||||
case state.gotNXDomain && !state.hasRecords:
|
case state.gotNXDomain && !state.hasRecords:
|
||||||
resp.Status = StatusNXDomain
|
resp.Status = StatusNXDomain
|
||||||
case state.gotTimeout && !state.answered:
|
|
||||||
resp.Status = StatusTimeout
|
|
||||||
resp.Error = "all queries timed out"
|
|
||||||
case state.gotErrorReply && !state.answered:
|
case state.gotErrorReply && !state.answered:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "server returned " + state.errorReply
|
resp.Error = "server returned " + state.errorReply
|
||||||
@@ -963,6 +1004,9 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
|||||||
case state.gotReferral && !state.answered:
|
case state.gotReferral && !state.answered:
|
||||||
resp.Status = StatusError
|
resp.Status = StatusError
|
||||||
resp.Error = "server returned a referral"
|
resp.Error = "server returned a referral"
|
||||||
|
case state.gotTimeout && !state.answered:
|
||||||
|
resp.Status = StatusTimeout
|
||||||
|
resp.Error = "all queries timed out"
|
||||||
// An NXDOMAIN reply with no records was taken by the first case.
|
// An NXDOMAIN reply with no records was taken by the first case.
|
||||||
case !state.hasRecords && len(resp.FailedTypes) == 0:
|
case !state.hasRecords && len(resp.FailedTypes) == 0:
|
||||||
resp.Status = StatusNoData
|
resp.Status = StatusNoData
|
||||||
@@ -1067,7 +1111,7 @@ func (r *Resolver) LookupNS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return r.followDelegation(
|
return r.followDelegation(
|
||||||
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
|
ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package resolver
|
package resolver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -15,8 +17,10 @@ import (
|
|||||||
// that answered some record types, even with no records, has not failed
|
// that answered some record types, even with no records, has not failed
|
||||||
// when its query for another type got no usable reply, whatever the
|
// when its query for another type got no usable reply, whatever the
|
||||||
// reason, and is ok, not nodata: that type may have records. One whose
|
// reason, and is ok, not nodata: that type may have records. One whose
|
||||||
// every query got none has failed. Only one whose every type answered
|
// every query got none has failed, and its error says all queries timed
|
||||||
// with no records is nodata.
|
// out only when every one did: a reply such as a refusal, or a network
|
||||||
|
// error, at one of its addresses is given instead. Only one whose every
|
||||||
|
// type answered with no records is nodata.
|
||||||
func TestClassifyResponse(t *testing.T) {
|
func TestClassifyResponse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -27,63 +31,46 @@ func TestClassifyResponse(t *testing.T) {
|
|||||||
wantStatus string
|
wantStatus string
|
||||||
wantError string
|
wantError string
|
||||||
}{
|
}{
|
||||||
{
|
{"every type answered with no records",
|
||||||
"every type answered with no records",
|
|
||||||
queryState{answered: true},
|
queryState{answered: true},
|
||||||
nil,
|
nil, StatusNoData, ""},
|
||||||
StatusNoData, "",
|
{"some types answered with no records, another timed out",
|
||||||
},
|
|
||||||
{
|
|
||||||
"some types answered with no records, another timed out",
|
|
||||||
queryState{answered: true, gotTimeout: true},
|
queryState{answered: true, gotTimeout: true},
|
||||||
[]string{"A"},
|
[]string{"A"}, StatusOK, ""},
|
||||||
StatusOK, "",
|
{"some types answered with no records, another got SERVFAIL",
|
||||||
},
|
queryState{answered: true, gotErrorReply: true, errorReply: "SERVFAIL"},
|
||||||
{
|
[]string{"A"}, StatusOK, ""},
|
||||||
"some types answered with no records, another got SERVFAIL",
|
{"some types answered with no records, another was refused",
|
||||||
queryState{
|
|
||||||
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
|
|
||||||
},
|
|
||||||
[]string{"A"},
|
|
||||||
StatusOK, "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"some types answered with no records, another was refused",
|
|
||||||
queryState{answered: true, gotRefused: true},
|
queryState{answered: true, gotRefused: true},
|
||||||
[]string{"A"},
|
[]string{"A"}, StatusOK, ""},
|
||||||
StatusOK, "",
|
{"some types answered with no records, another got a network error",
|
||||||
},
|
|
||||||
{
|
|
||||||
"some types answered with no records, another got a network error",
|
|
||||||
queryState{answered: true, netErr: syscall.ECONNREFUSED},
|
queryState{answered: true, netErr: syscall.ECONNREFUSED},
|
||||||
[]string{"A"},
|
[]string{"A"}, StatusOK, ""},
|
||||||
StatusOK, "",
|
{"some types answered with no records, another's reply was " +
|
||||||
},
|
|
||||||
{
|
|
||||||
"some types answered with no records, another's reply was " +
|
|
||||||
"truncated and its retry over TCP failed",
|
"truncated and its retry over TCP failed",
|
||||||
queryState{answered: true, netErr: ErrTruncated},
|
queryState{answered: true, netErr: ErrTruncated},
|
||||||
[]string{"TXT"},
|
[]string{"TXT"}, StatusOK, ""},
|
||||||
StatusOK, "",
|
{"some types answered with no records, another got a referral",
|
||||||
},
|
|
||||||
{
|
|
||||||
"some types answered with no records, another got a referral",
|
|
||||||
queryState{answered: true, gotReferral: true},
|
queryState{answered: true, gotReferral: true},
|
||||||
[]string{"A"},
|
[]string{"A"}, StatusOK, ""},
|
||||||
StatusOK, "",
|
{"every query timed out",
|
||||||
},
|
|
||||||
{
|
|
||||||
"every query timed out",
|
|
||||||
queryState{gotTimeout: true},
|
queryState{gotTimeout: true},
|
||||||
[]string{"A", "AAAA", "CNAME"},
|
[]string{"A", "AAAA", "CNAME"}, StatusTimeout, "all queries timed out"},
|
||||||
StatusTimeout, "all queries timed out",
|
{"every query got NOTIMP",
|
||||||
},
|
|
||||||
{
|
|
||||||
"every query got NOTIMP",
|
|
||||||
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
|
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
|
||||||
[]string{"A", "AAAA", "CNAME"},
|
[]string{"A", "AAAA", "CNAME"}, StatusError, "server returned NOTIMP"},
|
||||||
StatusError, "server returned NOTIMP",
|
{"every query got SERVFAIL at one address, timed out at another",
|
||||||
},
|
queryState{gotErrorReply: true, errorReply: "SERVFAIL", gotTimeout: true},
|
||||||
|
[]string{"A"}, StatusError, "server returned SERVFAIL"},
|
||||||
|
{"every query was refused at one address, timed out at another",
|
||||||
|
queryState{gotRefused: true, gotTimeout: true},
|
||||||
|
[]string{"A"}, StatusError, "server returned REFUSED"},
|
||||||
|
{"every query got a network error at one address, timed out at another",
|
||||||
|
queryState{netErr: syscall.ECONNREFUSED, gotTimeout: true},
|
||||||
|
[]string{"A"}, StatusError, "network error: connection refused"},
|
||||||
|
{"every query got a referral at one address, timed out at another",
|
||||||
|
queryState{gotReferral: true, gotTimeout: true},
|
||||||
|
[]string{"A"}, StatusError, "server returned a referral"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -147,3 +134,33 @@ func TestReadReply(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuerySingleType_ContextEnded asks a nameserver about one record
|
||||||
|
// type with a context that has already ended, so no query is sent. A
|
||||||
|
// query the context ended before it was sent says nothing about the
|
||||||
|
// nameserver and records no reason: a nameserver whose earlier query
|
||||||
|
// timed out is still reported as timed out, not as a network error.
|
||||||
|
func TestQuerySingleType_ContextEnded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := NewFromLogger(slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
resp := &NameserverResponse{Records: map[string][]string{}, Status: StatusOK}
|
||||||
|
|
||||||
|
// The query for an earlier record type timed out.
|
||||||
|
state := queryState{gotTimeout: true}
|
||||||
|
|
||||||
|
_, err := r.querySingleType(
|
||||||
|
ctx, []string{"192.0.2.1"}, "example.com.", dns.TypeA, resp, &state,
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, ErrContextCanceled)
|
||||||
|
assert.Equal(t, queryState{gotTimeout: true}, state)
|
||||||
|
|
||||||
|
classifyResponse(resp, state)
|
||||||
|
|
||||||
|
assert.Equal(t, StatusTimeout, resp.Status)
|
||||||
|
assert.Equal(t, "all queries timed out", resp.Error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
|||||||
// delegation of its own, so the lookup returns that walk's error, about
|
// delegation of its own, so the lookup returns that walk's error, about
|
||||||
// www.google.com, and tries no parent name: trying google.com and com
|
// www.google.com, and tries no parent name: trying google.com and com
|
||||||
// would end in ErrNoNameservers, or in the error of a walk for one of
|
// would end in ErrNoNameservers, or in the error of a walk for one of
|
||||||
// them.
|
// them. The servers that did not answer are those the walk started at,
|
||||||
|
// which no referral named, so the error is no NoAnswerError, as when
|
||||||
|
// the host running dnswatcher has lost its network.
|
||||||
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -134,6 +136,9 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
|||||||
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
|
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
|
||||||
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
|
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
|
||||||
assert.Empty(t, nameservers)
|
assert.Empty(t, nameservers)
|
||||||
|
|
||||||
|
var noAnswer *resolver.NoAnswerError
|
||||||
|
assert.NotErrorAs(t, err, &noAnswer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||||
@@ -355,6 +360,124 @@ func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
|
|||||||
assert.Equal(t, resolver.StatusOK, resp.Status)
|
assert.Equal(t, resolver.StatusOK, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryTypes_FirstAddressNeverAnswers asks a google.com nameserver
|
||||||
|
// about google.com's A and AAAA records at a list of addresses that puts
|
||||||
|
// 192.0.2.1, where nothing answers, before the nameserver's own. The
|
||||||
|
// nameserver answers at its own address, so it is ok, with its records.
|
||||||
|
// Each attempt has time for one round of tries at 192.0.2.1, two tries
|
||||||
|
// of two seconds, and not for two, so AAAA must be asked first at the
|
||||||
|
// address that answered A.
|
||||||
|
func TestQueryTypes_FirstAddressNeverAnswers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
ns := findOneNSForDomain(t, r, "google.com")
|
||||||
|
nsIPs := append(
|
||||||
|
[]string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
var resp *resolver.NameserverResponse
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", google.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
resp, err = r.QueryTypes(
|
||||||
|
ctx, ns, nsIPs, "google.com.",
|
||||||
|
[]uint16{dns.TypeA, dns.TypeAAAA},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.FailedTypes) > 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s gave no usable reply for %s",
|
||||||
|
livednstest.ErrNoAnswer, ns,
|
||||||
|
strings.Join(resp.FailedTypes, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusOK, resp.Status)
|
||||||
|
assert.NotEmpty(t, resp.Records["A"])
|
||||||
|
assert.NotEmpty(t, resp.Records["AAAA"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryTypes_EveryAddressFails asks a google.com nameserver about
|
||||||
|
// cloudflare.com, a zone it does not serve, at 192.0.2.1, where nothing
|
||||||
|
// answers, and then at its own address, which refuses. The nameserver
|
||||||
|
// has failed with the refusal, not a timeout, and the warning about the
|
||||||
|
// type gives the reason at each address.
|
||||||
|
func TestQueryTypes_EveryAddressFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||||
|
ns := findOneNSForDomain(t, r, "google.com")
|
||||||
|
nsIPs := append(
|
||||||
|
[]string{noAnswerAddress}, liveResolveNSIPs(t, r, []string{ns}, 1)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
var resp *resolver.NameserverResponse
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryTypes("+ns+" at "+strings.Join(nsIPs, ", ")+", cloudflare.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
logs.Reset()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
resp, err = r.QueryTypes(
|
||||||
|
ctx, ns, nsIPs, "cloudflare.com.", []uint16{dns.TypeA},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timeout or a network error at its own address is no
|
||||||
|
// reply at all.
|
||||||
|
if resp.Status == resolver.StatusTimeout ||
|
||||||
|
strings.HasPrefix(resp.Error, "network error") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusError, resp.Status)
|
||||||
|
assert.Equal(t, "server returned REFUSED", resp.Error)
|
||||||
|
assert.Contains(t, logs.String(), "@"+noAnswerAddress+": ")
|
||||||
|
assert.Contains(t, logs.String(), "@"+nsIPs[1]+": ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryTypes_NoAddress asks a nameserver given no address. There is
|
||||||
|
// nothing to ask it at, so it has failed, with an error that says it has
|
||||||
|
// no address; it is not ok.
|
||||||
|
func TestQueryTypes_NoAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resp, err := newTestResolver(t).QueryTypes(
|
||||||
|
t.Context(), "ns1.example.net.", nil, "www.example.net.",
|
||||||
|
[]uint16{dns.TypeA},
|
||||||
|
)
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAddress)
|
||||||
|
assert.Nil(t, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryNameserver_AAAA(t *testing.T) {
|
func TestQueryNameserver_AAAA(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -419,8 +542,9 @@ func TestQueryNameserver_TXT(t *testing.T) {
|
|||||||
// nameserver about google.com with a resolver whose retries over TCP
|
// nameserver about google.com with a resolver whose retries over TCP
|
||||||
// fail. google.com's TXT records do not fit in a reply over UDP, so TXT
|
// fail. google.com's TXT records do not fit in a reply over UDP, so TXT
|
||||||
// is reported as failed, holding none of the records that fit, and
|
// is reported as failed, holding none of the records that fit, and
|
||||||
// logged with the reason, while the nameserver, which answered the other
|
// logged with the reason, which names the address whose reply was
|
||||||
// types, is ok.
|
// truncated, while the nameserver, which answered the other types, is
|
||||||
|
// ok.
|
||||||
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
|
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -437,6 +561,13 @@ func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
|
|||||||
assert.Contains(t, logs.String(),
|
assert.Contains(t, logs.String(),
|
||||||
"hostname=google.com. nameserver="+ns+" type=TXT error=",
|
"hostname=google.com. nameserver="+ns+" type=TXT error=",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nsIPs := liveResolveNSIPs(t, newTestResolver(t), []string{ns}, 1)
|
||||||
|
for _, ip := range nsIPs {
|
||||||
|
assert.Contains(t, logs.String(),
|
||||||
|
"@"+ip+": "+resolver.ErrTruncated.Error(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryNameserver_NXDomain(t *testing.T) {
|
func TestQueryNameserver_NXDomain(t *testing.T) {
|
||||||
@@ -490,6 +621,57 @@ func TestQueryNameserver_Refused(t *testing.T) {
|
|||||||
assert.Equal(t, "server returned REFUSED", resp.Error)
|
assert.Equal(t, "server returned REFUSED", resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryNameserver_EveryAddressRefuses asks ada.ns.cloudflare.com,
|
||||||
|
// whose name has several IPv4 addresses, about google.com, a zone it
|
||||||
|
// does not serve, which it refuses. A record type is asked at each of
|
||||||
|
// the nameserver's addresses before it fails, so the warning about it
|
||||||
|
// names every one.
|
||||||
|
func TestQueryNameserver_EveryAddressRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const ns = "ada.ns.cloudflare.com."
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||||
|
nsIPs := liveResolveNSIPs(t, r, []string{ns}, 2)
|
||||||
|
|
||||||
|
var resp *resolver.NameserverResponse
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"QueryNameserver("+ns+", google.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
logs.Reset()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
resp, err = r.QueryNameserver(ctx, ns, "google.com")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A timeout or a network error at every address is no
|
||||||
|
// reply at all.
|
||||||
|
if resp.Status == resolver.StatusTimeout ||
|
||||||
|
strings.HasPrefix(resp.Error, "network error") {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusError, resp.Status)
|
||||||
|
assert.Equal(t, "server returned REFUSED", resp.Error)
|
||||||
|
|
||||||
|
for _, ip := range nsIPs {
|
||||||
|
assert.Contains(t, logs.String(), "@"+ip+": ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestQueryServers_RecursiveResolverRefused passes a public recursive
|
// TestQueryServers_RecursiveResolverRefused passes a public recursive
|
||||||
// resolver to QueryServers as the server of google.com. These resolvers
|
// resolver to QueryServers as the server of google.com. These resolvers
|
||||||
// refuse a query that does not ask for recursion and answer one that
|
// refuse a query that does not ask for recursion and answer one that
|
||||||
@@ -954,12 +1136,34 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
|
|||||||
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
||||||
|
|
||||||
nameservers, err := r.FollowDelegation(
|
nameservers, err := r.FollowDelegation(
|
||||||
t.Context(), "google.com.", []string{noAnswerAddress},
|
t.Context(), "google.com.", ".", nil, []string{noAnswerAddress},
|
||||||
)
|
)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Empty(t, nameservers)
|
assert.Empty(t, nameservers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFollowDelegation_NoAnswerAfterReferral starts the walk for
|
||||||
|
// www.example.net at the servers of example.net as a referral names
|
||||||
|
// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing
|
||||||
|
// answers. The error is a NoAnswerError naming them, which the watcher
|
||||||
|
// compares with the nameservers it saved for the name.
|
||||||
|
func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
||||||
|
nameservers := []string{"ns1.example.net.", "ns2.example.net."}
|
||||||
|
|
||||||
|
_, err := r.FollowDelegation(
|
||||||
|
t.Context(), "www.example.net.", "example.net.", nameservers,
|
||||||
|
[]string{noAnswerAddress},
|
||||||
|
)
|
||||||
|
|
||||||
|
var noAnswer *resolver.NoAnswerError
|
||||||
|
require.ErrorAs(t, err, &noAnswer)
|
||||||
|
assert.Equal(t, nameservers, noAnswer.Nameservers)
|
||||||
|
assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress)
|
||||||
|
}
|
||||||
|
|
||||||
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
|
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
|
||||||
// of www.google.com as the fallback walk does. As in
|
// of www.google.com as the fallback walk does. As in
|
||||||
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
|
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
|
||||||
@@ -1176,10 +1380,10 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
|
|||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
|
|
||||||
// Nothing answers at 192.0.2.1, a documentation address. The
|
// Nothing answers at 192.0.2.1, a documentation address. The
|
||||||
// resolver tries each query twice, and the first try gives up
|
// resolver tries each query twice, and each try gives up after two
|
||||||
// after two seconds. A deadline that ends during the first try
|
// seconds. This deadline ends during the second try of the first
|
||||||
// makes the status vary from run to run between error and
|
// record type; TestQueryNameserverIP_DeadlineDuringFirstTry covers
|
||||||
// timeout, so the deadline must outlast the first try.
|
// one that ends during the first.
|
||||||
ctx, cancel := context.WithTimeout(
|
ctx, cancel := context.WithTimeout(
|
||||||
context.Background(), 3*time.Second,
|
context.Background(), 3*time.Second,
|
||||||
)
|
)
|
||||||
@@ -1195,6 +1399,62 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
|
|||||||
assert.NotEmpty(t, resp.Error)
|
assert.NotEmpty(t, resp.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryNameserverIP_DeadlineDuringFirstTry gives a query to
|
||||||
|
// 192.0.2.1, where nothing answers, a deadline that ends during its
|
||||||
|
// first try. That try ends at the deadline as a timeout, so the
|
||||||
|
// nameserver is reported as timed out, not as answering.
|
||||||
|
func TestQueryNameserverIP_DeadlineDuringFirstTry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
// Well under the two seconds one try waits.
|
||||||
|
ctx, cancel := context.WithTimeout(
|
||||||
|
context.Background(), 500*time.Millisecond,
|
||||||
|
)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserverIP(
|
||||||
|
ctx, "unreachable.test.", "192.0.2.1",
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusTimeout, resp.Status)
|
||||||
|
assert.NotEmpty(t, resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryNameserverIP_CancelDuringFirstTry cancels the context while
|
||||||
|
// the first try of a query to 192.0.2.1, where nothing answers, waits
|
||||||
|
// for a reply. That try's timeout is all that is learned about the
|
||||||
|
// nameserver, so it is reported as timed out, not as answering.
|
||||||
|
func TestQueryNameserverIP_CancelDuringFirstTry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
// The first try starts at once and waits two seconds for a reply;
|
||||||
|
// the cancel comes a second in. The DNS client cuts a try short at
|
||||||
|
// the context's deadline but not at a cancel, so the try still
|
||||||
|
// waits its full two seconds and ends as a timeout a second after
|
||||||
|
// the cancel. By then the context has certainly ended, so no second
|
||||||
|
// try is made. With a deadline instead, the try ends at the moment
|
||||||
|
// the context does, and whether a second try is made depends on
|
||||||
|
// which the resolver sees first.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
time.AfterFunc(time.Second, cancel)
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserverIP(
|
||||||
|
ctx, "unreachable.test.", "192.0.2.1",
|
||||||
|
"example.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, resolver.StatusTimeout, resp.Status)
|
||||||
|
assert.NotEmpty(t, resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
// TestQueryNameserverIP_CancelledLogsNothing cancels the context while
|
// TestQueryNameserverIP_CancelledLogsNothing cancels the context while
|
||||||
// a query to 192.0.2.1, where nothing answers, is waiting for a reply,
|
// a query to 192.0.2.1, where nothing answers, is waiting for a reply,
|
||||||
// as shutdown does. The query was cut short, not failed, so nothing is
|
// as shutdown does. The query was cut short, not failed, so nothing is
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
const RequestTimeout time.Duration = requestTimeout
|
const RequestTimeout time.Duration = requestTimeout
|
||||||
|
|
||||||
// SetListenPort overrides the port Run binds. A test uses it to hand
|
// SetListenPort overrides the port Run binds. A test uses it to hand
|
||||||
// Run an unbindable port so ListenAndServe fails immediately and Run
|
// Run an unbindable port, so that Run stores its http.Server and then
|
||||||
// returns after storing its http.Server.
|
// returns an error without listening.
|
||||||
func SetListenPort(s *Server, port int) {
|
func SetListenPort(s *Server, port int) {
|
||||||
s.port = port
|
s.port = port
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
"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.
|
||||||
@@ -47,13 +45,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
public.Get("/", s.handlers.HandleDashboard())
|
public.Get("/", s.handlers.HandleDashboard())
|
||||||
|
|
||||||
// Static assets (embedded CSS/JS)
|
// Static assets (embedded CSS/JS)
|
||||||
public.Mount(
|
public.Mount("/s", http.StripPrefix("/s", s.handlers.HandleStatic()))
|
||||||
"/s",
|
|
||||||
http.StripPrefix(
|
|
||||||
"/s",
|
|
||||||
http.FileServer(http.FS(static.Static)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Health check (standard well-known path)
|
// Health check (standard well-known path)
|
||||||
public.Get(
|
public.Get(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -288,3 +289,42 @@ func TestMetricsRateLimitComesBeforeAuth(t *testing.T) {
|
|||||||
t.Errorf("another address: status = %d, want 200", rec.Code)
|
t.Errorf("another address: status = %d, want 200", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStaticDirectoriesAnswerNotFound checks that a request naming a
|
||||||
|
// directory of the static assets, with or without the trailing slash,
|
||||||
|
// answers 404 rather than a listing of it or a redirect to one, and
|
||||||
|
// that the stylesheet is still served as CSS.
|
||||||
|
func TestStaticDirectoriesAnswerNotFound(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||||
|
|
||||||
|
srv := routedServer(t)
|
||||||
|
|
||||||
|
for _, path := range []string{"/s", "/s/", "/s/css", "/s/css/"} {
|
||||||
|
rec := serve(srv, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, path, nil,
|
||||||
|
))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET %s: status = %d, want 404", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := serve(srv, httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/s/css/tailwind.min.css", nil,
|
||||||
|
))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET stylesheet: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := rec.Header().Get("Content-Type")
|
||||||
|
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil || mediaType != "text/css" {
|
||||||
|
t.Errorf(
|
||||||
|
"GET stylesheet: Content-Type = %q, want text/css",
|
||||||
|
contentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
|
|
||||||
err := srv.enableSentry()
|
err := srv.enableSentry()
|
||||||
@@ -120,9 +121,7 @@ func New(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
go srv.Run()
|
return srv.Run(ctx)
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
return srv.Shutdown(ctx)
|
return srv.Shutdown(ctx)
|
||||||
@@ -149,8 +148,10 @@ func newHTTPServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the HTTP server.
|
// Run binds the listen port, then serves HTTP on it in a new goroutine
|
||||||
func (s *Server) Run() {
|
// 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 {
|
||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|
||||||
listenAddr := fmt.Sprintf(":%d", s.port)
|
listenAddr := fmt.Sprintf(":%d", s.port)
|
||||||
@@ -158,10 +159,21 @@ func (s *Server) Run() {
|
|||||||
|
|
||||||
s.log.Info("http server starting", "addr", listenAddr)
|
s.log.Info("http server starting", "addr", listenAddr)
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
lc := &net.ListenConfig{}
|
||||||
|
|
||||||
|
listener, err := lc.Listen(ctx, "tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("starting http server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := s.httpServer.Serve(listener)
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
s.log.Error("http server error", "error", err)
|
s.log.Error("http server error", "error", err)
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully shuts down the server, then sends the error
|
// Shutdown gracefully shuts down the server, then sends the error
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -58,15 +60,13 @@ func buildServer(t *testing.T) *server.Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
||||||
// server actually serves — the one Run builds and hands to
|
// server actually serves — the one Run builds and serves on the port
|
||||||
// ListenAndServe — carries every socket-level timeout, plus the two
|
// it binds — carries every socket-level timeout, plus the two
|
||||||
// relationships the values must satisfy.
|
// relationships the values must satisfy.
|
||||||
//
|
//
|
||||||
// Run is driven to completion with an unbindable port: it builds and
|
// Run is given an unbindable port: it builds and stores s.httpServer,
|
||||||
// stores s.httpServer, then ListenAndServe fails at once and Run
|
// then the bind fails and Run returns the error without ever
|
||||||
// returns without ever listening. The assertions run in the same
|
// listening. Nothing here measures elapsed time.
|
||||||
// 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
|
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
|
||||||
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
||||||
@@ -84,7 +84,10 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
|
|||||||
srv := buildServer(t)
|
srv := buildServer(t)
|
||||||
server.SetListenPort(srv, -1)
|
server.SetListenPort(srv, -1)
|
||||||
|
|
||||||
srv.Run()
|
err := srv.Run(t.Context())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run bound port -1")
|
||||||
|
}
|
||||||
|
|
||||||
hs := server.HTTPServerOf(srv)
|
hs := server.HTTPServerOf(srv)
|
||||||
if hs == nil {
|
if hs == nil {
|
||||||
@@ -133,3 +136,38 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-2
@@ -4,6 +4,7 @@ package state
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -215,6 +216,24 @@ func (s *State) Load() error {
|
|||||||
return fmt.Errorf("parsing state file: %w", err)
|
return fmt.Errorf("parsing state file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A section the file leaves out, or holds as null, loads empty: the
|
||||||
|
// setters write into these maps, and writing into a nil map panics.
|
||||||
|
if snapshot.Domains == nil {
|
||||||
|
snapshot.Domains = make(map[string]*DomainState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.Hostnames == nil {
|
||||||
|
snapshot.Hostnames = make(map[string]*HostnameState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.Ports == nil {
|
||||||
|
snapshot.Ports = make(map[string]*PortState)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.Certificates == nil {
|
||||||
|
snapshot.Certificates = make(map[string]*CertificateState)
|
||||||
|
}
|
||||||
|
|
||||||
// A state file saved before each record value was stored once can
|
// A state file saved before each record value was stored once can
|
||||||
// hold a hostname's CNAME once for every record type asked for.
|
// hold a hostname's CNAME once for every record type asked for.
|
||||||
// Each value is kept once, so the first check does not see a
|
// Each value is kept once, so the first check does not see a
|
||||||
@@ -253,10 +272,20 @@ func (s *State) Save() error {
|
|||||||
return fmt.Errorf("creating data directory: %w", err)
|
return fmt.Errorf("creating data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic write: write to temp file, then rename
|
// Atomic write: write to temp file, then rename. The temp file is
|
||||||
|
// synced before the rename and the directory after it, or a crash
|
||||||
|
// can leave the renamed state file empty.
|
||||||
tmpPath := path + ".tmp"
|
tmpPath := path + ".tmp"
|
||||||
|
|
||||||
err = os.WriteFile(tmpPath, data, filePermissions)
|
//nolint:gosec // path is from trusted config
|
||||||
|
tmpFile, err := os.OpenFile(
|
||||||
|
tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePermissions,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
_, err = tmpFile.Write(data)
|
||||||
|
err = errors.Join(err, tmpFile.Sync(), tmpFile.Close())
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("writing temp state file: %w", err)
|
return fmt.Errorf("writing temp state file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -266,6 +295,15 @@ func (s *State) Save() error {
|
|||||||
return fmt.Errorf("renaming state file: %w", err)
|
return fmt.Errorf("renaming state file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dir, err := os.Open(filepath.Dir(path))
|
||||||
|
if err == nil {
|
||||||
|
err = errors.Join(dir.Sync(), dir.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("syncing data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
s.log.Debug("state saved to disk", "path", path)
|
s.log.Debug("state saved to disk", "path", path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -618,6 +618,53 @@ func TestSaveAtomicWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSaveOverLeftoverTempFile verifies that a temp file left by a
|
||||||
|
// crash, longer than the state Save writes, leaves nothing of itself in
|
||||||
|
// the state file, and that no temp file remains after Save.
|
||||||
|
func TestSaveOverLeftoverTempFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
statePath := filepath.Join(dir, "state.json")
|
||||||
|
tmpPath := statePath + ".tmp"
|
||||||
|
|
||||||
|
err := os.WriteFile(tmpPath, []byte(strings.Repeat("x", 64*1024)), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing leftover temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := state.NewForTestWithDataDir(dir)
|
||||||
|
|
||||||
|
populateState(t, s)
|
||||||
|
|
||||||
|
err = s.Save()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // test file with known path
|
||||||
|
data, err := os.ReadFile(statePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var snap state.Snapshot
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &snap)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("state file is not complete JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snap.Domains) != 2 {
|
||||||
|
t.Errorf("state file has %d domains, want 2", len(snap.Domains))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(tmpPath)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Error("temp file should not exist after save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadMissingFile verifies that loading a nonexistent file is not an error.
|
// TestLoadMissingFile verifies that loading a nonexistent file is not an error.
|
||||||
func TestLoadMissingFile(t *testing.T) {
|
func TestLoadMissingFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -648,6 +695,58 @@ func TestLoadMissingFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadFileWithoutSections loads a state file holding {}, and files
|
||||||
|
// that each leave out one section. Every section loads as an empty map.
|
||||||
|
// TestFirstCheckAfterLoadingFileWithoutSections in the watcher tests
|
||||||
|
// runs the first check after such a load.
|
||||||
|
func TestLoadFileWithoutSections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
}{
|
||||||
|
{"no sections", `{}`},
|
||||||
|
{"no domains", `{"hostnames": {}, "ports": {}, "certificates": {}}`},
|
||||||
|
{"no hostnames", `{"domains": {}, "ports": {}, "certificates": {}}`},
|
||||||
|
{"no ports", `{"domains": {}, "hostnames": {}, "certificates": {}}`},
|
||||||
|
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := os.WriteFile(
|
||||||
|
filepath.Join(dir, "state.json"), []byte(tt.file), 0o600,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := state.NewForTestWithDataDir(dir)
|
||||||
|
|
||||||
|
err = s.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := state.Snapshot{
|
||||||
|
Domains: map[string]*state.DomainState{},
|
||||||
|
Hostnames: map[string]*state.HostnameState{},
|
||||||
|
Ports: map[string]*state.PortState{},
|
||||||
|
Certificates: map[string]*state.CertificateState{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("loaded %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadCorruptFile verifies that a corrupt state file returns an error.
|
// TestLoadCorruptFile verifies that a corrupt state file returns an error.
|
||||||
func TestLoadCorruptFile(t *testing.T) {
|
func TestLoadCorruptFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ func TestCancelledCheckSavesNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStopBeforeFirstScanSendsNoStartupNotification runs a watcher with
|
||||||
|
// the startup notification on and its context already cancelled, so the
|
||||||
|
// first scan is cut short before its lookup is sent. The scan did not
|
||||||
|
// complete, so no startup notification may be sent.
|
||||||
|
func TestStopBeforeFirstScanSendsNoStartupNotification(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{testHost}
|
||||||
|
cfg.SendTestNotification = true
|
||||||
|
|
||||||
|
w, deps := newTestWatcher(t, cfg)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
w.Run(ctx)
|
||||||
|
|
||||||
|
notifications := deps.notifier.getNotifications()
|
||||||
|
if len(notifications) != 0 {
|
||||||
|
t.Errorf("sent %v, want no notifications", notifications)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newLoggingWatcher returns a watcher for a domain and a hostname, with
|
// newLoggingWatcher returns a watcher for a domain and a hostname, with
|
||||||
// the real resolver, that writes what it logs at warning level or above
|
// the real resolver, that writes what it logs at warning level or above
|
||||||
// into the returned buffer.
|
// into the returned buffer.
|
||||||
|
|||||||
@@ -122,6 +122,34 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
|||||||
w.runTLSChecks(ctx)
|
w.runTLSChecks(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckHostnameResult exports checkHostnameResult for testing.
|
||||||
|
func (w *Watcher) CheckHostnameResult(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
err error,
|
||||||
|
) bool {
|
||||||
|
return w.checkHostnameResult(ctx, hostname, results, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDomainResult exports checkDomainResult for testing.
|
||||||
|
func (w *Watcher) CheckDomainResult(
|
||||||
|
ctx context.Context,
|
||||||
|
domain string,
|
||||||
|
nameservers []string,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
w.checkDomainResult(ctx, domain, nameservers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailedNameservers exports failedNameservers for testing.
|
||||||
|
func FailedNameservers(
|
||||||
|
err error,
|
||||||
|
prev *state.HostnameState,
|
||||||
|
) map[string]*resolver.NameserverResponse {
|
||||||
|
return failedNameservers(err, prev)
|
||||||
|
}
|
||||||
|
|
||||||
// BuildHostnameState exports buildHostnameState for testing.
|
// BuildHostnameState exports buildHostnameState for testing.
|
||||||
func BuildHostnameState(
|
func BuildHostnameState(
|
||||||
results map[string]*resolver.NameserverResponse,
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ type DNSResolver interface {
|
|||||||
) ([]string, error)
|
) ([]string, error)
|
||||||
|
|
||||||
// LookupAllRecords queries all record types for a hostname,
|
// LookupAllRecords queries all record types for a hostname,
|
||||||
// returning each nameserver's response keyed by nameserver.
|
// returning each nameserver's response keyed by nameserver. When
|
||||||
|
// the walk to the nameservers reached a zone through a referral and
|
||||||
|
// none of that zone's nameservers answered, the error is a
|
||||||
|
// *resolver.NoAnswerError naming them.
|
||||||
LookupAllRecords(
|
LookupAllRecords(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package watcher_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIPv6AddressInBrackets runs the port checks, not the watcher's
|
||||||
|
// first, on hostname state built here for a hostname at an IPv6 address
|
||||||
|
// whose port 443 the previous check found closed. It is open now. Then
|
||||||
|
// it runs the TLS checks and the port checks again. The port and
|
||||||
|
// certificate keys and the Port Change notification write the address
|
||||||
|
// in brackets, so its last group cannot be read as the port, and the
|
||||||
|
// second port checks keep the certificate entry, whose address they read
|
||||||
|
// back from its key. When a last port check finds port 443 closed, the
|
||||||
|
// certificate entry is removed. Nothing is looked up.
|
||||||
|
func TestIPv6AddressInBrackets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const aaaaType = "AAAA"
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{host}
|
||||||
|
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
w.SetFirstRun(false)
|
||||||
|
|
||||||
|
deps.state.SetHostnameState(host, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{aaaaType: {"2001:db8::1"}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
deps.state.SetPortState("[2001:db8::1]:443", &state.PortState{
|
||||||
|
Hostnames: []string{host},
|
||||||
|
})
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
w.RunTLSChecks(t.Context())
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
snap := deps.state.GetSnapshot()
|
||||||
|
|
||||||
|
gotPorts := slices.Sorted(maps.Keys(snap.Ports))
|
||||||
|
wantPorts := []string{"[2001:db8::1]:443", "[2001:db8::1]:80"}
|
||||||
|
|
||||||
|
if !slices.Equal(gotPorts, wantPorts) {
|
||||||
|
t.Errorf("port entries %v, want %v", gotPorts, wantPorts)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotCerts := slices.Sorted(maps.Keys(snap.Certificates))
|
||||||
|
wantCerts := []string{"[2001:db8::1]:443:www.example.net"}
|
||||||
|
|
||||||
|
if !slices.Equal(gotCerts, wantCerts) {
|
||||||
|
t.Errorf("certificate entries %v, want %v", gotCerts, wantCerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := "Port Change: [2001:db8::1]:443"
|
||||||
|
want := `Hostnames: www.example.net
|
||||||
|
Address: [2001:db8::1]:443
|
||||||
|
Port now open`
|
||||||
|
|
||||||
|
got := deps.notifier.getNotifications()
|
||||||
|
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
|
||||||
|
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.portChecker.closed = true
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
|
||||||
|
if len(gotCerts) != 0 {
|
||||||
|
t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/config"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
)
|
)
|
||||||
@@ -74,21 +73,19 @@ b.ns.example.net.: 192.0.2.2`,
|
|||||||
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
notifier := &mockNotifier{}
|
cfg := defaultTestConfig(t)
|
||||||
w := watcher.NewForTest(
|
cfg.Domains = []string{domain}
|
||||||
&config.Config{Domains: []string{domain}},
|
|
||||||
nil, nil, nil, nil, notifier,
|
deps := newTestDeps(t, cfg)
|
||||||
)
|
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
|
||||||
|
|
||||||
// nsA's address changes, which also makes it differ from nsC; nsB
|
// nsA's address changes, which also makes it differ from nsC; nsB
|
||||||
// fails; nsC answers again; nsD is gone.
|
// fails; nsC answers again.
|
||||||
nsD := "d.ns.example.net."
|
|
||||||
w.DetectHostnameChanges(t.Context(), domain,
|
w.DetectHostnameChanges(t.Context(), domain,
|
||||||
saved(map[string]*state.NameserverRecordState{
|
saved(map[string]*state.NameserverRecordState{
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
nsB: answered(map[string][]string{"A": {ip1}}),
|
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||||
nsC: failed(),
|
nsC: failed(),
|
||||||
nsD: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
}),
|
}),
|
||||||
saved(map[string]*state.NameserverRecordState{
|
saved(map[string]*state.NameserverRecordState{
|
||||||
nsA: answered(map[string][]string{"A": {ip2}}),
|
nsA: answered(map[string][]string{"A": {ip2}}),
|
||||||
@@ -102,18 +99,17 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
|||||||
t.Context(), domain, cnameState(ip1), cnameState(ip2),
|
t.Context(), domain, cnameState(ip1), cnameState(ip2),
|
||||||
)
|
)
|
||||||
|
|
||||||
// NS Failure is sent for nsB failing and for nsD being gone.
|
|
||||||
want := map[string]int{
|
want := map[string]int{
|
||||||
"Record Change": 1,
|
"Record Change": 1,
|
||||||
"Inconsistency": 1,
|
"Inconsistency": 1,
|
||||||
"NS Failure": 2,
|
"NS Failure": 1,
|
||||||
"NS Recovery": 1,
|
"NS Recovery": 1,
|
||||||
"CNAME Address Change": 1,
|
"CNAME Address Change": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
sent := make(map[string]int)
|
sent := make(map[string]int)
|
||||||
|
|
||||||
for _, n := range notifier.getNotifications() {
|
for _, n := range deps.notifier.getNotifications() {
|
||||||
kind, _, _ := strings.Cut(n.Title, ":")
|
kind, _, _ := strings.Cut(n.Title, ":")
|
||||||
sent[kind]++
|
sent[kind]++
|
||||||
|
|
||||||
@@ -128,6 +124,89 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nsB, which answered, and nsC, which did not, are no longer among the
|
||||||
|
// nameservers. One NS Change lists both, for the hostname and for the
|
||||||
|
// configured apex domain with no delegation of its own. Nothing is sent
|
||||||
|
// for the domain whose delegation on the previous check listed them:
|
||||||
|
// the check of its delegation sends its NS Change.
|
||||||
|
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
target string
|
||||||
|
// The domain's nameservers saved by the previous check.
|
||||||
|
delegation []string
|
||||||
|
// The NS Change sent, or "" for none.
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "hostname",
|
||||||
|
target: host,
|
||||||
|
want: `Hostname: www.example.net
|
||||||
|
Removed: b.ns.example.net., c.ns.example.net.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "domain with no delegation of its own",
|
||||||
|
target: domain,
|
||||||
|
delegation: []string{},
|
||||||
|
want: `Domain: example.net
|
||||||
|
Removed: b.ns.example.net., c.ns.example.net.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "domain whose delegation listed them",
|
||||||
|
target: domain,
|
||||||
|
delegation: []string{nsA, nsB, nsC},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{domain}
|
||||||
|
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil, nil, nil, deps.notifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
deps.state.SetDomainState(domain, &state.DomainState{
|
||||||
|
Nameservers: tt.delegation,
|
||||||
|
})
|
||||||
|
|
||||||
|
prev := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
nsC: failed(),
|
||||||
|
})
|
||||||
|
current := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
})
|
||||||
|
|
||||||
|
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
|
||||||
|
|
||||||
|
got := deps.notifier.getNotifications()
|
||||||
|
|
||||||
|
if tt.want == "" {
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("sent %v, want nothing", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := "NS Change: " + tt.target
|
||||||
|
if len(got) != 1 || got[0].Title != title ||
|
||||||
|
got[0].Message != tt.want {
|
||||||
|
t.Errorf("sent %v, want one %q with message:\n%s",
|
||||||
|
got, title, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The startup notification counts the configured domains and hostnames,
|
// The startup notification counts the configured domains and hostnames,
|
||||||
// although the state's hostnames also hold the apex domain's own
|
// although the state's hostnames also hold the apex domain's own
|
||||||
// records. Nothing is looked up: the watcher has no resolver.
|
// records. Nothing is looked up: the watcher has no resolver.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,10 @@ import (
|
|||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errNoReply is why a walk that got no reply from a zone's servers
|
||||||
|
// failed.
|
||||||
|
var errNoReply = errors.New("all servers failed: i/o timeout")
|
||||||
|
|
||||||
// answered is what a check saves for a nameserver that answered with
|
// answered is what a check saves for a nameserver that answered with
|
||||||
// these records.
|
// these records.
|
||||||
func answered(records map[string][]string) *state.NameserverRecordState {
|
func answered(records map[string][]string) *state.NameserverRecordState {
|
||||||
@@ -183,6 +188,134 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestZoneNameserversAllStopAnswering runs three checks of host from
|
||||||
|
// the point where its lookup has returned. On the first, nsA and nsB,
|
||||||
|
// the nameservers of its zone, answer. On the second, the lookup fails
|
||||||
|
// with the error the walk to them returns when neither answers (see
|
||||||
|
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
|
||||||
|
// here because no live zone has nameservers that never answer. For a
|
||||||
|
// hostname it is the lookup of its records that fails, and for a domain
|
||||||
|
// with no delegation of its own the lookup of its delegation. Both are
|
||||||
|
// saved as failed, with the reason, and each gets an NS Failure. On the
|
||||||
|
// third, both answer, and each gets an NS Recovery. A domain's records
|
||||||
|
// are checked as a hostname's, so its first and third checks are too.
|
||||||
|
func TestZoneNameserversAllStopAnswering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
records := map[string][]string{"A": {ip1}}
|
||||||
|
bothAnswer := map[string]*resolver.NameserverResponse{
|
||||||
|
nsA: response(records), nsB: response(records),
|
||||||
|
}
|
||||||
|
noAnswer := &resolver.NoAnswerError{
|
||||||
|
Nameservers: []string{nsA, nsB}, Err: errNoReply,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
isDomain bool
|
||||||
|
}{
|
||||||
|
{"hostname", false},
|
||||||
|
{"domain with no delegation of its own", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
if tt.isDomain {
|
||||||
|
cfg.Domains = []string{host}
|
||||||
|
} else {
|
||||||
|
cfg.Hostnames = []string{host}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The watcher is given each lookup's result, so it has no
|
||||||
|
// resolver.
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
w.SetFirstRun(false)
|
||||||
|
|
||||||
|
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
|
||||||
|
|
||||||
|
if tt.isDomain {
|
||||||
|
w.CheckDomainResult(t.Context(), host, nil, noAnswer)
|
||||||
|
} else {
|
||||||
|
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, _ := deps.state.GetHostnameState(host)
|
||||||
|
|
||||||
|
for _, ns := range []string{nsA, nsB} {
|
||||||
|
got := current.RecordsByNameserver[ns]
|
||||||
|
if got.Status != failed().Status || got.Error != errNoReply.Error() {
|
||||||
|
t.Errorf(
|
||||||
|
"%s saved with status %q, error %q; want %q, %q",
|
||||||
|
ns, got.Status, got.Error, failed().Status, errNoReply,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
|
||||||
|
t.Errorf("sent %d NS Failure notifications, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
|
||||||
|
|
||||||
|
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
|
||||||
|
t.Errorf("sent %d NS Recovery notifications, want 2", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives
|
||||||
|
// FailedNameservers lookup errors after a check in which nsA and nsB
|
||||||
|
// answered. Only an error that names both makes them failed.
|
||||||
|
func TestFailedNameserversOnlyForThoseOfThePreviousCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
records := map[string][]string{"A": {ip1}}
|
||||||
|
bothAnswered := saved(map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(records), nsB: answered(records),
|
||||||
|
})
|
||||||
|
noAnswer := func(nameservers ...string) error {
|
||||||
|
return &resolver.NoAnswerError{Nameservers: nameservers, Err: errNoReply}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
prev *state.HostnameState
|
||||||
|
wantFailed bool
|
||||||
|
}{
|
||||||
|
{"both did not answer", noAnswer(nsA, nsB), bothAnswered, true},
|
||||||
|
{
|
||||||
|
"the servers of a parent zone did not answer",
|
||||||
|
noAnswer(nsC), bothAnswered, false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the zone's delegation lists only nsA now",
|
||||||
|
noAnswer(nsA), bothAnswered, false,
|
||||||
|
},
|
||||||
|
{"no root server answered", errNoReply, bothAnswered, false},
|
||||||
|
{"no previous check", noAnswer(nsA, nsB), nil, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := watcher.FailedNameservers(tt.err, tt.prev)
|
||||||
|
if (got != nil) != tt.wantFailed {
|
||||||
|
t.Errorf("returned %v, want failed: %v", got, tt.wantFailed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNameserverThatNeverAnswers asks a nameserver address where
|
// TestNameserverThatNeverAnswers asks a nameserver address where
|
||||||
// nothing answers, 192.0.2.1, and checks what the watcher saves for it.
|
// nothing answers, 192.0.2.1, and checks what the watcher saves for it.
|
||||||
// The deadline outlasts the resolver's first two-second try, as in the
|
// The deadline outlasts the resolver's first two-second try, as in the
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
@@ -172,7 +174,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
"A": {ip1}, "AAAA": {ip6},
|
"A": {ip1}, "AAAA": {ip6},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[]string{ip1 + ":443:" + host, ip6 + ":443:" + host},
|
[]string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"no nameserver answered",
|
"no nameserver answered",
|
||||||
@@ -182,7 +184,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
[]string{
|
[]string{
|
||||||
ip1 + ":443:" + host,
|
ip1 + ":443:" + host,
|
||||||
ip2 + ":443:" + host,
|
ip2 + ":443:" + host,
|
||||||
ip6 + ":443:" + host,
|
"[" + ip6 + "]:443:" + host,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -205,7 +207,8 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
|
|
||||||
for _, ip := range []string{ip1, ip2, ip6} {
|
for _, ip := range []string{ip1, ip2, ip6} {
|
||||||
deps.state.SetCertificateState(
|
deps.state.SetCertificateState(
|
||||||
ip+":443:"+host, &state.CertificateState{Status: "ok"},
|
net.JoinHostPort(ip, "443")+":"+host,
|
||||||
|
&state.CertificateState{Status: "ok"},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,3 +225,67 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCertificateStateForAPortClosed runs the port checks on hostname
|
||||||
|
// and port state built here for a configured hostname at ip1, whose
|
||||||
|
// port 443 the previous check found open and whose certificate entry
|
||||||
|
// is ok. The port is closed now: the certificate entry is removed, and
|
||||||
|
// only the Port Change is notified. When the port opens again, the next
|
||||||
|
// TLS check saves the certificate as new and notifies nothing about it.
|
||||||
|
// Nothing is looked up.
|
||||||
|
func TestCertificateStateForAPortClosed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Hostnames = []string{host}
|
||||||
|
|
||||||
|
deps := newTestDeps(t, cfg)
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg, deps.state, nil,
|
||||||
|
deps.portChecker, deps.tlsChecker, deps.notifier,
|
||||||
|
)
|
||||||
|
w.SetFirstRun(false)
|
||||||
|
|
||||||
|
certKey := ip1 + ":443:" + host
|
||||||
|
|
||||||
|
deps.state.SetHostnameState(host, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
deps.state.SetPortState(ip1+":443", &state.PortState{
|
||||||
|
Open: true, Hostnames: []string{host},
|
||||||
|
})
|
||||||
|
deps.state.SetCertificateState(certKey, &state.CertificateState{
|
||||||
|
CommonName: host, Status: "ok",
|
||||||
|
})
|
||||||
|
deps.portChecker.closed = true
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
|
||||||
|
if _, ok := deps.state.GetCertificateState(certKey); ok {
|
||||||
|
t.Errorf("certificate entry %s kept with port 443 closed", certKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
sent := deps.notifier.getNotifications()
|
||||||
|
if title := "Port Change: " + ip1 + ":443"; len(sent) != 1 ||
|
||||||
|
sent[0].Title != title {
|
||||||
|
t.Errorf("sent %v, want one %q", sent, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.portChecker.closed = false
|
||||||
|
|
||||||
|
w.CheckAllPorts(t.Context())
|
||||||
|
w.RunTLSChecks(t.Context())
|
||||||
|
|
||||||
|
cert, ok := deps.state.GetCertificateState(certKey)
|
||||||
|
if !ok || cert.Status != "ok" {
|
||||||
|
t.Errorf("certificate entry %s after port 443 opened: %+v", certKey, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range deps.notifier.getNotifications() {
|
||||||
|
if strings.HasPrefix(n.Title, "TLS") {
|
||||||
|
t.Errorf("sent %v, want no TLS notification", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+155
-40
@@ -6,8 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -133,7 +135,12 @@ func (w *Watcher) Run(ctx context.Context) {
|
|||||||
|
|
||||||
w.cleanupRemovedTargets()
|
w.cleanupRemovedTargets()
|
||||||
w.RunOnce(ctx)
|
w.RunOnce(ctx)
|
||||||
|
|
||||||
|
// RunOnce returns as usual when shutdown cuts the scan short, and
|
||||||
|
// the startup notification says the scan completed.
|
||||||
|
if ctx.Err() == nil {
|
||||||
w.maybeSendTestNotification(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)
|
||||||
@@ -284,12 +291,25 @@ func (w *Watcher) logFailedLookup(
|
|||||||
w.log.Error(msg, args...)
|
w.log.Error(msg, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkDomain looks up domain's delegation and checks domain with it
|
||||||
|
// (see checkDomainResult).
|
||||||
func (w *Watcher) checkDomain(
|
func (w *Watcher) checkDomain(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
) {
|
) {
|
||||||
nameservers, err := w.resolver.LookupNS(ctx, domain)
|
nameservers, err := w.resolver.LookupNS(ctx, domain)
|
||||||
|
|
||||||
|
w.checkDomainResult(ctx, domain, nameservers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDomainResult finishes a check of domain from nameservers and err,
|
||||||
|
// what the lookup of its delegation returned.
|
||||||
|
func (w *Watcher) checkDomainResult(
|
||||||
|
ctx context.Context,
|
||||||
|
domain string,
|
||||||
|
nameservers []string,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
// A domain that does not exist has no nameservers.
|
// A domain that does not exist has no nameservers.
|
||||||
nxdomain := errors.Is(err, resolver.ErrNXDomain)
|
nxdomain := errors.Is(err, resolver.ErrNXDomain)
|
||||||
if nxdomain {
|
if nxdomain {
|
||||||
@@ -304,6 +324,12 @@ func (w *Watcher) checkDomain(
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// For a domain with no delegation of its own, the servers that
|
||||||
|
// did not answer can be those of the zone it is in, at which
|
||||||
|
// its records are asked. They are then saved as failed, as a
|
||||||
|
// hostname's are.
|
||||||
|
w.checkHostnameResult(ctx, domain, nil, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +348,20 @@ func (w *Watcher) checkDomain(
|
|||||||
ctx, nameservers, prevAddresses,
|
ctx, nameservers, prevAddresses,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A domain that does not exist has no records of its own: none are
|
||||||
|
// asked for, and those saved by an earlier check are removed.
|
||||||
|
// Otherwise the apex domain's records are also checked and saved as
|
||||||
|
// a hostname's, so that the port and TLS checks find its addresses.
|
||||||
|
// Notifications about them name it as a domain (see nameLine). When
|
||||||
|
// their lookup fails, the check fails as when the NS lookup does, so
|
||||||
|
// the saved delegation and the saved records come from the same
|
||||||
|
// check, as detectNSDisappearances needs.
|
||||||
|
if nxdomain {
|
||||||
|
w.state.DeleteHostnameState(domain)
|
||||||
|
} else if !w.checkHostname(ctx, domain) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if hasPrev && !w.firstRun {
|
if hasPrev && !w.firstRun {
|
||||||
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
|
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
|
||||||
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
|
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
|
||||||
@@ -333,19 +373,6 @@ func (w *Watcher) checkDomain(
|
|||||||
NXDomain: nxdomain,
|
NXDomain: nxdomain,
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A domain that does not exist has no records of its own: none are
|
|
||||||
// asked for, and those saved by an earlier check are removed.
|
|
||||||
if nxdomain {
|
|
||||||
w.state.DeleteHostnameState(domain)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The apex domain's records are also checked and saved as a
|
|
||||||
// hostname's, so that the port and TLS checks find its addresses.
|
|
||||||
// Notifications about them name it as a domain (see nameLine).
|
|
||||||
w.checkHostname(ctx, domain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) detectNSChanges(
|
func (w *Watcher) detectNSChanges(
|
||||||
@@ -456,10 +483,12 @@ func (w *Watcher) detectNSAddressChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkHostname looks up hostname's records and checks them (see
|
||||||
|
// checkHostnameResult). It reports whether it saved them.
|
||||||
func (w *Watcher) checkHostname(
|
func (w *Watcher) checkHostname(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
) {
|
) bool {
|
||||||
results, err := w.resolver.LookupAllRecords(ctx, hostname)
|
results, err := w.resolver.LookupAllRecords(ctx, hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logFailedLookup(
|
w.logFailedLookup(
|
||||||
@@ -468,15 +497,70 @@ func (w *Watcher) checkHostname(
|
|||||||
"hostname", hostname,
|
"hostname", hostname,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return w.checkHostnameResult(ctx, hostname, results, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkHostnameResult finishes a check of hostname from results and err,
|
||||||
|
// what the lookup of its records returned, and reports whether it saved
|
||||||
|
// them. When the lookup failed, it saves nothing, unless the nameservers
|
||||||
|
// that did not answer are those of the previous check (see
|
||||||
|
// failedNameservers).
|
||||||
|
func (w *Watcher) checkHostnameResult(
|
||||||
|
ctx context.Context,
|
||||||
|
hostname string,
|
||||||
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
err error,
|
||||||
|
) bool {
|
||||||
prev, _ := w.state.GetHostnameState(hostname)
|
prev, _ := w.state.GetHostnameState(hostname)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
results = failedNameservers(err, prev)
|
||||||
|
if results == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.updateHostnameState(
|
w.updateHostnameState(
|
||||||
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// failedNameservers returns a failed response for each nameserver in
|
||||||
|
// prev, a name's state from the previous check, with err as the reason,
|
||||||
|
// when err says that the walk to the name's nameservers reached their
|
||||||
|
// zone and none of them answered. For any other error, as when the walk
|
||||||
|
// got no answer before it reached their zone, or when prev is nil, it
|
||||||
|
// returns nil.
|
||||||
|
func failedNameservers(
|
||||||
|
err error,
|
||||||
|
prev *state.HostnameState,
|
||||||
|
) map[string]*resolver.NameserverResponse {
|
||||||
|
var noAnswer *resolver.NoAnswerError
|
||||||
|
if !errors.As(err, &noAnswer) || prev == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver))
|
||||||
|
if !slices.Equal(noAnswer.Nameservers, saved) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(map[string]*resolver.NameserverResponse)
|
||||||
|
|
||||||
|
for _, ns := range saved {
|
||||||
|
results[ns] = &resolver.NameserverResponse{
|
||||||
|
Nameserver: ns,
|
||||||
|
Records: make(map[string][]string),
|
||||||
|
Status: resolver.StatusError,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateHostnameState finishes a check of hostname from newState, built
|
// updateHostnameState finishes a check of hostname from newState, built
|
||||||
@@ -772,28 +856,53 @@ func (w *Watcher) detectRecordChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectNSDisappearances sends one NS Change listing the nameservers of
|
||||||
|
// the previous check that are no longer those of the zone hostname is
|
||||||
|
// in. For a configured apex domain it leaves out those of the domain's
|
||||||
|
// saved delegation, which detectNSChanges notifies when they are removed
|
||||||
|
// from it. checkDomain saves a new delegation only after this runs, and
|
||||||
|
// only together with the records, so the saved delegation and the
|
||||||
|
// nameservers in prev come from the same check.
|
||||||
func (w *Watcher) detectNSDisappearances(
|
func (w *Watcher) detectNSDisappearances(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
prev, current *state.HostnameState,
|
prev, current *state.HostnameState,
|
||||||
) {
|
) {
|
||||||
for ns, prevNS := range prev.RecordsByNameserver {
|
var delegation []string
|
||||||
if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
|
|
||||||
continue
|
if w.isDomain(hostname) {
|
||||||
|
if domainState, ok := w.state.GetDomainState(hostname); ok {
|
||||||
|
delegation = domainState.Nameservers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var removed []string
|
||||||
|
|
||||||
|
for ns := range prev.RecordsByNameserver {
|
||||||
|
_, ok := current.RecordsByNameserver[ns]
|
||||||
|
if !ok && !slices.Contains(delegation, ns) {
|
||||||
|
removed = append(removed, ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(removed) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(removed)
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"%s\nNameserver: %s disappeared",
|
"%s\nRemoved: %s",
|
||||||
w.nameLine(hostname), ns,
|
w.nameLine(hostname),
|
||||||
|
strings.Join(removed, ", "),
|
||||||
)
|
)
|
||||||
|
|
||||||
w.notify.SendNotification(
|
w.notify.SendNotification(
|
||||||
ctx,
|
ctx,
|
||||||
"NS Failure: "+hostname,
|
"NS Change: "+hostname,
|
||||||
msg,
|
msg,
|
||||||
"error",
|
"warning",
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectNSFailures notifies when a nameserver that answered on the
|
// detectNSFailures notifies when a nameserver that answered on the
|
||||||
@@ -943,7 +1052,8 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
|
|||||||
|
|
||||||
// Phase 3: Remove port state entries that no longer have
|
// Phase 3: Remove port state entries that no longer have
|
||||||
// any hostname referencing them, and certificate entries for
|
// any hostname referencing them, and certificate entries for
|
||||||
// an address their name no longer has.
|
// an address whose port 443 is closed or that their name no
|
||||||
|
// longer has.
|
||||||
w.cleanupStalePorts(associations)
|
w.cleanupStalePorts(associations)
|
||||||
w.cleanupStaleCertificates()
|
w.cleanupStaleCertificates()
|
||||||
}
|
}
|
||||||
@@ -964,7 +1074,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
|||||||
ips := w.collectIPs(name)
|
ips := w.collectIPs(name)
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
for _, port := range monitoredPorts {
|
for _, port := range monitoredPorts {
|
||||||
key := fmt.Sprintf("%s:%d", ip, port)
|
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||||
if assoc[key] == nil {
|
if assoc[key] == nil {
|
||||||
assoc[key] = make(map[string]bool)
|
assoc[key] = make(map[string]bool)
|
||||||
}
|
}
|
||||||
@@ -989,18 +1099,15 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePortKey splits an "ip:port" key into its components.
|
// parsePortKey splits an "ip:port" key, written by net.JoinHostPort,
|
||||||
|
// into its components.
|
||||||
func parsePortKey(key string) (string, int) {
|
func parsePortKey(key string) (string, int) {
|
||||||
lastColon := strings.LastIndex(key, ":")
|
ip, port, err := net.SplitHostPort(key)
|
||||||
if lastColon < 0 {
|
if err != nil {
|
||||||
return key, 0
|
return key, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := key[:lastColon]
|
p, err := strconv.Atoi(port)
|
||||||
|
|
||||||
var p int
|
|
||||||
|
|
||||||
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ip, 0
|
return ip, 0
|
||||||
}
|
}
|
||||||
@@ -1030,13 +1137,21 @@ func (w *Watcher) cleanupStalePorts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cleanupStaleCertificates removes the certificate entries for an
|
// cleanupStaleCertificates removes the certificate entries for an
|
||||||
// address their name no longer resolves to. An entry saved for a name
|
// address whose port 443 was found closed, and for an address their
|
||||||
// none of whose nameservers answered is kept: that name's addresses are
|
// name no longer resolves to. An entry saved for a name none of whose
|
||||||
// not known, not gone.
|
// nameservers answered is kept unless its port is closed: that name's
|
||||||
|
// addresses are not known, not gone.
|
||||||
func (w *Watcher) cleanupStaleCertificates() {
|
func (w *Watcher) cleanupStaleCertificates() {
|
||||||
for _, key := range w.state.GetAllCertificateKeys() {
|
for _, key := range w.state.GetAllCertificateKeys() {
|
||||||
ip, hostname := parseCertKey(key)
|
ip, hostname := parseCertKey(key)
|
||||||
|
|
||||||
|
ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort)))
|
||||||
|
if ok && !ps.Open {
|
||||||
|
w.state.DeleteCertificateState(key)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if slices.Contains(w.collectIPs(hostname), ip) ||
|
if slices.Contains(w.collectIPs(hostname), ip) ||
|
||||||
w.noNameserverAnswered(hostname) {
|
w.noNameserverAnswered(hostname) {
|
||||||
continue
|
continue
|
||||||
@@ -1145,7 +1260,7 @@ func (w *Watcher) checkSinglePort(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key := fmt.Sprintf("%s:%d", ip, port)
|
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
prev, hasPrev := w.state.GetPortState(key)
|
prev, hasPrev := w.state.GetPortState(key)
|
||||||
|
|
||||||
@@ -1207,7 +1322,7 @@ func (w *Watcher) checkTLSForHostname(
|
|||||||
ips := w.collectIPs(hostname)
|
ips := w.collectIPs(hostname)
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
|
portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort))
|
||||||
|
|
||||||
ps, ok := w.state.GetPortState(portKey)
|
ps, ok := w.state.GetPortState(portKey)
|
||||||
if !ok || !ps.Open {
|
if !ok || !ps.Open {
|
||||||
@@ -1231,7 +1346,7 @@ func (w *Watcher) checkTLSCert(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
|
certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
prev, hasPrev := w.state.GetCertificateState(certKey)
|
prev, hasPrev := w.state.GetCertificateState(certKey)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -139,6 +140,22 @@ func (m *mockNotifier) getNotifications() []notification {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelOnNotify is a mockNotifier that calls cancel at each
|
||||||
|
// notification, as if shutdown cut the check short there.
|
||||||
|
type cancelOnNotify struct {
|
||||||
|
*mockNotifier
|
||||||
|
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n cancelOnNotify) SendNotification(
|
||||||
|
ctx context.Context,
|
||||||
|
title, message, priority string,
|
||||||
|
) {
|
||||||
|
n.mockNotifier.SendNotification(ctx, title, message, priority)
|
||||||
|
n.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers to build a Watcher and run its checks against live DNS ---
|
// --- Helpers to build a Watcher and run its checks against live DNS ---
|
||||||
|
|
||||||
type testDeps struct {
|
type testDeps struct {
|
||||||
@@ -461,6 +478,54 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFirstCheckAfterLoadingFileWithoutSections loads a state file
|
||||||
|
// holding {}, and one that leaves out the certificates section, and runs
|
||||||
|
// the first check. It saves domain, hostname, port and certificate
|
||||||
|
// entries; a section loaded as nil made it panic.
|
||||||
|
func TestFirstCheckAfterLoadingFileWithoutSections(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string
|
||||||
|
}{
|
||||||
|
{"no sections", `{}`},
|
||||||
|
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{testSmallDomain}
|
||||||
|
|
||||||
|
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
||||||
|
err := os.WriteFile(cfg.StatePath(), []byte(tt.file), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing the state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = deps.state.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loading the state file: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
snap := deps.state.GetSnapshot()
|
||||||
|
if len(snap.Domains) == 0 || len(snap.Hostnames) == 0 ||
|
||||||
|
len(snap.Ports) == 0 || len(snap.Certificates) == 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"saved %d domain, %d hostname, %d port and %d "+
|
||||||
|
"certificate entries, want at least one of each",
|
||||||
|
len(snap.Domains), len(snap.Hostnames),
|
||||||
|
len(snap.Ports), len(snap.Certificates),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNSChangeDetection(t *testing.T) {
|
func TestNSChangeDetection(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -558,6 +623,11 @@ func TestDomainThatDoesNotExist(t *testing.T) {
|
|||||||
// nameservers and without nxdomain, and its records, asked at the
|
// nameservers and without nxdomain, and its records, asked at the
|
||||||
// codeberg.page servers, are saved. Those are testSmallDomain's two
|
// codeberg.page servers, are saved. Those are testSmallDomain's two
|
||||||
// nameservers; github.io, the zone of the README's example, has eight.
|
// nameservers; github.io, the zone of the README's example, has eight.
|
||||||
|
// The previous check saved a delegation to oldNS1, now withdrawn, and
|
||||||
|
// records asked at oldNS1. A first check is cut short at its first
|
||||||
|
// notification, as shutdown would, and a second runs in full: across
|
||||||
|
// them the NS Change of its delegation removes oldNS1, and no other NS
|
||||||
|
// Change lists it again.
|
||||||
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -569,9 +639,30 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
|||||||
var deps *testDeps
|
var deps *testDeps
|
||||||
|
|
||||||
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
|
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
|
||||||
var w *watcher.Watcher
|
deps = newTestDeps(t, cfg)
|
||||||
|
|
||||||
w, deps = newTestWatcher(t, cfg)
|
firstCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w := watcher.NewForTest(
|
||||||
|
cfg,
|
||||||
|
deps.state,
|
||||||
|
resolver.NewFromLogger(slog.Default()),
|
||||||
|
deps.portChecker,
|
||||||
|
deps.tlsChecker,
|
||||||
|
cancelOnNotify{mockNotifier: deps.notifier, cancel: cancel},
|
||||||
|
)
|
||||||
|
|
||||||
|
deps.state.SetDomainState(domain, &state.DomainState{
|
||||||
|
Nameservers: []string{oldNS1},
|
||||||
|
})
|
||||||
|
deps.state.SetHostnameState(domain, saved(
|
||||||
|
map[string]*state.NameserverRecordState{
|
||||||
|
oldNS1: answered(map[string][]string{"A": {oldIP}}),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
w.RunOnce(firstCtx)
|
||||||
|
|
||||||
err := checkOnce(ctx, w, deps)
|
err := checkOnce(ctx, w, deps)
|
||||||
|
|
||||||
@@ -593,6 +684,22 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
|||||||
if _, ok := deps.state.GetHostnameState(domain); !ok {
|
if _, ok := deps.state.GetHostnameState(domain); !ok {
|
||||||
t.Errorf("no records saved for %s", domain)
|
t.Errorf("no records saved for %s", domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
title := "NS Change: " + domain
|
||||||
|
want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1
|
||||||
|
|
||||||
|
var changes []notification
|
||||||
|
|
||||||
|
for _, n := range deps.notifier.getNotifications() {
|
||||||
|
if n.Title == title {
|
||||||
|
changes = append(changes, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) != 1 || changes[0].Message != want {
|
||||||
|
t.Errorf("sent %v, want one %q with message:\n%s",
|
||||||
|
changes, title, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNSAddressChangeDetection(t *testing.T) {
|
func TestNSAddressChangeDetection(t *testing.T) {
|
||||||
@@ -718,7 +825,8 @@ func TestPortStateChange(t *testing.T) {
|
|||||||
|
|
||||||
hs, _ := deps.state.GetHostnameState(testHost)
|
hs, _ := deps.state.GetHostnameState(testHost)
|
||||||
assertNotified(
|
assertNotified(
|
||||||
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
|
t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"),
|
||||||
|
"warning",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,11 +1033,12 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
|||||||
|
|
||||||
// Port and TLS checks must use the addresses this check found.
|
// Port and TLS checks must use the addresses this check found.
|
||||||
for _, ip := range addresses(snap.Hostnames[testHost]) {
|
for _, ip := range addresses(snap.Hostnames[testHost]) {
|
||||||
if _, ok := snap.Ports[ip+":80"]; !ok {
|
portKey := net.JoinHostPort(ip, "80")
|
||||||
t.Error("port check used stale DNS: missing " + ip + ":80")
|
if _, ok := snap.Ports[portKey]; !ok {
|
||||||
|
t.Error("port check used stale DNS: missing " + portKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
certKey := ip + ":443:" + testHost
|
certKey := net.JoinHostPort(ip, "443") + ":" + testHost
|
||||||
if _, ok := snap.Certificates[certKey]; !ok {
|
if _, ok := snap.Certificates[certKey]; !ok {
|
||||||
t.Error("TLS check used stale DNS: missing " + certKey)
|
t.Error("TLS check used stale DNS: missing " + certKey)
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1158,7 @@ func TestSendTestNotification_Disabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNSFailureAndRecovery(t *testing.T) {
|
func TestNSChangeAndRecovery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
@@ -1059,7 +1168,7 @@ func TestNSFailureAndRecovery(t *testing.T) {
|
|||||||
|
|
||||||
// The saved state has every nameserver live DNS lists as one that
|
// The saved state has every nameserver live DNS lists as one that
|
||||||
// did not answer, and, as answering, one that live DNS does not
|
// did not answer, and, as answering, one that live DNS does not
|
||||||
// list, which then disappears.
|
// list, which the check then finds removed.
|
||||||
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
||||||
byNameserver := map[string]*state.NameserverRecordState{
|
byNameserver := map[string]*state.NameserverRecordState{
|
||||||
oldNS1: answered(map[string][]string{"A": {oldIP}}),
|
oldNS1: answered(map[string][]string{"A": {oldIP}}),
|
||||||
@@ -1071,7 +1180,7 @@ func TestNSFailureAndRecovery(t *testing.T) {
|
|||||||
deps.state.SetHostnameState(testHost, saved(byNameserver))
|
deps.state.SetHostnameState(testHost, saved(byNameserver))
|
||||||
})
|
})
|
||||||
|
|
||||||
assertNotified(t, deps, "NS Failure: "+testHost, "error")
|
assertNotified(t, deps, "NS Change: "+testHost, "warning")
|
||||||
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
|
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
|
||||||
|
|
||||||
// A nameserver that did not answer has no records to compare, so
|
// A nameserver that did not answer has no records to compare, so
|
||||||
|
|||||||
Reference in New Issue
Block a user