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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion cmd/ans-ra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/godaddy/ans/internal/adapter/store/sqlite"
"github.com/godaddy/ans/internal/adapter/tlclient"
"github.com/godaddy/ans/internal/config"
"github.com/godaddy/ans/internal/domain"
"github.com/godaddy/ans/internal/port"
"github.com/godaddy/ans/internal/ra/handler"
ramiddleware "github.com/godaddy/ans/internal/ra/middleware"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Comment on lines +509 to +519
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.
//
Expand Down
37 changes: 37 additions & 0 deletions internal/adapter/discovery/ans/ansbadge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package ans

import (
"fmt"

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

// BadgeRecord returns the `_ans-badge.<fqdn>` TXT record every ANS-family
// style emits as the trust attestation hook. Returns a one-element slice
// when reg has at least one endpoint (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,
}}
}
69 changes: 69 additions & 0 deletions internal/adapter/discovery/ans/ansbadge_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
55 changes: 55 additions & 0 deletions internal/adapter/discovery/ans/protocol.go
Original file line number Diff line number Diff line change
@@ -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=<token>`) and SVCB `alpn=<token>` 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 ""
}
}
46 changes: 46 additions & 0 deletions internal/adapter/discovery/ans/protocol_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
Loading