mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-12-11 14:45:52 +08:00
# Issue * New record type: "RP" (supported by BIND and GANDI_V5) * Cloudflare: CF_REDIRECT/CF_TEMP_REDIRECT now generate CF_SINGLE_REDIRECT records. All PAGE_RULE-based code is removed. PAGE_RULEs are deprecated at Cloudflare. (be careful when upgrading!) * New "v2" RecordConfig: RP and CF_SINGLE_REDIRECT are the only record types that use this method. It shifts most of the work out of JavaScript and into the Go code, making new record types easier to make, easier to test, and easier to use by providers. This opens the door to new things like a potential code-generator for rtypes. Converting existing rtypes will happen over the next year. * When only the TTL changes (MODIFY-TTL), the output lists the TTL change first, not at the end of the line where it is visually lost. * CF_REDIRECT/CF_TEMP_REDIRECT generate different rule "names". They will be updated the first time you "push" with this release. The order of the rules may also change. If you rules depend on a particular order, be very careful with this upgrade! Refactoring: * New "v2" RecordConfig: Record types using this new method simply package the parameters from dnsconfig.js statements like CF_REDIRECT(foo,bar) and send them (raw) to the Go code. The Go code does all processing, validation, etc. and turns them into RecordConfig that store all the rdata in `RecordConfig.F`. No more adding fields to RecordConfig for each new record type! * RecordConfig.IsModernType() returns true if the record uses the new v2 record mechanism. * PostProcess is now a method on DnsConfig and DomainConfig. * DOC: How to create new rtypes using the v2 method (incomplete) Other things: * Integration tests for CF "full proxy" are removed. This feature doesn't exist any more. * DEV: Debugger tips now includes VSCode advice * TESTING: The names of testgroup's can now have extra spaces to make data align better * CF_TEMP_REDIRECT/CF_REDIRECT is now a "builder" that generates CLOUDFLAREAPI_SINGLE_REDIRECT records. * And more! # Resolution --------- Co-authored-by: Jakob Ackermann <das7pad@outlook.com>
462 lines
14 KiB
Go
462 lines
14 KiB
Go
package cloudflare
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/cloudflare/cloudflare-go"
|
|
"golang.org/x/net/idna"
|
|
|
|
"github.com/StackExchange/dnscontrol/v4/models"
|
|
"github.com/StackExchange/dnscontrol/v4/pkg/domaintags"
|
|
"github.com/StackExchange/dnscontrol/v4/pkg/rtypecontrol"
|
|
"github.com/StackExchange/dnscontrol/v4/providers/cloudflare/rtypes/cfsingleredirect"
|
|
)
|
|
|
|
func (c *cloudflareProvider) fetchAllZones() (map[string]cloudflare.Zone, error) {
|
|
zones, err := c.cfClient.ListZones(context.Background())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed fetching domain list from cloudflare(%q): %w", c.cfClient.APIEmail, err)
|
|
}
|
|
|
|
m := make(map[string]cloudflare.Zone, len(zones))
|
|
for _, zone := range zones {
|
|
if encoded, err := idna.ToASCII(zone.Name); err == nil && encoded != zone.Name {
|
|
if _, ok := m[encoded]; ok {
|
|
fmt.Printf("WARNING: Zone %q appears twice in this cloudflare account\n", encoded)
|
|
}
|
|
m[encoded] = zone
|
|
}
|
|
if _, ok := m[zone.Name]; ok {
|
|
fmt.Printf("WARNING: Zone %q appears twice in this cloudflare account\n", zone.Name)
|
|
}
|
|
m[zone.Name] = zone
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// get all records for a domain
|
|
func (c *cloudflareProvider) getRecordsForDomain(id string, domain string) ([]*models.RecordConfig, error) {
|
|
records := []*models.RecordConfig{}
|
|
rrs, _, err := c.cfClient.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(id), cloudflare.ListDNSRecordsParams{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed fetching record list from cloudflare(%q): %w", c.cfClient.APIEmail, err)
|
|
}
|
|
for _, rec := range rrs {
|
|
rt, err := c.nativeToRecord(domain, rec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// nativeToRecord may return nil if the record is supposed to be skipped
|
|
// i.e. read only, cloudflare-managed, etc.
|
|
if rt != nil {
|
|
records = append(records, rt)
|
|
}
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func (c *cloudflareProvider) deleteDNSRecord(rec cloudflare.DNSRecord, domainID string) error {
|
|
return c.cfClient.DeleteDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), rec.ID)
|
|
}
|
|
|
|
func (c *cloudflareProvider) createZone(domainName string) (string, error) {
|
|
zone, err := c.cfClient.CreateZone(context.Background(), domainName, false, cloudflare.Account{ID: c.accountID}, "full")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if encoded, err := idna.ToASCII(zone.Name); err == nil && encoded != zone.Name {
|
|
c.zoneCache.SetZone(encoded, zone)
|
|
}
|
|
c.zoneCache.SetZone(domainName, zone)
|
|
return zone.ID, nil
|
|
}
|
|
|
|
func cfDnskeyData(rec *models.RecordConfig) *cfRecData {
|
|
return &cfRecData{
|
|
Algorithm: rec.DnskeyAlgorithm,
|
|
Flags: rec.DnskeyFlags,
|
|
Protocol: rec.DnskeyProtocol,
|
|
PublicKey: rec.DnskeyPublicKey,
|
|
}
|
|
}
|
|
|
|
func cfDSData(rec *models.RecordConfig) *cfRecData {
|
|
return &cfRecData{
|
|
KeyTag: rec.DsKeyTag,
|
|
Algorithm: rec.DsAlgorithm,
|
|
DigestType: rec.DsDigestType,
|
|
Digest: rec.DsDigest,
|
|
}
|
|
}
|
|
|
|
func cfSrvData(rec *models.RecordConfig) *cfRecData {
|
|
serverParts := strings.Split(rec.GetLabelFQDN(), ".")
|
|
c := &cfRecData{
|
|
Service: serverParts[0],
|
|
Proto: serverParts[1],
|
|
Name: strings.Join(serverParts[2:], "."),
|
|
Port: rec.SrvPort,
|
|
Priority: rec.SrvPriority,
|
|
Weight: rec.SrvWeight,
|
|
}
|
|
c.Target = cfTarget(rec.GetTargetField())
|
|
return c
|
|
}
|
|
|
|
func cfCaaData(rec *models.RecordConfig) *cfRecData {
|
|
return &cfRecData{
|
|
Tag: rec.CaaTag,
|
|
Flags: uint16(rec.CaaFlag),
|
|
Value: rec.GetTargetField(),
|
|
}
|
|
}
|
|
|
|
func cfTlsaData(rec *models.RecordConfig) *cfRecData {
|
|
return &cfRecData{
|
|
Usage: rec.TlsaUsage,
|
|
Selector: rec.TlsaSelector,
|
|
MatchingType: rec.TlsaMatchingType,
|
|
Certificate: rec.GetTargetField(),
|
|
}
|
|
}
|
|
|
|
func cfSshfpData(rec *models.RecordConfig) *cfRecData {
|
|
return &cfRecData{
|
|
Algorithm: rec.SshfpAlgorithm,
|
|
HashType: rec.SshfpFingerprint,
|
|
Fingerprint: rec.GetTargetField(),
|
|
}
|
|
}
|
|
|
|
func cfSvcbData(rec *models.RecordConfig) *cfRecData {
|
|
return &cfRecData{
|
|
Priority: rec.SvcPriority,
|
|
Target: cfTarget(rec.GetTargetField()),
|
|
Value: rec.SvcParams,
|
|
}
|
|
}
|
|
|
|
func cfLocData(rec *models.RecordConfig) *cfRecData {
|
|
latDir, latDeg, latMin, latSec := models.ReverseLatitude(rec.LocLatitude)
|
|
longDir, longDeg, longMin, longSec := models.ReverseLongitude(rec.LocLongitude)
|
|
|
|
return &cfRecData{
|
|
Altitude: models.ReverseAltitude(rec.LocAltitude),
|
|
LatDegrees: latDeg,
|
|
LatDirection: latDir,
|
|
LatMinutes: latMin,
|
|
LatSeconds: latSec,
|
|
LongDegrees: longDeg,
|
|
LongDirection: longDir,
|
|
LongMinutes: longMin,
|
|
LongSeconds: longSec,
|
|
PrecisionHorz: models.ReverseENotationInt(rec.LocHorizPre),
|
|
PrecisionVert: models.ReverseENotationInt(rec.LocVertPre),
|
|
Size: models.ReverseENotationInt(rec.LocSize),
|
|
}
|
|
}
|
|
|
|
func cfNaptrData(rec *models.RecordConfig) *cfNaptrRecData {
|
|
return &cfNaptrRecData{
|
|
Flags: rec.NaptrFlags,
|
|
Order: rec.NaptrOrder,
|
|
Preference: rec.NaptrPreference,
|
|
Regex: rec.NaptrRegexp,
|
|
Replacement: rec.GetTargetField(),
|
|
Service: rec.NaptrService,
|
|
}
|
|
}
|
|
|
|
func (c *cloudflareProvider) createRecDiff2(rec *models.RecordConfig, domainID string, msg string) []*models.Correction {
|
|
content := rec.GetTargetField()
|
|
if rec.Metadata[metaOriginalIP] != "" {
|
|
content = rec.Metadata[metaOriginalIP]
|
|
}
|
|
prio := ""
|
|
switch rec.Type {
|
|
case "MX":
|
|
prio = fmt.Sprintf(" %d ", rec.MxPreference)
|
|
case "TXT":
|
|
content = rec.GetTargetTXTJoined()
|
|
case "DS":
|
|
content = fmt.Sprintf("%d %d %d %s", rec.DsKeyTag, rec.DsAlgorithm, rec.DsDigestType, rec.DsDigest)
|
|
}
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("CREATE record: %s %s %d%s %s", rec.GetLabel(), rec.Type, rec.TTL, prio, content)
|
|
}
|
|
if rec.Metadata[metaProxy] == "on" || rec.Metadata[metaProxy] == "full" {
|
|
msg = msg + fmt.Sprintf("\nACTIVATE PROXY for new record %s %s %d %s", rec.GetLabel(), rec.Type, rec.TTL, rec.GetTargetField())
|
|
}
|
|
arr := []*models.Correction{{
|
|
Msg: msg,
|
|
F: func() error {
|
|
cf := cloudflare.CreateDNSRecordParams{
|
|
Name: rec.GetLabel(),
|
|
Type: rec.Type,
|
|
TTL: int(rec.TTL),
|
|
Content: content,
|
|
Priority: &rec.MxPreference,
|
|
}
|
|
switch rec.Type {
|
|
case "SRV":
|
|
cf.Data = cfSrvData(rec)
|
|
cf.Name = rec.GetLabelFQDN()
|
|
case "CAA":
|
|
cf.Data = cfCaaData(rec)
|
|
cf.Name = rec.GetLabelFQDN()
|
|
cf.Content = ""
|
|
case "TLSA":
|
|
cf.Data = cfTlsaData(rec)
|
|
cf.Name = rec.GetLabelFQDN()
|
|
case "SSHFP":
|
|
cf.Data = cfSshfpData(rec)
|
|
cf.Name = rec.GetLabelFQDN()
|
|
case "DNSKEY":
|
|
cf.Data = cfDnskeyData(rec)
|
|
case "DS":
|
|
cf.Data = cfDSData(rec)
|
|
case "NAPTR":
|
|
cf.Data = cfNaptrData(rec)
|
|
cf.Name = rec.GetLabelFQDN()
|
|
case "HTTPS", "SVCB":
|
|
cf.Data = cfSvcbData(rec)
|
|
case "LOC":
|
|
cf.Data = cfLocData(rec)
|
|
}
|
|
resp, err := c.cfClient.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), cf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Records are created with the proxy off. If proxy should be
|
|
// enabled, we do a second API call.
|
|
resultID := resp.ID
|
|
if rec.Metadata[metaProxy] == "on" || rec.Metadata[metaProxy] == "full" {
|
|
return c.modifyRecord(domainID, resultID, true, rec)
|
|
}
|
|
return nil
|
|
},
|
|
}}
|
|
return arr
|
|
}
|
|
|
|
func (c *cloudflareProvider) modifyRecord(domainID, recID string, proxied bool, rec *models.RecordConfig) error {
|
|
if domainID == "" || recID == "" {
|
|
return errors.New("cannot modify record if domain or record id are empty")
|
|
}
|
|
|
|
r := cloudflare.UpdateDNSRecordParams{
|
|
ID: recID,
|
|
Proxied: &proxied,
|
|
Name: rec.GetLabel(),
|
|
Type: rec.Type,
|
|
Content: rec.GetTargetField(),
|
|
Priority: &rec.MxPreference,
|
|
TTL: int(rec.TTL),
|
|
}
|
|
switch rec.Type {
|
|
case "TXT":
|
|
r.Content = rec.GetTargetTXTJoined()
|
|
case "SRV":
|
|
r.Data = cfSrvData(rec)
|
|
r.Name = rec.GetLabelFQDN()
|
|
case "CAA":
|
|
r.Data = cfCaaData(rec)
|
|
r.Name = rec.GetLabelFQDN()
|
|
r.Content = ""
|
|
case "TLSA":
|
|
r.Data = cfTlsaData(rec)
|
|
r.Name = rec.GetLabelFQDN()
|
|
case "SSHFP":
|
|
r.Data = cfSshfpData(rec)
|
|
r.Name = rec.GetLabelFQDN()
|
|
case "DNSKEY":
|
|
r.Data = cfDnskeyData(rec)
|
|
r.Content = ""
|
|
case "DS":
|
|
r.Data = cfDSData(rec)
|
|
r.Content = ""
|
|
case "NAPTR":
|
|
r.Data = cfNaptrData(rec)
|
|
r.Name = rec.GetLabelFQDN()
|
|
case "HTTPS", "SVCB":
|
|
r.Data = cfSvcbData(rec)
|
|
case "LOC":
|
|
r.Data = cfLocData(rec)
|
|
}
|
|
_, err := c.cfClient.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(domainID), r)
|
|
return err
|
|
}
|
|
|
|
// change universal ssl state
|
|
func (c *cloudflareProvider) changeUniversalSSL(domainID string, state bool) error {
|
|
_, err := c.cfClient.EditUniversalSSLSetting(context.Background(), domainID, cloudflare.UniversalSSLSetting{Enabled: state})
|
|
return err
|
|
}
|
|
|
|
// get universal ssl state
|
|
func (c *cloudflareProvider) getUniversalSSL(domainID string) (bool, error) {
|
|
result, err := c.cfClient.UniversalSSLSettingDetails(context.Background(), domainID)
|
|
return result.Enabled, err
|
|
}
|
|
|
|
func (c *cloudflareProvider) getSingleRedirects(id string, domain string) ([]*models.RecordConfig, error) {
|
|
rules, err := c.cfClient.GetEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(id), "http_request_dynamic_redirect")
|
|
if err != nil {
|
|
var e *cloudflare.NotFoundError
|
|
if errors.As(err, &e) {
|
|
return []*models.RecordConfig{}, nil
|
|
}
|
|
return nil, fmt.Errorf("failed fetching redirect rule list cloudflare: %w (%T)", err, err)
|
|
}
|
|
|
|
recs := []*models.RecordConfig{}
|
|
for _, pr := range rules.Rules {
|
|
thisPr := pr
|
|
|
|
// Extract the valuables from the rule, use it to make the sr:
|
|
srName := pr.Description
|
|
srWhen := pr.Expression
|
|
srThen := pr.ActionParameters.FromValue.TargetURL.Expression
|
|
code := uint16(pr.ActionParameters.FromValue.StatusCode)
|
|
|
|
rec, err := rtypecontrol.NewRecordConfigFromRaw(
|
|
"CLOUDFLAREAPI_SINGLE_REDIRECT",
|
|
1,
|
|
[]any{srName, code, srWhen, srThen},
|
|
domaintags.MakeDomainNameVarieties(domain))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rec.Original = thisPr
|
|
|
|
// Store the IDs. These will be needed for update/delete operations.
|
|
sr := rec.F.(*cfsingleredirect.SingleRedirectConfig)
|
|
sr.SRRRulesetID = rules.ID
|
|
sr.SRRRulesetRuleID = pr.ID
|
|
|
|
recs = append(recs, rec)
|
|
}
|
|
|
|
return recs, nil
|
|
}
|
|
|
|
func (c *cloudflareProvider) createSingleRedirect(domainID string, cfr cfsingleredirect.SingleRedirectConfig) error {
|
|
newSingleRedirectRulesActionParameters := cloudflare.RulesetRuleActionParameters{}
|
|
newSingleRedirectRule := cloudflare.RulesetRule{}
|
|
newSingleRedirectRules := []cloudflare.RulesetRule{}
|
|
newSingleRedirectRules = append(newSingleRedirectRules, newSingleRedirectRule)
|
|
newSingleRedirect := cloudflare.UpdateEntrypointRulesetParams{}
|
|
|
|
// Preserve query string if there isn't one in the replacement.
|
|
preserveQueryString := !strings.Contains(cfr.SRThen, "?")
|
|
|
|
newSingleRedirectRulesActionParameters.FromValue = &cloudflare.RulesetRuleActionParametersFromValue{}
|
|
// Redirect status code
|
|
newSingleRedirectRulesActionParameters.FromValue.StatusCode = uint16(cfr.Code)
|
|
// Incoming request expression
|
|
newSingleRedirectRules[0].Expression = cfr.SRWhen
|
|
// Redirect expression
|
|
newSingleRedirectRulesActionParameters.FromValue.TargetURL.Expression = cfr.SRThen
|
|
// Redirect name
|
|
newSingleRedirectRules[0].Description = cfr.SRName
|
|
|
|
// Rule action, should always be redirect in this case
|
|
newSingleRedirectRules[0].Action = "redirect"
|
|
// Phase should always be http_request_dynamic_redirect
|
|
newSingleRedirect.Phase = "http_request_dynamic_redirect"
|
|
|
|
// Assigns the values in the nested structs
|
|
newSingleRedirectRulesActionParameters.FromValue.PreserveQueryString = &preserveQueryString
|
|
newSingleRedirectRules[0].ActionParameters = &newSingleRedirectRulesActionParameters
|
|
|
|
// Get a list of current redirects so that the new redirect get appended to it
|
|
rules, err := c.cfClient.GetEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), "http_request_dynamic_redirect")
|
|
var e *cloudflare.NotFoundError
|
|
if err != nil && !errors.As(err, &e) {
|
|
return fmt.Errorf("failed fetching redirect rule list cloudflare: %w", err)
|
|
}
|
|
newSingleRedirect.Rules = newSingleRedirectRules
|
|
newSingleRedirect.Rules = append(rules.Rules, newSingleRedirect.Rules...)
|
|
|
|
_, err = c.cfClient.UpdateEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), newSingleRedirect)
|
|
|
|
return err
|
|
}
|
|
|
|
func (c *cloudflareProvider) deleteSingleRedirects(domainID string, cfr cfsingleredirect.SingleRedirectConfig) error {
|
|
err := c.cfClient.DeleteRulesetRule(context.Background(), cloudflare.ZoneIdentifier(domainID), cloudflare.DeleteRulesetRuleParams{
|
|
RulesetID: cfr.SRRRulesetID,
|
|
RulesetRuleID: cfr.SRRRulesetRuleID,
|
|
},
|
|
)
|
|
// NB(tlim): Yuck. This returns an error even when it is successful. Dig into the JSON for the real status.
|
|
if strings.Contains(err.Error(), `"success": true,`) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (c *cloudflareProvider) updateSingleRedirect(domainID string, oldrec, newrec *models.RecordConfig) error {
|
|
if err := c.deleteSingleRedirects(domainID, *oldrec.F.(*cfsingleredirect.SingleRedirectConfig)); err != nil {
|
|
return err
|
|
}
|
|
return c.createSingleRedirect(domainID, *newrec.F.(*cfsingleredirect.SingleRedirectConfig))
|
|
}
|
|
|
|
func (c *cloudflareProvider) getWorkerRoutes(id string, domain string) ([]*models.RecordConfig, error) {
|
|
res, err := c.cfClient.ListWorkerRoutes(context.Background(), cloudflare.ZoneIdentifier(id), cloudflare.ListWorkerRoutesParams{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed fetching worker route list cloudflare: %w", err)
|
|
}
|
|
|
|
recs := []*models.RecordConfig{}
|
|
for _, pr := range res.Routes {
|
|
thisPr := pr
|
|
r := &models.RecordConfig{
|
|
Type: "WORKER_ROUTE",
|
|
Original: thisPr,
|
|
TTL: 1,
|
|
}
|
|
r.SetLabel("@", domain)
|
|
err := r.SetTarget(fmt.Sprintf("%s,%s", // $PATTERN,$SCRIPT
|
|
pr.Pattern,
|
|
pr.ScriptName))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
recs = append(recs, r)
|
|
}
|
|
return recs, nil
|
|
}
|
|
|
|
func (c *cloudflareProvider) deleteWorkerRoute(recordID, domainID string) error {
|
|
_, err := c.cfClient.DeleteWorkerRoute(context.Background(), cloudflare.ZoneIdentifier(domainID), recordID)
|
|
return err
|
|
}
|
|
|
|
func (c *cloudflareProvider) updateWorkerRoute(recordID, domainID string, target string) error {
|
|
if err := c.deleteWorkerRoute(recordID, domainID); err != nil {
|
|
return err
|
|
}
|
|
return c.createWorkerRoute(domainID, target)
|
|
}
|
|
|
|
func (c *cloudflareProvider) createWorkerRoute(domainID string, target string) error {
|
|
// $PATTERN,$SCRIPT
|
|
parts := strings.Split(target, ",")
|
|
if len(parts) != 2 {
|
|
return fmt.Errorf("unexpected target: '%s' (expected: 'PATTERN,SCRIPT')", target)
|
|
}
|
|
wr := cloudflare.CreateWorkerRouteParams{
|
|
Pattern: parts[0],
|
|
Script: parts[1],
|
|
}
|
|
|
|
_, err := c.cfClient.CreateWorkerRoute(context.Background(), cloudflare.ZoneIdentifier(domainID), wr)
|
|
return err
|
|
}
|