dashboard and status API show why a check failed (closes #225) #235
@@ -230,9 +230,11 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
|
||||
|
||||
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
|
||||
- **Domains** with their discovered nameservers.
|
||||
- **Hostnames** with per-nameserver DNS records and status.
|
||||
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
||||
whose query failed, the reason is shown in place of the records.
|
||||
- **Ports** with open/closed state and associated hostnames.
|
||||
- **TLS certificates** with CN, issuer, expiry, and status.
|
||||
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
|
||||
the reason is shown in place of CN, issuer and expiry.
|
||||
- **Recent alerts** (last 100 notifications sent since the process started),
|
||||
displayed in reverse chronological order.
|
||||
|
||||
@@ -259,6 +261,10 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||
| `GET /api/v1/status` | Current monitoring state |
|
||||
| `GET /metrics` | Prometheus metrics, see below |
|
||||
|
||||
In `/api/v1/status`, each nameserver entry and certificate entry whose `status`
|
||||
is `error` also has `error`, the reason, as in the state file (see State File
|
||||
Format).
|
||||
|
||||
`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind
|
||||
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
|
||||
process, and counts of `/metrics` requests); dnswatcher records no metrics of
|
||||
|
||||
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or
|
||||
a certificate check failed, which only the state file showed (closes #225).
|
||||
- 2026-10-02: a name's CNAME is stored once per nameserver, not once per record
|
||||
type asked for; a state file with repeats loads each value once (closes #220).
|
||||
- 2026-10-02: a DNS lookup that shutdown cuts short logs no error; one that
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -78,3 +79,39 @@ func TestFormatRecords(t *testing.T) {
|
||||
t.Errorf("unexpected format: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardRow returns the table row of page that contains name.
|
||||
func dashboardRow(t *testing.T, page string, name string) string {
|
||||
t.Helper()
|
||||
|
||||
for row := range strings.SplitSeq(page, "<tr") {
|
||||
if strings.Contains(row, name) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("dashboard has no row containing %q", name)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestDashboardShowsFailureReasons checks that the dashboard shows the
|
||||
// reason in the row of a failed nameserver and of a failed certificate,
|
||||
// and not in the row of a nameserver that answered.
|
||||
func TestDashboardShowsFailureReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
page := get(t, newHandlersWithFailures(t).HandleDashboard())
|
||||
|
||||
if !strings.Contains(dashboardRow(t, page, failedNS), nsFailureReason) {
|
||||
t.Errorf("row of %s does not show %q", failedNS, nsFailureReason)
|
||||
}
|
||||
|
||||
if strings.Contains(dashboardRow(t, page, answeringNS), nsFailureReason) {
|
||||
t.Errorf("row of %s shows %q", answeringNS, nsFailureReason)
|
||||
}
|
||||
|
||||
if !strings.Contains(dashboardRow(t, page, certKey), certFailedReason) {
|
||||
t.Errorf("row of %s does not show %q", certKey, certFailedReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type statusDomainInfo struct {
|
||||
type statusHostnameNSInfo struct {
|
||||
Records map[string][]string `json:"records"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ type statusCertificateInfo struct {
|
||||
NotAfter time.Time `json:"notAfter"`
|
||||
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
@@ -144,6 +146,7 @@ func buildHostnames(
|
||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
||||
Records: recs,
|
||||
Status: nsState.Status,
|
||||
Error: nsState.Error,
|
||||
LastChecked: nsState.LastChecked,
|
||||
}
|
||||
}
|
||||
@@ -183,6 +186,7 @@ func buildCertificates(
|
||||
NotAfter: cs.NotAfter,
|
||||
SubjectAlternativeNames: sans,
|
||||
Status: cs.Status,
|
||||
Error: cs.Error,
|
||||
LastChecked: cs.LastChecked,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"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/notify"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// The state the handler tests serve: www.example.com has one nameserver
|
||||
// that answered and one whose query failed, and its certificate check
|
||||
// failed.
|
||||
const (
|
||||
testHostname = "www.example.com"
|
||||
answeringNS = "ns1.example.com."
|
||||
failedNS = "ns2.example.com."
|
||||
nsFailureReason = "server returned a referral"
|
||||
certKey = "192.0.2.1:443:www.example.com"
|
||||
certFailedReason = "x509: certificate has expired or is not yet valid"
|
||||
)
|
||||
|
||||
// newHandlersWithFailures builds real Handlers whose state holds the
|
||||
// entries described above.
|
||||
func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
|
||||
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)
|
||||
}
|
||||
|
||||
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("notify.New: %v", err)
|
||||
}
|
||||
|
||||
st, err := state.New(fxtest.NewLifecycle(t), state.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{DataDir: t.TempDir()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("state.New: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
st.SetHostnameState(testHostname, &state.HostnameState{
|
||||
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||
answeringNS: {
|
||||
Records: map[string][]string{"A": {"192.0.2.1"}},
|
||||
Status: "ok",
|
||||
LastChecked: now,
|
||||
},
|
||||
failedNS: {
|
||||
Records: map[string][]string{},
|
||||
Status: "error",
|
||||
Error: nsFailureReason,
|
||||
LastChecked: now,
|
||||
},
|
||||
},
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
st.SetCertificateState(certKey, &state.CertificateState{
|
||||
Status: "error",
|
||||
Error: certFailedReason,
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
hnd, err := handlers.New(nil, handlers.Params{
|
||||
Logger: log,
|
||||
Globals: glob,
|
||||
State: st,
|
||||
Notify: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handlers.New: %v", err)
|
||||
}
|
||||
|
||||
return hnd
|
||||
}
|
||||
|
||||
// get serves one GET request to handler and returns the response body.
|
||||
func get(t *testing.T, handler http.HandlerFunc) string {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// TestStatusGivesFailureReasons checks that /api/v1/status gives the
|
||||
// reason for a failed nameserver entry and a failed certificate entry,
|
||||
// and no error for a nameserver that answered.
|
||||
func TestStatusGivesFailureReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := get(t, newHandlersWithFailures(t).HandleStatus())
|
||||
|
||||
var resp struct {
|
||||
Hostnames map[string]struct {
|
||||
Nameservers map[string]map[string]any `json:"nameservers"`
|
||||
} `json:"hostnames"`
|
||||
Certificates map[string]map[string]any `json:"certificates"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
|
||||
nameservers := resp.Hostnames[testHostname].Nameservers
|
||||
|
||||
got := nameservers[failedNS]["error"]
|
||||
if got != nsFailureReason {
|
||||
t.Errorf("failed nameserver error = %v, want %q",
|
||||
got, nsFailureReason)
|
||||
}
|
||||
|
||||
_, has := nameservers[answeringNS]["error"]
|
||||
if has {
|
||||
t.Errorf("answering nameserver has an error field: %v",
|
||||
nameservers[answeringNS])
|
||||
}
|
||||
|
||||
got = resp.Certificates[certKey]["error"]
|
||||
if got != certFailedReason {
|
||||
t.Errorf("failed certificate error = %v, want %q",
|
||||
got, certFailedReason)
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,11 @@
|
||||
<td
|
||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||
>
|
||||
{{ if $nsr.Error }}
|
||||
<span class="text-red-400">{{ $nsr.Error }}</span>
|
||||
{{ else }}
|
||||
{{ formatRecords $nsr.Records }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $nsr.LastChecked }}
|
||||
@@ -258,6 +262,11 @@
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
{{ if $cs.Error }}
|
||||
<td colspan="3" class="py-2 px-3 text-red-400 break-all">
|
||||
<div class="max-w-xs">{{ $cs.Error }}</div>
|
||||
</td>
|
||||
{{ else }}
|
||||
<td class="py-2 px-3 text-slate-200">
|
||||
{{ $cs.CommonName }}
|
||||
</td>
|
||||
@@ -285,6 +294,7 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
{{ end }}
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $cs.LastChecked }}
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user