diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index 510cb02..386c39c 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -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" @@ -158,9 +159,22 @@ func run(cfgPath string) error { // Event bus. bus := eventbus.NewInMemoryBus(logger) + // Discovery registry: composes the bundled ANS-family port.DiscoveryStyle + // 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.NewDefaultDiscoveryRegistry() + if err != nil { + return fmt.Errorf("init discovery registry: %w", err) + } + if err := assertRegistryDomainCoherence(discoveryReg); err != nil { + return fmt.Errorf("init discovery registry: %w", err) + } + // 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, @@ -394,6 +408,40 @@ type providerWithAnonymous interface { Middleware() func(http.Handler) http.Handler } +// assertRegistryDomainCoherence verifies the discovery registry's +// wired styles are exactly the set domain advertises as valid via +// domain.ValidDNSRecordStyles(). Drift in either direction is a +// startup misconfig: registry-only style means request-side validation +// rejects it (operator error noise); domain-only style means +// applyDNSRecordStyles accepts a value verify-dns can never satisfy +// (silent broken-by-omission). Both fail server start. +func assertRegistryDomainCoherence(reg port.DiscoveryRegistry) error { + registryIDs := make(map[string]bool) + for _, id := range reg.IDs() { + registryIDs[string(id)] = true + } + domainIDs := make(map[string]bool) + for _, s := range domain.ValidDNSRecordStyles() { + 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) + } + } + if len(registryOnly) > 0 || len(domainOnly) > 0 { + return fmt.Errorf("drift between registry.IDs() and domain.ValidDNSRecordStyles(): 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. // diff --git a/internal/adapter/discovery/ans/ansbadge.go b/internal/adapter/discovery/ans/ansbadge.go new file mode 100644 index 0000000..b37d7ff --- /dev/null +++ b/internal/adapter/discovery/ans/ansbadge.go @@ -0,0 +1,37 @@ +package ans + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" +) + +// BadgeRecord returns the `_ans-badge.` TXT record every ANS-family +// style emits as the trust attestation hook. Returns a one-element slice +// when reg has at least one endpoint (the badge value points at the +// first endpoint's URL); returns an empty slice otherwise so callers +// can `append(records, BadgeRecord(reg)...)` unconditionally. +// +// 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) []domain.ExpectedDNSRecord { + if len(reg.Endpoints) == 0 { + return nil + } + version := reg.AnsName.Version().String() + value := fmt.Sprintf("v=ans-badge1; version=%s; url=%s", + version, reg.Endpoints[0].AgentURL) + return []domain.ExpectedDNSRecord{{ + Name: fmt.Sprintf("_ans-badge.%s", reg.FQDN()), + Type: domain.DNSRecordTXT, + Value: value, + Purpose: domain.PurposeBadge, + Required: true, + TTL: 3600, + }} +} diff --git a/internal/adapter/discovery/ans/ansbadge_test.go b/internal/adapter/discovery/ans/ansbadge_test.go new file mode 100644 index 0000000..bc5c801 --- /dev/null +++ b/internal/adapter/discovery/ans/ansbadge_test.go @@ -0,0 +1,69 @@ +package ans + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func TestBadgeRecord(t *testing.T) { + mustReg := func(t *testing.T, version string, host string, eps []domain.AgentEndpoint) *domain.AgentRegistration { + t.Helper() + v, err := domain.ParseSemVer(version) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{AnsName: ansName, Endpoints: eps} + } + + tests := []struct { + name string + reg *domain.AgentRegistration + wantEmpty bool + wantName string + wantValue string + }{ + { + name: "no_endpoints_emits_no_badge", + reg: mustReg(t, "1.0.0", "agent.example.com", nil), + wantEmpty: true, + }, + { + name: "single_endpoint_emits_one_badge", + reg: mustReg(t, "1.2.3", "agent.example.com", []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + }), + wantName: "_ans-badge.agent.example.com", + wantValue: "v=ans-badge1; version=1.2.3; url=https://agent.example.com/a2a", + }, + { + name: "multiple_endpoints_badge_uses_first_url", + reg: mustReg(t, "2.0.0", "agent.example.com", []domain.AgentEndpoint{ + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + }), + wantName: "_ans-badge.agent.example.com", + wantValue: "v=ans-badge1; version=2.0.0; url=https://agent.example.com/mcp", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := BadgeRecord(tc.reg) + if tc.wantEmpty { + assert.Empty(t, got) + return + } + require.Len(t, got, 1) + r := got[0] + assert.Equal(t, tc.wantName, r.Name) + assert.Equal(t, domain.DNSRecordTXT, r.Type) + assert.Equal(t, tc.wantValue, r.Value) + assert.Equal(t, domain.PurposeBadge, r.Purpose) + assert.True(t, r.Required, "_ans-badge is always Required=true alongside discovery records") + assert.Equal(t, 3600, r.TTL) + }) + } +} diff --git a/internal/adapter/discovery/ans/protocol.go b/internal/adapter/discovery/ans/protocol.go new file mode 100644 index 0000000..5788319 --- /dev/null +++ b/internal/adapter/discovery/ans/protocol.go @@ -0,0 +1,55 @@ +// Package ans implements the bundled ANS-family port.DiscoveryStyle +// adapters: SVCBStyle (the Consolidated Approach SVCB shape per RFC 9460 +// plus the `_ans-badge` TXT extension) and TXTStyle (the original `_ans` +// TXT shape, supported indefinitely for operators with existing zone-edit +// tooling). Both styles share two family-level trust records — the +// `_ans-badge` TXT and the server-cert TLSA — emitted by every ANS-family +// style and deduped at the service walker. +// +// Helpers private to this package handle the per-protocol bits both +// styles need: protocol.go for the human-friendly protocol token and +// well-known suffix mappings, svcb.go for the SVCB-specific port and +// card-sha256 helpers (kept next to their only consumer). +package ans + +import ( + "github.com/godaddy/ans/internal/domain" +) + +// protocolToANSValue maps a protocol enum to the wire token used inside +// `_ans` TXT payloads (`p=`) and SVCB `alpn=` SvcParams. +// Unknown protocols pass through unchanged so a future protocol added +// to the domain layer surfaces in records without a parallel edit here. +func protocolToANSValue(p domain.Protocol) string { + switch p { + case domain.ProtocolA2A: + return "a2a" + case domain.ProtocolMCP: + return "mcp" + case domain.ProtocolHTTPAPI: + return "http-api" + default: + return string(p) + } +} + +// wkPathFor returns the suffix-only well-known path published in the +// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only +// matches the consolidated-draft examples (§4 line 134); clients +// prepend `/.well-known/` to construct the full path. Empty result +// means the caller SHOULD omit `wk=` entirely (e.g. direct-mode agents +// that expose no canonical metadata file). +// +// A2A: `agent-card.json` (IANA-registered well-known per A2A spec). +// MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). +// HTTP-API: empty (no per-protocol metadata file convention). +func wkPathFor(p domain.Protocol) string { + switch p { + case domain.ProtocolA2A: + return "agent-card.json" + case domain.ProtocolMCP: + return "mcp.json" + default: + return "" + } +} diff --git a/internal/adapter/discovery/ans/protocol_test.go b/internal/adapter/discovery/ans/protocol_test.go new file mode 100644 index 0000000..b27d374 --- /dev/null +++ b/internal/adapter/discovery/ans/protocol_test.go @@ -0,0 +1,46 @@ +package ans + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/godaddy/ans/internal/domain" +) + +func TestProtocolToANSValue(t *testing.T) { + tests := []struct { + name string + in domain.Protocol + want string + }{ + {name: "a2a", in: domain.ProtocolA2A, want: "a2a"}, + {name: "mcp", in: domain.ProtocolMCP, want: "mcp"}, + {name: "http_api", in: domain.ProtocolHTTPAPI, want: "http-api"}, + {name: "unknown_protocol_passes_through_unchanged", in: domain.Protocol("UNKNOWN"), want: "UNKNOWN"}, + {name: "empty_protocol_passes_through_as_empty", in: domain.Protocol(""), want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, protocolToANSValue(tc.in)) + }) + } +} + +func TestWkPathFor(t *testing.T) { + tests := []struct { + name string + in domain.Protocol + want string + }{ + {name: "a2a_returns_agent_card_json", in: domain.ProtocolA2A, want: "agent-card.json"}, + {name: "mcp_returns_mcp_json", in: domain.ProtocolMCP, want: "mcp.json"}, + {name: "http_api_returns_empty", in: domain.ProtocolHTTPAPI, want: ""}, + {name: "unknown_protocol_returns_empty", in: domain.Protocol("UNKNOWN"), want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, wkPathFor(tc.in)) + }) + } +} diff --git a/internal/adapter/discovery/ans/svcb.go b/internal/adapter/discovery/ans/svcb.go new file mode 100644 index 0000000..dc64e72 --- /dev/null +++ b/internal/adapter/discovery/ans/svcb.go @@ -0,0 +1,132 @@ +package ans + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strconv" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// SVCBStyle implements port.DiscoveryStyle for the Consolidated +// Approach SVCB shape (ANS_SVCB). It emits one SVCB row per protocol +// endpoint at the agent's bare FQDN, plus the ANS-family trust +// records (`_ans-badge` TXT and TLSA) so the style is self-contained +// when registered alone. +// +// Records always returns SVCB rows with Required=true. The service +// walker post-processes the slice to flip Required=false on these +// rows when ANS_TXT is also in the resolved set (during the §4.4.2 +// transition the legacy `_ans` TXT family carries the operator's +// required signal and SVCB rides along as optional). Keeping the +// post-process at the service layer keeps SVCBStyle style-local — +// it does not need to know which other styles are in play. +// +// SvcParam composition per RFC 9460: +// - alpn: protocol token (a2a / mcp / http-api), distinguishes +// protocols within the same RRset. +// - port: from endpoint URL authority; defaults to 443 (https) / +// 80 (http) when the URL omits a port. +// - wk: well-known suffix per protocol (agent-card.json for A2A, +// mcp.json for MCP, omitted for HTTP-API). +// - card-sha256: base64url(reg.CapabilitiesHash) when set; absent +// otherwise, in which case verifiers fall back to TOFU on first +// Trust Card fetch. +// +// `wk` and `card-sha256` are not yet IANA-registered SvcParamKeys; +// see the consolidated-draft §6 note for the keyNNNNN-form fallback +// strict-RFC parsers may need. +type SVCBStyle struct{} + +// ID returns ANS_SVCB. +func (SVCBStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleSVCB } + +// Records returns the SVCB rows + family trust records the SVCB style +// needs an operator to publish. +func (s SVCBStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + fqdn := reg.FQDN() + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + records := make([]domain.ExpectedDNSRecord, 0, len(reg.Endpoints)+2) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + port := svcbPortFor(ep.AgentURL) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when the + // value has no characters special to the presentation format. + // alpn tokens, port digits, well-known path suffixes, and base64url + // digests all qualify. + value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, domain.ExpectedDNSRecord{ + Name: fqdn, + Type: domain.DNSRecordSVCB, + Value: value, + Purpose: domain.PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } + records = append(records, BadgeRecord(reg)...) + records = append(records, TLSARecord(reg)...) + return records +} + +// Compile-time interface satisfaction check. Catches accidental +// signature drift on port.DiscoveryStyle without needing a runtime +// assertion in cmd/main. +var _ port.DiscoveryStyle = SVCBStyle{} + +// svcbPortFor returns the TCP port to advertise in the SVCB SvcParam +// `port=`. Reads it from the endpoint URL's authority. Falls back to +// 443 (https) / 80 (http) when the URL omits a port. Empty input or +// unparseable URL returns 443 — the §4.4.2 default for agent endpoints. +// +// Without this, every endpoint would emit a hardcoded port=443 and +// silently break verify-dns for agents on non-443 endpoints (operator +// publishes their actual port; expected says 443; mismatch). +func svcbPortFor(agentURL string) int { + if agentURL == "" { + return 443 + } + u, err := url.Parse(agentURL) + if err != nil { + return 443 + } + if p := u.Port(); p != "" { + if n, perr := strconv.Atoi(p); perr == nil { + return n + } + } + if u.Scheme == "http" { + return 80 + } + return 443 +} + +// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest +// (the form `AgentRegistration.CapabilitiesHash` carries) into the +// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` +// SvcParam expects. Empty input returns empty output, which the caller +// treats as "omit the SvcParam entirely" — agents registered without +// `agentCardContent` have no committed value to publish. +func capabilitiesHashBase64URL(hexDigest string) string { + if hexDigest == "" { + return "" + } + raw, err := hex.DecodeString(hexDigest) + if err != nil || len(raw) == 0 { + // Malformed hex is logically equivalent to absence; the RA + // stores well-formed hex by construction (helpers.go: + // hashAgentCardContent), but defensive on the boundary. + return "" + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/internal/adapter/discovery/ans/svcb_test.go b/internal/adapter/discovery/ans/svcb_test.go new file mode 100644 index 0000000..aebfc9a --- /dev/null +++ b/internal/adapter/discovery/ans/svcb_test.go @@ -0,0 +1,252 @@ +package ans + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func mustReg(t *testing.T, host string, eps []domain.AgentEndpoint, capHash string, cert *domain.ByocServerCertificate) *domain.AgentRegistration { + t.Helper() + v, err := domain.NewSemVer(1, 0, 0) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{ + AnsName: ansName, + Endpoints: eps, + CapabilitiesHash: capHash, + ServerCert: cert, + } +} + +func TestSVCBStyle_ID(t *testing.T) { + assert.Equal(t, domain.DNSRecordStyleSVCB, SVCBStyle{}.ID()) +} + +// TestSVCBStyle_Records walks the SvcParam composition rules (alpn / +// port / wk / card-sha256) the consolidated-draft fixes, plus the +// always-Required default the service walker post-processes. +func TestSVCBStyle_Records(t *testing.T) { + const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + + tests := []struct { + name string + eps []domain.AgentEndpoint + capHash string + wantCount int // svcb rows expected + wantPort string + wantAlpn string + wantWk string // empty means MUST NOT appear + wantCard string // empty means MUST NOT appear + wantNotPort string // value MUST NOT contain this string (e.g. wrong default) + }{ + { + name: "a2a_https_default_port", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + }, + { + name: "mcp_emits_mcp_json_well_known", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=mcp", + wantWk: "wk=mcp.json", + }, + { + name: "http_api_omits_wk", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolHTTPAPI, AgentURL: "https://agent.example.com"}, + }, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=http-api", + wantWk: "", // HTTP-API has no per-protocol metadata file + }, + { + name: "card_sha256_present_when_capabilities_hash_set", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + capHash: cardHex, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + wantCard: "card-sha256=" + wantCardBase64, + }, + { + name: "non_443_port_from_url_authority", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com:8443"}, + }, + wantCount: 1, + wantPort: "port=8443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + wantNotPort: "port=443", + }, + { + name: "http_scheme_defaults_port_80", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "http://agent.example.com"}, + }, + wantCount: 1, + wantPort: "port=80", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + }, + { + // First row asserted below; assertions on the A2A protocol's + // SvcParam composition (port, alpn, wk). The MCP row's wk=mcp.json + // is covered by the dedicated mcp test case above; here we only + // pin that the count is right and the row order tracks endpoint + // order. + name: "two_endpoints_emits_two_svcb_rows", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + wantCount: 2, + wantPort: "port=443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + }, + { + name: "zero_endpoints_emits_no_svcb_rows", + eps: nil, + wantCount: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := mustReg(t, "agent.example.com", tc.eps, tc.capHash, nil) + records := SVCBStyle{}.Records(reg) + + var svcbRows []domain.ExpectedDNSRecord + for _, r := range records { + if r.Type == domain.DNSRecordSVCB { + svcbRows = append(svcbRows, r) + } + } + require.Len(t, svcbRows, tc.wantCount, "SVCB row count") + + if tc.wantCount == 0 { + return + } + + r := svcbRows[0] + assert.Equal(t, "agent.example.com", r.Name, "SVCB lives at the bare FQDN") + assert.Equal(t, domain.PurposeDiscovery, r.Purpose) + assert.Equal(t, 3600, r.TTL) + assert.True(t, r.Required, "SVCB always returns Required=true; service post-processes for the union case") + assert.Contains(t, r.Value, `1 . `, "ServiceMode (priority 1) with TargetName .") + if tc.wantAlpn != "" { + assert.Contains(t, r.Value, tc.wantAlpn) + } + if tc.wantPort != "" { + assert.Contains(t, r.Value, tc.wantPort) + } + if tc.wantNotPort != "" { + assert.NotContains(t, r.Value, tc.wantNotPort) + } + if tc.wantWk != "" { + assert.Contains(t, r.Value, tc.wantWk) + } else { + assert.NotContains(t, r.Value, "wk=", "wk= MUST be absent for protocols with no metadata file convention") + } + if tc.wantCard != "" { + assert.Contains(t, r.Value, tc.wantCard) + } else { + assert.NotContains(t, r.Value, "card-sha256", "card-sha256 MUST be absent when CapabilitiesHash is empty") + } + }) + } +} + +// TestSVCBStyle_RecordsIncludesFamilyTrustRecords pins that SVCBStyle +// is self-contained — it emits the family's badge and TLSA records too, +// so registering ANS_SVCB alone produces a complete set without any +// service-layer trust-record plumbing. +func TestSVCBStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { + reg := mustReg(t, "agent.example.com", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) + + records := SVCBStyle{}.Records(reg) + + var sawBadge, sawTLSA bool + for _, r := range records { + if r.Purpose == domain.PurposeBadge { + sawBadge = true + assert.True(t, strings.HasPrefix(r.Name, "_ans-badge.")) + } + if r.Purpose == domain.PurposeCertificateBinding { + sawTLSA = true + assert.True(t, strings.HasPrefix(r.Name, "_443._tcp.")) + } + } + assert.True(t, sawBadge, "SVCB style must include the family `_ans-badge` record") + assert.True(t, sawTLSA, "SVCB style must include the TLSA record when ServerCert is set") +} + +func TestSVCBPortFor(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "https_default_443", in: "https://agent.example.com", want: 443}, + {name: "http_default_80", in: "http://agent.example.com", want: 80}, + {name: "explicit_port_8443", in: "https://agent.example.com:8443", want: 8443}, + {name: "explicit_port_8080_http", in: "http://agent.example.com:8080", want: 8080}, + {name: "with_path_keeps_port", in: "https://agent.example.com:9443/a2a", want: 9443}, + {name: "empty_url_defaults_443", in: "", want: 443}, + {name: "malformed_url_defaults_443", in: "://not-a-url", want: 443}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, svcbPortFor(tc.in)) + }) + } +} + +func TestCapabilitiesHashBase64URL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "live_webmesh_trust_card_digest", + in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros", + in: "0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + {name: "empty_input_empty_output", in: "", want: ""}, + {name: "malformed_hex_returns_empty", in: "not hex", want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, capabilitiesHashBase64URL(tc.in)) + }) + } +} diff --git a/internal/adapter/discovery/ans/tlsa.go b/internal/adapter/discovery/ans/tlsa.go new file mode 100644 index 0000000..417f5eb --- /dev/null +++ b/internal/adapter/discovery/ans/tlsa.go @@ -0,0 +1,38 @@ +package ans + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" +) + +// TLSARecord returns the TLSA cert-binding record every ANS-family +// style emits for the agent's server cert. Returns a one-element slice +// when reg.ServerCert is set; returns an empty slice otherwise. +// +// Both ANS_SVCB and ANS_TXT styles call TLSARecord. When both are in +// the resolved set the service walker dedupes on (Name, Type, Value) +// so the TLSA lands once. +// +// Required=false: TLSA is only meaningful when the operator's zone is +// DNSSEC-signed, which is a runtime property the domain layer cannot +// know. The verify layer enforces a stricter rule at query time: when +// a TLSA response IS DNSSEC-validated, its value MUST match the +// expected fingerprint (otherwise an attacker rewrote the record in +// a signed zone — the worst failure mode). That post-verify check +// lives alongside the verifier (lifecycle.go), not in the record set. +// +// `3 1 1 ` = DANE-EE + SubjectPublicKeyInfo + SHA-256 (RFC 6698). +func TLSARecord(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + if reg.ServerCert == nil { + return nil + } + return []domain.ExpectedDNSRecord{{ + Name: fmt.Sprintf("_443._tcp.%s", reg.FQDN()), + Type: domain.DNSRecordTLSA, + Value: fmt.Sprintf("3 1 1 %s", reg.ServerCert.Fingerprint), + Purpose: domain.PurposeCertificateBinding, + Required: false, + TTL: 3600, + }} +} diff --git a/internal/adapter/discovery/ans/tlsa_test.go b/internal/adapter/discovery/ans/tlsa_test.go new file mode 100644 index 0000000..be07f6b --- /dev/null +++ b/internal/adapter/discovery/ans/tlsa_test.go @@ -0,0 +1,60 @@ +package ans + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func TestTLSARecord(t *testing.T) { + mustReg := func(t *testing.T, host string, cert *domain.ByocServerCertificate) *domain.AgentRegistration { + t.Helper() + v, err := domain.NewSemVer(1, 0, 0) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{AnsName: ansName, ServerCert: cert} + } + + tests := []struct { + name string + reg *domain.AgentRegistration + wantEmpty bool + wantName string + wantValue string + }{ + { + name: "no_server_cert_emits_no_tlsa", + reg: mustReg(t, "agent.example.com", nil), + wantEmpty: true, + }, + { + name: "with_cert_emits_dane_ee_spki_sha256_record", + reg: mustReg(t, "agent.example.com", &domain.ByocServerCertificate{ + Fingerprint: "abcdef0123456789", + }), + wantName: "_443._tcp.agent.example.com", + wantValue: "3 1 1 abcdef0123456789", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := TLSARecord(tc.reg) + if tc.wantEmpty { + assert.Empty(t, got) + return + } + require.Len(t, got, 1) + r := got[0] + assert.Equal(t, tc.wantName, r.Name) + assert.Equal(t, domain.DNSRecordTLSA, r.Type) + assert.Equal(t, tc.wantValue, r.Value) + assert.Equal(t, domain.PurposeCertificateBinding, r.Purpose) + assert.False(t, r.Required, "TLSA is non-required because operator zones may not be DNSSEC-signed") + assert.Equal(t, 3600, r.TTL) + }) + } +} diff --git a/internal/adapter/discovery/ans/txt.go b/internal/adapter/discovery/ans/txt.go new file mode 100644 index 0000000..5575297 --- /dev/null +++ b/internal/adapter/discovery/ans/txt.go @@ -0,0 +1,66 @@ +package ans + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// TXTStyle implements port.DiscoveryStyle for the original `_ans` TXT +// shape (ANS_TXT). It emits one TXT row per protocol endpoint at +// `_ans.` plus an HTTPS RR at the bare FQDN (when at least one +// endpoint is present), plus the ANS-family trust records. +// +// Behavior change vs. the pre-refactor domain function: the HTTPS RR +// is now gated on `len(reg.Endpoints) > 0`. The pre-refactor code +// emitted an HTTPS RR unconditionally inside the `if emitTXT` branch, +// producing a degenerate `[HTTPS-RR-only]` record set when an +// operator selected ANS_TXT with zero endpoints — a service binding +// for a non-existent agent. The refactor folds this fix in; the PR +// description calls it out. +// +// The HTTPS RR carries `1 . alpn=h2` — service binding for HTTP/2. +// Required=false because operators on CNAME-fronted apex zones cannot +// publish this record at the same name (CNAME at @ blocks HTTPS RR +// per RFC 1034 §3.6.2); the spec does not block them on its absence. +type TXTStyle struct{} + +// ID returns ANS_TXT. +func (TXTStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleTXT } + +// Records returns the `_ans` TXT rows (one per endpoint) plus the +// HTTPS RR (when at least one endpoint exists) plus the family trust +// records. +func (s TXTStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + fqdn := reg.FQDN() + version := reg.AnsName.Version().String() + var records []domain.ExpectedDNSRecord + for _, ep := range reg.Endpoints { + value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", + version, protocolToANSValue(ep.Protocol), ep.AgentURL) + records = append(records, domain.ExpectedDNSRecord{ + Name: fmt.Sprintf("_ans.%s", fqdn), + Type: domain.DNSRecordTXT, + Value: value, + Purpose: domain.PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } + if len(reg.Endpoints) > 0 { + records = append(records, domain.ExpectedDNSRecord{ + Name: fqdn, + Type: domain.DNSRecordHTTPS, + Value: `1 . alpn=h2`, + Purpose: domain.PurposeDiscovery, + Required: false, + TTL: 3600, + }) + } + records = append(records, BadgeRecord(reg)...) + records = append(records, TLSARecord(reg)...) + return records +} + +var _ port.DiscoveryStyle = TXTStyle{} diff --git a/internal/adapter/discovery/ans/txt_test.go b/internal/adapter/discovery/ans/txt_test.go new file mode 100644 index 0000000..8c63820 --- /dev/null +++ b/internal/adapter/discovery/ans/txt_test.go @@ -0,0 +1,149 @@ +package ans + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func TestTXTStyle_ID(t *testing.T) { + assert.Equal(t, domain.DNSRecordStyleTXT, TXTStyle{}.ID()) +} + +func TestTXTStyle_Records(t *testing.T) { + tests := []struct { + name string + eps []domain.AgentEndpoint + wantTXTRows int + wantHTTPS bool + // when wantTXTRows > 0, assertions on the first TXT row's value: + wantInValue []string + wantNotIn []string + }{ + { + name: "one_a2a_endpoint_emits_one_ans_txt_and_one_https_rr", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + }, + wantTXTRows: 1, + wantHTTPS: true, + wantInValue: []string{ + "v=ans1", + "version=1.0.0", + "p=a2a", + "mode=direct", + "url=https://agent.example.com/a2a", + }, + wantNotIn: []string{"v1.0.0", "1-0-0"}, + }, + { + name: "two_endpoints_emit_two_ans_txt_rows_one_https_rr", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + wantTXTRows: 2, + wantHTTPS: true, + }, + { + // Behavioral correction bundled in this refactor: zero + // endpoints with ANS_TXT previously emitted an HTTPS RR + // with no `_ans` TXT companions — a service binding for a + // non-existent agent. The gate on len(endpoints) > 0 + // closes that degenerate output. + name: "zero_endpoints_emits_no_records_no_https_rr", + eps: nil, + wantTXTRows: 0, + wantHTTPS: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := mustReg(t, "agent.example.com", tc.eps, "", nil) + records := TXTStyle{}.Records(reg) + + var txtRows int + var httpsRows int + var firstTXTValue string + for _, r := range records { + if r.Type == domain.DNSRecordTXT && strings.HasPrefix(r.Name, "_ans.") { + if txtRows == 0 { + firstTXTValue = r.Value + } + txtRows++ + assert.True(t, r.Required, "_ans TXT must be Required=true") + assert.Equal(t, domain.PurposeDiscovery, r.Purpose) + assert.Equal(t, 3600, r.TTL) + } + if r.Type == domain.DNSRecordHTTPS { + httpsRows++ + assert.Equal(t, "agent.example.com", r.Name, "HTTPS RR lives at the bare FQDN") + assert.False(t, r.Required, "HTTPS RR is non-required (CNAME-at-apex precludes publishing)") + assert.Contains(t, r.Value, "alpn=h2") + } + } + assert.Equal(t, tc.wantTXTRows, txtRows, "_ans TXT row count") + if tc.wantHTTPS { + assert.Equal(t, 1, httpsRows, "exactly one HTTPS RR per registration") + } else { + assert.Zero(t, httpsRows, "zero endpoints must emit zero HTTPS RRs") + } + + for _, want := range tc.wantInValue { + assert.Contains(t, firstTXTValue, want) + } + for _, notWant := range tc.wantNotIn { + assert.NotContains(t, firstTXTValue, notWant) + } + }) + } +} + +// TestTXTStyle_RecordsIncludesFamilyTrustRecords pins that TXTStyle is +// self-contained in the same way as SVCBStyle: it emits the family +// badge and TLSA records. +func TestTXTStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { + reg := mustReg(t, "agent.example.com", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) + + records := TXTStyle{}.Records(reg) + + var sawBadge, sawTLSA bool + for _, r := range records { + if r.Purpose == domain.PurposeBadge { + sawBadge = true + } + if r.Purpose == domain.PurposeCertificateBinding { + sawTLSA = true + } + } + assert.True(t, sawBadge) + assert.True(t, sawTLSA) +} + +// TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords pins the +// existing-behavior contract that an empty endpoint list produces an +// empty record set when ServerCert is also nil. Zero endpoints + nil +// cert means there's nothing meaningful to publish. +func TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords(t *testing.T) { + reg := mustReg(t, "agent.example.com", nil, "", nil) + records := TXTStyle{}.Records(reg) + require.Empty(t, records) +} + +// TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA pins that even with +// zero endpoints, a registration that has a server cert still gets the +// TLSA record. (The badge requires endpoints; TLSA does not.) +func TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA(t *testing.T) { + reg := mustReg(t, "agent.example.com", nil, "", + &domain.ByocServerCertificate{Fingerprint: "abcd"}) + records := TXTStyle{}.Records(reg) + require.Len(t, records, 1) + assert.Equal(t, domain.DNSRecordTLSA, records[0].Type) +} diff --git a/internal/adapter/discovery/registry/registry.go b/internal/adapter/discovery/registry/registry.go new file mode 100644 index 0000000..556b096 --- /dev/null +++ b/internal/adapter/discovery/registry/registry.go @@ -0,0 +1,73 @@ +// Package registry holds the immutable composition facade for +// port.DiscoveryStyle implementations. It is the bundled +// port.DiscoveryRegistry: cmd/ans-ra/main.go calls registry.New(...) +// with the styles the binary should serve, and the service consumes the +// returned *Registry through the port interface. +// +// The registry itself is intentionally small (no global state, no +// init-time registration, no plug-in loading). Adding a new style is a +// matter of registering an additional port.DiscoveryStyle in +// cmd/ans-ra/main.go — the contributor walk-through lives in +// docs/contributing-discovery-profiles.md. +package registry + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// Registry composes port.DiscoveryStyle implementations by ID. Immutable +// post-construction: New stores both a lookup map (for O(1) Get) and an +// insertion-order slice (for stable IDs() iteration). Reads are safe for +// concurrent use without locking; there is no Add/Remove API. +type Registry struct { + styles map[domain.DNSRecordStyle]port.DiscoveryStyle + order []domain.DNSRecordStyle +} + +// New constructs a Registry from the given styles in argument order. +// Returns an error when any style's ID is invalid (per +// domain.DNSRecordStyle.IsValid) or when two styles share the same ID — +// both are deterministic startup misconfigurations the wiring code in +// cmd/ans-ra/main.go must surface as a fail-loud server-start error, +// rather than degrading silently at the first registration. +// +// Iteration order matches argument order (stable across process restarts +// for a given wiring) so the service walker's emission order on the wire +// is determined here, not by request input. +func New(styles ...port.DiscoveryStyle) (*Registry, error) { + r := &Registry{ + styles: make(map[domain.DNSRecordStyle]port.DiscoveryStyle, len(styles)), + order: make([]domain.DNSRecordStyle, 0, len(styles)), + } + for _, s := range styles { + id := s.ID() + if !id.IsValid() { + return nil, fmt.Errorf("registry: style ID %q is not a valid DNSRecordStyle", id) + } + if _, dup := r.styles[id]; dup { + return nil, fmt.Errorf("registry: duplicate style ID %q", id) + } + r.styles[id] = s + r.order = append(r.order, id) + } + return r, nil +} + +// Get returns the style registered under id, or (nil, false) when no such +// style is wired. Implements port.DiscoveryRegistry. +func (r *Registry) Get(id domain.DNSRecordStyle) (port.DiscoveryStyle, bool) { + s, ok := r.styles[id] + return s, ok +} + +// IDs returns the registered style IDs in insertion order. The returned +// slice is a fresh copy; callers may mutate it without affecting the +// registry. Implements port.DiscoveryRegistry. +func (r *Registry) IDs() []domain.DNSRecordStyle { + out := make([]domain.DNSRecordStyle, len(r.order)) + copy(out, r.order) + return out +} diff --git a/internal/adapter/discovery/registry/registry_test.go b/internal/adapter/discovery/registry/registry_test.go new file mode 100644 index 0000000..e8b62b5 --- /dev/null +++ b/internal/adapter/discovery/registry/registry_test.go @@ -0,0 +1,158 @@ +package registry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// fakeStyle is a minimal port.DiscoveryStyle test double. ID() is the +// only behavior the registry inspects; Records() returns nil so the +// fake stays cheap to instantiate in tables. +type fakeStyle struct{ id domain.DNSRecordStyle } + +func (f fakeStyle) ID() domain.DNSRecordStyle { return f.id } +func (f fakeStyle) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { + return nil +} + +func TestNew(t *testing.T) { + tests := []struct { + name string + styles []port.DiscoveryStyle + wantErr string // substring; empty means success expected + wantOrder []domain.DNSRecordStyle + }{ + { + name: "empty_registry_constructs", + styles: nil, + wantOrder: []domain.DNSRecordStyle{}, + }, + { + name: "single_valid_style", + styles: []port.DiscoveryStyle{fakeStyle{id: domain.DNSRecordStyleSVCB}}, + wantOrder: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + { + name: "two_valid_styles_preserve_argument_order", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyleTXT}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + }, + wantOrder: []domain.DNSRecordStyle{ + domain.DNSRecordStyleTXT, + domain.DNSRecordStyleSVCB, + }, + }, + { + name: "duplicate_id_rejected", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyleSVCB}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + }, + wantErr: "duplicate style ID", + }, + { + name: "invalid_id_rejected", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyle("NOT_A_STYLE")}, + }, + wantErr: "is not a valid DNSRecordStyle", + }, + { + name: "invalid_id_rejected_after_valid_one", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyleSVCB}, + fakeStyle{id: domain.DNSRecordStyle("")}, + }, + wantErr: "is not a valid DNSRecordStyle", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r, err := New(tc.styles...) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.Nil(t, r) + return + } + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, tc.wantOrder, r.IDs()) + }) + } +} + +func TestGet(t *testing.T) { + svcb := fakeStyle{id: domain.DNSRecordStyleSVCB} + txt := fakeStyle{id: domain.DNSRecordStyleTXT} + r, err := New(svcb, txt) + require.NoError(t, err) + + tests := []struct { + name string + id domain.DNSRecordStyle + wantHit bool + wantID domain.DNSRecordStyle + }{ + {name: "hit_svcb", id: domain.DNSRecordStyleSVCB, wantHit: true, wantID: domain.DNSRecordStyleSVCB}, + {name: "hit_txt", id: domain.DNSRecordStyleTXT, wantHit: true, wantID: domain.DNSRecordStyleTXT}, + {name: "miss_unknown_style", id: domain.DNSRecordStyle("UNKNOWN_FAMILY"), wantHit: false}, + {name: "miss_empty_id", id: domain.DNSRecordStyle(""), wantHit: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := r.Get(tc.id) + assert.Equal(t, tc.wantHit, ok) + if tc.wantHit { + require.NotNil(t, got) + assert.Equal(t, tc.wantID, got.ID()) + } else { + assert.Nil(t, got) + } + }) + } +} + +// TestIDs_ReturnsCopy pins that the slice IDs() returns is a fresh copy — +// callers mutating it must not affect the registry's internal order, so +// concurrent readers can safely iterate without coordination. +func TestIDs_ReturnsCopy(t *testing.T) { + r, err := New( + fakeStyle{id: domain.DNSRecordStyleTXT}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + ) + require.NoError(t, err) + + first := r.IDs() + first[0] = domain.DNSRecordStyle("MUTATED") + + second := r.IDs() + assert.Equal(t, []domain.DNSRecordStyle{ + domain.DNSRecordStyleTXT, + domain.DNSRecordStyleSVCB, + }, second, "mutating one IDs() result must not affect a subsequent call") +} + +// TestIDs_StableAcrossCalls pins that two consecutive IDs() calls return +// the same insertion order. Map iteration is non-deterministic in Go; +// the registry must materialize order from the order slice, not the map. +func TestIDs_StableAcrossCalls(t *testing.T) { + r, err := New( + fakeStyle{id: domain.DNSRecordStyleTXT}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + ) + require.NoError(t, err) + + for range 100 { + assert.Equal(t, []domain.DNSRecordStyle{ + domain.DNSRecordStyleTXT, + domain.DNSRecordStyleSVCB, + }, r.IDs()) + } +} diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 0cb0347..24f289e 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -252,6 +252,157 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { } } +// TestLookupVerifier_SVCB exercises the Consolidated Approach SVCB +// verifier across match, missing, and shape-mismatch paths. The match +// case tests the same presentation form the RA's +// ComputeRequiredDNSRecords emits (see internal/domain/dnsrecords.go). +// +// Restricted to IANA-registered SvcParamKeys (alpn + port) because the +// miekg/dns zone-file parser used by the test fixture rejects symbolic +// names for the still-provisional Consolidated Approach SvcParams (`wk`, +// `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per +// RFC 9460 §6, the verifier-side test exercises the dispatch and +// matching path with registered keys; the unregistered keys are +// unit-tested at the domain layer (internal/domain/dnsrecords_test.go). +func TestLookupVerifier_SVCB(t *testing.T) { + tests := []struct { + name string + zoneName string // RR owner-name in zone fixture + zoneRR string // full RR as miekg/dns zone-file syntax + queryName string // ExpectedDNSRecord.Name + want string // ExpectedDNSRecord.Value + found bool + why string + }{ + { + name: "match", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443`, + found: true, + }, + { + name: "missing-different-name-in-zone", + zoneName: "other.example.com.", + zoneRR: `other.example.com. 3600 IN SVCB 1 . alpn=a2a`, + queryName: "agent.example.com", + want: `1 . alpn=a2a`, + found: false, + why: "SVCB must not be Found when the zone has no matching record", + }, + { + name: "alias-mode-vs-service-mode-mismatch", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 0 host.provider.example.`, + queryName: "agent.example.com", + want: `1 . alpn=a2a`, + found: false, + why: "ServiceMode expectation should not match an AliasMode record", + }, + { + // RFC 9460 §8 unknown-key ignore: a live record with extra + // SvcParams (e.g. another agentic spec adding its own keys to + // the same SVCB row) must still match when our committed + // SvcParams are present with equal values. A strict-equality + // matcher would fail this and — under DNSSEC AD=true — trip + // the SVCB_DNSSEC_MISMATCH hard fail. + name: "extra-svcparams-tolerated-rfc9460-section-8", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443 mandatory=alpn`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443`, + found: true, + why: "subset match: live record carries extra `mandatory` param, expected params still satisfied", + }, + { + // Mirror of the tolerance case to pin the missing-required- + // param failure: if the live record drops one of our + // committed SvcParams, the match must fail even though it + // shares priority+target with the expected value. + name: "missing-expected-param-fails-subset-match", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443`, + found: false, + why: "subset match requires every expected SvcParam present in the live record", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.add(tc.zoneName, "SVCB", tc.zoneRR) + + recs := []domain.ExpectedDNSRecord{{ + Name: tc.queryName, + Type: domain.DNSRecordSVCB, + Value: tc.want, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found != tc.found { + if tc.why != "" { + t.Error(tc.why) + } + t.Errorf("found=%v want %v; got=%+v", got[0].found, tc.found, got[0]) + } + }) + } +} + +// TestLookupVerifier_HTTPS_DNSSECFlagPropagates locks in that +// verifyHTTPS surfaces the AD bit so a DNSSEC-validated mismatch in a +// signed zone trips the lifecycle hard-fail rule (HTTPS_DNSSEC_MISMATCH) +// the same way TLSA_DNSSEC_MISMATCH does. Without this propagation the +// service layer would silently accept a rewritten HTTPS record. +func TestLookupVerifier_HTTPS_DNSSECFlagPropagates(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.setAD(true) + s.add("agent.example.com.", "HTTPS", + `agent.example.com. 3600 IN HTTPS 1 . alpn="h2"`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", Type: domain.DNSRecordHTTPS, + Value: `1 . alpn=h2`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if !got[0].found { + t.Errorf("HTTPS should match; got=%+v", got[0]) + } + if !got[0].dnssec { + t.Error("DNSSECVerified must surface true for HTTPS when the response carried AD=1") + } +} + +// TestLookupVerifier_SVCB_DNSSECFlagPropagates is the SVCB-side +// counterpart to the HTTPS test above. SVCB carries the security- +// bearing card-sha256 SvcParam (when the RA committed one), so the AD +// bit is load-bearing for the lifecycle SVCB_DNSSEC_MISMATCH rule. +func TestLookupVerifier_SVCB_DNSSECFlagPropagates(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.setAD(true) + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a port=443`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if !got[0].found { + t.Errorf("SVCB should match; got=%+v", got[0]) + } + if !got[0].dnssec { + t.Error("DNSSECVerified must surface true for SVCB when the response carried AD=1") + } +} + func TestLookupVerifier_NXDOMAINSurfacedAsError(t *testing.T) { t.Parallel() s := newTestServer(t) diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index ed5cc33..fd39a38 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net" + "strconv" "strings" "time" @@ -86,6 +87,8 @@ func (v *LookupVerifier) VerifyRecords( r = v.verifyTLSA(lookupCtx, server, rec) case domain.DNSRecordHTTPS: r = v.verifyHTTPS(lookupCtx, server, rec) + case domain.DNSRecordSVCB: + r = v.verifySVCB(lookupCtx, server, rec) default: r.Error = fmt.Sprintf("unsupported record type: %s", rec.Type) } @@ -211,6 +214,13 @@ func (v *LookupVerifier) verifyTLSA(ctx context.Context, server string, rec doma // verifyHTTPS checks for an HTTPS-type record (RFC 9460). Matching // compares the SvcPriority + TargetName + params text verbatim // against the expected value after whitespace normalization. +// +// Captures the DNSSEC AuthenticatedData bit on the response, mirroring +// verifyTLSA and verifySVCB. The service-layer post-verify rule +// (lifecycle.go verifyDNSRecords) treats a DNSSEC-authenticated HTTPS +// record whose value disagrees with the expected one as a hard fail +// — same threat shape as TLSA: an attacker rewrote a record in a +// signed zone. func (v *LookupVerifier) verifyHTTPS(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { r := port.RecordVerification{Record: rec} resp, err := v.exchange(ctx, server, rec.Name, dns.TypeHTTPS) @@ -222,6 +232,7 @@ func (v *LookupVerifier) verifyHTTPS(ctx context.Context, server string, rec dom r.Error = fmt.Sprintf("rcode %s", dns.RcodeToString[resp.Rcode]) return r } + r.DNSSECVerified = resp.AuthenticatedData wantNorm := normalizeHTTPS(rec.Value) for _, rr := range resp.Answer { https, ok := rr.(*dns.HTTPS) @@ -253,6 +264,127 @@ func formatHTTPSValue(s *dns.SVCB) string { return sb.String() } +// verifySVCB checks for a Consolidated Approach SVCB record (RFC 9460) +// at the agent's bare FQDN. Multiple SVCB records can share one RRset +// name distinguished by alpn, and the Consolidated Approach explicitly +// designs for multi-family coexistence in a single record — sibling +// families can share one SVCB row, distinguished by their own +// SvcParamKeys. Verification therefore implements RFC 9460 §8 +// unknown-key ignore semantics as a *subset* match: priority and +// target must equal the expected value exactly, every expected +// SvcParam must be present in the live record with an equal value, +// and additional SvcParams in the live record are tolerated. +// +// A strict-equality matcher would mark a multi-spec record not-found +// and (in a DNSSEC-signed zone) trip the SVCB_DNSSEC_MISMATCH hard +// fail in the lifecycle layer — defeating the entire point of the +// Consolidated Approach. +func (v *LookupVerifier) verifySVCB(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { + r := port.RecordVerification{Record: rec} + resp, err := v.exchange(ctx, server, rec.Name, dns.TypeSVCB) + if err != nil { + r.Error = err.Error() + return r + } + if resp.Rcode != dns.RcodeSuccess { + r.Error = fmt.Sprintf("rcode %s", dns.RcodeToString[resp.Rcode]) + return r + } + r.DNSSECVerified = resp.AuthenticatedData + + expected, err := parseSVCBValue(rec.Value) + if err != nil { + r.Error = fmt.Sprintf("expected SVCB value: %v", err) + return r + } + for _, rr := range resp.Answer { + svcb, ok := rr.(*dns.SVCB) + if !ok { + continue + } + gotStr := formatHTTPSValue(svcb) + if r.Actual == "" { + r.Actual = gotStr + } + actual, err := parseSVCBValue(gotStr) + if err != nil { + // Skip records we can't parse — they'll surface as + // not-found if no other answer matches. + continue + } + if matchesSVCBSubset(expected, actual) { + r.Found = true + r.Actual = gotStr + return r + } + } + return r +} + +// parsedSVCB is the structured form of an SVCB or HTTPS record's +// presentation value: priority, target, and a SvcParam map. Used for +// RFC 9460 §8-compliant subset matching in verifySVCB so that live +// records carrying extra SvcParams from coexisting specs aren't +// treated as mismatches. +type parsedSVCB struct { + priority int + target string + params map[string]string +} + +// parseSVCBValue parses the presentation form +// " [k=v] [k=v] ..." that formatHTTPSValue emits +// (and that ComputeRequiredDNSRecords stores in +// ExpectedDNSRecord.Value). Whitespace inside SvcParam values is not +// supported because neither side emits it. +func parseSVCBValue(s string) (parsedSVCB, error) { + fields := strings.Fields(s) + if len(fields) < 2 { + return parsedSVCB{}, fmt.Errorf("svcb: too few fields in %q", s) + } + priority, err := strconv.Atoi(fields[0]) + if err != nil { + return parsedSVCB{}, fmt.Errorf("svcb: priority %q: %w", fields[0], err) + } + out := parsedSVCB{ + priority: priority, + target: fields[1], + params: make(map[string]string, len(fields)-2), + } + for _, f := range fields[2:] { + eq := strings.IndexByte(f, '=') + if eq < 0 { + // Valueless SvcParamKey (e.g. `no-default-alpn`); store + // with empty value so an expected entry can still match. + out.params[f] = "" + continue + } + out.params[f[:eq]] = f[eq+1:] + } + return out, nil +} + +// matchesSVCBSubset reports whether `actual` carries all SvcParams +// in `expected` (with equal values), tolerating any additional +// SvcParams in `actual`. Priority and target must match exactly. +// +// This is the verifier-side embodiment of RFC 9460 §8 unknown-key +// ignore semantics: the RA only verifies the SvcParams it committed +// to write; SvcParams from other agentic specs sharing the same +// SVCB row pass through unexamined. +func matchesSVCBSubset(expected, actual parsedSVCB) bool { + if expected.priority != actual.priority || expected.target != actual.target { + return false + } + for k, want := range expected.params { + got, ok := actual.params[k] + if !ok || got != want { + return false + } + } + return true +} + // normalizeTLSA collapses whitespace and lowercases the hex so // "3 1 1 abcd..." matches "3 1 1 ABCD...". func normalizeTLSA(s string) string { diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 4e3c49a..8b80e52 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1023,6 +1023,23 @@ components: type: string enum: [A2A, MCP, HTTP-API] + DNSRecordStyle: + type: string + enum: [ANS_SVCB, ANS_TXT] + description: | + Names one DNS record family the RA can emit for an agent + registration. Used as the element type of dnsRecordStyles[]. + + - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN + per RFC 9460. One row per protocol carrying alpn, port, and + capability-locator SvcParams (wk, card-sha256). The + recommended default for new integrations. + - ANS_TXT: original `_ans` TXT shape (one row per protocol), + supported indefinitely for operators with existing zone-edit + tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at + the bare FQDN alongside, since `_ans` TXT carries no + connection hints. + RevocationReason: type: string enum: @@ -1065,6 +1082,27 @@ components: type: string identityCsrPEM: type: string + dnsRecordStyles: + type: array + items: + $ref: '#/components/schemas/DNSRecordStyle' + uniqueItems: true + minItems: 1 + description: | + Set of DNS record families the RA emits in the 202 register + response's dnsRecords[] and in the AGENT_REGISTERED TL + event's attestations.dnsRecordsProvisioned[]. Not echoed on + GET /v2/ans/agents/{agentId}. + + Each value names one record family; an operator publishing + the union (Consolidated Approach SVCB plus the original + `_ans` TXT shape) sends both. Order is not significant and + duplicates are rejected (`uniqueItems: true`). + + Omitted/missing normalizes to ["ANS_SVCB"] server-side + (the recommended default per RFC 9460). An explicit empty + array is rejected (`minItems: 1`). + example: ["ANS_SVCB"] required: - agentDisplayName - version @@ -1283,7 +1321,7 @@ components: type: string type: type: string - enum: [HTTPS, TLSA, TXT] + enum: [HTTPS, SVCB, TLSA, TXT] value: type: string priority: diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index a06be3f..abd7bd3 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -39,6 +39,7 @@ type agentRow struct { SupersedesRegistrationID sql.NullInt64 `db:"supersedes_registration_id"` ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` + DNSRecordStyles sql.NullString `db:"dns_record_styles"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -73,9 +74,58 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.ACMEChallengeExpiresAtMs.Valid { reg.ACMEChallenge.ExpiresAt = msToTime(r.ACMEChallengeExpiresAtMs.Int64) } + if r.DNSRecordStyles.Valid && r.DNSRecordStyles.String != "" { + styles, err := decodeDNSRecordStyles(r.DNSRecordStyles.String) + if err != nil { + return nil, fmt.Errorf("sqlite: decode dns_record_styles: %w", err) + } + reg.DNSRecordStyles = styles + } return reg, nil } +// decodeDNSRecordStyles parses the JSON-array string stored in +// agent_registrations.dns_record_styles into the typed domain slice. +// Empty array unmarshals to a nil slice (the domain layer treats +// empty as "use default") so post-load behavior matches a freshly +// registered agent that didn't set the field. +func decodeDNSRecordStyles(raw string) ([]domain.DNSRecordStyle, error) { + var strs []string + if err := json.Unmarshal([]byte(raw), &strs); err != nil { + return nil, err + } + if len(strs) == 0 { + return nil, nil + } + out := make([]domain.DNSRecordStyle, len(strs)) + for i, s := range strs { + out[i] = domain.DNSRecordStyle(s) + } + return out, nil +} + +// encodeDNSRecordStyles renders a typed style slice as the canonical +// JSON-array string the agent_registrations.dns_record_styles column +// stores. nil/empty input renders empty string so nullableString() +// stamps SQL NULL — domain treats NULL the same as the default set +// per ComputeRequiredDNSRecords. +func encodeDNSRecordStyles(styles []domain.DNSRecordStyle) string { + if len(styles) == 0 { + return "" + } + strs := make([]string, len(styles)) + for i, s := range styles { + strs[i] = string(s) + } + b, err := json.Marshal(strs) + if err != nil { + // Marshalling a []string never errors in practice; surface as + // empty so the column is NULL rather than corrupted JSON. + return "" + } + return string(b) +} + // Save inserts or updates an AgentRegistration. Endpoints, server cert, // and identity CSR are persisted via their dedicated tables — Save only // writes the root aggregate row. @@ -93,8 +143,9 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) registration_timestamp_ms, last_renewal_timestamp_ms, supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, + dns_record_styles, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -109,6 +160,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), + nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), now, now, ) if err != nil { @@ -131,6 +183,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id = ?, acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, + dns_record_styles = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -141,6 +194,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), + nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), now, agent.ID, ) diff --git a/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql b/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql new file mode 100644 index 0000000..8ec2f1f --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql @@ -0,0 +1,40 @@ +-- 006_agent_dns_record_styles.sql +-- Persist the operator's chosen set of DNS record families on the +-- registration row so verify-acme / verify-dns / badge responses +-- carry the same shape the operator chose at registration time. +-- +-- Stored as a JSON array of CONSTANT_CASE strings matching the V2 +-- register schema's DNSRecordStyle enum: +-- "ANS_SVCB" — Consolidated Approach SVCB rows + shared records +-- (RFC 9460; recommended default). +-- "ANS_TXT" — original `_ans` TXT shape + HTTPS RR + shared +-- records. Supported indefinitely for operators with +-- existing zone-edit tooling targeting `_ans.{fqdn}`. +-- +-- Examples: +-- '["ANS_SVCB"]' — default for new V2 registrations +-- '["ANS_TXT"]' — V1 lane + pre-PR rows +-- '["ANS_SVCB","ANS_TXT"]' — §4.4.2 transition union +-- +-- Nullable to allow rows that pre-date this migration to load. The +-- backfill below sets every such row to ["ANS_TXT"] because every +-- agent registered before this PR shipped received the original +-- `_ans` TXT shape — defaulting them to ["ANS_SVCB"] would silently +-- demand SVCB records they were never told to publish. CHECK uses +-- json_valid() (SQLite JSON1) so a malformed array fails at the +-- storage boundary instead of silently coercing in the domain. +-- Element-level validation lives in the service layer, where the +-- INVALID_DNS_RECORD_STYLE error is raised before the row is written. + +ALTER TABLE agent_registrations + ADD COLUMN dns_record_styles TEXT + CHECK (dns_record_styles IS NULL OR json_valid(dns_record_styles)); + +-- Backfill: every row registered before this migration shipped was +-- emitting the legacy `_ans` TXT shape (the only shape pre-PR-13). +-- Stamp them as ["ANS_TXT"] so post-deploy verify-dns calls demand +-- the record family the operator actually published. New rows get +-- the value written explicitly by applyDNSRecordStyles in the service. +UPDATE agent_registrations + SET dns_record_styles = '["ANS_TXT"]' + WHERE dns_record_styles IS NULL; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 71efd91..6149609 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -103,6 +103,22 @@ type AgentRegistration struct { // deviation. ACMEChallenge ACMEChallenge `json:"acmeChallenge,omitzero"` + // CapabilitiesHash is the hex-lowercase SHA-256 digest of the + // operator's submitted Trust Card body. SVCB record emission + // surfaces it as the `card-sha256=` SvcParam (base64url-encoded) + // when non-empty; empty leaves the SvcParam absent and verifiers + // fall back to TOFU. PR13 carries the read-side surface; the + // populator path is intentionally not wired in this PR. + CapabilitiesHash string `json:"capabilitiesHash,omitempty"` + + // DNSRecordStyles is the set of DNS record families the RA emits + // for this registration. Each value names one family — typically + // {ANS_SVCB} (Consolidated Approach), {ANS_TXT} (original `_ans` + // TXT shape), or the {ANS_SVCB, ANS_TXT} transition union. Empty + // at the domain layer is treated as DefaultDNSRecordStyles() by + // ComputeRequiredDNSRecords. + DNSRecordStyles []DNSRecordStyle `json:"dnsRecordStyles,omitempty"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index e1d1794..47bc211 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,6 +1,68 @@ package domain -import "fmt" +// DNSRecordStyle names one DNS record family the RA can emit for an +// agent registration. A registration carries a *set* of styles +// (AgentRegistration.DNSRecordStyles); operators publishing the union +// during a Consolidated Approach transition include both ANS_SVCB and +// ANS_TXT in the same set. +// +// Wire values are CONSTANT_CASE, matching every other enum on the V2 +// register schema (Protocol, RevocationReason, AgentLifecycleStatus, +// NextStep.action, ChallengeInfo.type, DnsRecord.type, etc.). The +// `ANS_` prefix anchors the namespace so a future second agentic spec +// adding its own SVCB family doesn't collide. +type DNSRecordStyle string + +const ( + // DNSRecordStyleSVCB emits Consolidated Approach SVCB records per + // RFC 9460 — one row per protocol at the bare FQDN, carrying alpn, + // port, wk, and card-sha256 SvcParams. + DNSRecordStyleSVCB DNSRecordStyle = "ANS_SVCB" + + // DNSRecordStyleTXT emits the original `_ans` TXT shape — one row + // per protocol at `_ans.{fqdn}`. Supported indefinitely for + // operators with existing zone-edit tooling that targets `_ans.`. + // Includes an HTTPS RR at the bare FQDN since `_ans` TXT carries + // no connection hints. + DNSRecordStyleTXT DNSRecordStyle = "ANS_TXT" +) + +// DefaultDNSRecordStyles is the set applied when the registration +// request omits dnsRecordStyles entirely. Pinned to {ANS_SVCB} so new +// integrations follow §4.4.2's "publish one SVCB record... rather than +// parallel per-ecosystem record trees" SHOULD by default. Returned as a +// fresh slice so callers can mutate without affecting the canonical set. +func DefaultDNSRecordStyles() []DNSRecordStyle { + return []DNSRecordStyle{DNSRecordStyleSVCB} +} + +// IsValid reports whether s is one of the defined styles. Empty +// string is treated as invalid; callers normalize empty/missing +// dnsRecordStyles to DefaultDNSRecordStyles() before validation. +// +// Coherence with the discovery registry is enforced at server start: +// cmd/ans-ra/main.go asserts that every style in +// ValidDNSRecordStyles() has a registered port.DiscoveryStyle adapter +// and vice versa. Drift fails server start, not the first verify-dns +// call. +func (s DNSRecordStyle) IsValid() bool { + switch s { + case DNSRecordStyleSVCB, DNSRecordStyleTXT: + return true + } + return false +} + +// ValidDNSRecordStyles returns the canonical valid set as strings — +// the single source of truth for enum membership. Used by error +// messages and spec generation tooling so adding a third style is a +// one-place change rather than a shotgun edit. +func ValidDNSRecordStyles() []string { + return []string{ + string(DNSRecordStyleSVCB), + string(DNSRecordStyleTXT), + } +} // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -9,6 +71,13 @@ const ( DNSRecordTXT DNSRecordType = "TXT" DNSRecordTLSA DNSRecordType = "TLSA" DNSRecordHTTPS DNSRecordType = "HTTPS" + // DNSRecordSVCB is the "Consolidated Approach" service binding + // record (RFC 9460) emitted at the agent's bare FQDN. One SVCB + // record per protocol carries that protocol's connection hints and + // capability locators in a single DNS lookup. SvcParams from + // sibling families coexist in the same record per RFC 9460 §8 + // unknown-key ignore semantics. + DNSRecordSVCB DNSRecordType = "SVCB" ) // DNSRecordPurpose describes why a DNS record is needed. @@ -30,91 +99,3 @@ type ExpectedDNSRecord struct { Required bool `json:"required"` TTL int `json:"ttl"` } - -// ComputeRequiredDNSRecords generates the DNS records an operator must create -// for a given agent registration. The RA does not create these records — the -// operator manages their own DNS. The RA only verifies they exist. -func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { - fqdn := reg.FQDN() - // Version is emitted as a bare semver string ("1.2.0"). The - // `v`-prefixed form only appears inside the ANS name's hostname - // label — TXT record payloads carry the machine-readable semver - // directly, matching the shape a client would parse with any - // semver library. - version := reg.AnsName.Version().String() - var records []ExpectedDNSRecord - - // _ans TXT record for each protocol endpoint — agent discovery. - for _, ep := range reg.Endpoints { - value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", - version, protocolToANSValue(ep.Protocol), ep.AgentURL) - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans.%s", fqdn), - Type: DNSRecordTXT, - Value: value, - Purpose: PurposeDiscovery, - Required: true, - TTL: 3600, - }) - } - - // _ans-badge TXT record — trust badge. Required alongside _ans: - // resolvers and badge-verifying clients expect to find both, and - // publishing _ans without _ans-badge would advertise an agent - // that fails the public discovery handshake. - if len(reg.Endpoints) > 0 { - badgeValue := fmt.Sprintf("v=ans-badge1; version=%s; url=%s", - version, reg.Endpoints[0].AgentURL) - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans-badge.%s", fqdn), - Type: DNSRecordTXT, - Value: badgeValue, - Purpose: PurposeBadge, - Required: true, - TTL: 3600, - }) - } - - // TLSA record for certificate binding. Every registration has a - // server cert — either BYOC (operator-submitted) or CSR-signed - // (RA issues via its configured `ServerCertificateAuthority`). - // Both paths land through the same ByocServerCertificate struct, - // so `reg.ServerCert` is set for any registration that's reached - // verify-dns. - // - // `3 1 1 ` = DANE-EE + SubjectPublicKeyInfo + SHA-256 - // (RFC 6698). Required=false: operators whose zones aren't - // DNSSEC-signed can't produce a trustworthy TLSA record, so the - // RA doesn't block verify-dns on its presence. The verify layer - // enforces a stricter rule at query time: when a TLSA response - // IS DNSSEC-validated, its value must match the expected - // fingerprint (otherwise an attacker rewrote the record in a - // signed zone — the worst failure mode). That post-verify - // check lives alongside the verifier, not in the record set. - if reg.ServerCert == nil { - return records - } - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_443._tcp.%s", fqdn), - Type: DNSRecordTLSA, - Value: fmt.Sprintf("3 1 1 %s", reg.ServerCert.Fingerprint), - Purpose: PurposeCertificateBinding, - Required: false, - TTL: 3600, - }) - - return records -} - -func protocolToANSValue(p Protocol) string { - switch p { - case ProtocolA2A: - return "a2a" - case ProtocolMCP: - return "mcp" - case ProtocolHTTPAPI: - return "http-api" - default: - return string(p) - } -} diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 9929dec..2ebc77d 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -1,99 +1,47 @@ package domain import ( - "strings" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 2, 3), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, - }, - } - - records := ComputeRequiredDNSRecords(reg) - require.NotEmpty(t, records) - - // 2 endpoints → 2 _ans TXT records + 1 badge record. - var anxCount, badgeCount, tlsaCount int - for _, r := range records { - switch r.Purpose { - case PurposeDiscovery: - anxCount++ - assert.Equal(t, DNSRecordTXT, r.Type) - assert.True(t, strings.HasPrefix(r.Name, "_ans.")) - assert.True(t, r.Required) - assert.Contains(t, r.Value, "v=ans1") - // Version is bare semver, not DNS-label form — TXT - // payloads carry the machine-parseable semver directly. - assert.Contains(t, r.Value, "version=1.2.3") - assert.NotContains(t, r.Value, "v1.2.3", "no v-prefix in TXT payload") - assert.NotContains(t, r.Value, "1-2-3", "no dash form anywhere") - case PurposeBadge: - badgeCount++ - assert.Equal(t, DNSRecordTXT, r.Type) - assert.True(t, strings.HasPrefix(r.Name, "_ans-badge.")) - // Both _ans and _ans-badge are required: badge-verifying - // clients won't trust an agent that publishes _ans alone. - assert.True(t, r.Required) - case PurposeCertificateBinding: - tlsaCount++ - } - } +// TestValidDNSRecordStyles pins the canonical valid set of +// DNSRecordStyle values returned by the helper used in the V2 +// INVALID_DNS_RECORD_STYLE error message and (eventually) by spec +// generation tooling. Order and contents are stable so an external +// client's error-message fixtures can match. +func TestValidDNSRecordStyles(t *testing.T) { + got := ValidDNSRecordStyles() + want := []string{"ANS_SVCB", "ANS_TXT"} + assert.Equal(t, want, got) +} - assert.Equal(t, 2, anxCount) - assert.Equal(t, 1, badgeCount) - assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") +// TestDefaultDNSRecordStyles pins the default set applied when a V2 +// register request omits dnsRecordStyles. {ANS_SVCB} per §4.4.2. +func TestDefaultDNSRecordStyles(t *testing.T) { + got := DefaultDNSRecordStyles() + want := []DNSRecordStyle{DNSRecordStyleSVCB} + assert.Equal(t, want, got) } -func TestComputeRequiredDNSRecords_WithCert(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, - }, - ServerCert: &ByocServerCertificate{Fingerprint: "abcdef"}, +// TestDNSRecordStyle_IsValid covers the typed-enum membership predicate +// applyDNSRecordStyles and the registry-coherence check both rely on. +func TestDNSRecordStyle_IsValid(t *testing.T) { + tests := []struct { + name string + s DNSRecordStyle + want bool + }{ + {name: "ans_svcb_is_valid", s: DNSRecordStyleSVCB, want: true}, + {name: "ans_txt_is_valid", s: DNSRecordStyleTXT, want: true}, + {name: "empty_is_invalid", s: DNSRecordStyle(""), want: false}, + {name: "unknown_is_invalid", s: DNSRecordStyle("UNKNOWN_FAMILY"), want: false}, + {name: "lowercase_is_invalid", s: DNSRecordStyle("ans_svcb"), want: false}, } - - records := ComputeRequiredDNSRecords(reg) - - var tlsaFound bool - for _, r := range records { - if r.Purpose == PurposeCertificateBinding { - tlsaFound = true - assert.Equal(t, DNSRecordTLSA, r.Type) - assert.Contains(t, r.Name, "_443._tcp.") - assert.Contains(t, r.Value, "abcdef") - // Required=false: TLSA is only meaningful when the - // operator's zone is DNSSEC-signed, which is a runtime - // property the domain layer can't know. The verifier - // enforces a post-verify rule: if the TLSA response was - // DNSSEC-validated, its value must match. See - // RegistrationService.verifyDNSRecords. - assert.False(t, r.Required) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.s.IsValid()) + }) } - assert.True(t, tlsaFound) -} - -func TestComputeRequiredDNSRecords_NoEndpoints(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{AnsName: ansName} - records := ComputeRequiredDNSRecords(reg) - assert.Empty(t, records) -} - -func TestProtocolToANSValue(t *testing.T) { - assert.Equal(t, "a2a", protocolToANSValue(ProtocolA2A)) - assert.Equal(t, "mcp", protocolToANSValue(ProtocolMCP)) - assert.Equal(t, "http-api", protocolToANSValue(ProtocolHTTPAPI)) - assert.Equal(t, "UNKNOWN", protocolToANSValue(Protocol("UNKNOWN"))) } diff --git a/internal/port/discovery.go b/internal/port/discovery.go new file mode 100644 index 0000000..2cad34a --- /dev/null +++ b/internal/port/discovery.go @@ -0,0 +1,51 @@ +package port + +import ( + "github.com/godaddy/ans/internal/domain" +) + +// DiscoveryStyle is one named DNS discovery family the RA can emit for an +// agent registration. Implementations live under internal/adapter/discovery/ +// /. Today the bundled set is the ANS family (ANS_SVCB, ANS_TXT); +// additional families plug in as new vendor packages without touching the +// service or domain layers. +// +// Records is a pure function: no I/O, no context, no error. The service +// layer composes per-style outputs by walking a DiscoveryRegistry and +// concatenating each style's records, deduping by (Name, Type, Value) so +// per-family trust records (e.g. _ans-badge, TLSA) emitted by multiple +// styles in the same family land once. +type DiscoveryStyle interface { + // ID returns the wire-format identifier (e.g. "ANS_SVCB", "ANS_TXT"). + // Persisted on agent rows; surfaced on the V2 register schema; used + // as the registry key. + ID() domain.DNSRecordStyle + + // Records returns the DNS records this style needs an operator to + // publish for reg. Includes both per-style discovery records and any + // family-level trust attestation records the style requires (e.g. + // _ans-badge for the ANS family, TLSA for any HTTPS-endpoint binding). + // The service walker dedupes across styles, so a family's shared + // records emit once even when multiple sibling styles request them. + Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord +} + +// DiscoveryRegistry is the lookup surface the service uses to compose +// per-style outputs. Implementations are immutable post-construction so +// reads are safe under concurrent registration / verify-dns load without +// locking. The bundled implementation lives at +// internal/adapter/discovery/registry/registry.go. +type DiscoveryRegistry interface { + // Get returns the style registered under id, or (nil, false) when no + // such style is wired. A miss is informational, not an error: the + // service skips unknown stored styles (e.g. post-decommission rows) + // and logs a WARN, rather than failing the registration. + Get(id domain.DNSRecordStyle) (DiscoveryStyle, bool) + + // IDs returns every registered style's ID in registry-wired + // insertion order. Order is stable across calls and process restarts + // for a given wiring — the service walker iterates IDs() and gates + // each by membership in reg.DNSRecordStyles, so wiring order + // determines emission order on the wire (TL canonical bytes). + IDs() []domain.DNSRecordStyle +} diff --git a/internal/port/dns.go b/internal/port/dns.go index 03d96f2..553612a 100644 --- a/internal/port/dns.go +++ b/internal/port/dns.go @@ -13,10 +13,13 @@ type RecordVerification struct { Actual string // What was actually returned by DNS (empty if not found). Error string // Lookup error, if any. // DNSSECVerified is true when the response carried an - // authenticated-data (AD) bit from a validating resolver. Only - // meaningful for TLSA records — surfacing this to the TL lets a - // downstream verifier trust the cert-binding assertion without - // re-querying DNS themselves. + // authenticated-data (AD) bit from a validating resolver. Set + // on TLSA, SVCB, and HTTPS responses; surfaced to the TL + // attestation so a downstream verifier can trust the cert / + // capability / service binding without re-querying DNS. The + // service layer enforces a hard-fail rule when AD=true and the + // record's value disagrees with the expected one (the threat + // shape: an attacker rewrote a record in a DNSSEC-signed zone). DNSSECVerified bool } diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index f124b9b..d5c8471 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -87,10 +87,10 @@ type agentDetails struct { Links []linkDTO `json:"links"` } -func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { +func mapAgentDetails(res *service.DetailResult, r *http.Request, svc *service.RegistrationService) agentDetails { reg := res.Registration // Stamp endpoints onto the aggregate so the pending-block builder's - // call to domain.ComputeRequiredDNSRecords produces the full record + // call to svc.ComputeRequiredDNSRecords produces the full record // set (endpoints live in their own table and are returned as a // sibling slice by the service layer). reg.Endpoints = res.Endpoints @@ -104,7 +104,7 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { AgentStatus: string(reg.Status), Endpoints: mapEndpointsToDTO(res.Endpoints), RegistrationTimestamp: reg.Details.RegistrationTimestamp.Format("2006-01-02T15:04:05Z07:00"), - RegistrationPending: buildRegistrationPendingBlock(reg, r), + RegistrationPending: buildRegistrationPendingBlock(reg, r, svc), Links: []linkDTO{ {Rel: "self", Href: agentURL(r, reg.AgentID)}, }, @@ -119,7 +119,7 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { // buildV1RegistrationPending. Agents still driving validation/DNS // expose the outstanding challenges + DNS records needed to // progress; terminal states omit the block. -func buildRegistrationPendingBlock(reg *domain.AgentRegistration, r *http.Request) *registrationPendingResponse { +func buildRegistrationPendingBlock(reg *domain.AgentRegistration, r *http.Request, svc *service.RegistrationService) *registrationPendingResponse { switch reg.Status { case domain.StatusPendingValidation: base := schemeOf(r) + "://" + r.Host + "/v2/ans/agents/" + reg.AgentID @@ -150,7 +150,7 @@ func buildRegistrationPendingBlock(reg *domain.AgentRegistration, r *http.Reques } case domain.StatusPendingDNS: base := schemeOf(r) + "://" + r.Host + "/v2/ans/agents/" + reg.AgentID - expected := domain.ComputeRequiredDNSRecords(reg) + expected := svc.ComputeRequiredDNSRecords(reg) dnsRecords := make([]dnsRecordDTO, 0, len(expected)) for _, rec := range expected { dnsRecords = append(dnsRecords, dnsRecordDTO{ diff --git a/internal/ra/handler/lifecycle.go b/internal/ra/handler/lifecycle.go index b345410..e42184d 100644 --- a/internal/ra/handler/lifecycle.go +++ b/internal/ra/handler/lifecycle.go @@ -91,7 +91,7 @@ func (h *LifecycleHandler) Detail(w http.ResponseWriter, r *http.Request) { WriteError(w, err) return } - WriteJSON(w, http.StatusOK, mapAgentDetails(res, r)) + WriteJSON(w, http.StatusOK, mapAgentDetails(res, r, h.svc)) } // ----- GET /v2/ans/agents/{agentId}/certificates/identity ----- diff --git a/internal/ra/handler/lifecycle_test.go b/internal/ra/handler/lifecycle_test.go index e73b23b..71ee272 100644 --- a/internal/ra/handler/lifecycle_test.go +++ b/internal/ra/handler/lifecycle_test.go @@ -774,8 +774,12 @@ func newHandlerFixture(t *testing.T) *handlerFixture { t.Fatal(err) } + discoveryReg, err := service.NewDefaultDiscoveryRegistry() + if err != nil { + t.Fatal(err) + } svc := 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: "ra-signer", diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index 51656bc..375497f 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -39,6 +39,14 @@ type registrationRequest struct { ServerCsrPEM string `json:"serverCsrPEM,omitempty"` ServerCertificatePEM string `json:"serverCertificatePEM,omitempty"` ServerCertificateChainPEM string `json:"serverCertificateChainPEM,omitempty"` + + // DNSRecordStyles is the set of DNS record families the RA emits + // for this registration. Each element is one of "ANS_SVCB" or + // "ANS_TXT". Typical values: ["ANS_SVCB"] (default, recommended), + // ["ANS_TXT"], or ["ANS_SVCB", "ANS_TXT"] (transition union). + // Empty/missing → ["ANS_SVCB"]. Any invalid element rejected + // with 422 INVALID_DNS_RECORD_STYLE. See ANS_SPEC.md §4.4.2. + DNSRecordStyles []string `json:"dnsRecordStyles,omitempty"` } type endpointDTO struct { @@ -153,6 +161,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, + DNSRecordStyles: toDomainDNSRecordStyles(req.DNSRecordStyles), }) if err != nil { WriteError(w, err) @@ -162,6 +171,26 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusAccepted, mapRegistrationResponse(resp, r)) } +// toDomainDNSRecordStyles converts the wire []string into the typed +// domain slice while preserving the nil-vs-empty distinction. nil +// (field omitted in the JSON request) flows through as nil so the +// service layer applies DefaultDNSRecordStyles(); a non-nil empty +// slice (explicit `"dnsRecordStyles": []`) flows through as an +// empty non-nil []DNSRecordStyle so the service layer can reject it +// per the spec's `minItems: 1`. Per-element validity, duplicate +// rejection, and empty-array rejection all live in +// applyDNSRecordStyles. +func toDomainDNSRecordStyles(raw []string) []domain.DNSRecordStyle { + if raw == nil { + return nil + } + out := make([]domain.DNSRecordStyle, len(raw)) + for i, s := range raw { + out[i] = domain.DNSRecordStyle(s) + } + return out +} + // mapEndpointsFromDTO converts the incoming JSON endpoints to the // domain types, returning a validation error on malformed input. func mapEndpointsFromDTO(dtos []endpointDTO) ([]domain.AgentEndpoint, error) { diff --git a/internal/ra/handler/v1registration.go b/internal/ra/handler/v1registration.go index ca9c296..cc5e4ab 100644 --- a/internal/ra/handler/v1registration.go +++ b/internal/ra/handler/v1registration.go @@ -246,7 +246,7 @@ func (h *V1RegistrationHandler) Detail(w http.ResponseWriter, r *http.Request) { WriteError(w, err) return } - WriteJSON(w, http.StatusOK, mapV1AgentDetail(res.Registration, res.Endpoints, r)) + WriteJSON(w, http.StatusOK, mapV1AgentDetail(res.Registration, res.Endpoints, r, h.svc)) } // ----- DTO mapping helpers ----- @@ -376,7 +376,7 @@ func rfc3339Zero(t time.Time) string { // Endpoints arrive as a separate slice because the domain aggregate // stores them in their own repository; the service layer gathers // both and hands them in. -func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEndpoint, r *http.Request) *v1AgentDetailResponse { +func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEndpoint, r *http.Request, svc *service.RegistrationService) *v1AgentDetailResponse { eps := make([]v1EndpointDTO, len(endpoints)) for i, e := range endpoints { fns := make([]v1FunctionDTO, len(e.Functions)) @@ -404,7 +404,7 @@ func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEnd lastRenewal = reg.Details.LastRenewalTimestamp.UTC().Format("2006-01-02T15:04:05Z") } // Stamp endpoints onto the aggregate so the buildV1RegistrationPending - // helper's call to domain.ComputeRequiredDNSRecords produces the + // helper's call to svc.ComputeRequiredDNSRecords produces the // full TRUST / BADGE / DISCOVERY / TLSA record set. The service // layer returns endpoints as a sibling slice (they live in their // own table); the pending block builder needs them on the @@ -421,7 +421,7 @@ func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEnd Endpoints: eps, RegistrationTimestamp: reg.Details.RegistrationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), LastRenewalTimestamp: lastRenewal, - RegistrationPending: buildV1RegistrationPending(reg, r), + RegistrationPending: buildV1RegistrationPending(reg, r, svc), Links: []v1LinkDTO{ {Rel: "self", Href: base}, }, @@ -444,7 +444,7 @@ func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEnd // publish (DISCOVERY/TRUST/BADGE/ // CERTIFICATE_BINDING), VERIFY_DNS nextStep, // expiresAt scaled from the challenge deadline. -func buildV1RegistrationPending(reg *domain.AgentRegistration, r *http.Request) *v1RegistrationPendingResponse { +func buildV1RegistrationPending(reg *domain.AgentRegistration, r *http.Request, svc *service.RegistrationService) *v1RegistrationPendingResponse { switch reg.Status { case domain.StatusPendingValidation: base := schemeOf(r) + "://" + r.Host + "/v1/agents/" + reg.AgentID @@ -474,7 +474,7 @@ func buildV1RegistrationPending(reg *domain.AgentRegistration, r *http.Request) } case domain.StatusPendingDNS: base := schemeOf(r) + "://" + r.Host + "/v1/agents/" + reg.AgentID - expected := domain.ComputeRequiredDNSRecords(reg) + expected := svc.ComputeRequiredDNSRecords(reg) dnsRecords := make([]v1DNSRecordDTO, 0, len(expected)) for _, rec := range expected { dnsRecords = append(dnsRecords, v1DNSRecordDTO{ diff --git a/internal/ra/service/discovery_default.go b/internal/ra/service/discovery_default.go new file mode 100644 index 0000000..016575e --- /dev/null +++ b/internal/ra/service/discovery_default.go @@ -0,0 +1,31 @@ +package service + +import ( + "github.com/godaddy/ans/internal/adapter/discovery/ans" + "github.com/godaddy/ans/internal/adapter/discovery/registry" + "github.com/godaddy/ans/internal/port" +) + +// NewDefaultDiscoveryRegistry returns a registry pre-wired with the +// bundled ANS-family styles (TXTStyle, SVCBStyle) in the canonical +// emission order — TXT first, SVCB second — that the V2 TL canonical +// bytes for the union case were established at. cmd/ans-ra/main.go +// uses it for production wiring; tests across the RA layer use it +// for fixture construction so all paths exercise the same emission +// shape. +// +// Iteration order is the load-bearing part: the service walker +// emits records in registry insertion order, and TL leaves carry +// `dnsRecordsProvisioned[]` byte-for-byte from that ordering. Any +// future production deployment that swaps in a different style set +// MUST construct the registry with TXTStyle and SVCBStyle in this +// same relative order to preserve canonical-bytes parity for +// existing agents. +// +// Errors only when registry.New rejects the wiring (duplicate IDs, +// invalid IDs) — the bundled set passes both checks deterministically, +// but the error return preserves callers' ability to fail loudly on +// startup misconfig per the no-panic-in-request-paths rule. +func NewDefaultDiscoveryRegistry() (port.DiscoveryRegistry, error) { + return registry.New(ans.TXTStyle{}, ans.SVCBStyle{}) +} diff --git a/internal/ra/service/dnsrecords.go b/internal/ra/service/dnsrecords.go new file mode 100644 index 0000000..2b03d84 --- /dev/null +++ b/internal/ra/service/dnsrecords.go @@ -0,0 +1,160 @@ +package service + +import ( + "github.com/rs/zerolog/log" + + "github.com/godaddy/ans/internal/domain" +) + +// ComputeRequiredDNSRecords returns the DNS records the operator must +// publish for reg, composed by walking the discovery registry. The RA +// does not create these records — the operator manages their own DNS; +// the RA only verifies they exist and emits the same set onto the TL +// as `dnsRecordsProvisioned[]`. +// +// Composition rules: +// +// 1. The set of styles to emit is reg.DNSRecordStyles, filtered to +// those the registry actually has wired. Empty after filtering +// (operator omitted dnsRecordStyles, or every entry was unknown +// to the registry) normalizes to domain.DefaultDNSRecordStyles(). +// 2. Iteration order is the registry's insertion order (cmd/main +// wires [TXTStyle, SVCBStyle], so emission proceeds TXT-first +// then SVCB). User-supplied order on reg.DNSRecordStyles has no +// effect — `dnsRecordStyles` is set semantics on the wire. +// 3. Each style's full record list (discovery + family trust records) +// is collected and deduped by (Name, Type, Value). Family trust +// records that overlap across sibling styles in the same family +// (e.g. `_ans-badge` from both ANS_SVCB and ANS_TXT) emit once. +// 4. Records are reordered into discovery-then-trust groupings, +// preserving within-group iteration order. This pins the V2 TL +// `dnsRecordsProvisioned[]` canonical bytes for the union case +// to the historical `[discovery..., badge, TLSA]` shape. +// 5. SVCB rows arrive from the adapter with Required=true. When TXT +// is also resolved, every SVCB row is post-processed to +// Required=false — during the §4.4.2 transition the legacy +// `_ans` TXT family carries the operator's required signal and +// SVCB rides along as optional. +// +// Returns nil when reg has no endpoints AND no server cert (nothing +// meaningful for the operator to publish), matching the pre-refactor +// domain function's empty-input contract. +// +// s.discoveryRegistry is guaranteed non-nil by NewRegistrationService +// (constructor panics on nil), so the walker dereferences it +// unconditionally. +func (s *RegistrationService) ComputeRequiredDNSRecords(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + requested := s.resolveRequestedStyles(reg) + + logger := log.Debug(). + Str("agentId", reg.AgentID). + Strs("requestedStyles", styleStrings(reg.DNSRecordStyles)). + Strs("resolvedStyles", styleStrings(setToSlice(requested))) + logger.Msg("computing required DNS records") + + collected, seen := []domain.ExpectedDNSRecord{}, make(map[string]bool) + for _, id := range s.discoveryRegistry.IDs() { + if !requested[id] { + continue + } + style, ok := s.discoveryRegistry.Get(id) + if !ok { + continue + } + emitted := style.Records(reg) + log.Debug(). + Str("agentId", reg.AgentID). + Str("style", string(id)). + Int("emittedCount", len(emitted)). + Msg("style emitted records") + for _, r := range emitted { + key := r.Name + "|" + string(r.Type) + "|" + r.Value + if seen[key] { + continue + } + seen[key] = true + collected = append(collected, r) + } + } + + // Group: discovery records first (in walker order), then trust + // records (badge, TLSA) — preserves the V2 union-case canonical + // bytes shape `[discovery..., badge, TLSA]`. + result := make([]domain.ExpectedDNSRecord, 0, len(collected)) + var trust []domain.ExpectedDNSRecord + for _, r := range collected { + if r.Purpose == domain.PurposeDiscovery { + result = append(result, r) + } else { + trust = append(trust, r) + } + } + result = append(result, trust...) + + // SVCB Required-flag post-process: §4.4.2 says TXT carries the + // required signal during the transition; SVCB stays optional + // alongside. + if requested[domain.DNSRecordStyleTXT] { + for i := range result { + if result[i].Type == domain.DNSRecordSVCB { + result[i].Required = false + } + } + } + + if len(result) == 0 && len(reg.Endpoints) > 0 { + log.Warn(). + Str("agentId", reg.AgentID). + Strs("resolvedStyles", styleStrings(setToSlice(requested))). + Msg("DNS record computation produced no records despite having endpoints; check discovery registry wiring") + } + + return result +} + +// resolveRequestedStyles filters reg.DNSRecordStyles to those the +// registry has wired, normalizing empty/all-invalid to the default +// set. Unknown styles trigger a WARN log so an operator can spot a +// post-decommission row in their data without parsing verify-dns +// failures. +func (s *RegistrationService) resolveRequestedStyles(reg *domain.AgentRegistration) map[domain.DNSRecordStyle]bool { + requested := make(map[domain.DNSRecordStyle]bool) + for _, id := range reg.DNSRecordStyles { + if _, ok := s.discoveryRegistry.Get(id); ok { + requested[id] = true + continue + } + log.Warn(). + Str("agentId", reg.AgentID). + Str("style", string(id)). + Msg("registration carries DNS style unknown to the running registry; skipping") + } + if len(requested) == 0 { + for _, id := range domain.DefaultDNSRecordStyles() { + requested[id] = true + } + } + return requested +} + +func styleStrings(styles []domain.DNSRecordStyle) []string { + out := make([]string, len(styles)) + for i, s := range styles { + out[i] = string(s) + } + return out +} + +// setToSlice converts the requested-set map to a deterministic slice +// for logging. Order tracks domain.ValidDNSRecordStyles() so logs are +// stable across runs. +func setToSlice(set map[domain.DNSRecordStyle]bool) []domain.DNSRecordStyle { + var out []domain.DNSRecordStyle + for _, valid := range domain.ValidDNSRecordStyles() { + id := domain.DNSRecordStyle(valid) + if set[id] { + out = append(out, id) + } + } + return out +} diff --git a/internal/ra/service/dnsrecords_test.go b/internal/ra/service/dnsrecords_test.go new file mode 100644 index 0000000..dcd66dd --- /dev/null +++ b/internal/ra/service/dnsrecords_test.go @@ -0,0 +1,480 @@ +package service_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/adapter/discovery/registry" + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + "github.com/godaddy/ans/internal/ra/service" +) + +// newTestRegistry returns the bundled ANS-family registry every +// service-level test uses. Mirrors cmd/ans-ra/main.go's wiring so +// emission order in tests matches production. +func newTestRegistry(t *testing.T) port.DiscoveryRegistry { + t.Helper() + r, err := service.NewDefaultDiscoveryRegistry() + require.NoError(t, err) + return r +} + +// newComputeOnlyService returns a RegistrationService wired only with +// the discovery registry — sufficient for ComputeRequiredDNSRecords +// tests, which never touch storage / signing / DNS verification. +// Other dependencies are passed nil; the walker is a pure function of +// reg + registry. +func newComputeOnlyService(t *testing.T) *service.RegistrationService { + t.Helper() + return service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + newTestRegistry(t), + ) +} + +func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoint, capHash string, cert *domain.ByocServerCertificate, styles []domain.DNSRecordStyle) *domain.AgentRegistration { + t.Helper() + v, err := domain.ParseSemVer(version) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{ + AnsName: ansName, + Endpoints: eps, + CapabilitiesHash: capHash, + ServerCert: cert, + DNSRecordStyles: styles, + } +} + +// TestComputeRequiredDNSRecords_StyleMatrix_Integration is the +// migrated cross-style integration matrix from +// internal/domain/dnsrecords_test.go:105-284. Per-adapter tests cover +// within-style rules; this table is the regression suite for the +// styles cross-product (e.g. "SVCB-sole emits no HTTPS RR" — only +// testable across both adapters' output). +func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { + const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + + tests := []struct { + name string + styles []domain.DNSRecordStyle + protocol domain.Protocol + agentURL string + capabilitiesHash string + wantHTTPS bool + wantSVCB bool + wantSVCBRequired bool // applies only when wantSVCB is true + wantLegacyTXT bool + wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") + wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" + wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" + }{ + { + name: "ans_txt_only_emits_https_rr_no_svcb", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + }, + { + name: "ans_svcb_only_omits_https_rr", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // SVCB-sole: only PurposeDiscovery record, must be required + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "union_emits_both_families", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + wantSVCB: true, + // wantSVCBRequired: false — legacy `_ans` TXT carries the + // Required signal during the §4.4.2 transition; SVCB rides + // along as optional. + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb_mcp_wk_mcp_json", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolMCP, + agentURL: "https://agent.example.com/mcp", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=mcp.json", + }, + { + name: "svcb_http_api_omits_wk", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolHTTPAPI, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + }, + { + name: "svcb_card_sha256_present_when_set", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + capabilitiesHash: cardHex, + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + wantSVCBCard: "card-sha256=" + wantCardBase64, + }, + { + name: "svcb_non_443_port_from_url", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com:8443", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=8443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb_http_scheme_defaults_port_80", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "http://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=80", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "empty_styles_coerces_to_default", + styles: nil, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // default ({ANS_SVCB}) is SVCB-sole + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "all_invalid_styles_falls_back_to_default", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage"), domain.DNSRecordStyle("nonsense")}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // fallback default ({ANS_SVCB}) is SVCB-sole + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + } + + svc := newComputeOnlyService(t) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: tc.protocol, AgentURL: tc.agentURL}}, + tc.capabilitiesHash, nil, tc.styles) + + records := svc.ComputeRequiredDNSRecords(reg) + + var sawHTTPS, sawSVCB, sawLegacyTXT bool + var svcbValue string + var svcbRequired bool + for _, r := range records { + switch r.Type { + case domain.DNSRecordHTTPS: + sawHTTPS = true + case domain.DNSRecordSVCB: + sawSVCB = true + svcbValue = r.Value + svcbRequired = r.Required + case domain.DNSRecordTXT: + if strings.HasPrefix(r.Name, "_ans.") { + sawLegacyTXT = true + } + } + } + + assert.Equal(t, tc.wantHTTPS, sawHTTPS, "HTTPS RR presence") + assert.Equal(t, tc.wantSVCB, sawSVCB, "SVCB row presence") + assert.Equal(t, tc.wantLegacyTXT, sawLegacyTXT, "_ans TXT presence") + + if tc.wantSVCB { + assert.Equal(t, tc.wantSVCBRequired, svcbRequired, + "SVCB Required flag mismatch (true iff ANS_SVCB is the sole resolved style)") + assert.Contains(t, svcbValue, tc.wantSVCBPort, + "SVCB port SvcParam mismatch") + if tc.wantSVCBWk != "" { + assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB wk SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "wk=", + "SVCB MUST NOT carry wk= when protocol has no metadata convention") + } + if tc.wantSVCBCard != "" { + assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "card-sha256", + "SVCB MUST NOT carry card-sha256 when CapabilitiesHash is empty") + } + } + }) + } +} + +// TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords pins +// that when the union {ANS_SVCB, ANS_TXT} emits, family trust records +// (`_ans-badge`, TLSA) appear ONCE in the output even though both +// adapters emit them. Catches a regression where the dedup pass is +// removed or the dedup key drifts. +func TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords(t *testing.T) { + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", &domain.ByocServerCertificate{Fingerprint: "abcdef"}, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + + records := svc.ComputeRequiredDNSRecords(reg) + + var badgeCount, tlsaCount int + for _, r := range records { + if r.Purpose == domain.PurposeBadge { + badgeCount++ + } + if r.Purpose == domain.PurposeCertificateBinding { + tlsaCount++ + } + } + assert.Equal(t, 1, badgeCount, "exactly one `_ans-badge` record across the union") + assert.Equal(t, 1, tlsaCount, "exactly one TLSA record across the union") +} + +// TestComputeRequiredDNSRecords_NoEndpoints pins the empty-input +// contract: no endpoints → no records (ServerCert + nil endpoints +// alone is also covered, but the typical case is the V1/V2 detail +// handler hitting an aggregate that hasn't reached PENDING_DNS yet). +func TestComputeRequiredDNSRecords_NoEndpoints(t *testing.T) { + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.0.0", nil, "", nil, nil) + records := svc.ComputeRequiredDNSRecords(reg) + assert.Empty(t, records) +} + +// TestNewRegistrationService_PanicsOnNilDiscoveryRegistry pins the +// fail-loud invariant the constructor enforces. A missing registry +// would silently emit zero `dnsRecordsProvisioned[]` and accept any +// DNS state at verify-dns — trust-root corruption masquerading as +// graceful degradation. Construction is process-start-time, not a +// request path, so the panic does not violate the no-panics-in- +// request-paths rule. +func TestNewRegistrationService_PanicsOnNilDiscoveryRegistry(t *testing.T) { + defer func() { + r := recover() + require.NotNil(t, r, "constructor must panic when discoveryRegistry is nil") + msg, ok := r.(string) + require.True(t, ok, "panic value must be a string explaining the missing dependency") + assert.Contains(t, msg, "discoveryRegistry is required") + }() + _ = service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) +} + +// TestComputeRequiredDNSRecords_UnknownStyleSkipped pins that a +// reg.DNSRecordStyles entry the registry doesn't have is silently +// skipped (with a WARN log; not asserted in this test). The remaining +// valid styles still emit. If every entry is unknown, the walker +// falls back to DefaultDNSRecordStyles. +func TestComputeRequiredDNSRecords_UnknownStyleSkipped(t *testing.T) { + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", nil, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyle("UNKNOWN_FUTURE")}) + + records := svc.ComputeRequiredDNSRecords(reg) + + // SVCB is recognized → SVCB-sole emission. UNKNOWN_FUTURE is dropped. + var sawSVCB bool + for _, r := range records { + if r.Type == domain.DNSRecordSVCB { + sawSVCB = true + } + } + assert.True(t, sawSVCB, "valid style alongside unknown-style still emits") +} + +// TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression pins +// the V2 TL `dnsRecordsProvisioned[]` canonical wire for the §4.4.2 +// transition union (ANS_SVCB + ANS_TXT). Any change to slice ORDER +// (JCS preserves array order per RFC 8785 §3.2.2) would shift the +// SHA-256, signal a wire-shape regression, and break offline-verifier +// hashes for in-flight agents at deploy time. +// +// The hex constant was captured from the pre-refactor domain function +// against this exact input. Do NOT regenerate without explicit +// approval — a change here is a wire-format change, not a test fix. +func TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression(t *testing.T) { + const wantSHA256Hex = "20b7c2c90986deb7891e8637e2be0adf439b3050ecad9d07429f0b707ad05875" + + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.2.3", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + &domain.ByocServerCertificate{Fingerprint: "deadbeefcafe1234"}, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + + records := svc.ComputeRequiredDNSRecords(reg) + + // The expected emission shape (order-preserved) is: + // 1. _ans. TXT (a2a) Required=true + // 2. _ans. TXT (mcp) Required=true + // 3. HTTPS (1 . alpn=h2) Required=false + // 4. SVCB (a2a) Required=false (TXT also resolved) + // 5. SVCB (mcp) Required=false + // 6. _ans-badge. TXT (badge) Required=true + // 7. _443._tcp. TLSA Required=false + require.Len(t, records, 7, "union case must emit exactly 7 records") + assert.Equal(t, "_ans.agent.example.com", records[0].Name) + assert.Equal(t, domain.DNSRecordTXT, records[0].Type) + assert.Equal(t, "_ans.agent.example.com", records[1].Name) + assert.Equal(t, "agent.example.com", records[2].Name) + assert.Equal(t, domain.DNSRecordHTTPS, records[2].Type) + assert.Equal(t, "agent.example.com", records[3].Name) + assert.Equal(t, domain.DNSRecordSVCB, records[3].Type) + assert.False(t, records[3].Required, "SVCB Required=false during transition (TXT carries the required signal)") + assert.Equal(t, "agent.example.com", records[4].Name) + assert.Equal(t, domain.DNSRecordSVCB, records[4].Type) + assert.Equal(t, "_ans-badge.agent.example.com", records[5].Name) + assert.Equal(t, "_443._tcp.agent.example.com", records[6].Name) + assert.Equal(t, domain.DNSRecordTLSA, records[6].Type) + + // SHA-256 over JCS-canonical bytes — pins the exact wire bytes + // the V2 TL leaf will canonicalize. + jsonBytes, err := json.Marshal(records) + require.NoError(t, err) + canonical, err := anscrypto.Canonicalize(jsonBytes) + require.NoError(t, err) + sum := sha256.Sum256(canonical) + gotHex := hex.EncodeToString(sum[:]) + assert.Equal(t, wantSHA256Hex, gotHex, + "V2 union canonical-bytes SHA-256 drifted; investigate before changing the constant") +} + +// TestNewDefaultDiscoveryRegistry pins the default-wiring contract: +// returns a registry containing both ANS-family styles in TXT-then-SVCB +// insertion order. The order is the V2 canonical-bytes input. +func TestNewDefaultDiscoveryRegistry(t *testing.T) { + r, err := service.NewDefaultDiscoveryRegistry() + require.NoError(t, err) + + got := r.IDs() + want := []domain.DNSRecordStyle{domain.DNSRecordStyleTXT, domain.DNSRecordStyleSVCB} + assert.Equal(t, want, got, "default registry must wire TXT before SVCB to preserve V2 union canonical bytes") +} + +// TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission +// pins that a non-default registry wiring (SVCB before TXT) actually +// produces a different emission order — proving the walker honours +// registry insertion order rather than user-supplied +// reg.DNSRecordStyles order. +func TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission(t *testing.T) { + // Build a "production" service (default registry wiring: TXT, SVCB) + // and a custom one with SVCB before TXT. + defaultSvc := newComputeOnlyService(t) + + customReg, err := registry.New(svcStub{id: domain.DNSRecordStyleSVCB, marker: "S"}, svcStub{id: domain.DNSRecordStyleTXT, marker: "T"}) + require.NoError(t, err) + customSvc := service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, customReg) + + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", nil, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + + defaultOut := defaultSvc.ComputeRequiredDNSRecords(reg) + customOut := customSvc.ComputeRequiredDNSRecords(reg) + + // Default: TXT first → first record is the `_ans` TXT. + require.NotEmpty(t, defaultOut) + assert.Equal(t, "_ans.agent.example.com", defaultOut[0].Name, + "default registry wires TXT first; first emitted record is `_ans` TXT") + + // Custom: SVCB stub (marker=S) first, no records (stub returns + // empty slice). Then TXT stub (marker=T), also empty. So custom + // out is empty — pinning that the custom registry was actually + // consulted (without the registry, the walker would fall back to + // the default and produce non-empty records). + assert.Empty(t, customOut, "stub registry produces no records; default fallback is gated by registry presence, not adapter output") +} + +// svcStub is a minimal port.DiscoveryStyle for ordering tests; emits +// no records so the test asserts purely on walker behavior. +type svcStub struct { + id domain.DNSRecordStyle + marker string +} + +func (s svcStub) ID() domain.DNSRecordStyle { return s.id } +func (svcStub) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { + return nil +} + +// inconsistentRegistry violates the IDs()/Get consistency contract: +// IDs() advertises a style that Get() does not have. The walker's +// defensive `if !ok { continue }` branch is the safety net for that +// contract violation. The bundled registry maintains the contract by +// construction, so this fake exercises a branch only a custom +// port.DiscoveryRegistry implementation could ever reach. +type inconsistentRegistry struct{} + +func (inconsistentRegistry) IDs() []domain.DNSRecordStyle { + return []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB} +} + +func (inconsistentRegistry) Get(domain.DNSRecordStyle) (port.DiscoveryStyle, bool) { + return nil, false +} + +// TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic pins the +// defensive branch the walker takes when registry.IDs() and Get fall +// out of sync. The branch is unreachable in production wiring; it +// exists so a future custom port.DiscoveryRegistry implementation +// (e.g. one that hot-reloads styles and races between IDs() and Get) +// degrades to "skip the missing ID" instead of nil-dereferencing the +// returned style. +func TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic(t *testing.T) { + svc := service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + inconsistentRegistry{}) + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", nil, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}) + + // IDs() returns SVCB; Get returns (nil, false). Walker must + // continue without dereferencing style. Result: empty record set + // since the walker has nothing to emit. No panic. + records := svc.ComputeRequiredDNSRecords(reg) + assert.Empty(t, records) +} diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index fda9cd6..b20abd2 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -6,11 +6,84 @@ import ( "encoding/pem" "errors" "fmt" + "strings" "time" "github.com/godaddy/ans/internal/domain" ) +// applyDNSRecordStyles resolves the set of DNS record families the +// registration emits and stores it on the aggregate, enforcing the +// V2 spec's dnsRecordStyles validation rules at the API boundary. +// +// V1 lane is pinned to {ANS_TXT} regardless of the request: V1 +// callers predate the Consolidated Approach and their tooling expects +// the original `_ans` TXT shape. V1 has no dnsRecordStyles field on +// the wire, so this branch is the only path V1 registrations take. +// +// V2 validation enforces the OpenAPI contract: +// - Field absent (nil slice) → defaults to DefaultDNSRecordStyles() +// ({ANS_SVCB}). The spec doesn't list dnsRecordStyles in +// `required`, so omission is legal and the server picks the +// recommended Consolidated Approach. +// - Field present but empty (`"dnsRecordStyles": []`) → 422 +// INVALID_DNS_RECORD_STYLE. Matches `minItems: 1` in the spec — +// a caller who explicitly sends an empty list is signalling +// intent that the schema doesn't permit. +// - Duplicate elements → 422 INVALID_DNS_RECORD_STYLE. Matches +// `uniqueItems: true`. Silent dedup would let a malformed +// client request persist as state the caller didn't intend. +// - Invalid element (not in ValidDNSRecordStyles()) → 422 +// INVALID_DNS_RECORD_STYLE. +// +// The handler-side conversion (toDomainDNSRecordStyles) preserves +// the nil-vs-empty distinction so this function can tell field +// omission from explicit empty. +// +// V1 detection routes through isV1Lane (lifecycle.go) so a future +// schema-version evolution updates one site, not several. The error +// messages reference ValidDNSRecordStyles() so adding a third style +// is a one-place change. +func applyDNSRecordStyles(reg *domain.AgentRegistration, req RegisterRequest) error { + if isV1Lane(req.SchemaVersion) { + reg.DNSRecordStyles = []domain.DNSRecordStyle{domain.DNSRecordStyleTXT} + return nil + } + if req.DNSRecordStyles == nil { + reg.DNSRecordStyles = domain.DefaultDNSRecordStyles() + return nil + } + if len(req.DNSRecordStyles) == 0 { + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + "dnsRecordStyles must contain at least one element when present (omit the field to default to ["+ + string(domain.DNSRecordStyleSVCB)+"])", + ) + } + seen := make(map[domain.DNSRecordStyle]struct{}, len(req.DNSRecordStyles)) + out := make([]domain.DNSRecordStyle, 0, len(req.DNSRecordStyles)) + for _, s := range req.DNSRecordStyles { + if !s.IsValid() { + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyles element %q is not one of %s", + string(s), + strings.Join(domain.ValidDNSRecordStyles(), ", ")), + ) + } + if _, dup := seen[s]; dup { + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyles must not contain duplicates (saw %q twice)", string(s)), + ) + } + seen[s] = struct{}{} + out = append(out, s) + } + reg.DNSRecordStyles = out + return nil +} + // fingerprintOf returns the SHA-256 fingerprint of the DER certificate // inside the given PEM string, formatted as `SHA256:`. // The `SHA256:` prefix matches the algorithm-prefixed form the diff --git a/internal/ra/service/helpers_test.go b/internal/ra/service/helpers_test.go index 7eb0204..cd0c28c 100644 --- a/internal/ra/service/helpers_test.go +++ b/internal/ra/service/helpers_test.go @@ -14,6 +14,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" "math/big" "strings" "testing" @@ -201,3 +202,185 @@ func selfSignedCertPEM(t *testing.T) string { } return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } + +// ----- applyDNSRecordStyles ----- + +// TestApplyDNSRecordStyles covers the V1-pin / V2-default / V2-validate +// branches, including every INVALID_DNS_RECORD_STYLE rejection path +// the API guards: invalid element, duplicate, and explicit empty +// array. The integration tests follow happy paths through +// RegisterAgent and don't reach the rejection branches directly. +func TestApplyDNSRecordStyles(t *testing.T) { + tests := []struct { + name string + req RegisterRequest + wantStyles []domain.DNSRecordStyle + wantErrCode string + }{ + { + name: "v1_pins_to_ans_txt_ignoring_request_field", + req: RegisterRequest{ + SchemaVersion: "V1", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + }, + { + name: "v2_nil_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2"}, + wantStyles: domain.DefaultDNSRecordStyles(), + }, + { + // minItems: 1 in the spec — an explicit empty array is a + // signal of intent the schema doesn't allow. Distinct from + // "field omitted", which still defaults. + name: "v2_explicit_empty_slice_rejected", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{}}, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + { + name: "unset_schema_treated_as_v2_default", + req: RegisterRequest{}, + wantStyles: domain.DefaultDNSRecordStyles(), + }, + { + name: "v2_valid_ans_svcb_only", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + { + name: "v2_valid_ans_txt_only", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + }, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + }, + { + name: "v2_valid_union_preserves_order", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, + }, + wantStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, + }, + { + // uniqueItems: true in the spec — duplicates are rejected + // rather than silently deduped. A caller sending the same + // style twice has either a client bug or an unclear + // intention; surfacing 422 forces the issue out into the + // open instead of letting a request the caller didn't + // quite mean to send become persisted state. + name: "v2_duplicate_elements_rejected", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, + }, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + { + name: "v2_invalid_element_rejected", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, + }, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + { + // CONSTANT_CASE is the wire form. lowercase is rejected so the + // V2 enum stays consistent with every other enum on the spec. + name: "v2_lowercase_element_rejected_as_invalid", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("ans_svcb")}, + }, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + { + // First valid, second invalid — error surfaces at the + // invalid element, no partial state stamped on the aggregate. + name: "v2_mixed_valid_then_invalid_rejected", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyle("garbage"), + }, + }, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := &domain.AgentRegistration{} + err := applyDNSRecordStyles(reg, tc.req) + if tc.wantErrCode != "" { + if err == nil { + t.Fatalf("want error code %q, got nil", tc.wantErrCode) + } + var verr *domain.Error + if !errors.As(err, &verr) { + t.Fatalf("want *domain.Error, got %T: %v", err, err) + } + if verr.Code != tc.wantErrCode { + t.Errorf("code: got %q want %q", verr.Code, tc.wantErrCode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !sameStyles(reg.DNSRecordStyles, tc.wantStyles) { + t.Errorf("DNSRecordStyles: got %v want %v", reg.DNSRecordStyles, tc.wantStyles) + } + }) + } +} + +// TestApplyDNSRecordStyles_ErrorMessageListsValidValues confirms the +// error detail enumerates the canonical valid set so SDK authors get +// an actionable message. Sourced from domain.ValidDNSRecordStyles(). +func TestApplyDNSRecordStyles_ErrorMessageListsValidValues(t *testing.T) { + reg := &domain.AgentRegistration{} + err := applyDNSRecordStyles(reg, RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, + }) + if err == nil { + t.Fatal("expected error") + } + for _, want := range domain.ValidDNSRecordStyles() { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message must list %q; got %q", want, err.Error()) + } + } +} + +// sameStyles compares two style slices for set-equal-with-order. +// Used by TestApplyDNSRecordStyles to assert ordering on the happy +// paths without pulling in reflect.DeepEqual semantics that +// distinguish nil from empty. +func sameStyles(a, b []domain.DNSRecordStyle) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index b6a9f6b..7471e5a 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -456,7 +456,7 @@ type DNSMismatch struct { } // VerifyDNS checks the operator's authoritative nameserver for the -// required records (computed by domain.ComputeRequiredDNSRecords) and +// required records (computed by s.ComputeRequiredDNSRecords) and // advances the registration to ACTIVE on success. // // On success, emits an AGENT_ACTIVE event whose attestations carry @@ -502,7 +502,7 @@ func (s *RegistrationService) VerifyDNS(ctx context.Context, agentID string, in reg.ServerCert = byoc } - expected := domain.ComputeRequiredDNSRecords(reg) + expected := s.ComputeRequiredDNSRecords(reg) mismatches, perRecord, err := s.verifyDNSRecords(ctx, reg.FQDN(), expected) if err != nil { @@ -601,18 +601,29 @@ func (s *RegistrationService) verifyDNSRecords(ctx context.Context, fqdn string, } var out []DNSMismatch for _, r := range res.Results { - // DNSSEC-authenticated TLSA that doesn't match is a hard - // fail regardless of the Required flag. `r.Found` from the - // TLSA verifier is true only when the actual matched the - // expected value after case-insensitive hex normalization, - // so `DNSSECVerified && !Found` captures "response was - // signed, but its content disagreed with the cert we - // issued" — the exact attack we block. - if r.Record.Type == domain.DNSRecordTLSA && r.DNSSECVerified && !r.Found { - out = append(out, DNSMismatch{ - Expected: r.Record, Found: r.Actual, Code: "TLSA_DNSSEC_MISMATCH", - }) - continue + // DNSSEC-authenticated record whose committed value disagrees + // with the expected one is a hard fail regardless of the + // Required flag. `r.Found` is true only when the actual + // matched after type-specific normalization, so + // `DNSSECVerified && !Found` captures "response was signed, + // but its content disagreed with what we issued" — the exact + // attack we block (an attacker rewrote a record in a signed + // zone). Applies to TLSA (cert binding), SVCB (capability + // locator with card-sha256), and HTTPS (service binding). + if r.DNSSECVerified && !r.Found { + switch r.Record.Type { + case domain.DNSRecordTLSA, domain.DNSRecordSVCB, domain.DNSRecordHTTPS: + out = append(out, DNSMismatch{ + Expected: r.Record, Found: r.Actual, + Code: string(r.Record.Type) + "_DNSSEC_MISMATCH", + }) + continue + case domain.DNSRecordTXT: + // TXT records (discovery, badge) carry no + // cryptographic commitment, so a DNSSEC-validated + // mismatch isn't a hard fail — fall through to the + // Required check below. + } } if !r.Record.Required { continue @@ -651,8 +662,8 @@ func (s *RegistrationService) buildAgentRegisteredEvent( // // DNSSECVerified carries forward from the per-record verification // result (set true by the lookup verifier when a validating - // resolver marked the response with the AD bit). Only ever true - // for TLSA today — TXT and HTTPS records don't carry the flag. + // resolver marked the response with the AD bit). True on TLSA, + // SVCB, and HTTPS records; TXT records don't carry the flag. dnssecByKey := make(map[string]bool, len(perRecord)) for _, r := range perRecord { if r.DNSSECVerified { @@ -798,7 +809,7 @@ func (s *RegistrationService) Revoke(ctx context.Context, agentID string, in Rev return &RevokeResult{ Registration: reg, RevokedAt: now, - DNSRecordsToRemove: domain.ComputeRequiredDNSRecords(reg), + DNSRecordsToRemove: s.ComputeRequiredDNSRecords(reg), }, nil } @@ -912,6 +923,6 @@ func (s *RegistrationService) Revoke(ctx context.Context, agentID string, in Rev return &RevokeResult{ Registration: reg, RevokedAt: now, - DNSRecordsToRemove: domain.ComputeRequiredDNSRecords(reg), + DNSRecordsToRemove: s.ComputeRequiredDNSRecords(reg), }, nil } diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 8c3c050..aba9486 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -72,6 +72,15 @@ type RegisterRequest struct { ServerCertificatePEM string ServerCertificateChainPEM string SchemaVersion string + + // DNSRecordStyles is the set of DNS record families the RA emits + // in dnsRecordsProvisioned and tells the operator to publish. + // Each element is one of domain.ValidDNSRecordStyles(); typical + // values are {ANS_SVCB} (default), {ANS_TXT}, or the + // {ANS_SVCB, ANS_TXT} transition union. Empty/nil normalizes to + // domain.DefaultDNSRecordStyles(); any invalid element surfaces + // as INVALID_DNS_RECORD_STYLE before the aggregate is created. + DNSRecordStyles []domain.DNSRecordStyle } // RegisterResponse is returned to the HTTP handler after a successful @@ -120,18 +129,19 @@ type OutboxPayload struct { // sqlx.Tx, cloud adapters can use TransactWriteItems-style atomic // batches. type RegistrationService struct { - agents port.AgentStore - endpoints port.EndpointStore - certs port.CertificateStore - byoc port.ByocCertificateStore - renewals port.RenewalStore - validator port.CertificateValidator - identityCA port.IdentityCertificateAuthority - serverCA port.ServerCertificateAuthority // optional; nil = CSR path rejected - bus port.EventBus - outbox OutboxEnqueuer - uow port.UnitOfWork - dnsVerifier port.DNSVerifier + agents port.AgentStore + endpoints port.EndpointStore + certs port.CertificateStore + byoc port.ByocCertificateStore + renewals port.RenewalStore + validator port.CertificateValidator + identityCA port.IdentityCertificateAuthority + serverCA port.ServerCertificateAuthority // optional; nil = CSR path rejected + bus port.EventBus + outbox OutboxEnqueuer + uow port.UnitOfWork + dnsVerifier port.DNSVerifier + discoveryRegistry port.DiscoveryRegistry // signer is the KeyManager + keyID + raID tuple used to sign // outbox events. When nil, events are still persisted but without // a signature — this is only valid for tests; production configs @@ -150,6 +160,18 @@ type EventSigner struct { // NewRegistrationService constructs a RegistrationService. Dependencies // are injected per SOLID; tests substitute fakes. +// +// discoveryRegistry is required at construction and the constructor +// panics on nil — a missing registry would silently emit zero +// `dnsRecordsProvisioned[]` and accept any DNS state at verify-dns +// (trust-root corruption masquerading as graceful degradation), so +// fail-loud at construction is the only correct policy. Construction +// runs at process start, never on a request path, so the no-panics-in- +// request-paths rule (CLAUDE.md) is upheld. Production builds wire the +// bundled ANS-family registry in cmd/ans-ra/main.go via +// registry.New(ans.TXTStyle{}, ans.SVCBStyle{}); tests build the same +// registry through service.NewDefaultDiscoveryRegistry. There is no +// optional builder. func NewRegistrationService( agents port.AgentStore, endpoints port.EndpointStore, @@ -161,19 +183,24 @@ func NewRegistrationService( bus port.EventBus, outbox OutboxEnqueuer, uow port.UnitOfWork, + discoveryRegistry port.DiscoveryRegistry, ) *RegistrationService { + if discoveryRegistry == nil { + panic("service.NewRegistrationService: discoveryRegistry is required (nil interface — wire registry.New(...) at construction)") + } return &RegistrationService{ - agents: agents, - endpoints: endpoints, - certs: certs, - byoc: byoc, - renewals: renewals, - validator: validator, - identityCA: identityCA, - bus: bus, - outbox: outbox, - uow: uow, - clock: time.Now, + agents: agents, + endpoints: endpoints, + certs: certs, + byoc: byoc, + renewals: renewals, + validator: validator, + identityCA: identityCA, + bus: bus, + outbox: outbox, + uow: uow, + discoveryRegistry: discoveryRegistry, + clock: time.Now, } } @@ -310,6 +337,10 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR + if err := applyDNSRecordStyles(reg, req); err != nil { + return nil, err + } + // Generate the ACME DNS-01 challenge token + expiry. The only // DNS action the operator should take before verify-acme. dns01, _, err := generateChallengeTokens() diff --git a/internal/ra/service/registration_test.go b/internal/ra/service/registration_test.go index 5eadb75..c31fed6 100644 --- a/internal/ra/service/registration_test.go +++ b/internal/ra/service/registration_test.go @@ -68,6 +68,7 @@ func TestRegistration_NoSigner(t *testing.T) { svcNoSig := service.NewRegistrationService( fx.agents, fx.endpoints, fx.certs, fx.byoc, fx.renewals, fx.validator, fx.identityCA, fx.bus, fx.outboxStore, fx.uow, + fx.discoveryReg, ).WithServerCertificateAuthority(fx.serverCA) // Use a fresh ANS name + matching CSR + matching endpoints so @@ -124,6 +125,7 @@ func TestRegistration_RollsBackOnPartialFailure(t *testing.T) { svc := service.NewRegistrationService( fx.agents, failingEndpoints, fx.certs, fx.byoc, fx.renewals, fx.validator, fx.identityCA, fx.bus, fx.outboxStore, fx.uow, + fx.discoveryReg, ).WithServerCertificateAuthority(fx.serverCA) if _, err := svc.RegisterAgent(context.Background(), fx.req); err == nil { @@ -198,6 +200,7 @@ func TestRevoke_RollsBackOnOutboxFailure(t *testing.T) { svc := service.NewRegistrationService( fx.agents, fx.endpoints, fx.certs, fx.byoc, fx.renewals, fx.validator, fx.identityCA, fx.bus, &failingOutbox{}, fx.uow, + fx.discoveryReg, ).WithServerCertificateAuthority(fx.serverCA) if _, err := svc.Revoke(context.Background(), agentID, service.RevokeInput{ @@ -266,6 +269,7 @@ type regFixture struct { identityCA port.IdentityCertificateAuthority serverCA port.ServerCertificateAuthority bus port.EventBus + discoveryReg port.DiscoveryRegistry signerPubPEM string } @@ -321,8 +325,13 @@ func newRegFixture(t *testing.T) *regFixture { t.Fatal(err) } + discoveryReg, err := service.NewDefaultDiscoveryRegistry() + if err != nil { + t.Fatal(err) + } + svc := 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: "ra-signer", @@ -349,6 +358,7 @@ func newRegFixture(t *testing.T) *regFixture { identityCA: identityCA, serverCA: serverCA, bus: bus, + discoveryReg: discoveryReg, signerPubPEM: pubPEM, req: service.RegisterRequest{ OwnerID: "owner-1", diff --git a/internal/ra/service/v1event.go b/internal/ra/service/v1event.go index 7b8ba00..d42e95d 100644 --- a/internal/ra/service/v1event.go +++ b/internal/ra/service/v1event.go @@ -265,7 +265,7 @@ func (s *RegistrationService) buildAgentRevokedV1Event( // sees the full record set (including per-endpoint metadata // records). If it didn't, we'd get back an empty list and the // revoke envelope would ship with no DNS tear-down guidance. - expected := domain.ComputeRequiredDNSRecords(reg) + expected := s.ComputeRequiredDNSRecords(reg) dnsMap := make(map[string]string, len(expected)) for _, r := range expected { dnsMap[r.Name] = r.Value diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 4e3c49a..8b80e52 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1023,6 +1023,23 @@ components: type: string enum: [A2A, MCP, HTTP-API] + DNSRecordStyle: + type: string + enum: [ANS_SVCB, ANS_TXT] + description: | + Names one DNS record family the RA can emit for an agent + registration. Used as the element type of dnsRecordStyles[]. + + - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN + per RFC 9460. One row per protocol carrying alpn, port, and + capability-locator SvcParams (wk, card-sha256). The + recommended default for new integrations. + - ANS_TXT: original `_ans` TXT shape (one row per protocol), + supported indefinitely for operators with existing zone-edit + tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at + the bare FQDN alongside, since `_ans` TXT carries no + connection hints. + RevocationReason: type: string enum: @@ -1065,6 +1082,27 @@ components: type: string identityCsrPEM: type: string + dnsRecordStyles: + type: array + items: + $ref: '#/components/schemas/DNSRecordStyle' + uniqueItems: true + minItems: 1 + description: | + Set of DNS record families the RA emits in the 202 register + response's dnsRecords[] and in the AGENT_REGISTERED TL + event's attestations.dnsRecordsProvisioned[]. Not echoed on + GET /v2/ans/agents/{agentId}. + + Each value names one record family; an operator publishing + the union (Consolidated Approach SVCB plus the original + `_ans` TXT shape) sends both. Order is not significant and + duplicates are rejected (`uniqueItems: true`). + + Omitted/missing normalizes to ["ANS_SVCB"] server-side + (the recommended default per RFC 9460). An explicit empty + array is rejected (`minItems: 1`). + example: ["ANS_SVCB"] required: - agentDisplayName - version @@ -1283,7 +1321,7 @@ components: type: string type: type: string - enum: [HTTPS, TLSA, TXT] + enum: [HTTPS, SVCB, TLSA, TXT] value: type: string priority: