diff --git a/cmd/f3redirect/main.go b/cmd/f3redirect/main.go index 806ae61..5de95f0 100644 --- a/cmd/f3redirect/main.go +++ b/cmd/f3redirect/main.go @@ -248,13 +248,17 @@ func cmdDNS(args []string) error { } tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) - fmt.Fprintln(tw, "TYPE\tNAME\tVALUE") + fmt.Fprintln(tw, "TYPE\tNAME\tVALUE\tNEEDED") for _, m := range cfg.Mappings { if only != "" && mappings.NormalizeHost(m.Host) != only { continue } for _, rec := range mappings.DNSInstructions(m, opt) { - fmt.Fprintf(tw, "%s\t%s\t%s\n", rec.Type, rec.Name, rec.Value) + needed := "required" + if rec.Optional { + needed = "recommended" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", rec.Type, rec.Name, rec.Value, needed) } } return tw.Flush() diff --git a/internal/mappings/dns.go b/internal/mappings/dns.go index 251eb72..05ae33c 100644 --- a/internal/mappings/dns.go +++ b/internal/mappings/dns.go @@ -8,6 +8,9 @@ type DNSRecord struct { Name string // the record name (the host itself) Value string // the value: a static IP (A) or our canonical hostname (CNAME) Note string // human-friendly explanation + // Optional reports whether the record is merely recommended (true) vs. + // required to activate the redirect (false). + Optional bool } // DNSOptions describe our serving endpoint so we can tell tenants what to point @@ -23,31 +26,51 @@ type DNSOptions struct { } // DNSInstructions returns the DNS record(s) required to activate the mapping. +// This mirrors the web app's dnsInstructions() exactly (see the shared +// contract in testdata/dns-instructions.json): // -// - An apex/root domain cannot CNAME, so it gets an A record to StaticIP. -// - A subdomain gets a CNAME to CanonicalHost (or, if unset, to its own apex). +// - apex: a required A record to StaticIP, plus a recommended CNAME so the +// www subdomain redirects too (apex can't CNAME itself). +// - subdomain: a single required CNAME to CanonicalHost (or, if unset, to its +// own apex, which must carry the A record). func DNSInstructions(m Mapping, opt DNSOptions) []DNSRecord { host := NormalizeHost(m.Host) if IsApex(host) { + return []DNSRecord{ + { + Type: "A", + Name: host, + Value: opt.StaticIP, + Note: fmt.Sprintf("Required: %s is an apex domain and cannot use a CNAME, so point an A record at the redirect tier's static IP.", host), + Optional: false, + }, + { + Type: "CNAME", + Name: "www." + host, + Value: host, + Note: fmt.Sprintf("Recommended: so www.%s redirects too. Point it at %s (which carries the A record above).", host, host), + Optional: true, + }, + } + } + + if opt.CanonicalHost != "" { return []DNSRecord{{ - Type: "A", - Name: host, - Value: opt.StaticIP, - Note: fmt.Sprintf("%s is an apex domain and cannot use a CNAME; point an A record at the redirect tier's static IP.", host), + Type: "CNAME", + Name: host, + Value: opt.CanonicalHost, + Note: fmt.Sprintf("Required: %s is a subdomain; add a single CNAME to %s. No A record is needed.", host, opt.CanonicalHost), + Optional: false, }} } - target := opt.CanonicalHost - note := fmt.Sprintf("%s is a subdomain; CNAME it to our canonical redirect host.", host) - if target == "" { - target = ApexOf(host) - note = fmt.Sprintf("%s is a subdomain; CNAME it to %s (which must carry an A record to %s).", host, target, opt.StaticIP) - } + apex := ApexOf(host) return []DNSRecord{{ - Type: "CNAME", - Name: host, - Value: target, - Note: note, + Type: "CNAME", + Name: host, + Value: apex, + Note: fmt.Sprintf("Required: %s is a subdomain; CNAME it to %s (which must carry an A record to %s).", host, apex, opt.StaticIP), + Optional: false, }} } diff --git a/internal/mappings/dns_parity_test.go b/internal/mappings/dns_parity_test.go new file mode 100644 index 0000000..067e6ff --- /dev/null +++ b/internal/mappings/dns_parity_test.go @@ -0,0 +1,74 @@ +package mappings + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +// The DNS-instruction rules are implemented in BOTH Go (here) and TypeScript +// (web/src/lib/domains.ts). This test asserts the Go implementation matches the +// shared contract in testdata/dns-instructions.json; an equivalent test in the +// web suite asserts the TS side. If either drifts, one suite goes red. + +type parityRecord struct { + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + Optional bool `json:"optional"` +} + +type parityCase struct { + Name string `json:"name"` + Host string `json:"host"` + Options struct { + StaticIP string `json:"staticIP"` + CanonicalHost string `json:"canonicalHost"` + } `json:"options"` + Records []parityRecord `json:"records"` +} + +func sortParity(rs []parityRecord) { + sort.Slice(rs, func(i, j int) bool { + if rs[i].Type != rs[j].Type { + return rs[i].Type < rs[j].Type + } + return rs[i].Name < rs[j].Name + }) +} + +func TestDNSInstructionsParity(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "testdata", "dns-instructions.json")) + if err != nil { + t.Fatalf("read shared fixture: %v", err) + } + var fx struct { + Cases []parityCase `json:"cases"` + } + if err := json.Unmarshal(b, &fx); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if len(fx.Cases) == 0 { + t.Fatal("fixture has no cases") + } + + for _, c := range fx.Cases { + got := DNSInstructions( + Mapping{Host: c.Host}, + DNSOptions{StaticIP: c.Options.StaticIP, CanonicalHost: c.Options.CanonicalHost}, + ) + gotRecs := make([]parityRecord, 0, len(got)) + for _, r := range got { + gotRecs = append(gotRecs, parityRecord{Type: r.Type, Name: r.Name, Value: r.Value, Optional: r.Optional}) + } + want := append([]parityRecord(nil), c.Records...) + sortParity(gotRecs) + sortParity(want) + if !reflect.DeepEqual(gotRecs, want) { + t.Errorf("case %q (host %s): \n got=%+v\nwant=%+v", c.Name, c.Host, gotRecs, want) + } + } +} diff --git a/internal/mappings/dns_test.go b/internal/mappings/dns_test.go index b5aeec4..3b31a96 100644 --- a/internal/mappings/dns_test.go +++ b/internal/mappings/dns_test.go @@ -7,12 +7,19 @@ func TestDNSInstructionsApex(t *testing.T) { Mapping{Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, DNSOptions{StaticIP: "203.0.113.10"}, ) - if len(recs) != 1 { - t.Fatalf("apex should yield 1 record, got %d", len(recs)) + // Required A record first. + if recs[0].Type != "A" || recs[0].Name != "f3muletown.com" || recs[0].Value != "203.0.113.10" || recs[0].Optional { + t.Errorf("apex required A record = %+v", recs[0]) } - r := recs[0] - if r.Type != "A" || r.Name != "f3muletown.com" || r.Value != "203.0.113.10" { - t.Errorf("apex record = %+v", r) + // Plus a recommended www CNAME pointing at the apex. + var www *DNSRecord + for i := range recs { + if recs[i].Name == "www.f3muletown.com" { + www = &recs[i] + } + } + if www == nil || www.Type != "CNAME" || www.Value != "f3muletown.com" || !www.Optional { + t.Errorf("apex should also recommend a www CNAME, got %+v", recs) } } diff --git a/testdata/dns-instructions.json b/testdata/dns-instructions.json new file mode 100644 index 0000000..07c6561 --- /dev/null +++ b/testdata/dns-instructions.json @@ -0,0 +1,39 @@ +{ + "_comment": "Shared contract for DNS-instruction generation. Asserted by BOTH the Go (internal/mappings) and TS (web/src/lib/domains.ts) suites so the two implementations cannot drift. Only structural fields (type/name/value/optional) are compared; human-readable notes may differ in wording.", + "cases": [ + { + "name": "apex with canonical host: required A + recommended www CNAME", + "host": "f3muletown.com", + "options": { "staticIP": "203.0.113.10", "canonicalHost": "redirect.example.net" }, + "records": [ + { "type": "A", "name": "f3muletown.com", "value": "203.0.113.10", "optional": false }, + { "type": "CNAME", "name": "www.f3muletown.com", "value": "f3muletown.com", "optional": true } + ] + }, + { + "name": "subdomain with canonical host: single required CNAME to canonical", + "host": "stats.f3muletown.com", + "options": { "staticIP": "203.0.113.10", "canonicalHost": "redirect.example.net" }, + "records": [ + { "type": "CNAME", "name": "stats.f3muletown.com", "value": "redirect.example.net", "optional": false } + ] + }, + { + "name": "subdomain without canonical host: CNAME falls back to apex", + "host": "www.f3marshall.com", + "options": { "staticIP": "203.0.113.10", "canonicalHost": "" }, + "records": [ + { "type": "CNAME", "name": "www.f3marshall.com", "value": "f3marshall.com", "optional": false } + ] + }, + { + "name": "apex without canonical host still recommends www", + "host": "example.com", + "options": { "staticIP": "198.51.100.7", "canonicalHost": "" }, + "records": [ + { "type": "A", "name": "example.com", "value": "198.51.100.7", "optional": false }, + { "type": "CNAME", "name": "www.example.com", "value": "example.com", "optional": true } + ] + } + ] +} diff --git a/web/src/lib/domains.parity.test.ts b/web/src/lib/domains.parity.test.ts new file mode 100644 index 0000000..5574027 --- /dev/null +++ b/web/src/lib/domains.parity.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { dnsInstructions } from "./domains"; + +// The DNS-instruction rules live in BOTH TypeScript (here) and Go +// (internal/mappings). This asserts the TS implementation matches the shared +// contract in ../testdata/dns-instructions.json; the Go suite asserts the same +// file. If either drifts, one suite goes red. + +type Rec = { type: string; name: string; value: string; optional: boolean }; +type Case = { + name: string; + host: string; + options: { staticIP: string; canonicalHost: string }; + records: Rec[]; +}; + +const fixture = JSON.parse( + readFileSync(path.join(process.cwd(), "..", "testdata", "dns-instructions.json"), "utf8"), +) as { cases: Case[] }; + +function norm(recs: { type: string; name: string; value: string; optional: boolean }[]): Rec[] { + return recs + .map((r) => ({ type: r.type, name: r.name, value: r.value, optional: r.optional })) + .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type.localeCompare(b.type))); +} + +describe("DNS instruction parity (shared contract with the Go tier)", () => { + it("fixture has cases", () => { + expect(fixture.cases.length).toBeGreaterThan(0); + }); + + for (const c of fixture.cases) { + it(c.name, () => { + const got = dnsInstructions(c.host, { + staticIP: c.options.staticIP, + canonicalHost: c.options.canonicalHost, + }); + expect(norm(got)).toEqual(norm(c.records)); + }); + } +});