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
+24
View File
@@ -229,6 +229,30 @@ A typical internal zone:
Names are relative to the zone apex; `@` is the apex itself and `*` is a
wildcard. Quoting and 255-character chunking for TXT records is handled for you.
### An extended zone
```bash
# UI: Zones → Forward Zones → Add Zone → Zone type: Extended
```
Use this when you want to override a handful of records for a domain that is
otherwise a real public domain, without copying every one of its public
records into vibeDNS. Only the names you add records for are answered
locally; every other name (and every other record type at names you did add)
is forwarded to the upstream resolver, exactly like a query for a domain with
no local zone at all. If the local lookup finds nothing, the query goes to
the internet; if that also fails, the client gets an error, not a false
NXDOMAIN.
| Name | Type | Value | TTL |
|---|---|---|---|
| `vpn` | A | `192.168.1.10` | 3600 |
With a zone like `example.com` set to Extended and only `vpn.example.com`
configured, `vpn.example.com` resolves locally while `www.example.com`,
`example.com` itself, and everything else under the domain still resolve
against the public DNS.
### Reverse DNS
The point of the reverse zone form is that you never calculate a zone name.
+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])
}
}
+9
View File
@@ -188,6 +188,14 @@ func buildZone(mz models.Zone, recs []models.Record) (*Zone, []BuildError) {
}
}
// 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 {
@@ -203,6 +211,7 @@ func buildZone(mz models.Zone, recs []models.Record) (*Zone, []BuildError) {
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)
+9
View File
@@ -31,6 +31,7 @@
<label class="form-label" for="kindSelect">Zone type</label>
<select class="form-select" id="kindSelect" name="kind">
<option value="forward" {{if eq $kind "forward"}}selected{{end}}>Forward — resolve names to addresses</option>
<option value="extended" {{if eq $kind "extended"}}selected{{end}}>Extended — override records for a domain otherwise resolved on the public DNS</option>
<option value="reverse4" {{if eq $kind "reverse4"}}selected{{end}}>Reverse IPv4 — resolve addresses back to names</option>
<option value="reverse6" {{if eq $kind "reverse6"}}selected{{end}}>Reverse IPv6 — resolve addresses back to names</option>
</select>
@@ -52,6 +53,14 @@
<input type="hidden" name="kind" value="{{$kind}}">
{{end}}
{{if eq $kind "extended"}}
<div class="alert alert-info small mb-3">
An extended zone answers locally only for the records you add below. Any other
name or record type under this zone is forwarded to the public DNS, and only
fails if the internet lookup also fails.
</div>
{{end}}
<div class="mb-3">
<label class="form-label" for="zoneName">
Zone name {{if and $new $isReverse}}<span class="text-body-secondary fw-normal">(optional if a subnet is given)</span>{{end}}
+3 -3
View File
@@ -37,7 +37,7 @@
<thead>
<tr>
<th>Zone</th>
{{if $reverse}}<th>Kind</th>{{end}}
<th>Kind</th>
<th class="text-end">Records</th>
<th class="text-end">Serial</th>
<th class="text-end">TTL</th>
@@ -53,7 +53,7 @@
<a href="/zones/{{.ID}}" class="fw-semibold text-decoration-none mono">{{trimDot .Name}}</a>
{{if .Description}}<div class="small text-body-secondary">{{truncate 80 .Description}}</div>{{end}}
</td>
{{if $reverse}}<td><span class="badge text-bg-light text-dark">{{.Kind.Label}}</span></td>{{end}}
<td><span class="badge text-bg-light text-dark">{{.Kind.Label}}</span></td>
<td class="text-end">{{num .RecordCount}}</td>
<td class="text-end mono small">{{.Serial}}</td>
<td class="text-end">{{.DefaultTTL}}</td>
@@ -98,7 +98,7 @@
"Message" "Reverse zones answer PTR lookups that turn an IP address back into a host name. Enter a subnet such as 192.168.1.0/24 and the correct in-addr.arpa zone name is worked out for you."}}
{{else}}
{{template "empty" dict "Icon" "bi-diagram-3" "Title" "No forward zones yet"
"Message" "A forward zone lets this server answer authoritatively for a domain such as internal.example.com, independently of the public DNS."}}
"Message" "A forward zone lets this server answer authoritatively for a domain such as internal.example.com, independently of the public DNS. An extended zone does the same but only for the records you add, forwarding everything else to the public DNS."}}
{{end}}
<div class="text-center pb-4">
<a href="/zones/new?kind={{if $reverse}}reverse4{{else}}forward{{end}}" class="btn btn-primary">