package certs import ( "context" "fmt" "slices" "strings" "jarl/internal/acme" "jarl/internal/model" ) // Route says where the DNS-01 challenge for one certificate name goes, // and why. type Route struct { Domain string `json:"domain"` // the name on the certificate Record string `json:"record"` // _acme-challenge. // Target is where the TXT record is written: Record, or where its // CNAME points. Target string `json:"target"` Route string `json:"route,omitempty"` // jarl or porkbun; "" when there is none Zone string `json:"zone,omitempty"` // the zone on the internet that holds Target PublicNS []string `json:"public_ns,omitempty"` Why string `json:"why"` Error string `json:"error,omitempty"` } func (r Route) String() string { s := r.Domain + ": " + r.Route if r.Target != r.Record { s += " via CNAME " + strings.TrimSuffix(r.Target, ".") } return s + " (zone " + strings.TrimSuffix(r.Zone, ".") + ")" } func isPorkbunNS(ns []string) bool { for _, n := range ns { if strings.HasSuffix(n, ".porkbun.com") { return true } } return false } // route decides where the challenge for domain goes. challenge is the // certificate's setting: auto picks from the zone's public name servers. // target is the record name after CNAMEs ("" to look it up). func (m *Manager) route(ctx context.Context, st *model.State, challenge, domain, target string) Route { r := Route{Domain: domain, Record: challengeName(domain)} fail := func(format string, a ...any) Route { r.Error = fmt.Sprintf(format, a...) return r } if target == "" { t, err := m.res.follow(ctx, r.Record) if err != nil { return fail("%v", err) } target = t } r.Target = target zone, err := m.res.zoneOf(ctx, target) if err != nil { return fail("%v", err) } r.Zone = zone ns, err := m.res.nameServers(ctx, zone) if err != nil { return fail("%v", err) } r.PublicNS = ns zoneName, nsList := strings.TrimSuffix(zone, "."), strings.Join(ns, ", ") // Jarl can answer when it has the zone and the internet asks the name // servers listed in it. var jarlNS []string jarlHas := false for _, z := range st.SortedZones() { if z.Type != model.ZonePrimary || z.Disabled || normName(z.Name) != normName(zone) { continue } jarlHas = true for _, rec := range st.ZoneRecords(z.ID) { if rec.Type == "NS" && !rec.Disabled && normName(rec.Name) == normName(zone) { jarlNS = append(jarlNS, normName(rec.Data)) } } } slices.Sort(jarlNS) jarlNS = slices.Compact(jarlNS) servedByJarl := jarlHas && slices.ContainsFunc(ns, func(n string) bool { return slices.Contains(jarlNS, n) }) porkbunOK := m.cfg.Porkbun != nil && (m.cfg.PorkbunAllows == nil || m.cfg.PorkbunAllows(zoneName)) porkbun := func() Route { r.Route, r.Why = model.ChallengePorkbun, fmt.Sprintf("%s is served by Porkbun (%s), so the record goes in through Porkbun's API", zoneName, nsList) return r } jarl := func() Route { r.Route, r.Why = model.ChallengeJarl, fmt.Sprintf("%s is served by this server's name servers (%s), so Jarl writes the record itself", zoneName, nsList) return r } switch challenge { case model.ChallengePorkbun: switch { case m.cfg.Porkbun == nil: return fail("the challenge is set to porkbun, but jarl.yaml has no porkbun.api_key") case !porkbunOK: return fail("the challenge is set to porkbun, but %s isn't in porkbun.domains in jarl.yaml", zoneName) case !isPorkbunNS(ns): return fail("the challenge is set to porkbun, but on the internet %s is served by %s, not Porkbun: the CA would never see the record (set the challenge to auto)", zoneName, nsList) } return porkbun() case model.ChallengeJarl: switch { case !jarlHas: return fail("the challenge is set to jarl, but no primary zone in Jarl is %s (where %s is on the internet)", zoneName, strings.TrimSuffix(target, ".")) case !servedByJarl: return fail("the challenge is set to jarl, but on the internet %s is served by %s, not by the name servers in Jarl's zone (%s): the CA would never see the record (set the challenge to auto)", zoneName, nsList, strings.Join(jarlNS, ", ")) } return jarl() } switch { case isPorkbunNS(ns) && porkbunOK: return porkbun() case servedByJarl: return jarl() case isPorkbunNS(ns) && m.cfg.Porkbun == nil: return fail("%s is served by Porkbun (%s) and jarl.yaml has no porkbun.api_key. Add the key, or point %s with a CNAME at a name in a zone this server serves on the internet", zoneName, nsList, strings.TrimSuffix(r.Record, ".")) case isPorkbunNS(ns): return fail("%s is served by Porkbun (%s) but isn't in porkbun.domains in jarl.yaml", zoneName, nsList) case jarlHas: return fail("on the internet %s is served by %s, not by the name servers in Jarl's zone (%s), so the CA would never see a record Jarl writes. Delegate the zone to this server, or point %s with a CNAME at a name in a zone it serves", zoneName, nsList, strings.Join(jarlNS, ", "), strings.TrimSuffix(r.Record, ".")) } return fail("%s is served by %s, which Jarl can't write to. Point %s with a CNAME at a name in a zone this server serves on the internet (for example %s.acme., in a zone acme. delegated here): Jarl follows the CNAME and answers there", zoneName, nsList, strings.TrimSuffix(r.Record, "."), strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(r.Record, "_acme-challenge."), "."), ".", "-")) } // primaryZones lists the zones Jarl can write challenge records into. func primaryZones(st *model.State) []acme.Zone { var out []acme.Zone for _, z := range st.SortedZones() { if z.Type == model.ZonePrimary && !z.Disabled { out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z), Link: z.LinkGroup}) } } return out }