diff --git a/checker/collect.go b/checker/collect.go index 361bec2..2f8f24e 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -2,6 +2,8 @@ package checker import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "net" @@ -95,6 +97,28 @@ func (p *ptrProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any return data, nil } +// ShareKey implements sdk.ObservationSharer. The reverse-DNS observation (zone +// location, authoritative NS, the PTR RRset, and the forward-confirm of the +// effective target) is determined entirely by the reverse-arpa owner name — i.e. +// the IP being asked about — never by which forward domain triggered the check. +// Two targets that interrogate the same reverse name produce identical data, so +// the host can run the PTR + FCrDNS lookups once and serve the rest. +// +// The declared target and TTL are folded in because they are part of the +// observation and can change it: when no PTR is published the effective target +// (and therefore its forward-confirm) falls back to the declared value. This +// stays a pure function of opts (no network) per the contract. An empty owner +// returns "" so the host falls back to the default per-target caching. +func (p *ptrProvider) ShareKey(opts sdk.CheckerOptions) (string, error) { + owner, declaredTarget, declaredTTL, err := resolvePTRInputs(opts) + if err != nil || owner == "" { + return "", nil + } + + h := sha256.Sum256(fmt.Appendf(nil, "%s|%s|%d", lowerFQDN(owner), declaredTarget, declaredTTL)) + return "ptr:" + hex.EncodeToString(h[:8]), nil +} + // resolvePTRInputs extracts the PTR owner, declared target and TTL from the // auto-filled options. func resolvePTRInputs(opts sdk.CheckerOptions) (owner, target string, ttl uint32, err error) { diff --git a/checker/collect_test.go b/checker/collect_test.go index 5698b52..1e51e3c 100644 --- a/checker/collect_test.go +++ b/checker/collect_test.go @@ -3,6 +3,7 @@ package checker import ( "encoding/json" "net" + "strings" "testing" sdk "git.happydns.org/checker-sdk-go/checker" @@ -158,3 +159,49 @@ func TestResolvePTRInputs_WrongServiceType(t *testing.T) { t.Fatal("expected error for non-PTR service type") } } + +func TestShareKey_StableAndPrefixed(t *testing.T) { + p := &ptrProvider{} + opts := sdk.CheckerOptions{"domain_name": "4.3.2.1.in-addr.arpa"} + a, err := p.ShareKey(opts) + if err != nil { + t.Fatal(err) + } + b, _ := p.ShareKey(opts) + if a != b { + t.Fatalf("non-deterministic key: %q vs %q", a, b) + } + if !strings.HasPrefix(a, "ptr:") { + t.Fatalf("missing prefix: %q", a) + } +} + +func TestShareKey_DiffersByOwner(t *testing.T) { + p := &ptrProvider{} + a, _ := p.ShareKey(sdk.CheckerOptions{"domain_name": "4.3.2.1.in-addr.arpa"}) + b, _ := p.ShareKey(sdk.CheckerOptions{"domain_name": "5.3.2.1.in-addr.arpa"}) + if a == b { + t.Fatalf("expected different keys for different owners") + } +} + +func TestShareKey_DiffersByDeclaredTarget(t *testing.T) { + p := &ptrProvider{} + base := sdk.CheckerOptions{"domain_name": "4.3.2.1.in-addr.arpa"} + a, _ := p.ShareKey(base) + b, _ := p.ShareKey(sdk.CheckerOptions{"domain_name": "4.3.2.1.in-addr.arpa", "expected_target": "host.example.org"}) + if a == b { + t.Fatalf("declared target must affect the key") + } +} + +func TestShareKey_EmptyWhenUnresolvable(t *testing.T) { + p := &ptrProvider{} + sk, err := p.ShareKey(sdk.CheckerOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sk != "" { + t.Fatalf("expected empty key, got %q", sk) + } +}