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:
@@ -229,6 +229,30 @@ A typical internal zone:
|
|||||||
Names are relative to the zone apex; `@` is the apex itself and `*` is a
|
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.
|
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
|
### Reverse DNS
|
||||||
|
|
||||||
The point of the reverse zone form is that you never calculate a zone name.
|
The point of the reverse zone form is that you never calculate a zone name.
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func (a *App) normaliseZoneInput(in ZoneInput, base models.Zone) (models.Zone, s
|
|||||||
kind = models.ZoneKind(validate.ZoneKindForName(z.Name))
|
kind = models.ZoneKind(validate.ZoneKindForName(z.Name))
|
||||||
}
|
}
|
||||||
if !kind.Valid() {
|
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
|
z.Kind = kind
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"github.com/owen/vibedns/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxCNAMEChain bounds in-zone CNAME following so a loop cannot hang a query.
|
// 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)
|
node, synthesised := z.resolveNode(name)
|
||||||
|
|
||||||
if node == nil {
|
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
|
// The name has no data. Distinguish "exists but no records of this
|
||||||
// type" (NODATA) from "does not exist at all" (NXDOMAIN).
|
// type" (NODATA) from "does not exist at all" (NXDOMAIN).
|
||||||
if _, exists := z.ents[name]; exists || depth > 0 {
|
if _, exists := z.ents[name]; exists || depth > 0 {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// Automatic SOA management: a zone always answers with a SOA, whether or
|
||||||
// not one was explicitly stored.
|
// not one was explicitly stored.
|
||||||
if z.soa == nil {
|
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)
|
apex.types[dns.TypeNS] = append(apex.types[dns.TypeNS], ns)
|
||||||
z.ns = append(z.ns, ns)
|
z.ns = append(z.ns, ns)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return z, problems
|
return z, problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func scanZone(sc interface{ Scan(...any) error }) (models.Zone, error) {
|
|||||||
|
|
||||||
// ZoneFilter narrows a zone listing.
|
// ZoneFilter narrows a zone listing.
|
||||||
type ZoneFilter struct {
|
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
|
Search string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +47,8 @@ func (db *DB) Zones(ctx context.Context, f ZoneFilter) ([]models.Zone, error) {
|
|||||||
// no filter
|
// no filter
|
||||||
case "reverse":
|
case "reverse":
|
||||||
where = append(where, "z.kind IN ('reverse4','reverse6')")
|
where = append(where, "z.kind IN ('reverse4','reverse6')")
|
||||||
|
case "forward":
|
||||||
|
where = append(where, "z.kind IN ('forward','extended')")
|
||||||
default:
|
default:
|
||||||
where = append(where, "z.kind = ?")
|
where = append(where, "z.kind = ?")
|
||||||
args = append(args, f.Kind)
|
args = append(args, f.Kind)
|
||||||
|
|||||||
@@ -12,17 +12,29 @@ const (
|
|||||||
ZoneForward ZoneKind = "forward"
|
ZoneForward ZoneKind = "forward"
|
||||||
ZoneReverse4 ZoneKind = "reverse4"
|
ZoneReverse4 ZoneKind = "reverse4"
|
||||||
ZoneReverse6 ZoneKind = "reverse6"
|
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.
|
// Valid reports whether k is a kind the application understands.
|
||||||
func (k ZoneKind) Valid() bool {
|
func (k ZoneKind) Valid() bool {
|
||||||
switch k {
|
switch k {
|
||||||
case ZoneForward, ZoneReverse4, ZoneReverse6:
|
case ZoneForward, ZoneReverse4, ZoneReverse6, ZoneExtended:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
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.
|
// Label returns a human readable name for the zone kind.
|
||||||
func (k ZoneKind) Label() string {
|
func (k ZoneKind) Label() string {
|
||||||
switch k {
|
switch k {
|
||||||
@@ -30,6 +42,8 @@ func (k ZoneKind) Label() string {
|
|||||||
return "Reverse IPv4"
|
return "Reverse IPv4"
|
||||||
case ZoneReverse6:
|
case ZoneReverse6:
|
||||||
return "Reverse IPv6"
|
return "Reverse IPv6"
|
||||||
|
case ZoneExtended:
|
||||||
|
return "Extended"
|
||||||
default:
|
default:
|
||||||
return "Forward"
|
return "Forward"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ".")))
|
setFlash(w, r, "success", fmt.Sprintf("Zone %s and its records were deleted.", strings.TrimSuffix(zone.Name, ".")))
|
||||||
target := "/zones"
|
target := "/zones"
|
||||||
if zone.Kind != models.ZoneForward {
|
if !zone.Kind.IsForward() {
|
||||||
target = "/zones/reverse"
|
target = "/zones/reverse"
|
||||||
}
|
}
|
||||||
return s.redirect(w, r, target)
|
return s.redirect(w, r, target)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<label class="form-label" for="kindSelect">Zone type</label>
|
<label class="form-label" for="kindSelect">Zone type</label>
|
||||||
<select class="form-select" id="kindSelect" name="kind">
|
<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="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="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>
|
<option value="reverse6" {{if eq $kind "reverse6"}}selected{{end}}>Reverse IPv6 — resolve addresses back to names</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -52,6 +53,14 @@
|
|||||||
<input type="hidden" name="kind" value="{{$kind}}">
|
<input type="hidden" name="kind" value="{{$kind}}">
|
||||||
{{end}}
|
{{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">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="zoneName">
|
<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}}
|
Zone name {{if and $new $isReverse}}<span class="text-body-secondary fw-normal">(optional if a subnet is given)</span>{{end}}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Zone</th>
|
<th>Zone</th>
|
||||||
{{if $reverse}}<th>Kind</th>{{end}}
|
<th>Kind</th>
|
||||||
<th class="text-end">Records</th>
|
<th class="text-end">Records</th>
|
||||||
<th class="text-end">Serial</th>
|
<th class="text-end">Serial</th>
|
||||||
<th class="text-end">TTL</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>
|
<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}}
|
{{if .Description}}<div class="small text-body-secondary">{{truncate 80 .Description}}</div>{{end}}
|
||||||
</td>
|
</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">{{num .RecordCount}}</td>
|
||||||
<td class="text-end mono small">{{.Serial}}</td>
|
<td class="text-end mono small">{{.Serial}}</td>
|
||||||
<td class="text-end">{{.DefaultTTL}}</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."}}
|
"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}}
|
{{else}}
|
||||||
{{template "empty" dict "Icon" "bi-diagram-3" "Title" "No forward zones yet"
|
{{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}}
|
{{end}}
|
||||||
<div class="text-center pb-4">
|
<div class="text-center pb-4">
|
||||||
<a href="/zones/new?kind={{if $reverse}}reverse4{{else}}forward{{end}}" class="btn btn-primary">
|
<a href="/zones/new?kind={{if $reverse}}reverse4{{else}}forward{{end}}" class="btn btn-primary">
|
||||||
|
|||||||
Reference in New Issue
Block a user