package server import ( "fmt" "net" "net/http/httptest" "net/netip" "os" "path/filepath" "sort" "strconv" "strings" "testing" "time" "github.com/miekg/dns" "jarl/internal/nscheck" "jarl/internal/porkbun" ) // soaServers answers SOA queries for zone with serial on each address // (all on one port, which it returns). func soaServers(t *testing.T, zone string, serial uint32, addrs ...string) string { t.Helper() port := "0" for _, a := range addrs { pc, err := net.ListenPacket("udp", net.JoinHostPort(a, port)) if err != nil { t.Skipf("can't listen on %s: %v", a, err) } _, port, _ = net.SplitHostPort(pc.LocalAddr().String()) srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) { m := new(dns.Msg) m.SetReply(req) m.Authoritative = true if req.Question[0].Qtype == dns.TypeSOA { rr, _ := dns.NewRR(zone + " 300 IN SOA ns1." + zone + " h." + zone + " " + strconv.FormatUint(uint64(serial), 10) + " 3600 900 1209600 300") m.Answer = []dns.RR{rr} } w.WriteMsg(m) })} go srv.ActivateAndServe() t.Cleanup(func() { srv.Shutdown() }) } return port } func TestRegistrar(t *testing.T) { fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol") pts := httptest.NewServer(fake) t.Cleanup(pts.Close) e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"}, PorkbunAllows: func(d string) bool { return d == "adbraun.lol" }}) e.do("POST", "/zones", e.token, map[string]any{ "name": "adbraun.lol", "nameservers": []string{"ns1.adbraun.lol.", "ns2.adbraun.lol."}, "records": []map[string]any{ {"name": "ns1", "type": "A", "data": "127.0.0.41"}, {"name": "ns1", "type": "AAAA", "data": "::1"}, {"name": "ns2", "type": "A", "data": "127.0.0.42"}, }, }, 201) e.do("POST", "/zones", e.token, map[string]any{"name": "other.lol"}, 201) e.do("GET", "/zones/other.lol/registrar", e.token, nil, 400) st := e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200) if st["ns_match"] != false || len(st["glue"].([]any)) != 2 { t.Fatalf("status: %v", st) } for _, g := range st["glue"].([]any) { if g.(map[string]any)["action"] != "create" { t.Errorf("glue: %v", g) } } // Glue: a dry run changes nothing; then both hosts, IPv4 and IPv6. out := e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, map[string]any{"dry_run": true}, 200) if len(out["done"].([]any)) != 2 || len(fake.Domains["adbraun.lol"].Glue) != 0 { t.Errorf("dry run: %v", out) } e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 200) g := fake.Domains["adbraun.lol"].Glue sort.Strings(g["ns1.adbraun.lol"]) if strings.Join(g["ns1.adbraun.lol"], " ") != "127.0.0.41 ::1" || strings.Join(g["ns2.adbraun.lol"], " ") != "127.0.0.42" { t.Errorf("glue at Porkbun: %v", g) } // NS: refused while the servers don't answer... e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(netip.Addr) bool { return false }} out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 409) if !strings.Contains(out["error"].(map[string]any)["message"].(string), "doesn't serve the zone") { t.Errorf("set-ns without servers: %v", out) } chk := e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"dry_run": true}, 200) if len(chk["checks"].([]any)) == 0 { t.Errorf("dry run checks: %v", chk) } // ...and allowed once they serve the zone. serial := uint32(e.do("GET", "/zones/adbraun.lol", e.token, nil, 200)["soa"].(map[string]any)["serial"].(float64)) port := soaServers(t, "adbraun.lol.", serial, "127.0.0.41", "127.0.0.42", "::1") e.srv.nsChecker.Port = port e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 200) if ns := fake.Domains["adbraun.lol"].NS; strings.Join(ns, " ") != "ns1.adbraun.lol ns2.adbraun.lol" { t.Errorf("NS at Porkbun: %v", ns) } if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); st["ns_match"] != true { t.Errorf("after set-ns: %v", st) } // A DS record for an unsigned zone: reported, removable; and setting // NS while a foreign DS is there is refused. fake.Domains["adbraun.lol"].DS[4242] = porkbun.DS{KeyTag: 4242, Algorithm: 13, DigestType: 2, Digest: "AB"} st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200) found := false for _, f := range st["findings"].([]any) { if f.(map[string]any)["level"] == "error" && strings.Contains(f.(map[string]any)["message"].(string), "isn't signed") { found = true } } if !found { t.Errorf("DS on an unsigned zone not reported: %v", st["findings"]) } out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"ns": []string{"ns1.adbraun.lol"}}, 409) if !strings.Contains(out["error"].(map[string]any)["message"].(string), "DS records") { t.Errorf("set-ns with a foreign DS: %v", out) } e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // not signed e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 200) if len(fake.Domains["adbraun.lol"].DS) != 0 { t.Error("DS not removed") } e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 404) // A signed zone whose key named has published long enough: its DS // goes to Porkbun. e.do("PATCH", "/zones/adbraun.lol", e.token, map[string]any{"dnssec_policy": "default"}, 200) e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // no key yet tag := writeKey(t, filepath.Join(e.srv.eng.Config().Paths.KeysDir(), "_default"), "adbraun.lol.") e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200) if d, ok := fake.Domains["adbraun.lol"].DS[tag]; !ok || d.Algorithm != 13 || d.DigestType != 2 || len(d.Digest) != 64 { t.Errorf("DS at Porkbun: %+v", fake.Domains["adbraun.lol"].DS) } st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200) if !st["ds"].([]any)[0].(map[string]any)["matches"].(bool) { t.Errorf("DS doesn't match: %v", st["ds"]) } out = e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200) if len(out["done"].([]any)) != 0 { t.Errorf("published twice: %v", out) } // Registrar errors are passed on. fake.Domains["adbraun.lol"].APIAccess = false if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); !strings.Contains(st["error"].(string), "API_ACCESS_DISABLED") { t.Errorf("error: %v", st) } e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 502) } // writeKey writes the .key and .state files named would have for a key // of zone that is ready for its DS record, and returns its tag. func writeKey(t *testing.T, dir, zone string) uint16 { t.Helper() k := &dns.DNSKEY{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET, Ttl: 3600}, Flags: 257, Protocol: 3, Algorithm: dns.ECDSAP256SHA256} if _, err := k.Generate(256); err != nil { t.Fatal(err) } base := filepath.Join(dir, fmt.Sprintf("K%s+013+%05d", zone, k.KeyTag())) os.MkdirAll(dir, 0o755) os.WriteFile(base+".key", []byte(k.String()+"\n"), 0o644) os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644) return k.KeyTag() }