Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cmd/f3redirect/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
55 changes: 39 additions & 16 deletions internal/mappings/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}}
}
74 changes: 74 additions & 0 deletions internal/mappings/dns_parity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
17 changes: 12 additions & 5 deletions internal/mappings/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
39 changes: 39 additions & 0 deletions testdata/dns-instructions.json
Original file line number Diff line number Diff line change
@@ -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 }
]
}
]
}
43 changes: 43 additions & 0 deletions web/src/lib/domains.parity.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
}
});
Loading