3 Commits

Author SHA1 Message Date
ff66ecc0c9 ci: re-run make check on every cibuild instead of serving it from the layer cache (closes #115)
All checks were successful
check / check (push) Successful in 43s
`script/cibuild` was plain `docker build .`. The Dockerfile does
`COPY . .` and then `RUN make check`, and Docker invalidates `COPY . .`
only on a content change, so on a byte-identical tree the check layer
was reused and the suite never ran. The script's header comment claimed
that a successful build implies all checks pass, which was false
whenever the cache was warm. Reproduced on this branch's parent: a
second consecutive run returned success in 283 ms with
`#13 [builder 9/10] RUN make check` reported `CACHED`.

That matters more here than in a typical repo. DNS is never mocked in
this repository, so the suite queries live DNS and its outcome varies
with real-world conditions; caching the verdict of a non-deterministic
check replays a stale result in exactly the case where re-running is
most valuable. It is also the gate every PR is verified through.

Fix: declare `ARG CHECK_EPOCH` immediately above the check step and
expand it into the command, with `script/cibuild` passing a fresh
`$(date +%s%N)` per invocation. A build argument's value participates in
the cache key of later instructions in the stage even when they do not
reference it, so a fresh value busts this layer either way; the value is
expanded into the command deliberately, which makes the invalidation a
property of the command string itself rather than of how a given builder
treats unreferenced args, and surfaces the epoch in the build log as a
diagnostic. Placing the ARG here and no earlier keeps the pinned
toolchain installs and `go mod download` above the invalidation line, so
only the check and the steps after it re-run. The epoch is nanosecond
granular so that two concurrent invocations starting in the same second
cannot share a value.

A plain `docker build` without the argument caches as before; nothing
outside the CI entrypoint changes behaviour.

Verified by experiment, not inspection:

- Two consecutive runs on an unchanged tree: 55.2 s and 42.2 s, both
  exit 0, with distinct epochs. The second run shows
  `RUN echo "check epoch: ..." && make check` executing for 36.0 s and
  216 passing tests across all eight packages, while `apk add`, both
  pinned `go install` steps, `go mod download`, `COPY go.mod go.sum` and
  `COPY . .` all report `CACHED`.
- Negative control: planted `internal/config/zz_negative_control_test.go`
  calling `t.Fatal("NEGATIVE-CONTROL-115: planted failure, cache did not
  serve this layer")`. The build failed in 24.7 s with exit 1, printing
  that exact message and `--- FAIL: TestNegativeControlIssue115`, and the
  check step exited with code 2. A cached layer cannot produce a failure
  predicted in advance, so this establishes the suite ran. The file was
  then removed, `git status` confirmed clean, and the tree built green
  again in 48.1 s.
- Total build time 42-55 s against the policy's 5-minute ceiling.
- `make check` green. No pin touched: the `golang` and `alpine` sha256
  digests, golangci-lint `c0d3ddc9`, and goimports `009367f5` are
  unchanged, and `.golangci.yml` still hashes to `021cc83f4e6f...`.
2026-08-09 06:22:28 +00:00
9347a2838b build: update golangci-lint to v2.12.2 with org-standard v2 config (#96)
All checks were successful
check / check (push) Successful in 4s
Updates golangci-lint to v2.12.2 and sets `.golangci.yml` to the org-standard v2-schema config already deployed across the org's repos. The config change is owner-authorized (see #96 (comment) and #96 (comment)); the same file is being landed as canonical via prompts PR #24 (sneak/prompts#24).

## Changes

- **Commit-pinned installs**: golangci-lint pinned to commit `c0d3ddc9cf3faa61a4e378e879ece580256d76e5` (v2.12.2, released 2026-05-06) in `Dockerfile` and `script/bootstrap`.
- **`.golangci.yml` set to the org-standard v2 config** (sha256 `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`), byte-identical to the file used across the org's other repos. Settings live under `linters.settings`, so the `lll`/`funlen`/`cyclop`/`dupl` thresholds are actually applied (under the old hybrid file, v2 silently ignored the top-level `linters-settings` block).
- **Lint fixes** required by the now-active thresholds:
  - `goconst`: shared constants for repeated status/priority/DNS-fixture strings in `internal/watcher/watcher.go` and the notify, state, and watcher tests
  - `dupl`: consolidated duplicated ntfy/slack HTTP-error tests and SendNotification endpoint-error tests behind shared helpers in `internal/notify/delivery_test.go`
  - `lll`: wrapped long test table entries and comments in `internal/config/classify_test.go`, `internal/notify/history_test.go`, `internal/state/state_test.go`, `internal/watcher/watcher_test.go`; shortened one inline nolint justification in `internal/notify/retry.go`
- **`TODO.md`**: Completed Steps entry updated in the same commit.
- Rebased onto current `main` (`f79cd98`); the branch is one clean commit.

## Notes

- v2.12 deprecates the `gomodguard` linter in favor of `gomodguard_v2`. The org-standard config does not disable the deprecated linter, so golangci-lint may emit an informational deprecation warning; this is accepted by the owner and does not affect the exit status (this exact config+code combination was CI-green at `dea7e44`).

## Verification

- `make check` exits 0 (fmt-check, tests, lint)
- `make lint`: 0 issues; no deprecation warning surfaced in the runs performed
- sha256 of `.golangci.yml` at HEAD verified equal to `021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb`

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #96
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 23:15:47 +02:00
f79cd98107 docs: document the no-DNS-mocking policy in README (closes #94) (#95)
All checks were successful
check / check (push) Successful in 5s
Adds a prominent "No DNS mocking. Ever." section near the top of `README.md`, per owner policy (sneak, 2026-08-07):

- DNS is never mocked in this project — no mock resolvers, fake DNS servers, or stubbed lookups, in tests or anywhere else.
- Tests exercise real iterative resolution against live nameservers by design.
- Flaky live tests are fixed with robustness (retries, multiple nameservers, timeouts) or explicit opt-in gating decided by the owner — never with mocks.
- Contributions introducing DNS mocks will be rejected.

Markdown-only change; matches the README's existing tone and hard-wrap style. `script/fmt` covers Go only, so no formatter output applies to this file. Verified via `script/cibuild` (docker build runs `make check` with the pinned toolchain) — green. A direct local `make check` shows 21 pre-existing `goconst` lint findings that come from a newer local `golangci-lint` (v2.12.2 vs the pinned v2.10.1) and are unrelated to this change.

Related: #93 is being reframed under this policy.
Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #95
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 22:31:48 +02:00
17 changed files with 831 additions and 659 deletions

View File

@@ -1,5 +1,9 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -14,8 +18,7 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -27,6 +30,5 @@ linters-settings:
threshold: 100 threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

View File

@@ -4,8 +4,8 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4
RUN apk add --no-cache git make gcc musl-dev binutils-gold RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.10.1 # golangci-lint v2.12.2, 2026-08-07
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
# goimports v0.42.0 # goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
@@ -15,8 +15,25 @@ RUN go mod download
COPY . . COPY . .
# Run all checks - build fails if any check fails # Run all checks - build fails if any check fails.
RUN make check #
# CHECK_EPOCH is a cache-busting build argument. Without it, an
# unchanged tree leaves this layer's cache key identical and Docker
# serves the previous verdict instead of re-running the suite, so the
# build reports a green it did not earn. A build argument's value
# participates in the cache key of later instructions in the stage even
# when they do not reference it, so a fresh value busts this layer
# either way. It is expanded into the command deliberately: that makes
# the invalidation a property of the command string itself rather than
# of how a given builder treats unreferenced args, and it surfaces the
# epoch in the build log as a diagnostic.
#
# Placing the ARG here and nowhere earlier keeps everything above it
# (toolchain install, go mod download) cached, so only the check and the
# steps after it re-run. script/cibuild passes a fresh value per run; a
# plain `docker build` without it caches as before.
ARG CHECK_EPOCH
RUN echo "check epoch: ${CHECK_EPOCH}" && make check
# Build the binary # Build the binary
RUN make build RUN make build

View File

@@ -17,6 +17,26 @@ without requiring an external database.
--- ---
## No DNS mocking. Ever.
**DNS is never mocked in this project — not in tests, not anywhere else.**
No mock resolvers, no fake DNS servers, no stubbed lookups.
dnswatcher's entire purpose is correct behavior against the real DNS.
Tests exercise real iterative resolution against live nameservers by
design; a test suite that passes against a mock proves nothing about the
one thing this program exists to do.
When live tests are flaky, that is a robustness problem, and it gets
fixed with robustness: retries with backoff, querying multiple
independent nameservers, longer timeouts — or explicit opt-in gating
decided by the project owner. Never with mocks.
Contributions that introduce mocked, faked, or stubbed DNS will be
rejected.
---
## Features ## Features
### DNS Domain Monitoring (Apex Domains) ### DNS Domain Monitoring (Apex Domains)
@@ -373,7 +393,10 @@ them. We provide:
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via - `script/docker` — build the Docker image tagged via
`script/projectname` `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: `docker build .` with a fresh
`CHECK_EPOCH` build argument, so the Dockerfile's `make check` layer
is never served from the cache and a green build always means the
checks ran on this invocation
- `script/precommit` — run by the git pre-commit hook; `go mod tidy` - `script/precommit` — run by the git pre-commit hook; `go mod tidy`
guard, then `script/check` guard, then `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook

View File

@@ -2,10 +2,8 @@
## DNS Resolution Tests ## DNS Resolution Tests
All tests that involve DNS resolution — in every package, including All resolver tests **MUST** use live queries against real DNS servers.
consumers of the resolver such as the watcher — **MUST** use live No mocking of the DNS client layer is permitted.
queries against real DNS servers. No mocking, faking, or stubbing of
DNS at any layer is permitted.
### Rationale ### Rationale
@@ -14,8 +12,6 @@ the full delegation chain. Mocked responses cannot faithfully represent
the variety of real-world DNS behavior (truncation, referrals, glue the variety of real-world DNS behavior (truncation, referrals, glue
records, DNSSEC, varied response times, EDNS, etc.). Testing against records, DNSSEC, varied response times, EDNS, etc.). Testing against
real servers ensures the resolver works correctly in production. real servers ensures the resolver works correctly in production.
Robustness comes from handling real-world DNS behavior with tolerant
assertions and sensible timeouts, not from mocks.
### Constraints ### Constraints
@@ -28,14 +24,11 @@ assertions and sensible timeouts, not from mocks.
- Flaky failures from transient network issues are acceptable and - Flaky failures from transient network issues are acceptable and
should be investigated as potential resolver bugs, not papered over should be investigated as potential resolver bugs, not papered over
with mocks or skip flags with mocks or skip flags
- Watcher change-detection tests seed a synthetic *previous state*
and compare it against fresh live lookups; the DNS side is never
faked
### What NOT to do ### What NOT to do
- **Do not mock `DNSClient`**, the watcher's `DNSResolver` interface, - **Do not mock `DNSClient`** for resolver tests (the mock constructor
or any other DNS abstraction — in any package, for any reason exists for unit-testing other packages that consume the resolver)
- **Do not add `-short` flags** to skip slow tests - **Do not add `-short` flags** to skip slow tests
- **Do not increase `-timeout`** to hide hanging queries - **Do not increase `-timeout`** to hide hanging queries
- **Do not modify linter configuration** to suppress findings - **Do not modify linter configuration** to suppress findings

42
TODO.md
View File

@@ -14,10 +14,7 @@ pre-1.0. No git tags. Core resolver work in flight on feature/resolver
(dirty: internal/resolver/resolver_test.go). Local checkout has diverged (dirty: internal/resolver/resolver_test.go). Local checkout has diverged
from origin: origin/main is 8 commits ahead (watcher orchestrator, from origin: origin/main is 8 commits ahead (watcher orchestrator,
unified TARGETS) and origin/feature/resolver already contains the full unified TARGETS) and origin/feature/resolver already contains the full
iterative resolver implementation. DNS mocking is banned in this repo iterative resolver implementation with hermetic mocked tests.
(see `TESTING.md`): all tests use live DNS only. The hermetic mocked
tests previously noted on `feature/resolver` are gone from its current
tip, which carries a live-DNS suite against `*.dns.sneak.cloud`.
# Next Step # Next Step
@@ -28,15 +25,28 @@ confirm make check still passes.
# Completed Steps # Completed Steps
- 2026-08-07: DNS mocking removed from the entire test suite; watcher - 2026-08-09: `script/cibuild` can no longer report a green it did not
tests now drive the real iterative resolver against live DNS and earn. The Dockerfile declares `ARG CHECK_EPOCH` immediately above the
`TESTING.md` bans DNS mocks in every package (`remove-dns-mocking` check step and expands it into the `RUN` command, and `script/cibuild`
branch) passes a fresh `$(date +%s%N)` per invocation, so the `make check`
layer is always re-executed while the pinned toolchain install and
`go mod download` stay cached. Verified by experiment: before the fix
a second run on an unchanged tree returned in 283 ms with the check
layer `CACHED`; after it the check runs every time, and a deliberately
planted always-failing test made the build fail with exactly that
test's message
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
org-standard v2-schema config used across the org's repos
(owner-authorized; same file is being landed as canonical via prompts
PR #24), with settings under `linters.settings` so the
lll/funlen/cyclop/dupl thresholds apply; fixed the resulting
`goconst`, `dupl`, and `lll` findings; the informational `gomodguard`
deprecation warning under this config is accepted
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-02-20: iterative DNS resolver implemented; tests made hermetic - 2026-02-20: iterative DNS resolver implemented; tests made hermetic
with mocked DNS (origin/feature/resolver, unmerged; superseded — DNS with mocked DNS (origin/feature/resolver, unmerged)
mocking is banned, see `TESTING.md`)
- 2026-02-20: CI actions and go install refs pinned to commit SHAs; - 2026-02-20: CI actions and go install refs pinned to commit SHAs;
Gitea Actions workflow for make check (origin/ci/make-check, unmerged) Gitea Actions workflow for make check (origin/ci/make-check, unmerged)
- 2026-02-20: watcher monitoring orchestrator merged to main (#8) - 2026-02-20: watcher monitoring orchestrator merged to main (#8)
@@ -63,9 +73,8 @@ Branch reconciliation:
- Sync local checkout with origin: local main is 8 commits behind - Sync local checkout with origin: local main is 8 commits behind
origin/main; local feature/resolver has diverged from origin/main; local feature/resolver has diverged from
origin/feature/resolver, which already implements the resolver origin/feature/resolver, which already implements the resolver
- Merge in-flight branches to main once green: feature/resolver - Merge in-flight branches to main once green: feature/resolver,
(confirm its tests remain live-DNS — DNS mocking is banned, see ci/make-check, feature/portcheck-implementation,
`TESTING.md`), ci/make-check, feature/portcheck-implementation,
feature/tlscheck-implementation feature/tlscheck-implementation
Resolver (plan from untracked TODO.md; largely implemented on Resolver (plan from untracked TODO.md; largely implemented on
@@ -149,7 +158,6 @@ Infrastructure notes (from untracked TODO.md):
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de - Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
remote intentionally; do not "fix" it remote intentionally; do not "fix" it
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix - Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
- Resolver tests originally used live DNS against `*.dns.sneak.cloud` - Resolver tests originally used live DNS against *.dns.sneak.cloud
(required records documented in the test file header); `main` now (required records documented in the test file header); origin now has
tests against live public DNS. DNS mocking is banned (see mocked hermetic tests, keep them hermetic
`TESTING.md`); never reintroduce hermetic mocked DNS tests

View File

@@ -17,13 +17,33 @@ func TestClassifyDNSName(t *testing.T) {
}{ }{
{name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain},
{name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname},
{name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain}, {
{name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname}, name: "apex domain multi-part TLD",
input: "example.co.uk",
want: config.DNSNameTypeDomain,
},
{
name: "hostname multi-part TLD",
input: "api.example.co.uk",
want: config.DNSNameTypeHostname,
},
{name: "public suffix itself", input: "co.uk", wantErr: true}, {name: "public suffix itself", input: "co.uk", wantErr: true},
{name: "empty string", input: "", wantErr: true}, {name: "empty string", input: "", wantErr: true},
{name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname}, {
{name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain}, name: "deeply nested hostname",
{name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname}, input: "a.b.c.example.com",
want: config.DNSNameTypeHostname,
},
{
name: "trailing dot stripped",
input: "example.com.",
want: config.DNSNameTypeDomain,
},
{
name: "uppercase normalized",
input: "WWW.Example.COM",
want: config.DNSNameTypeHostname,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@@ -25,6 +25,20 @@ const (
colorDefault = "#6c757d" colorDefault = "#6c757d"
) )
// Priority strings used across multiple tests.
const (
prioError = "error"
prioWarning = "warning"
prioSuccess = "success"
prioInfo = "info"
prioUnknown = "unknown"
prioDefault = "default"
prioUrgent = "urgent"
)
// testHost is the hostname used in request construction tests.
const testHost = "example.com"
// errSimulated is a static error for transport failures. // errSimulated is a static error for transport failures.
var errSimulated = errors.New("simulated transport failure") var errSimulated = errors.New("simulated transport failure")
@@ -101,13 +115,13 @@ func TestNtfyPriority(t *testing.T) {
input string input string
want string want string
}{ }{
{"error", "urgent"}, {prioError, prioUrgent},
{"warning", "high"}, {prioWarning, "high"},
{"success", "default"}, {prioSuccess, prioDefault},
{"info", "low"}, {prioInfo, "low"},
{"", "default"}, {"", prioDefault},
{"unknown", "default"}, {prioUnknown, prioDefault},
{"critical", "default"}, {"critical", prioDefault},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -134,12 +148,12 @@ func TestSlackColor(t *testing.T) {
input string input string
want string want string
}{ }{
{"error", colorError}, {prioError, colorError},
{"warning", colorWarning}, {prioWarning, colorWarning},
{"success", colorSuccess}, {prioSuccess, colorSuccess},
{"info", colorInfo}, {prioInfo, colorInfo},
{"", colorDefault}, {"", colorDefault},
{"unknown", colorDefault}, {prioUnknown, colorDefault},
{"critical", colorDefault}, {"critical", colorDefault},
} }
@@ -165,7 +179,7 @@ func TestNewRequest(t *testing.T) {
target := &url.URL{ target := &url.URL{
Scheme: "https", Scheme: "https",
Host: "example.com", Host: testHost,
Path: "/webhook", Path: "/webhook",
} }
body := bytes.NewBufferString("hello") body := bytes.NewBufferString("hello")
@@ -187,9 +201,9 @@ func TestNewRequest(t *testing.T) {
) )
} }
if req.Host != "example.com" { if req.Host != testHost {
t.Errorf( t.Errorf(
"Host = %q, want %q", req.Host, "example.com", "Host = %q, want %q", req.Host, testHost,
) )
} }
@@ -217,7 +231,7 @@ func TestNewRequestPreservesContext(t *testing.T) {
ctxKey("k"), ctxKey("k"),
"v", "v",
) )
target := &url.URL{Scheme: "https", Host: "example.com"} target := &url.URL{Scheme: "https", Host: testHost}
req := notify.NewRequestForTest( req := notify.NewRequestForTest(
ctx, http.MethodGet, target, http.NoBody, ctx, http.MethodGet, target, http.NoBody,
@@ -289,10 +303,10 @@ func TestSendNtfyHeaders(t *testing.T) {
) )
} }
if captured.priority != "urgent" { if captured.priority != prioUrgent {
t.Errorf( t.Errorf(
"Priority header = %q, want %q", "Priority header = %q, want %q",
captured.priority, "urgent", captured.priority, prioUrgent,
) )
} }
@@ -311,10 +325,10 @@ func TestSendNtfyAllPriorities(t *testing.T) {
input string input string
want string want string
}{ }{
{"error", "urgent"}, {prioError, prioUrgent},
{"warning", "high"}, {prioWarning, "high"},
{"success", "default"}, {prioSuccess, prioDefault},
{"info", "low"}, {prioInfo, "low"},
} }
for _, tc := range priorities { for _, tc := range priorities {
@@ -356,56 +370,69 @@ func TestSendNtfyAllPriorities(t *testing.T) {
} }
} }
func TestSendNtfyClientError(t *testing.T) { // assertSendStatusError verifies that send returns an error
t.Parallel() // wrapping wantErr when the server responds with status.
func assertSendStatusError(
t *testing.T,
status int,
wantErr error,
send func(*notify.Service, *url.URL) error,
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(status)
}), }),
) )
defer srv.Close() defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport) svc := notify.NewTestService(srv.Client().Transport)
topicURL, _ := url.Parse(srv.URL) target, _ := url.Parse(srv.URL)
err := svc.SendNtfy( err := send(svc, target)
context.Background(), topicURL, "t", "m", "info",
)
if err == nil { if err == nil {
t.Fatal("expected error for 403 response") t.Fatalf("expected error for %d response", status)
} }
if !errors.Is(err, notify.ErrNtfyFailed) { if !errors.Is(err, wantErr) {
t.Errorf("error = %v, want ErrNtfyFailed", err) t.Errorf("error = %v, want %v", err, wantErr)
} }
} }
func sendNtfyInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendNtfy(
context.Background(), target, "t", "m", prioInfo,
)
}
func sendSlackInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendSlack(
context.Background(), target, "t", "m", prioInfo,
)
}
func TestSendNtfyClientError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusForbidden,
notify.ErrNtfyFailed, sendNtfyInfo,
)
}
func TestSendNtfyServerError(t *testing.T) { func TestSendNtfyServerError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendStatusError(
http.HandlerFunc( t, http.StatusInternalServerError,
func(w http.ResponseWriter, _ *http.Request) { notify.ErrNtfyFailed, sendNtfyInfo,
w.WriteHeader(http.StatusInternalServerError)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
topicURL, _ := url.Parse(srv.URL)
err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 500 response")
}
if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want ErrNtfyFailed", err)
}
} }
func TestSendNtfySuccess(t *testing.T) { func TestSendNtfySuccess(t *testing.T) {
@@ -550,11 +577,11 @@ func TestSendSlackAllColors(t *testing.T) {
priority string priority string
want string want string
}{ }{
{"error", colorError}, {prioError, colorError},
{"warning", colorWarning}, {prioWarning, colorWarning},
{"success", colorSuccess}, {prioSuccess, colorSuccess},
{"info", colorInfo}, {prioInfo, colorInfo},
{"unknown", colorDefault}, {prioUnknown, colorDefault},
} }
for _, tc := range colors { for _, tc := range colors {
@@ -606,53 +633,19 @@ func TestSendSlackAllColors(t *testing.T) {
func TestSendSlackClientError(t *testing.T) { func TestSendSlackClientError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendStatusError(
http.HandlerFunc( t, http.StatusBadRequest,
func(w http.ResponseWriter, _ *http.Request) { notify.ErrSlackFailed, sendSlackInfo,
w.WriteHeader(http.StatusBadRequest)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 400 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
} }
func TestSendSlackServerError(t *testing.T) { func TestSendSlackServerError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendStatusError(
http.HandlerFunc( t, http.StatusBadGateway,
func(w http.ResponseWriter, _ *http.Request) { notify.ErrSlackFailed, sendSlackInfo,
w.WriteHeader(http.StatusBadGateway)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "error",
)
if err == nil {
t.Fatal("expected error for 502 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
} }
func TestSendSlackNetworkError(t *testing.T) { func TestSendSlackNetworkError(t *testing.T) {
@@ -977,74 +970,62 @@ func TestSendNotificationMattermostOnly(t *testing.T) {
} }
} }
func TestSendNotificationNtfyError(t *testing.T) { // assertSendNotificationTolerates verifies SendNotification
t.Parallel() // neither panics nor blocks when the endpoint configured by
// setURL responds with status.
func assertSendNotificationTolerates(
t *testing.T,
status int,
priority string,
setURL func(*notify.Service, *url.URL),
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(status)
}), }),
) )
defer srv.Close() defer srv.Close()
ntfyURL, _ := url.Parse(srv.URL) target, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport) svc := notify.NewTestService(http.DefaultTransport)
svc.SetNtfyURL(ntfyURL) setURL(svc, target)
// Should not panic or block.
svc.SendNotification( svc.SendNotification(
context.Background(), "t", "m", "error", context.Background(), "t", "m", priority,
) )
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
func TestSendNotificationNtfyError(t *testing.T) {
t.Parallel()
assertSendNotificationTolerates(
t, http.StatusInternalServerError, prioError,
(*notify.Service).SetNtfyURL,
)
}
func TestSendNotificationSlackError(t *testing.T) { func TestSendNotificationSlackError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendNotificationTolerates(
http.HandlerFunc( t, http.StatusForbidden, prioError,
func(w http.ResponseWriter, _ *http.Request) { (*notify.Service).SetSlackWebhookURL,
w.WriteHeader(http.StatusForbidden)
}),
) )
defer srv.Close()
slackURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetSlackWebhookURL(slackURL)
svc.SendNotification(
context.Background(), "t", "m", "error",
)
time.Sleep(100 * time.Millisecond)
} }
func TestSendNotificationMattermostError(t *testing.T) { func TestSendNotificationMattermostError(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer( assertSendNotificationTolerates(
http.HandlerFunc( t, http.StatusBadGateway, prioWarning,
func(w http.ResponseWriter, _ *http.Request) { (*notify.Service).SetMattermostWebhookURL,
w.WriteHeader(http.StatusBadGateway)
}),
) )
defer srv.Close()
mmURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetMattermostWebhookURL(mmURL)
svc.SendNotification(
context.Background(), "t", "m", "warning",
)
time.Sleep(100 * time.Millisecond)
} }
// ── SlackPayload JSON marshaling ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────

View File

@@ -29,14 +29,14 @@ func TestAlertHistoryAddAndRecent(t *testing.T) {
Timestamp: now.Add(-2 * time.Minute), Timestamp: now.Add(-2 * time.Minute),
Title: "first", Title: "first",
Message: "msg1", Message: "msg1",
Priority: "info", Priority: prioInfo,
}) })
h.Add(notify.AlertEntry{ h.Add(notify.AlertEntry{
Timestamp: now.Add(-1 * time.Minute), Timestamp: now.Add(-1 * time.Minute),
Title: "second", Title: "second",
Message: "msg2", Message: "msg2",
Priority: "warning", Priority: prioWarning,
}) })
entries := h.Recent() entries := h.Recent()

View File

@@ -69,7 +69,7 @@ func (rc RetryConfig) backoff(attempt int) time.Duration {
lo := raw * (1 - jitterFraction) lo := raw * (1 - jitterFraction)
hi := raw * (1 + jitterFraction) hi := raw * (1 + jitterFraction)
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand
return time.Duration(jittered) return time.Duration(jittered)
} }

View File

@@ -7,8 +7,8 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
) )
// DNSClient abstracts DNS wire-protocol exchanges over a single // DNSClient abstracts DNS wire-protocol exchanges so the resolver
// transport, letting the resolver switch between UDP and TCP. // can be tested without hitting real nameservers.
type DNSClient interface { type DNSClient interface {
ExchangeContext( ExchangeContext(
ctx context.Context, ctx context.Context,

View File

@@ -67,4 +67,17 @@ func NewFromLogger(log *slog.Logger) *Resolver {
} }
} }
// NewFromLoggerWithClient creates a Resolver with a custom DNS
// client, useful for testing with mock DNS responses.
func NewFromLoggerWithClient(
log *slog.Logger,
client DNSClient,
) *Resolver {
return &Resolver{
log: log,
client: client,
tcp: client,
}
}
// Method implementations are in iterative.go. // Method implementations are in iterative.go.

View File

@@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -623,41 +624,58 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Unreachable nameserver tests // Timeout tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
func TestQueryNameserverIP_UnreachableServer(t *testing.T) { func TestQueryNameserverIP_Timeout(t *testing.T) {
t.Parallel() t.Parallel()
r := newTestResolver(t) log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
r := resolver.NewFromLoggerWithClient(
log, &timeoutClient{},
)
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second, context.Background(), 10*time.Second,
) )
t.Cleanup(cancel) t.Cleanup(cancel)
// 192.0.2.1 is an RFC 5737 documentation address: no // Query any IP — the client always returns a timeout error.
// nameserver can exist there. Depending on the network
// path the queries either time out (silent drop) or fail
// fast (ICMP unreachable), so accept any non-OK status;
// the resolver must return a classified response with no
// records rather than an error or a hang.
resp, err := r.QueryNameserverIP( resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1", ctx, "unreachable.test.", "192.0.2.1",
"example.com", "example.com",
) )
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, resolver.StatusOK, resp.Status) assert.Equal(t, resolver.StatusTimeout, resp.Status)
assert.NotEmpty(t, resp.Error)
totalRecords := 0
for _, values := range resp.Records {
totalRecords += len(values)
}
assert.Zero(t, totalRecords)
} }
// timeoutClient simulates DNS timeout errors for testing.
type timeoutClient struct{}
func (c *timeoutClient) ExchangeContext(
_ context.Context,
_ *dns.Msg,
_ string,
) (*dns.Msg, time.Duration, error) {
return nil, 0, &net.OpError{
Op: "read",
Net: "udp",
Err: &timeoutError{},
}
}
type timeoutError struct{}
func (e *timeoutError) Error() string { return "i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }
func TestResolveIPAddresses_ContextCanceled(t *testing.T) { func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -13,6 +13,16 @@ import (
const testHostname = "www.example.com" const testHostname = "www.example.com"
// Shared fixture values used across tests.
const (
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testAltNS1 = "ns1.test.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
statusError = "error"
)
// populateState fills a State with representative test data across all categories. // populateState fills a State with representative test data across all categories.
func populateState(t *testing.T, s *state.State) { func populateState(t *testing.T, s *state.State) {
t.Helper() t.Helper()
@@ -20,7 +30,7 @@ func populateState(t *testing.T, s *state.State) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
s.SetDomainState("example.com", &state.DomainState{ s.SetDomainState("example.com", &state.DomainState{
Nameservers: []string{"ns1.example.com.", "ns2.example.com."}, Nameservers: []string{testNS1, testNS2},
LastChecked: now, LastChecked: now,
}) })
@@ -31,17 +41,17 @@ func populateState(t *testing.T, s *state.State) {
s.SetHostnameState(testHostname, &state.HostnameState{ s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.example.com.": { testNS1: {
Records: map[string][]string{ Records: map[string][]string{
"A": {"93.184.216.34"}, "A": {testIPv4},
"AAAA": {"2606:2800:220:1:248:1893:25c8:1946"}, "AAAA": {"2606:2800:220:1:248:1893:25c8:1946"},
}, },
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
"ns2.example.com.": { testNS2: {
Records: map[string][]string{ Records: map[string][]string{
"A": {"93.184.216.34"}, "A": {testIPv4},
}, },
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
@@ -152,13 +162,13 @@ func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
func verifyNS1Records(t *testing.T, hn *state.HostnameState) { func verifyNS1Records(t *testing.T, hn *state.HostnameState) {
t.Helper() t.Helper()
ns1, ok := hn.RecordsByNameserver["ns1.example.com."] ns1, ok := hn.RecordsByNameserver[testNS1]
if !ok { if !ok {
t.Fatal("missing nameserver ns1.example.com.") t.Fatal("missing nameserver ns1.example.com.")
} }
aRecords := ns1.Records["A"] aRecords := ns1.Records["A"]
if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" { if len(aRecords) != 1 || aRecords[0] != testIPv4 {
t.Errorf("ns1 A records: got %v", aRecords) t.Errorf("ns1 A records: got %v", aRecords)
} }
@@ -213,7 +223,8 @@ func TestSaveLoadRoundTrip_Ports(t *testing.T) {
} }
} }
// TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle. // TestSaveLoadRoundTrip_Certificates verifies certificate data
// survives a save/load cycle.
func TestSaveLoadRoundTrip_Certificates(t *testing.T) { func TestSaveLoadRoundTrip_Certificates(t *testing.T) {
t.Parallel() t.Parallel()
@@ -653,7 +664,7 @@ func TestDomainState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
ds := &state.DomainState{ ds := &state.DomainState{
Nameservers: []string{"ns1.test.com."}, Nameservers: []string{testAltNS1},
LastChecked: now, LastChecked: now,
} }
@@ -664,7 +675,7 @@ func TestDomainState_GetSet(t *testing.T) {
t.Fatal("expected true for existing domain") t.Fatal("expected true for existing domain")
} }
if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." { if len(got.Nameservers) != 1 || got.Nameservers[0] != testAltNS1 {
t.Errorf("nameservers: got %v", got.Nameservers) t.Errorf("nameservers: got %v", got.Nameservers)
} }
@@ -674,7 +685,7 @@ func TestDomainState_GetSet(t *testing.T) {
// Overwrite. // Overwrite.
ds2 := &state.DomainState{ ds2 := &state.DomainState{
Nameservers: []string{"ns1.test.com.", "ns2.test.com."}, Nameservers: []string{testAltNS1, "ns2.test.com."},
LastChecked: now.Add(time.Hour), LastChecked: now.Add(time.Hour),
} }
@@ -704,8 +715,8 @@ func TestHostnameState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{ hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.example.com.": { testNS1: {
Records: map[string][]string{"A": {"1.2.3.4"}}, Records: map[string][]string{"A": {testIP}},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
@@ -720,7 +731,7 @@ func TestHostnameState_GetSet(t *testing.T) {
t.Fatal("expected true for existing hostname") t.Fatal("expected true for existing hostname")
} }
nsState, ok := got.RecordsByNameserver["ns1.example.com."] nsState, ok := got.RecordsByNameserver[testNS1]
if !ok { if !ok {
t.Fatal("missing nameserver entry") t.Fatal("missing nameserver entry")
} }
@@ -730,7 +741,7 @@ func TestHostnameState_GetSet(t *testing.T) {
} }
aRecords := nsState.Records["A"] aRecords := nsState.Records["A"]
if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" { if len(aRecords) != 1 || aRecords[0] != testIP {
t.Errorf("A records: got %v", aRecords) t.Errorf("A records: got %v", aRecords)
} }
} }
@@ -869,7 +880,7 @@ func TestCertificateState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
cs := &state.CertificateState{ cs := &state.CertificateState{
Status: "error", Status: statusError,
Error: "connection refused", Error: "connection refused",
LastChecked: now, LastChecked: now,
} }
@@ -893,8 +904,8 @@ func TestCertificateState_ErrorField(t *testing.T) {
t.Fatal("missing certificate after load") t.Fatal("missing certificate after load")
} }
if got.Status != "error" { if got.Status != statusError {
t.Errorf("status: got %q, want %q", got.Status, "error") t.Errorf("status: got %q, want %q", got.Status, statusError)
} }
if got.Error != "connection refused" { if got.Error != "connection refused" {
@@ -912,9 +923,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{ hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.example.com.": { testNS1: {
Records: nil, Records: nil,
Status: "error", Status: statusError,
Error: "SERVFAIL", Error: "SERVFAIL",
LastChecked: now, LastChecked: now,
}, },
@@ -941,9 +952,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
t.Fatal("missing hostname after load") t.Fatal("missing hostname after load")
} }
nsState := got.RecordsByNameserver["ns1.example.com."] nsState := got.RecordsByNameserver[testNS1]
if nsState.Status != "error" { if nsState.Status != statusError {
t.Errorf("status: got %q, want %q", nsState.Status, "error") t.Errorf("status: got %q, want %q", nsState.Status, statusError)
} }
if nsState.Error != "SERVFAIL" { if nsState.Error != "SERVFAIL" {
@@ -1062,7 +1073,8 @@ func TestConcurrentGetSet(t *testing.T) {
wg.Wait() wg.Wait()
} }
// runConcurrentOps performs a series of get/set/delete operations for concurrency testing. // runConcurrentOps performs a series of get/set/delete
// operations for concurrency testing.
func runConcurrentOps(s *state.State, key string, now time.Time) { func runConcurrentOps(s *state.State, key string, now time.Time) {
const iterations = 50 const iterations = 50
@@ -1085,7 +1097,7 @@ func runConcurrentOps(s *state.State, key string, now time.Time) {
s.SetHostnameState(key+".example.com", &state.HostnameState{ s.SetHostnameState(key+".example.com", &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.test.": { "ns1.test.": {
Records: map[string][]string{"A": {"1.2.3.4"}}, Records: map[string][]string{"A": {testIP}},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },

View File

@@ -26,6 +26,12 @@ const tlsPort = 443
// hoursPerDay converts days to hours for duration calculations. // hoursPerDay converts days to hours for duration calculations.
const hoursPerDay = 24 const hoursPerDay = 24
// Status values recorded for nameserver and certificate checks.
const (
statusOK = "ok"
statusError = "error"
)
// Params contains dependencies for Watcher. // Params contains dependencies for Watcher.
type Params struct { type Params struct {
fx.In fx.In
@@ -344,7 +350,7 @@ func buildHostnameState(
for ns, recs := range records { for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs, Records: recs,
Status: "ok", Status: statusOK,
LastChecked: now, LastChecked: now,
} }
} }
@@ -402,7 +408,7 @@ func (w *Watcher) detectNSDisappearances(
current map[string]map[string][]string, current map[string]map[string][]string,
) { ) {
for ns, prevNS := range prev.RecordsByNameserver { for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current[ns]; ok || prevNS.Status != "ok" { if _, ok := current[ns]; ok || prevNS.Status != statusOK {
continue continue
} }
@@ -421,7 +427,7 @@ func (w *Watcher) detectNSDisappearances(
for ns := range current { for ns := range current {
prevNS, ok := prev.RecordsByNameserver[ns] prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != "error" { if !ok || prevNS.Status != statusError {
continue continue
} }
@@ -705,7 +711,7 @@ func (w *Watcher) handleTLSError(
now time.Time, now time.Time,
err error, err error,
) { ) {
if hasPrev && !w.firstRun && prev.Status == "ok" { if hasPrev && !w.firstRun && prev.Status == statusOK {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nError: %s", "Host: %s\nIP: %s\nError: %s",
hostname, ip, err, hostname, ip, err,
@@ -721,7 +727,7 @@ func (w *Watcher) handleTLSError(
w.state.SetCertificateState( w.state.SetCertificateState(
certKey, &state.CertificateState{ certKey, &state.CertificateState{
Status: "error", Status: statusError,
Error: err.Error(), Error: err.Error(),
LastChecked: now, LastChecked: now,
}, },
@@ -748,7 +754,7 @@ func (w *Watcher) handleTLSSuccess(
Issuer: cert.Issuer, Issuer: cert.Issuer,
NotAfter: cert.NotAfter, NotAfter: cert.NotAfter,
SubjectAlternativeNames: cert.SubjectAlternativeNames, SubjectAlternativeNames: cert.SubjectAlternativeNames,
Status: "ok", Status: statusOK,
LastChecked: now, LastChecked: now,
}, },
) )
@@ -760,7 +766,7 @@ func (w *Watcher) detectTLSChanges(
prev *state.CertificateState, prev *state.CertificateState,
cert *tlscheck.CertificateInfo, cert *tlscheck.CertificateInfo,
) { ) {
if prev.Status == "error" { if prev.Status == statusError {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nTLS recovered", "Host: %s\nIP: %s\nTLS recovered",
hostname, ip, hostname, ip,

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07 (same pins as the Dockerfile) # Pinned versions, 2026-08-07 (same pins as the Dockerfile)
# golangci-lint v2.10.1 # golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee" GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# goimports v0.42.0 # goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"

View File

@@ -1,13 +1,18 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so # script/cibuild: run the CI build. The Dockerfile runs make check, and
# a successful build implies all checks pass. # the CHECK_EPOCH build argument below is fresh on every invocation, so
# the check layer is never served from the Docker layer cache: a
# successful build means the checks were executed and passed on this
# run, not on some earlier one. Only the check step and the steps after
# it are invalidated; the toolchain install and go mod download stay
# cached.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build . docker build --build-arg CHECK_EPOCH="$(date +%s%N)" .
} }
main "$@" main "$@"