Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1741b67
feat(dns): emit + verify Consolidated Approach SVCB records
scourtney-godaddy May 16, 2026
47b5768
feat(ra): dnsRecordStyle on V2 register controls DNS-record family
scourtney-godaddy May 16, 2026
e920b8d
feat(dns): emit HTTPS RR alongside legacy _ans TXT family
scourtney-godaddy May 16, 2026
cd12fd0
feat(dns): update DNS record style to use CONSTANT_CASE and enhance v…
kperry-godaddy May 20, 2026
6be5dcc
feat(dns): update DNS record style to support multiple families and e…
kperry-godaddy May 21, 2026
9cc50da
chore(sqlite): rename migration 007 → 006 (PR12 capabilities_hash slo…
kperry-godaddy May 22, 2026
385d4e7
fix(ra): enforce dnsRecordStyles validation per spec
kperry-godaddy May 22, 2026
e083b3b
fix(domain): require SVCB when ANS_SVCB is the sole style
kperry-godaddy May 22, 2026
e543cec
fix(dns): subset-match SVCB SvcParams per RFC 9460 §8
kperry-godaddy May 22, 2026
e00928a
feat(discovery): integrate default discovery registry and update DNS …
kperry-godaddy May 26, 2026
2bedb62
feat(discovery): integrate default discovery registry and update DNS …
kperry-godaddy May 26, 2026
fcfa927
feat(badge): update _ans-badge URL to point at transparency log endpoint
kperry-godaddy May 29, 2026
c3898fb
feat(dns): enhance DNSMismatch classification for better error reporting
kperry-godaddy May 29, 2026
c959798
Merge branch 'main' into feat/plan-d-consolidated-svcb
kperry-godaddy May 29, 2026
994fb71
Merge branch 'main' into feat/plan-d-svcb-on-main
kperry-godaddy Jun 2, 2026
7b9af37
fix(discovery): publishable keyNNNNN SVCB params, per-port DANE,
kperry-godaddy Jun 11, 2026
1b7d47b
Merge branch 'main' into feat/plan-d-consolidated-svcb
kperry-godaddy Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions cmd/ans-dns/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import (
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
Expand Down Expand Up @@ -373,7 +372,3 @@ func runClear(args []string) error {
fmt.Printf("cleared %d records for %s from %s\n", n, agentID, *zonePath)
return nil
}

// Ensure net package stays referenced so future edits that thread a
// net.Listener / net.PacketConn through don't require chasing imports.
var _ = net.InterfaceAddrs
178 changes: 178 additions & 0 deletions cmd/ans-dns/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/miekg/dns"
)

// svcbWithKeyNNNNN is the Consolidated Approach SVCB presentation the
// RA's ANS_SVCB profile emits: ServiceMode `1 .` plus alpn, port, the
// well-known suffix as the RFC 9460 §14.3.1 Private Use key65280, and
// the capability digest as key65281. These keyNNNNN forms are what
// makes the value publishable — see the named-form negative case below.
const (
svcbValueKeyNNNNN = `1 . alpn=a2a port=443 key65280=agent-card.json key65281=CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc`
// svcbValueNamedWK is the pre-fix named form. dns.NewRR rejects it
// (`bad SVCB key`), so answersFor drops it and the server returns no
// answer — the unpublishability that Fix A's no-migration argument
// rests on. ans-dns serving this value is indistinguishable from
// NXDOMAIN to a resolver.
svcbValueNamedWK = `1 . alpn=a2a port=443 wk=agent-card.json`
tlsaValueSel0 = `3 0 1 deadbeefcafe1234`
txtValue = `v=ans1; version=1.0.0; p=a2a; mode=direct; url=https://agent.example.com/a2a`
)

// TestAnswersFor_ServePathRoundTrip drives the serve path (answersFor)
// directly: each zone record is composed into a presentation line and
// parsed with dns.NewRR exactly as the running server does, so a value
// the parser rejects yields zero answers. Table-driven over the record
// shapes the RA emits post-Fix-A/B2.
func TestAnswersFor_ServePathRoundTrip(t *testing.T) {
const fqdn = "agent.example.com"

tests := []struct {
name string
record zoneRecord
queryName string
queryType uint16
wantAnswer bool // true → exactly one RR served
wantInRR string // substring required in the served RR string (when wantAnswer)
}{
{
name: "svcb_keyNNNNN_parses_and_serves",
record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueKeyNNNNN, TTL: 3600},
queryName: fqdn,
queryType: dns.TypeSVCB,
wantAnswer: true,
// dns.NewRR re-renders Private Use SvcParams quoted; pin the
// key numbers survive the round-trip.
wantInRR: `key65280="agent-card.json"`,
},
{
name: "svcb_keyNNNNN_carries_capability_digest",
record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueKeyNNNNN, TTL: 3600},
queryName: fqdn,
queryType: dns.TypeSVCB,
wantAnswer: true,
wantInRR: `key65281="CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc"`,
},
{
name: "svcb_named_wk_rejected_and_dropped",
record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueNamedWK, TTL: 3600},
queryName: fqdn,
queryType: dns.TypeSVCB,
wantAnswer: false, // dns.NewRR("… wk=…") errors → answersFor skips it
},
{
name: "txt_value_served_quoted",
record: zoneRecord{Name: "_ans." + fqdn, Type: "TXT", Value: txtValue, TTL: 3600},
queryName: "_ans." + fqdn,
queryType: dns.TypeTXT,
wantAnswer: true,
wantInRR: `"` + txtValue + `"`,
},
{
name: "tlsa_selector0_served",
record: zoneRecord{Name: "_443._tcp." + fqdn, Type: "TLSA", Value: tlsaValueSel0, TTL: 3600},
queryName: "_443._tcp." + fqdn,
queryType: dns.TypeTLSA,
wantAnswer: true,
wantInRR: "3 0 1 deadbeefcafe1234",
},
{
name: "type_mismatch_yields_no_answer",
record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueKeyNNNNN, TTL: 3600},
queryName: fqdn,
queryType: dns.TypeA, // querying A against an SVCB record
wantAnswer: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
q := dns.Question{Name: dns.Fqdn(tc.queryName), Qtype: tc.queryType, Qclass: dns.ClassINET}
answers := answersFor(q, []zoneRecord{tc.record})

if !tc.wantAnswer {
if len(answers) != 0 {
t.Fatalf("want zero answers, got %d: %v", len(answers), answers)
}
return
}
if len(answers) != 1 {
t.Fatalf("want exactly one answer, got %d: %v", len(answers), answers)
}
got := answers[0].String()
if tc.wantInRR != "" && !strings.Contains(got, tc.wantInRR) {
t.Errorf("served RR %q does not contain %q", got, tc.wantInRR)
}
})
}
}

// TestLoadZoneThenServe pins the full disk-to-wire path: a JSON zone
// file written by `install` is loaded by loadZone, flattened, and
// served by answersFor. Exercises the keyNNNNN SVCB and selector-0 TLSA
// records together as one agent's record set, the way an operator
// publishes them.
func TestLoadZoneThenServe(t *testing.T) {
const fqdn = "agent.example.com"
dir := t.TempDir()
zonePath := filepath.Join(dir, "zone.json")

zoneJSON := `{
"records": {
"agent-1": [
{"name": "agent.example.com", "type": "SVCB", "value": "1 . alpn=a2a port=443 key65280=agent-card.json key65281=CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", "ttl": 3600},
{"name": "_443._tcp.agent.example.com", "type": "TLSA", "value": "3 0 1 deadbeefcafe1234", "ttl": 3600}
]
}
}`
if err := os.WriteFile(zonePath, []byte(zoneJSON), 0o600); err != nil {
t.Fatal(err)
}

z, err := loadZone(zonePath)
if err != nil {
t.Fatalf("loadZone: %v", err)
}
records := z.flatten()
if len(records) != 2 {
t.Fatalf("want 2 flattened records, got %d", len(records))
}

svcb := answersFor(dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeSVCB, Qclass: dns.ClassINET}, records)
if len(svcb) != 1 {
t.Fatalf("want one SVCB answer, got %d", len(svcb))
}
if !strings.Contains(svcb[0].String(), `key65280="agent-card.json"`) {
t.Errorf("SVCB answer missing key65280: %q", svcb[0].String())
}
if !strings.Contains(svcb[0].String(), `key65281="CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc"`) {
t.Errorf("SVCB answer missing key65281 capability digest after disk round-trip: %q", svcb[0].String())
}

tlsa := answersFor(dns.Question{Name: dns.Fqdn("_443._tcp." + fqdn), Qtype: dns.TypeTLSA, Qclass: dns.ClassINET}, records)
if len(tlsa) != 1 {
t.Fatalf("want one TLSA answer, got %d", len(tlsa))
}
if !strings.Contains(tlsa[0].String(), "3 0 1 deadbeefcafe1234") {
t.Errorf("TLSA answer missing selector-0 binding: %q", tlsa[0].String())
}
}

// TestLoadZoneMissingFileIsEmpty pins loadZone's "no file → empty zone"
// contract that lets `serve` start before any `install` has run.
func TestLoadZoneMissingFileIsEmpty(t *testing.T) {
z, err := loadZone(filepath.Join(t.TempDir(), "does-not-exist.json"))
if err != nil {
t.Fatalf("loadZone on missing file must not error, got %v", err)
}
if len(z.flatten()) != 0 {
t.Errorf("missing-file zone must be empty, got %d records", len(z.flatten()))
}
}
77 changes: 71 additions & 6 deletions cmd/ans-ra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/godaddy/ans/internal/adapter/store/sqlite"
"github.com/godaddy/ans/internal/adapter/tlclient"
"github.com/godaddy/ans/internal/config"
"github.com/godaddy/ans/internal/domain"
"github.com/godaddy/ans/internal/port"
"github.com/godaddy/ans/internal/ra/handler"
ramiddleware "github.com/godaddy/ans/internal/ra/middleware"
Expand Down Expand Up @@ -163,21 +164,40 @@ func run(cfgPath string) error {
// Event bus.
bus := eventbus.NewInMemoryBus(logger)

// Discovery registry: composes the bundled ANS-family port.ProfileEmitter
// adapters (ANS_TXT, ANS_SVCB) the V2 register / verify-dns paths walk
// to compute `dnsRecordsProvisioned[]`. Insertion order here pins the
// canonical-bytes emission order for the §4.4.2 union case
// (`[TXT×N, HTTPS, SVCB×N, badge, TLSA]`).
discoveryReg, err := service.NewDefaultProfileRegistry(cfg.TLClient.PublicBaseURL)
if err != nil {
return fmt.Errorf("init discovery registry: %w", err)
}
if err := assertRegistryDomainCoherence(discoveryReg); err != nil {
return fmt.Errorf("discovery registry coherence: %w", err)
}
logger.Info().
Strs("profiles", profileIDStrings(discoveryReg.IDs())).
Msg("discovery registry ready")

// Services.
regSvc := service.NewRegistrationService(
agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db,
agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, discoveryReg,
).WithSigner(service.EventSigner{
KeyManager: km,
KeyID: signerKeyID,
RaID: cfg.Signer.RaID,
}).WithDNSVerifier(dnsVerifier).
WithServerCertificateAuthority(serverCA).
WithTLPublicBaseURL(cfg.TLClient.PublicBaseURL)
WithServerCertificateAuthority(serverCA)

// HTTP.
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
// middleware.RealIP is deliberately NOT wired: chi 5.3 deprecates it
// as IP-spoofable (it trusts X-Forwarded-For / X-Real-IP regardless
// of whether a proxy set them), and nothing in this service reads
// RemoteAddr — no request logger, no rate limiter, no IP audit.
r.Use(middleware.Timeout(30 * time.Second))
r.Use(middleware.AllowContentType("application/json"))
r.Use(authProvider.Middleware())
Expand Down Expand Up @@ -301,9 +321,10 @@ func run(cfgPath string) error {
logger.Info().Str("addr", addr).Msg("listening")
// Hardened timeouts:
// - ReadHeaderTimeout caps slowloris-style header dribbling.
// - WriteTimeout > the 30s chi handler timeout (line 176) so
// chi gets the chance to write a clean 503 before the server
// drops the connection on a slow handler/client.
// - WriteTimeout > the 30s chi middleware.Timeout wired on the
// router above, so chi gets the chance to write a clean 503
// before the server drops the connection on a slow
// handler/client.
// - IdleTimeout caps how long an idle keep-alive connection
// can sit on the runner; pairs with HTTP/1.1 connection reuse
// for SDK clients while keeping a hard ceiling.
Expand Down Expand Up @@ -399,6 +420,50 @@ type providerWithAnonymous interface {
Middleware() func(http.Handler) http.Handler
}

// assertRegistryDomainCoherence verifies the discovery registry's
// wired profiles are exactly the set domain advertises as valid via
// domain.ValidDiscoveryProfiles(). Drift in either direction is a
// startup misconfig: registry-only profile means request-side validation
// rejects it (operator error noise); domain-only profile means
// applyDiscoveryProfiles accepts a value verify-dns can never satisfy
// (silent broken-by-omission). Both fail server start.
// profileIDStrings converts the registry's typed profile IDs to the
// plain strings the startup readiness log line wants.
func profileIDStrings(ids []domain.DiscoveryProfile) []string {
out := make([]string, len(ids))
for i, id := range ids {
out[i] = string(id)
}
return out
}

func assertRegistryDomainCoherence(reg port.ProfileRegistry) error {
registryIDs := make(map[string]bool)
for _, id := range reg.IDs() {
registryIDs[string(id)] = true
}
domainIDs := make(map[string]bool)
for _, s := range domain.ValidDiscoveryProfiles() {
domainIDs[s] = true
}
var registryOnly, domainOnly []string
for id := range registryIDs {
if !domainIDs[id] {
registryOnly = append(registryOnly, id)
}
}
for id := range domainIDs {
if !registryIDs[id] {
domainOnly = append(domainOnly, id)
}
}
Comment on lines +449 to +459
if len(registryOnly) > 0 || len(domainOnly) > 0 {
return fmt.Errorf("drift between registry.IDs() and domain.ValidDiscoveryProfiles(): registry-only=%v, domain-only=%v",
registryOnly, domainOnly)
}
return nil
}

// selectDNSVerifier returns the configured DNS adapter. Returns a
// port.DNSVerifier so the service layer can wire it directly.
//
Expand Down
11 changes: 8 additions & 3 deletions cmd/ans-tl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ func run(cfgPath string) error {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
// middleware.RealIP is deliberately NOT wired: chi 5.3 deprecates it
// as IP-spoofable (it trusts X-Forwarded-For / X-Real-IP regardless
// of whether a proxy set them), and nothing in this service reads
// RemoteAddr — no request logger, no rate limiter, no IP audit.
r.Use(middleware.Timeout(30 * time.Second))
r.Use(authProvider.Middleware())

Expand Down Expand Up @@ -278,9 +282,10 @@ func run(cfgPath string) error {
logger.Info().Str("addr", addr).Msg("listening")
// Hardened timeouts — see the matching block in cmd/ans-ra/main.go
// for the full rationale. WriteTimeout sits above the 30s chi
// handler timeout (line 213) so chi can write a clean 503 first;
// IdleTimeout caps keep-alive idle time; MaxHeaderBytes is the
// Go default written explicitly for audit visibility.
// middleware.Timeout wired on the router above so chi can write a
// clean 503 first; IdleTimeout caps keep-alive idle time;
// MaxHeaderBytes is the Go default written explicitly for audit
// visibility.
srv := &http.Server{
Addr: addr,
Handler: r,
Expand Down
50 changes: 50 additions & 0 deletions internal/adapter/discovery/ans/ansbadge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package ans

import (
"fmt"
"net/url"

"github.com/godaddy/ans/internal/domain"
)

// BadgeRecord returns the `_ans-badge.<fqdn>` TXT record every ANS-family
// style emits as the trust attestation hook. Returns a one-element slice
// when reg has at least one endpoint; returns an empty slice otherwise so
// callers can `append(records, BadgeRecord(reg, tlPublicBaseURL)...)`
// unconditionally.
//
// The badge url= points at the Transparency Log's badge endpoint for this
// agent (`<tlPublicBaseURL>/v1/agents/<agentID>`) when tlPublicBaseURL is
// set and the registration carries an AgentID; otherwise it falls back to
// the first endpoint's own URL. Pointing at the TL is the correct target —
// badge verifiers resolve trust through the log, not the agent's host.
//
// Both ANS_SVCB and ANS_TXT styles call BadgeRecord. When both are in
// the resolved set the service walker dedupes on (Name, Type, Value),
// so the badge lands once per registration regardless of style count.
//
// Required=true: badge-verifying clients won't trust an agent that
// publishes its discovery records without a paired badge — every
// non-badge ANS record on the wire is meaningless without it.
func BadgeRecord(reg *domain.AgentRegistration, tlPublicBaseURL string) []domain.ExpectedDNSRecord {
if len(reg.Endpoints) == 0 {
return nil
}
version := reg.AnsName.Version().String()
badgeURL := reg.Endpoints[0].AgentURL
if tlPublicBaseURL != "" && reg.AgentID != "" {
// tlPublicBaseURL is validated at config load (https, no
// query/fragment/userinfo), so JoinPath cannot fail here.
badgeURL, _ = url.JoinPath(tlPublicBaseURL, "v1", "agents", reg.AgentID)
}
value := fmt.Sprintf("v=ans-badge1; version=%s; url=%s",
version, badgeURL)
return []domain.ExpectedDNSRecord{{
Name: fmt.Sprintf("_ans-badge.%s", reg.FQDN()),
Type: domain.DNSRecordTXT,
Value: value,
Purpose: domain.PurposeBadge,
Required: true,
TTL: 3600,
}}
}
Loading