Initial commit
This commit is contained in:
commit
e6eb2e081e
15 changed files with 2058 additions and 0 deletions
223
checker/collect.go
Normal file
223
checker/collect.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Collect runs the delegation probe and returns a *DelegationData populated
|
||||
// with raw facts only. All judgment (severity, option-driven thresholds,
|
||||
// pass/fail) is deferred to the rules in rule.go.
|
||||
//
|
||||
// The collector resolves the parent zone's authoritative servers, asks each
|
||||
// of them for the delegation of the target FQDN, then turns around and
|
||||
// queries every delegated server using ONLY the NS names + glue learned
|
||||
// from the parent. The child zone is never used as a source of truth.
|
||||
func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
svc, err := loadService(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentZone, subdomain := loadNames(opts)
|
||||
if subdomain == "" {
|
||||
return nil, fmt.Errorf("missing 'subdomain' option")
|
||||
}
|
||||
if parentZone == "" {
|
||||
return nil, fmt.Errorf("missing 'domain_name' option")
|
||||
}
|
||||
|
||||
delegatedFQDN := dns.Fqdn(strings.TrimSuffix(subdomain, ".") + "." + strings.TrimSuffix(parentZone, ".") + ".")
|
||||
|
||||
data := &DelegationData{
|
||||
DelegatedFQDN: delegatedFQDN,
|
||||
ParentZone: dns.Fqdn(parentZone),
|
||||
DeclaredNS: normalizeNSList(svc.NameServers),
|
||||
}
|
||||
for _, d := range svc.DS {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
data.DeclaredDS = append(data.DeclaredDS, NewDSRecord(d))
|
||||
}
|
||||
|
||||
// Resolve parent's authoritative servers.
|
||||
_, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone)
|
||||
if err != nil {
|
||||
data.ParentDiscoveryError = err.Error()
|
||||
return data, nil
|
||||
}
|
||||
data.ParentNS = parentServers
|
||||
|
||||
// Phase A: query every parent server. Record raw outcomes only.
|
||||
for _, ps := range parentServers {
|
||||
view := ParentView{Server: ps}
|
||||
|
||||
ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN)
|
||||
if qerr != nil {
|
||||
view.UDPNSError = qerr.Error()
|
||||
} else {
|
||||
view.NS = ns
|
||||
view.Glue = glue
|
||||
}
|
||||
|
||||
if _, _, _, terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil {
|
||||
view.TCPNSError = terr.Error()
|
||||
}
|
||||
|
||||
dsRRs, sigs, dserr := queryDS(ctx, ps, delegatedFQDN)
|
||||
if dserr != nil {
|
||||
view.DSQueryError = dserr.Error()
|
||||
} else {
|
||||
for _, d := range dsRRs {
|
||||
view.DS = append(view.DS, NewDSRecord(d))
|
||||
}
|
||||
for _, sig := range sigs {
|
||||
view.DSRRSIGs = append(view.DSRRSIGs, DSRRSIGObservation{
|
||||
Inception: sig.Inception,
|
||||
Expiration: sig.Expiration,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data.ParentViews = append(data.ParentViews, view)
|
||||
}
|
||||
|
||||
// Pick the first view that actually returned an NS RRset as the
|
||||
// source of truth for Phase B. If none succeeded, skip Phase B; the
|
||||
// rules will flag the absence of child data.
|
||||
var primary *ParentView
|
||||
for i := range data.ParentViews {
|
||||
if data.ParentViews[i].UDPNSError == "" && len(data.ParentViews[i].NS) > 0 {
|
||||
primary = &data.ParentViews[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Phase B: query each child name server using only parent-supplied data.
|
||||
for _, nsName := range primary.NS {
|
||||
child := ChildNSView{NSName: nsName}
|
||||
addrs := primary.Glue[nsName]
|
||||
if len(addrs) == 0 {
|
||||
// Out-of-bailiwick: resolve via the system resolver.
|
||||
resolved, rerr := resolveHost(ctx, nsName)
|
||||
if rerr != nil {
|
||||
child.ResolveError = rerr.Error()
|
||||
data.Children = append(data.Children, child)
|
||||
continue
|
||||
}
|
||||
addrs = resolved
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
srv := hostPort(addr, "53")
|
||||
av := ChildAddressView{Address: addr, Server: srv}
|
||||
|
||||
soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN)
|
||||
if qerr != nil {
|
||||
av.UDPError = qerr.Error()
|
||||
av.Authoritative = aa
|
||||
child.Addresses = append(child.Addresses, av)
|
||||
continue
|
||||
}
|
||||
av.Authoritative = aa
|
||||
if soa != nil {
|
||||
av.SOASerial = soa.Serial
|
||||
av.SOASerialKnown = true
|
||||
}
|
||||
|
||||
if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil {
|
||||
av.TCPError = terr.Error()
|
||||
}
|
||||
|
||||
childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN)
|
||||
if nerr != nil {
|
||||
av.ChildNSError = nerr.Error()
|
||||
} else {
|
||||
av.ChildNS = childNS
|
||||
}
|
||||
|
||||
if isInBailiwick(nsName, delegatedFQDN) {
|
||||
addrsAt, _ := queryAddrsAt(ctx, srv, nsName)
|
||||
av.ChildGlueAddrs = addrsAt
|
||||
}
|
||||
|
||||
// Only bother probing DNSKEY when the parent has at least one
|
||||
// DS to match against. The rule confirms this precondition.
|
||||
parentHasDS := false
|
||||
for _, pv := range data.ParentViews {
|
||||
if len(pv.DS) > 0 {
|
||||
parentHasDS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if parentHasDS {
|
||||
keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN)
|
||||
if kerr != nil {
|
||||
av.DNSKEYError = kerr.Error()
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
av.DNSKEYs = append(av.DNSKEYs, NewDNSKEYRecord(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.Addresses = append(child.Addresses, av)
|
||||
}
|
||||
|
||||
data.Children = append(data.Children, child)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// queryDelegationTCP is the TCP variant of queryDelegation. It is split out
|
||||
// so the per-server observations keep their UDP/TCP roles distinct.
|
||||
func queryDelegationTCP(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
|
||||
msg, err = dnsExchange(ctx, "tcp", parentServer, q, true)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if msg.Rcode != dns.RcodeSuccess {
|
||||
return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// loadService extracts the abstract.Delegation payload from the auto-filled
|
||||
// "service" option. We parse it into our local minimal type so this checker
|
||||
// does not have to import the full happyDomain server module.
|
||||
func loadService(opts sdk.CheckerOptions) (*delegationService, error) {
|
||||
svc, ok := sdk.GetOption[serviceMessage](opts, "service")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing 'service' option")
|
||||
}
|
||||
if svc.Type != "" && svc.Type != "abstract.Delegation" {
|
||||
return nil, fmt.Errorf("service is %s, expected abstract.Delegation", svc.Type)
|
||||
}
|
||||
var d delegationService
|
||||
if err := json.Unmarshal(svc.Service, &d); err != nil {
|
||||
return nil, fmt.Errorf("decoding delegation service: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func loadNames(opts sdk.CheckerOptions) (parentZone, subdomain string) {
|
||||
if v, ok := sdk.GetOption[string](opts, "domain_name"); ok {
|
||||
parentZone = v
|
||||
}
|
||||
if v, ok := sdk.GetOption[string](opts, "subdomain"); ok {
|
||||
subdomain = v
|
||||
}
|
||||
return
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue