0.25.0 — DNS resolution check kind (KindDNS) + VPS monitoring fix
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.
This commit is contained in:
@@ -33,6 +33,7 @@ const (
|
|||||||
KindBackup = "backup-freshness"
|
KindBackup = "backup-freshness"
|
||||||
KindCertExpiry = "cert-expiry"
|
KindCertExpiry = "cert-expiry"
|
||||||
KindVMStatus = "vm-status"
|
KindVMStatus = "vm-status"
|
||||||
|
KindDNS = "dns"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBackupMaxAge is how long a backup target may go without a new
|
// 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,
|
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:
|
case KindCertExpiry:
|
||||||
// The host whose cert to read (SNI / cert CN). Prefer an explicit
|
// The host whose cert to read (SNI / cert CN). Prefer an explicit
|
||||||
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry
|
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ func executeCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledChec
|
|||||||
return checkSSHScript(ctx, pool, cd)
|
return checkSSHScript(ctx, pool, cd)
|
||||||
case "backup-freshness":
|
case "backup-freshness":
|
||||||
return checkBackupFreshness(ctx, cd)
|
return checkBackupFreshness(ctx, cd)
|
||||||
|
case "dns":
|
||||||
|
return checkDNS(ctx, cd)
|
||||||
default:
|
default:
|
||||||
return checkResult{health: "unknown"}
|
return checkResult{health: "unknown"}
|
||||||
}
|
}
|
||||||
@@ -484,6 +486,53 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResu
|
|||||||
return checkResult{health: "healthy"}
|
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.
|
// checkDisk performs a disk usage check.
|
||||||
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
cfg := struct {
|
cfg := struct {
|
||||||
|
|||||||
@@ -381,11 +381,13 @@ entity_types:
|
|||||||
layer: infrastructure
|
layer: infrastructure
|
||||||
lifecycle: infrastructure
|
lifecycle: infrastructure
|
||||||
description: DNS zone (e.g. split-horizon hubris.network).
|
description: DNS zone (e.g. split-horizon hubris.network).
|
||||||
monitoring: none # no `dns` checker exists yet; declaring [dns]
|
monitoring: [dns] # resolves the zone's apex via the configured
|
||||||
# made every zone an unresolvable `unmonitored`
|
# resolver, verifying the zone is authoritatively
|
||||||
# signal. Flip back to [dns] when a checker lands.
|
# reachable. (2026-08-05: was `none` — the DNS layer
|
||||||
# Requires ontology re-ingest to take effect;
|
# had zero checks, so a stale record like
|
||||||
# coverageSweep then auto-clears the stale signals.
|
# 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}}}
|
attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}}
|
||||||
dns-record:
|
dns-record:
|
||||||
parent: entity
|
parent: entity
|
||||||
|
|||||||
Reference in New Issue
Block a user