diff --git a/README.md b/README.md index 3625b9d..fc48782 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/app/zones.go b/internal/app/zones.go index 3fb4a0e..83caf7d 100644 --- a/internal/app/zones.go +++ b/internal/app/zones.go @@ -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 diff --git a/internal/authoritative/answer.go b/internal/authoritative/answer.go index 33e2fa8..006e793 100644 --- a/internal/authoritative/answer.go +++ b/internal/authoritative/answer.go @@ -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 { diff --git a/internal/authoritative/extended_test.go b/internal/authoritative/extended_test.go new file mode 100644 index 0000000..c97b4c5 --- /dev/null +++ b/internal/authoritative/extended_test.go @@ -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]) + } +} diff --git a/internal/authoritative/index.go b/internal/authoritative/index.go index 489afca..5789930 100644 --- a/internal/authoritative/index.go +++ b/internal/authoritative/index.go @@ -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 } diff --git a/internal/database/store_zones.go b/internal/database/store_zones.go index eb7a9aa..9ebe5a3 100644 --- a/internal/database/store_zones.go +++ b/internal/database/store_zones.go @@ -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) diff --git a/internal/models/models.go b/internal/models/models.go index 58357ec..1b20cb6 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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" } diff --git a/internal/web/handlers_zones.go b/internal/web/handlers_zones.go index fa12495..c7707b2 100644 --- a/internal/web/handlers_zones.go +++ b/internal/web/handlers_zones.go @@ -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) diff --git a/web/templates/pages/zone_form.html b/web/templates/pages/zone_form.html index c31ee06..5fbc4d2 100644 --- a/web/templates/pages/zone_form.html +++ b/web/templates/pages/zone_form.html @@ -31,6 +31,7 @@ @@ -52,6 +53,14 @@ {{end}} + {{if eq $kind "extended"}} +
+ 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. +
+ {{end}} +