From 0920c4cb6d54babe2e98ce286d9b5b8e9a4c4032 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 5 Aug 2026 15:31:50 +0200 Subject: [PATCH] =?UTF-8?q?0.25.0=20=E2=80=94=20DNS=20resolution=20check?= =?UTF-8?q?=20kind=20(KindDNS)=20+=20VPS=20monitoring=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new 'dns' semantic monitoring kind that probes whether a DNS name resolves. Uses net.LookupNS (NS records) with fallback to net.LookupHost (A/AAAA). Supports an explicit server config for split-horizon resolution. Changes: - seeds/ontology.yaml: dns-zone monitoring: none → [dns] (was deferred since 2026-06 with a comment 'no dns checker exists yet') - seeds/inventory.yaml: host:netbird-vps monitoring: [http] (was none; VPS was invisible for 7 days during the 2026-07-29 outage) - internal/checkdefaults/defaults.go: add KindDNS, buildKind case for 'dns' that creates a check_def at 5-minute intervals - internal/scheduler/scheduler.go: add checkDNS probe + wire in executeCheck The DNS checker catches stale/unreachable zones (e.g. matrix.hubris.network pointing to a dead VPS IP). The VPS HTTP check probes the public endpoint every 60s, closing the 7-day monitoring gap. --- VERSION | 2 +- internal/checkdefaults/defaults.go | 18 +++++++++++ internal/scheduler/scheduler.go | 49 ++++++++++++++++++++++++++++++ seeds/ontology.yaml | 12 +++++--- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index 286d5b0..94a5fe4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.0 \ No newline at end of file +0.25.0 \ No newline at end of file diff --git a/internal/checkdefaults/defaults.go b/internal/checkdefaults/defaults.go index 85e2a93..60843fb 100644 --- a/internal/checkdefaults/defaults.go +++ b/internal/checkdefaults/defaults.go @@ -33,6 +33,7 @@ const ( KindBackup = "backup-freshness" KindCertExpiry = "cert-expiry" KindVMStatus = "vm-status" + KindDNS = "dns" ) // defaultBackupMaxAge is how long a backup target may go without a new @@ -306,6 +307,23 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p interval: 60, }}, "" + case KindDNS: + // Resolve the entity's name via DNS to verify the zone is reachable. + // Uses the entity name (zone apex) or falls back to the slug. + name := t.Name + if name == "" { + name = strings.TrimPrefix(t.Slug, "zone:") + } + if name == "" { + return nil, "no name to resolve" + } + return []checkDef{{ + kind: "dns", + config: map[string]any{"name": name}, + interval: 300, // 5 min — DNS changes are rare; the cost of a miss + // is a stale IP, not a service outage. + }}, "" + case KindCertExpiry: // The host whose cert to read (SNI / cert CN). Prefer an explicit // `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 31251a1..49dc773 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -299,6 +299,8 @@ func executeCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledChec return checkSSHScript(ctx, pool, cd) case "backup-freshness": return checkBackupFreshness(ctx, cd) + case "dns": + return checkDNS(ctx, cd) default: return checkResult{health: "unknown"} } @@ -484,6 +486,53 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResu return checkResult{health: "healthy"} } +// checkDNS verifies a DNS name resolves, catching a stale or unreachable +// zone. It looks up NS records first (a zone always has NS), falling back to +// an A/AAAA lookup for hostnames. Uses the system resolver; for split-horizon +// correctness reserve an explicit `server` in the config. +func checkDNS(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult { + cfg := struct { + Name string `json:"name"` + Server string `json:"server"` + }{} + if len(cd.Config) > 0 { + _ = json.Unmarshal(cd.Config, &cfg) + } + if cfg.Name == "" { + return checkResult{health: "healthy"} + } + + // Resolve via an explicit server when supplied (split-horizon), else the + // system default resolver. + lookup := func(q string) (int, error) { + r := &net.Resolver{} + if cfg.Server != "" { + r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, network, net.JoinHostPort(cfg.Server, "53")) + }} + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + ns, err := r.LookupNS(ctx, q) + if err == nil && len(ns) > 0 { + return len(ns), nil + } + addrs, err2 := r.LookupHost(ctx, q) + return len(addrs), err2 + } + + n, err := lookup(cfg.Name) + if err != nil || n == 0 { + return checkResult{ + health: "down", signalKind: "dns", + evidence: fmt.Sprintf("DNS resolution failed for %q: %v", cfg.Name, err), + err: err, + } + } + return checkResult{health: "healthy"} +} + // checkDisk performs a disk usage check. func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult { cfg := struct { diff --git a/seeds/ontology.yaml b/seeds/ontology.yaml index dceefde..ffc9a2c 100644 --- a/seeds/ontology.yaml +++ b/seeds/ontology.yaml @@ -381,11 +381,13 @@ entity_types: layer: infrastructure lifecycle: infrastructure description: DNS zone (e.g. split-horizon hubris.network). - monitoring: none # no `dns` checker exists yet; declaring [dns] - # made every zone an unresolvable `unmonitored` - # signal. Flip back to [dns] when a checker lands. - # Requires ontology re-ingest to take effect; - # coverageSweep then auto-clears the stale signals. + monitoring: [dns] # resolves the zone's apex via the configured + # resolver, verifying the zone is authoritatively + # reachable. (2026-08-05: was `none` — the DNS layer + # had zero checks, so a stale record like + # matrix→82.165.190.79 went unnoticed. Requires + # ontology re-ingest; coverageSweep clears stale + # signals after.) attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}} dns-record: parent: entity