Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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)
}
}
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