Add extended forward zone type for overriding public domain records

An extended zone answers authoritatively only for the records it holds
locally; any name with nothing configured falls through to recursive
resolution instead of a synthesized NXDOMAIN, so a public domain's
records don't need to be duplicated just to override a few of them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8c76Xr2BycaHHu5N4buqi
This commit is contained in:
2026-08-20 17:35:41 -05:00
co-authored by Claude Sonnet 5
parent e6deb5fe84
commit b9cfe5ddce
10 changed files with 181 additions and 21 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ func (a *App) normaliseZoneInput(in ZoneInput, base models.Zone) (models.Zone, s
kind = models.ZoneKind(validate.ZoneKindForName(z.Name))
}
if !kind.Valid() {
return z, "", Invalid("Zone kind %q must be forward, reverse4 or reverse6.", in.Kind)
return z, "", Invalid("Zone kind %q must be forward, extended, reverse4 or reverse6.", in.Kind)
}
z.Kind = kind
+14
View File
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
)
// maxCNAMEChain bounds in-zone CNAME following so a loop cannot hang a query.
@@ -53,6 +55,18 @@ func (z *Zone) Answer(req *dns.Msg, do bool) *dns.Msg {
node, synthesised := z.resolveNode(name)
if node == nil {
// An extended zone is only authoritative for the records it
// explicitly holds. A name with nothing configured for it - at
// the queried name itself, or reached by following a locally
// configured CNAME - is not ours to answer NXDOMAIN or NODATA
// for: the caller falls through to recursive resolution so the
// name is looked up on the public DNS instead.
if z.Kind == models.ZoneExtended {
if depth == 0 {
return nil
}
return m
}
// The name has no data. Distinguish "exists but no records of this
// type" (NODATA) from "does not exist at all" (NXDOMAIN).
if _, exists := z.ents[name]; exists || depth > 0 {
+88
View File
@@ -0,0 +1,88 @@
package authoritative
import (
"testing"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
)
// extendedIndex builds an "extended" zone that overrides a single name
// (www.example.com) and otherwise carries no data, mimicking a domain whose
// public records should still be resolved on the internet.
func extendedIndex(t *testing.T) *Index {
t.Helper()
zone := models.Zone{
ID: 1, Name: "example.com.", Kind: models.ZoneExtended, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
}
recs := []models.Record{
{ZoneID: 1, Name: "www", Type: "A", Data: "10.0.0.1", Enabled: true},
{ZoneID: 1, Name: "www", Type: "AAAA", Data: "2001:db8::1", Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
for _, p := range problems {
t.Fatalf("unexpected build problem: %v", p)
}
return idx
}
func TestExtendedZoneOverridesLocalRecord(t *testing.T) {
idx := extendedIndex(t)
resp := query(t, idx, "www.example.com.", dns.TypeA)
if resp == nil {
t.Fatal("expected a local answer for the overridden name, got nil (fallthrough)")
}
if !resp.Authoritative {
t.Error("expected the override answer to be authoritative")
}
if len(resp.Answer) != 1 {
t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
}
a, ok := resp.Answer[0].(*dns.A)
if !ok || a.A.String() != "10.0.0.1" {
t.Errorf("unexpected answer: %v", resp.Answer[0])
}
}
func TestExtendedZoneFallsThroughForUnknownName(t *testing.T) {
idx := extendedIndex(t)
// No local record for mail.example.com at all: the caller must see nil
// so it forwards the query upstream, instead of a synthesised NXDOMAIN.
resp := query(t, idx, "mail.example.com.", dns.TypeA)
if resp != nil {
t.Fatalf("expected fallthrough (nil) for an unconfigured name, got %+v", resp)
}
}
func TestExtendedZoneFallsThroughForApex(t *testing.T) {
idx := extendedIndex(t)
// The apex itself was never configured (no auto SOA/NS synthesis for an
// extended zone), so even a query at the zone name falls through.
resp := query(t, idx, "example.com.", dns.TypeMX)
if resp != nil {
t.Fatalf("expected fallthrough (nil) for the bare apex, got %+v", resp)
}
}
func TestExtendedZoneNoDataForOverriddenNameOtherType(t *testing.T) {
idx := extendedIndex(t)
// www.example.com is locally overridden for A/AAAA; a query for a type
// that was not configured there is answered locally as NODATA rather
// than forwarded, since the name itself is under local management.
resp := query(t, idx, "www.example.com.", dns.TypeTXT)
if resp == nil {
t.Fatal("expected a local NODATA answer, got nil (fallthrough)")
}
if len(resp.Answer) != 0 {
t.Errorf("expected no answer records, got %d", len(resp.Answer))
}
if resp.Rcode != dns.RcodeSuccess {
t.Errorf("expected NOERROR/NODATA, got %s", dns.RcodeToString[resp.Rcode])
}
}
+23 -14
View File
@@ -188,20 +188,29 @@ func buildZone(mz models.Zone, recs []models.Record) (*Zone, []BuildError) {
}
}
// Automatic SOA management: a zone always answers with a SOA, whether or
// not one was explicitly stored.
if z.soa == nil {
z.soa = synthesiseSOA(mz)
apex := z.node(mz.Name)
apex.types[dns.TypeSOA] = []dns.RR{z.soa}
z.addENT(mz.Name)
}
// Likewise a zone should always have at least one apex NS record.
if len(z.ns) == 0 {
ns := synthesiseNS(mz)
apex := z.node(mz.Name)
apex.types[dns.TypeNS] = append(apex.types[dns.TypeNS], ns)
z.ns = append(z.ns, ns)
// Automatic SOA/NS management makes a zone answer authoritatively for
// every name under its apex, including ones with no configured records
// (NXDOMAIN/NODATA rather than a fall-through). An extended zone is
// deliberately not authoritative for the whole namespace - only for the
// records it explicitly overrides - so it does not get synthesised
// zone metadata: a name with nothing configured must be free to fall
// through to recursive resolution.
if z.Kind != models.ZoneExtended {
// Automatic SOA management: a zone always answers with a SOA, whether or
// not one was explicitly stored.
if z.soa == nil {
z.soa = synthesiseSOA(mz)
apex := z.node(mz.Name)
apex.types[dns.TypeSOA] = []dns.RR{z.soa}
z.addENT(mz.Name)
}
// Likewise a zone should always have at least one apex NS record.
if len(z.ns) == 0 {
ns := synthesiseNS(mz)
apex := z.node(mz.Name)
apex.types[dns.TypeNS] = append(apex.types[dns.TypeNS], ns)
z.ns = append(z.ns, ns)
}
}
return z, problems
}
+3 -1
View File
@@ -33,7 +33,7 @@ func scanZone(sc interface{ Scan(...any) error }) (models.Zone, error) {
// ZoneFilter narrows a zone listing.
type ZoneFilter struct {
Kind string // "", "forward", "reverse4", "reverse6", or "reverse" for both
Kind string // "", "forward" (includes extended), "extended", "reverse4", "reverse6", or "reverse" for both reverse kinds
Search string
}
@@ -47,6 +47,8 @@ func (db *DB) Zones(ctx context.Context, f ZoneFilter) ([]models.Zone, error) {
// no filter
case "reverse":
where = append(where, "z.kind IN ('reverse4','reverse6')")
case "forward":
where = append(where, "z.kind IN ('forward','extended')")
default:
where = append(where, "z.kind = ?")
args = append(args, f.Kind)
+15 -1
View File
@@ -12,17 +12,29 @@ const (
ZoneForward ZoneKind = "forward"
ZoneReverse4 ZoneKind = "reverse4"
ZoneReverse6 ZoneKind = "reverse6"
// ZoneExtended is a forward zone that is authoritative only for the
// records it explicitly holds. Any name it has no local data for falls
// through to recursive resolution instead of being answered NXDOMAIN, so
// it can be used to override a handful of records in an otherwise public
// domain without duplicating that domain's entire record set.
ZoneExtended ZoneKind = "extended"
)
// Valid reports whether k is a kind the application understands.
func (k ZoneKind) Valid() bool {
switch k {
case ZoneForward, ZoneReverse4, ZoneReverse6:
case ZoneForward, ZoneReverse4, ZoneReverse6, ZoneExtended:
return true
}
return false
}
// IsForward reports whether k belongs on the forward-zone side of the UI
// (as opposed to the reverse-zone side).
func (k ZoneKind) IsForward() bool {
return k == ZoneForward || k == ZoneExtended
}
// Label returns a human readable name for the zone kind.
func (k ZoneKind) Label() string {
switch k {
@@ -30,6 +42,8 @@ func (k ZoneKind) Label() string {
return "Reverse IPv4"
case ZoneReverse6:
return "Reverse IPv6"
case ZoneExtended:
return "Extended"
default:
return "Forward"
}
+1 -1
View File
@@ -174,7 +174,7 @@ func (s *Server) handleZoneDelete(w http.ResponseWriter, r *http.Request) error
}
setFlash(w, r, "success", fmt.Sprintf("Zone %s and its records were deleted.", strings.TrimSuffix(zone.Name, ".")))
target := "/zones"
if zone.Kind != models.ZoneForward {
if !zone.Kind.IsForward() {
target = "/zones/reverse"
}
return s.redirect(w, r, target)