Compare commits
3 Commits
584b5f5b39
...
fix/98-sec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e97a4e523f | ||
| 9347a2838b | |||
| f79cd98107 |
@@ -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
|
||||||
|
|||||||
63
README.md
63
README.md
@@ -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)
|
||||||
@@ -162,6 +182,46 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
|||||||
| `GET /api/v1/status` | Current monitoring state |
|
| `GET /api/v1/status` | Current monitoring state |
|
||||||
| `GET /metrics` | Prometheus metrics (optional) |
|
| `GET /metrics` | Prometheus metrics (optional) |
|
||||||
|
|
||||||
|
### Security Headers
|
||||||
|
|
||||||
|
Every response — the dashboard, the static assets under `/s/...`, the
|
||||||
|
healthchecks, the JSON API, and `/metrics` — carries the following
|
||||||
|
headers, set by a global middleware:
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|-----------------------------|---------------------------------------|
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
|
||||||
|
| `Content-Security-Policy` | see below |
|
||||||
|
| `X-Frame-Options` | `DENY` |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` |
|
||||||
|
| `Referrer-Policy` | `no-referrer` |
|
||||||
|
| `Permissions-Policy` | all unused browser features denied |
|
||||||
|
|
||||||
|
The content security policy is:
|
||||||
|
|
||||||
|
```
|
||||||
|
default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self';
|
||||||
|
font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none';
|
||||||
|
form-action 'none'; frame-ancestors 'none'
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard ships no JavaScript (the 30-second refresh is a
|
||||||
|
`<meta http-equiv="refresh">`), no inline styles, no inline event
|
||||||
|
handlers, and no images; its only subresource is the embedded stylesheet
|
||||||
|
at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The
|
||||||
|
policy therefore needs neither `unsafe-inline` nor `unsafe-eval`.
|
||||||
|
`frame-ancestors 'none'` is the primary anti-framing control, with
|
||||||
|
`X-Frame-Options: DENY` retained as the legacy fallback.
|
||||||
|
|
||||||
|
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
|
||||||
|
expected to run behind a TLS-terminating reverse proxy, and the browser
|
||||||
|
must still be told to enforce HTTPS end to end, so the header is never
|
||||||
|
gated on whether the request itself arrived over TLS.
|
||||||
|
|
||||||
|
`Referrer-Policy: no-referrer` is stricter than the
|
||||||
|
`strict-origin-when-cross-origin` baseline: the dashboard has no
|
||||||
|
cross-origin navigation needs, and its URL may name internal hosts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -174,7 +234,8 @@ internal/
|
|||||||
globals/globals.go Build-time variables (version)
|
globals/globals.go Build-time variables (version)
|
||||||
logger/logger.go slog structured logging (TTY detection)
|
logger/logger.go slog structured logging (TTY detection)
|
||||||
healthcheck/healthcheck.go Health check service
|
healthcheck/healthcheck.go Health check service
|
||||||
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
|
middleware/middleware.go HTTP middleware (logging, CORS, security
|
||||||
|
headers, metrics auth)
|
||||||
handlers/handlers.go HTTP request handlers
|
handlers/handlers.go HTTP request handlers
|
||||||
server/
|
server/
|
||||||
server.go HTTP server lifecycle
|
server.go HTTP server lifecycle
|
||||||
|
|||||||
19
TODO.md
19
TODO.md
@@ -25,9 +25,24 @@ confirm make check still passes.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-09: security response headers middleware
|
||||||
|
(`SecurityHeaders()` in `internal/middleware/middleware.go`)
|
||||||
|
registered globally in `internal/server/routes.go`, so HSTS, CSP,
|
||||||
|
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and
|
||||||
|
`Permissions-Policy` are set on every response including `/s/...` and
|
||||||
|
`/metrics`; the CSP needs no `unsafe-inline`/`unsafe-eval` because the
|
||||||
|
dashboard ships no JavaScript and no inline styles; HSTS is emitted
|
||||||
|
unconditionally per policy (TLS-terminating proxy in front). Remaining
|
||||||
|
1.0 hardening items — `http.Server` timeouts, request body limits,
|
||||||
|
rate limiting, CORS scoping — are tracked separately
|
||||||
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
||||||
in `Dockerfile` and `script/bootstrap`); fixed the resulting
|
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
|
||||||
`goconst` findings. `.golangci.yml` unchanged (canonical)
|
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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -21,6 +21,60 @@ import (
|
|||||||
// corsMaxAge is the maximum age for CORS preflight responses.
|
// corsMaxAge is the maximum age for CORS preflight responses.
|
||||||
const corsMaxAge = 300
|
const corsMaxAge = 300
|
||||||
|
|
||||||
|
// Security response header values applied to every response.
|
||||||
|
//
|
||||||
|
// The CSP is as strict as the dashboard allows: the template ships no
|
||||||
|
// JavaScript, no inline styles, no inline event handlers and no images,
|
||||||
|
// and its only subresource is the embedded stylesheet at
|
||||||
|
// /s/css/tailwind.min.css, which style-src 'self' permits. Neither
|
||||||
|
// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the
|
||||||
|
// primary anti-framing control; X-Frame-Options is the legacy fallback.
|
||||||
|
const (
|
||||||
|
// hstsValue is emitted unconditionally, including over plain HTTP,
|
||||||
|
// because the service runs behind a TLS-terminating proxy and the
|
||||||
|
// browser must still enforce HTTPS end to end.
|
||||||
|
hstsValue = "max-age=31536000; includeSubDomains"
|
||||||
|
|
||||||
|
cspValue = "default-src 'self'; " +
|
||||||
|
"script-src 'none'; " +
|
||||||
|
"style-src 'self'; " +
|
||||||
|
"img-src 'self'; " +
|
||||||
|
"font-src 'none'; " +
|
||||||
|
"connect-src 'none'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"base-uri 'none'; " +
|
||||||
|
"form-action 'none'; " +
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
|
||||||
|
frameOptionsValue = "DENY"
|
||||||
|
|
||||||
|
contentTypeOptionsValue = "nosniff"
|
||||||
|
|
||||||
|
// referrerPolicyValue is stricter than the policy minimum of
|
||||||
|
// strict-origin-when-cross-origin: the dashboard has no
|
||||||
|
// cross-origin navigation needs and its URL may name internal
|
||||||
|
// hosts.
|
||||||
|
referrerPolicyValue = "no-referrer"
|
||||||
|
|
||||||
|
permissionsPolicyValue = "accelerometer=(), " +
|
||||||
|
"autoplay=(), " +
|
||||||
|
"camera=(), " +
|
||||||
|
"display-capture=(), " +
|
||||||
|
"encrypted-media=(), " +
|
||||||
|
"fullscreen=(), " +
|
||||||
|
"geolocation=(), " +
|
||||||
|
"gyroscope=(), " +
|
||||||
|
"magnetometer=(), " +
|
||||||
|
"microphone=(), " +
|
||||||
|
"midi=(), " +
|
||||||
|
"payment=(), " +
|
||||||
|
"picture-in-picture=(), " +
|
||||||
|
"publickey-credentials-get=(), " +
|
||||||
|
"screen-wake-lock=(), " +
|
||||||
|
"usb=(), " +
|
||||||
|
"xr-spatial-tracking=()"
|
||||||
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Middleware.
|
// Params contains dependencies for Middleware.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -186,6 +240,37 @@ func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecurityHeaders returns middleware that sets the security response
|
||||||
|
// headers required for production internet exposure on every response.
|
||||||
|
//
|
||||||
|
// The headers are set before the request reaches the next handler so
|
||||||
|
// that they are present on every response, including panics recovered
|
||||||
|
// by chi's Recoverer and timeouts produced by chi's Timeout.
|
||||||
|
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
) {
|
||||||
|
header := writer.Header()
|
||||||
|
header.Set("Strict-Transport-Security", hstsValue)
|
||||||
|
header.Set("Content-Security-Policy", cspValue)
|
||||||
|
header.Set("X-Frame-Options", frameOptionsValue)
|
||||||
|
header.Set(
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
contentTypeOptionsValue,
|
||||||
|
)
|
||||||
|
header.Set("Referrer-Policy", referrerPolicyValue)
|
||||||
|
header.Set(
|
||||||
|
"Permissions-Policy",
|
||||||
|
permissionsPolicyValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
next.ServeHTTP(writer, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MetricsAuth returns basic auth middleware for /metrics.
|
// MetricsAuth returns basic auth middleware for /metrics.
|
||||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
if m.params.Config.MetricsUsername == "" {
|
if m.params.Config.MetricsUsername == "" {
|
||||||
|
|||||||
333
internal/middleware/middleware_test.go
Normal file
333
internal/middleware/middleware_test.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/middleware"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expected security header values, spelled out literally so that any
|
||||||
|
// change to the middleware has to be made deliberately here as well.
|
||||||
|
const (
|
||||||
|
wantHSTS = "max-age=31536000; includeSubDomains"
|
||||||
|
|
||||||
|
wantCSP = "default-src 'self'; " +
|
||||||
|
"script-src 'none'; " +
|
||||||
|
"style-src 'self'; " +
|
||||||
|
"img-src 'self'; " +
|
||||||
|
"font-src 'none'; " +
|
||||||
|
"connect-src 'none'; " +
|
||||||
|
"object-src 'none'; " +
|
||||||
|
"base-uri 'none'; " +
|
||||||
|
"form-action 'none'; " +
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
|
||||||
|
wantFrameOptions = "DENY"
|
||||||
|
|
||||||
|
wantContentTypeOptions = "nosniff"
|
||||||
|
|
||||||
|
wantReferrerPolicy = "no-referrer"
|
||||||
|
|
||||||
|
wantPermissionsPolicy = "accelerometer=(), " +
|
||||||
|
"autoplay=(), " +
|
||||||
|
"camera=(), " +
|
||||||
|
"display-capture=(), " +
|
||||||
|
"encrypted-media=(), " +
|
||||||
|
"fullscreen=(), " +
|
||||||
|
"geolocation=(), " +
|
||||||
|
"gyroscope=(), " +
|
||||||
|
"magnetometer=(), " +
|
||||||
|
"microphone=(), " +
|
||||||
|
"midi=(), " +
|
||||||
|
"payment=(), " +
|
||||||
|
"picture-in-picture=(), " +
|
||||||
|
"publickey-credentials-get=(), " +
|
||||||
|
"screen-wake-lock=(), " +
|
||||||
|
"usb=(), " +
|
||||||
|
"xr-spatial-tracking=()"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stylesheetPath is the only subresource the dashboard loads.
|
||||||
|
const stylesheetPath = "/s/css/tailwind.min.css"
|
||||||
|
|
||||||
|
// newTestLogger builds a logger for direct component construction.
|
||||||
|
func newTestLogger(t *testing.T) *logger.Logger {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
glob, err := globals.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("globals.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log, err := logger.New(nil, logger.Params{Globals: glob})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestMiddleware builds a Middleware without an fx application.
|
||||||
|
func newTestMiddleware(t *testing.T) *middleware.Middleware {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
glob, err := globals.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("globals.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mw, err := middleware.New(nil, middleware.Params{
|
||||||
|
Logger: newTestLogger(t),
|
||||||
|
Globals: glob,
|
||||||
|
Config: &config.Config{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("middleware.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mw
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWithSecurityHeaders runs a GET through SecurityHeaders and
|
||||||
|
// returns the recorded response.
|
||||||
|
func serveWithSecurityHeaders(
|
||||||
|
t *testing.T,
|
||||||
|
target string,
|
||||||
|
handler http.Handler,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mw := newTestMiddleware(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, target, nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
mw.SecurityHeaders()(handler).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// okHandler writes a trivial 200 response.
|
||||||
|
func okHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
) {
|
||||||
|
writer.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"hsts",
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
wantHSTS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"csp",
|
||||||
|
"Content-Security-Policy",
|
||||||
|
wantCSP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"frame options",
|
||||||
|
"X-Frame-Options",
|
||||||
|
wantFrameOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content type options",
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
wantContentTypeOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"referrer policy",
|
||||||
|
"Referrer-Policy",
|
||||||
|
wantReferrerPolicy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"permissions policy",
|
||||||
|
"Permissions-Policy",
|
||||||
|
wantPermissionsPolicy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
||||||
|
|
||||||
|
got := rec.Header().Get(tt.header)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf(
|
||||||
|
"%s = %q, want %q",
|
||||||
|
tt.header, got, tt.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeadersCSPDirectives guards the properties the repo
|
||||||
|
// policy requires of the content security policy itself.
|
||||||
|
func TestSecurityHeadersCSPDirectives(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
||||||
|
csp := rec.Header().Get("Content-Security-Policy")
|
||||||
|
|
||||||
|
forbidden := []string{"unsafe-inline", "unsafe-eval"}
|
||||||
|
for _, directive := range forbidden {
|
||||||
|
if strings.Contains(csp, directive) {
|
||||||
|
t.Errorf("CSP must not contain %q: %q", directive, csp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required := []string{
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'none'",
|
||||||
|
"style-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
}
|
||||||
|
for _, directive := range required {
|
||||||
|
if !strings.Contains(csp, directive) {
|
||||||
|
t.Errorf("CSP must contain %q: %q", directive, csp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeadersOnErrorResponse verifies the headers are emitted
|
||||||
|
// even when the wrapped handler fails, since they are set before the
|
||||||
|
// handler runs.
|
||||||
|
func TestSecurityHeadersOnErrorResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
failing := http.HandlerFunc(func(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
_ *http.Request,
|
||||||
|
) {
|
||||||
|
http.Error(
|
||||||
|
writer,
|
||||||
|
"boom",
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := serveWithSecurityHeaders(t, "/api/v1/status", failing)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want 500", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rec.Header().Get(
|
||||||
|
"X-Content-Type-Options",
|
||||||
|
); got != wantContentTypeOptions {
|
||||||
|
t.Errorf(
|
||||||
|
"X-Content-Type-Options = %q, want %q",
|
||||||
|
got, wantContentTypeOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := rec.Header().Get(
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
); got != wantHSTS {
|
||||||
|
t.Errorf(
|
||||||
|
"Strict-Transport-Security = %q, want %q",
|
||||||
|
got, wantHSTS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestHandlers builds real Handlers with empty monitoring state.
|
||||||
|
func newTestHandlers(t *testing.T) *handlers.Handlers {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
glob, err := globals.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("globals.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := newTestLogger(t)
|
||||||
|
|
||||||
|
notifier, err := notify.New(nil, notify.Params{
|
||||||
|
Logger: log,
|
||||||
|
Config: &config.Config{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("notify.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hnd, err := handlers.New(nil, handlers.Params{
|
||||||
|
Logger: log,
|
||||||
|
Globals: glob,
|
||||||
|
State: state.NewForTest(),
|
||||||
|
Notify: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handlers.New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDashboardRendersWithSecurityHeaders renders the real dashboard
|
||||||
|
// through the middleware and checks that the policy still permits the
|
||||||
|
// one stylesheet the page loads.
|
||||||
|
func TestDashboardRendersWithSecurityHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newTestMiddleware(t)
|
||||||
|
hnd := newTestHandlers(t)
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(mw.SecurityHeaders())
|
||||||
|
router.Get("/", hnd.HandleDashboard())
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, stylesheetPath) {
|
||||||
|
t.Errorf("dashboard does not reference %q", stylesheetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, "dnswatcher") {
|
||||||
|
t.Errorf("dashboard body looks empty: %d bytes", len(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
csp := rec.Header().Get("Content-Security-Policy")
|
||||||
|
if csp != wantCSP {
|
||||||
|
t.Errorf("CSP = %q, want %q", csp, wantCSP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stylesheet is same-origin, so style-src 'self' allows it.
|
||||||
|
if !strings.Contains(csp, "style-src 'self'") {
|
||||||
|
t.Errorf("CSP would block %q: %q", stylesheetPath, csp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,18 +25,20 @@ const (
|
|||||||
colorDefault = "#6c757d"
|
colorDefault = "#6c757d"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Priority and fixture values shared across tests.
|
// Priority strings used across multiple tests.
|
||||||
const (
|
const (
|
||||||
prioError = "error"
|
prioError = "error"
|
||||||
prioWarning = "warning"
|
prioWarning = "warning"
|
||||||
prioInfo = "info"
|
|
||||||
prioSuccess = "success"
|
prioSuccess = "success"
|
||||||
|
prioInfo = "info"
|
||||||
prioUnknown = "unknown"
|
prioUnknown = "unknown"
|
||||||
ntfyUrgent = "urgent"
|
prioDefault = "default"
|
||||||
ntfyDefault = "default"
|
prioUrgent = "urgent"
|
||||||
testHost = "example.com"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
@@ -113,13 +115,13 @@ func TestNtfyPriority(t *testing.T) {
|
|||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{prioError, ntfyUrgent},
|
{prioError, prioUrgent},
|
||||||
{prioWarning, "high"},
|
{prioWarning, "high"},
|
||||||
{prioSuccess, ntfyDefault},
|
{prioSuccess, prioDefault},
|
||||||
{prioInfo, "low"},
|
{prioInfo, "low"},
|
||||||
{"", ntfyDefault},
|
{"", prioDefault},
|
||||||
{prioUnknown, ntfyDefault},
|
{prioUnknown, prioDefault},
|
||||||
{"critical", ntfyDefault},
|
{"critical", prioDefault},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -301,10 +303,10 @@ func TestSendNtfyHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if captured.priority != ntfyUrgent {
|
if captured.priority != prioUrgent {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"Priority header = %q, want %q",
|
"Priority header = %q, want %q",
|
||||||
captured.priority, ntfyUrgent,
|
captured.priority, prioUrgent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,9 +325,9 @@ func TestSendNtfyAllPriorities(t *testing.T) {
|
|||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{prioError, ntfyUrgent},
|
{prioError, prioUrgent},
|
||||||
{prioWarning, "high"},
|
{prioWarning, "high"},
|
||||||
{prioSuccess, ntfyDefault},
|
{prioSuccess, prioDefault},
|
||||||
{prioInfo, "low"},
|
{prioInfo, "low"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,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) {
|
||||||
@@ -618,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) {
|
||||||
@@ -989,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 ──────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Global middleware
|
// Global middleware
|
||||||
s.router.Use(chimw.Recoverer)
|
s.router.Use(chimw.Recoverer)
|
||||||
s.router.Use(chimw.RequestID)
|
s.router.Use(chimw.RequestID)
|
||||||
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(chimw.Timeout(requestTimeout))
|
s.router.Use(chimw.Timeout(requestTimeout))
|
||||||
|
|||||||
@@ -223,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()
|
||||||
|
|
||||||
@@ -1072,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
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ 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
|
||||||
|
|
||||||
// statusError is the status value recorded for failed checks.
|
// Status values recorded for nameserver and certificate checks.
|
||||||
const statusError = "error"
|
const (
|
||||||
|
statusOK = "ok"
|
||||||
|
statusError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
// Params contains dependencies for Watcher.
|
// Params contains dependencies for Watcher.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
@@ -347,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,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,
|
||||||
@@ -751,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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user