package porkbun import ( "encoding/json" "net/http" "strconv" "strings" "sync" ) // Fake is an in-memory stand-in for the Porkbun API, for tests: it knows // one account's domains and checks the key like Porkbun does. type Fake struct { APIKey, Secret string mu sync.Mutex Domains map[string]*FakeDomain // Calls lists the paths called, in order. Calls []string } // FakeDomain is a domain in a Fake. type FakeDomain struct { NS []string Glue map[string][]string // host -> addresses DS map[uint16]DS APIAccess bool } // NewFake returns a Fake with the given domains (API access on, Porkbun's // own name servers, no glue or DS). func NewFake(key, secret string, domains ...string) *Fake { f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}} for _, d := range domains { f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true} } return f } func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() reply := func(status int, v map[string]any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } fail := func(status int, code, msg string) { reply(status, map[string]any{"status": "ERROR", "code": code, "message": msg}) } var body map[string]any json.NewDecoder(r.Body).Decode(&body) path := strings.TrimPrefix(r.URL.Path, "/api/json/v3") f.Calls = append(f.Calls, path) if body["apikey"] != f.APIKey || body["secretapikey"] != f.Secret { fail(http.StatusForbidden, "INVALID_API_KEYS_001", "Invalid API key.") return } if path == "/ping" { reply(200, map[string]any{"status": "SUCCESS", "yourIp": "192.0.2.1", "credentialsValid": true, "account": "tester", "scopes": []string{"full"}}) return } parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) < 3 { fail(404, "NOT_FOUND", "Unknown endpoint.") return } op, domain := parts[0]+"/"+parts[1], parts[2] d := f.Domains[domain] if d == nil { fail(400, "DOMAIN_NOT_FOUND", "Domain not found in this account.") return } if !d.APIAccess { fail(403, "API_ACCESS_DISABLED", "The domain is not opted in to API access.") return } host := "" if len(parts) > 3 { host = parts[3] + "." + domain } ips := func() []string { var out []string if l, ok := body["ips"].([]any); ok { for _, v := range l { out = append(out, v.(string)) } } return out } switch op { case "domain/getNs": reply(200, map[string]any{"status": "SUCCESS", "ns": d.NS}) case "domain/updateNs": var ns []string for _, v := range body["ns"].([]any) { n := v.(string) if strings.HasSuffix(n, "."+domain) && d.Glue[n] == nil { fail(400, "INVALID_NAMESERVER", "Host "+n+" doesn't exist at the registry.") return } ns = append(ns, n) } d.NS = ns reply(200, map[string]any{"status": "SUCCESS"}) case "domain/getGlue": hosts := []any{} for h, a := range d.Glue { v4, v6 := []string{}, []string{} for _, ip := range a { if strings.Contains(ip, ":") { v6 = append(v6, ip) } else { v4 = append(v4, ip) } } hosts = append(hosts, []any{h, map[string]any{"v4": v4, "v6": v6}}) } reply(200, map[string]any{"status": "SUCCESS", "hosts": hosts}) case "domain/createGlue": if d.Glue[host] != nil { fail(400, "GLUE_EXISTS", "Host already exists.") return } d.Glue[host] = ips() reply(200, map[string]any{"status": "SUCCESS"}) case "domain/updateGlue": if d.Glue[host] == nil { fail(400, "GLUE_NOT_FOUND", "Host not found.") return } d.Glue[host] = ips() reply(200, map[string]any{"status": "SUCCESS"}) case "domain/deleteGlue": for _, n := range d.NS { if n == host { fail(400, "GLUE_IN_USE", "Host is in use as a name server.") return } } delete(d.Glue, host) reply(200, map[string]any{"status": "SUCCESS"}) case "dns/getDnssecRecords": if len(d.DS) == 0 { reply(200, map[string]any{"status": "SUCCESS", "records": []any{}}) return } recs := map[string]any{} for tag, ds := range d.DS { recs[strconv.Itoa(int(tag))] = map[string]any{"keyTag": strconv.Itoa(int(tag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": strings.ToLower(ds.Digest)} } reply(200, map[string]any{"status": "SUCCESS", "records": recs}) case "dns/createDnssecRecord": num := func(k string) int { n, _ := strconv.Atoi(body[k].(string)); return n } tag := uint16(num("keyTag")) d.DS[tag] = DS{KeyTag: tag, Algorithm: uint8(num("alg")), DigestType: uint8(num("digestType")), Digest: strings.ToUpper(body["digest"].(string))} reply(200, map[string]any{"status": "SUCCESS"}) case "dns/deleteDnssecRecord": tag, _ := strconv.Atoi(parts[3]) delete(d.DS, uint16(tag)) reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."}) default: fail(404, "NOT_FOUND", "Unknown endpoint.") } }