Skip to content

feat(dns): add DNS auto-provisioning via RFC 2136 dynamic updates - #24

Open
fobispo-tc wants to merge 2 commits into
agentnameservice:mainfrom
fobispo-tc:feat/dns-auto-provisioning
Open

feat(dns): add DNS auto-provisioning via RFC 2136 dynamic updates#24
fobispo-tc wants to merge 2 commits into
agentnameservice:mainfrom
fobispo-tc:feat/dns-auto-provisioning

Conversation

@fobispo-tc

Copy link
Copy Markdown
Contributor

Summary

  • Add port.DNSProvisioner interface for automatic DNS record creation/deletion
  • Implement RFC 2136 (DDNS) adapter with TSIG authentication using miekg/dns
  • Hook provisioning into VerifyDNS (auto-create before verify) and Revoke (best-effort cleanup)
  • Add dns.provisioner config section — independent of the dns.type verifier setting
  • Includes the _ans-badge URL fix from PR fix(domain): point _ans-badge DNS record URL at transparency log #23 (badge records now point to TL, not agent endpoint)

Motivation

In org-level deployments where the RA controls the DNS zone, requiring operators to manually add _ans, _ans-badge, and _443._tcp TLSA records between VerifyACME and VerifyDNS is unnecessary friction. This feature lets the RA provision them automatically via RFC 2136 dynamic DNS updates, collapsing the registration flow from 4 steps to 2.

Configuration

dns:
  type: lookup                      # verifier (unchanged)
  server: "8.8.8.8:53"
  provisioner:                      # NEW — optional
    type: ddns
    ddns:
      server: "ns1.example.com:53"  # authoritative nameserver
      zone: "example.com."          # zone to update
      tsig-name: "ans-updater."     # TSIG key name
      tsig-secret: "base64..."      # TSIG shared secret
      tsig-algorithm: "hmac-sha256" # default
      timeout: 5s                   # default

When dns.provisioner is absent or empty, behavior is unchanged (manual DNS).

Design Decisions

  1. Separate DNSProvisioner interface — not combined with DNSVerifier (ISP compliance, matches existing port pattern)
  2. Provisioning inside VerifyDNS — TLSA records need server cert fingerprint (only available after VerifyACME), so provisioning can't happen earlier
  3. Best-effort cleanup on Revoke — DNS deletion failure is logged but doesn't block revocation (TL event is authoritative)
  4. Independent config — verifier and provisioner are orthogonal (dns.type: lookup + dns.provisioner.type: ddns)

Files Changed

File Change
internal/port/dns.go Add DNSProvisioner interface
internal/adapter/dns/ddns.go New — RFC 2136 adapter with TSIG
internal/adapter/dns/ddns_test.go New — 10 tests against in-process UDP server
internal/adapter/dns/noop_provisioner.go New — no-op for dev/test
internal/config/config.go Add provisioner config structs + validation + PublicBaseURL
internal/domain/dnsrecords.go Accept tlPublicBaseURL param for badge URL fix
internal/domain/dnsrecords_test.go 2 new badge URL tests
internal/ra/service/registration.go Add dnsProvisioner field + builder
internal/ra/service/lifecycle.go Hook provisioning into VerifyDNS + Revoke cleanup
internal/ra/handler/*.go Thread TL public URL for badge fix
cmd/ans-ra/main.go Wire provisioner from config
config/ra-{local,docker}.yaml Document new config options

Test plan

  • All 23 packages pass (go test ./...)
  • 10 new DDNS adapter tests (TXT, TLSA, delete, idempotency, TSIG, error cases)
  • 2 new badge URL domain tests
  • Clean build (go build ./...)
  • Deploy with DDNS config against a real BIND/PowerDNS server
  • Register agent → verify records auto-created
  • Revoke agent → verify records auto-deleted

@fobispo-tc
fobispo-tc force-pushed the feat/dns-auto-provisioning branch 4 times, most recently from 61e83a5 to 807624e Compare May 27, 2026 23:54
@kperry-godaddy kperry-godaddy added the draft Great for things that are not ready to ship yet. label Jun 2, 2026
@fobispo-tc
fobispo-tc force-pushed the feat/dns-auto-provisioning branch from 807624e to 763c090 Compare June 15, 2026 18:45
@fobispo-tc
fobispo-tc marked this pull request as ready for review June 15, 2026 18:46
Copilot AI review requested due to automatic review settings June 15, 2026 18:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds optional automatic DNS record provisioning (RFC 2136 DDNS) and domain-suffix-based host qualification to streamline agent registration/verification/revocation, plus introduces SVCB record generation for endpoint connectivity metadata.

Changes:

  • Add port.DNSProvisioner and implement DDNS + noop provisioners; wire into RA registration lifecycle.
  • Support registration.domain-suffix so handlers can accept short hostnames and the service qualifies them to FQDNs.
  • Extend required DNS record computation to include SVCB records (and adjust TLSA emission timing).

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
internal/ra/service/registration.go Adds DNS provisioner + domain suffix hooks; adds auto-provision registration path that issues certs and provisions DNS.
internal/ra/service/lifecycle.go Provisions DNS records in VerifyDNS; attempts cleanup in Revoke.
internal/ra/handler/v1registration.go Qualifies host via service before building AnsName.
internal/ra/handler/registration.go Qualifies host via service before building AnsName; updates comment.
internal/port/dns.go Defines DNSProvisioner interface.
internal/domain/dnsrecords.go Adds SVCB record type/purpose and SVCB value builder; adjusts TLSA emission.
internal/domain/dnsrecords_test.go Adds tests for SVCB generation and SVCB value building.
internal/config/config.go Adds provisioner config + registration domain suffix; validates provisioner settings.
internal/adapter/dns/noop_provisioner.go Adds no-op provisioner implementation.
internal/adapter/dns/ddns.go Adds RFC 2136 DDNS provisioner implementation (TXT/TLSA/SVCB).
internal/adapter/dns/ddns_test.go Adds DDNS provisioner unit tests and parsing helpers tests.
config/ra-local.yaml Documents optional DDNS provisioner config.
cmd/ans-ra/main.go Wires in DNS provisioner + domain suffix; adds config-based selection.
Comments suppressed due to low confidence (1)

cmd/ans-ra/main.go:160

  • This introduces a duplicate TL endpoints log entry (the second logger.Info() block appears to repeat the same fields). Consolidate into a single log statement to avoid noisy logs and confusion during debugging.
	logger.Info().
		Str("tlPublicBaseURL", cfg.TLClient.PublicBaseURL).
		Str("tlBaseURL", cfg.TLClient.BaseURL).
		Msg("transparency log endpoints configured")

	logger.Info().
		Str("tlPublicBaseURL", cfg.TLClient.PublicBaseURL).
		Str("tlBaseURL", cfg.TLClient.BaseURL).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +480 to +484
// Provision DNS records via DDNS.
expected := domain.ComputeRequiredDNSRecords(reg, s.tlPublicBaseURL)
if err := s.dnsProvisioner.ProvisionRecords(ctx, reg.FQDN(), expected); err != nil {
return nil, fmt.Errorf("dns provision: %w", err)
}
Comment on lines +491 to +495
// Persist agent + certs + TL event atomically.
if err := s.uow.Run(ctx, func(txCtx context.Context) error {
if err := s.agents.Save(txCtx, reg); err != nil {
return err
}
Comment on lines +522 to +525
return s.enqueueTLEvent(txCtx, string(event.TypeAgentRegistered), reg, inner, now)
}); err != nil {
return nil, err
}
Comment on lines +247 to +258
// QualifyHost appends the domain suffix to a hostname if configured
// and the host doesn't already end with it.
func (s *RegistrationService) QualifyHost(host string) string {
if s.domainSuffix == "" {
return host
}
suffix := "." + s.domainSuffix
if strings.HasSuffix(host, suffix) || host == s.domainSuffix {
return host
}
return host + suffix
}
Comment on lines +951 to +954
dnsToRemove := domain.ComputeRequiredDNSRecords(reg, s.tlPublicBaseURL)
if s.dnsProvisioner != nil && len(dnsToRemove) > 0 {
_ = s.dnsProvisioner.DeleteRecords(ctx, reg.FQDN(), dnsToRemove)
}
Comment thread internal/config/config.go Outdated
Comment on lines +476 to +481
if d.TSIGAlgorithm == "" {
d.TSIGAlgorithm = "hmac-sha256"
}
if d.Timeout <= 0 {
d.Timeout = 5 * time.Second
}
Comment on lines +128 to +130
// Parse version + host into an AnsName. The service may append
// a domain suffix to the host (e.g. "my-agent" → "my-agent.agents.example.com").
qualifiedHost := h.svc.QualifyHost(req.AgentHost)
return
}
ansName, err := domain.NewAnsName(semver, req.AgentHost)
ansName, err := domain.NewAnsName(semver, qualifiedHost)
@fobispo-tc
fobispo-tc force-pushed the feat/dns-auto-provisioning branch 2 times, most recently from 8bc5d0f to 2a983f3 Compare June 15, 2026 19:23
  Add port.DNSProvisioner interface and DDNS adapter for automatic DNS
  record management (TXT, TLSA, SVCB) using RFC 2136 with TSIG auth.

  When configured, registration issues certs, provisions DNS, and
  activates in a single call. Revoke auto-deletes records. Domain
  suffix config auto-qualifies short hostnames. SVCB records provide
  agent connectivity parameters per the DNS-AID proposal (RFC 9460).

  Includes the _ans-badge URL fix: badge records point to the TL
  badge endpoint when tl-client.public-base-url is configured.

  Signed-off-by: Francisco Obispo <fobispo@tucows.com>

Signed-off-by: Francisco Obispo <fobispo@tucows.com>
Add GET /v2/public/agents and GET /v2/public/agents/{agentId} as
unauthenticated read-only routes on the RA. These enable agent
discovery without API credentials — agent metadata is inherently
public (published to DNS and the transparency log).

Changes span the full hexagonal stack:
- port: ListAll on AgentStore (owner-agnostic query)
- sqlite: ListAll implementation with same pagination/filtering
- service: ListPublic method on RegistrationService
- handler: new PublicHandler with List and Detail methods
- router: /v2/public/ registered as anonymous path prefix
- dto: parameterized self-link prefix for public vs private routes

The public detail endpoint strips the registrationPending block
(contains ACME challenge tokens) for security. All 21 new tests
pass and the 90% coverage gate is maintained.

Signed-off-by: Fernando Obispo <fobispo@tucowsinc.com>
Signed-off-by: Francisco Obispo <fobispo@tucows.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

draft Great for things that are not ready to ship yet.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants