dnscontrol/commands/getZones.go
Tom Limoncelli 1b2f5d4d34
BUGFIX: IDN support is broken for domain names (#3845)
# Issue

Fixes https://github.com/StackExchange/dnscontrol/issues/3842

CC @das7pad

# Resolution

Convert domain.Name to IDN earlier in the pipeline. Hack the --domains
processing to convert everything to IDN.

* Domain names are now stored 3 ways: The original input from
dnsconfig.js, canonical IDN format (`xn--...`), and Unicode format. All
are downcased. Providers that haven't been updated will receive the IDN
format instead of the original input format. This might break some
providers but only for users with unicode in their D("domain.tld").
PLEASE TEST YOUR PROVIDER.
* BIND filename formatting options have been added to access the new
formats.

# Breaking changes

* BIND zonefiles may change. The default used the name input in the D()
statement. It now defaults to the IDN name + "!tag" if there is a tag.
* Providers that are not IDN-aware may break (hopefully only if they
weren't processing IDN already)

---------

Co-authored-by: Jakob Ackermann <das7pad@outlook.com>
2025-11-29 12:17:44 -05:00

426 lines
14 KiB
Go

package commands
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/credsfile"
"github.com/StackExchange/dnscontrol/v4/pkg/prettyzone"
"github.com/StackExchange/dnscontrol/v4/providers"
"github.com/urfave/cli/v2"
)
var _ = cmd(catUtils, func() *cli.Command {
var args GetZoneArgs
return &cli.Command{
Name: "get-zones",
Aliases: []string{"get-zone"},
Usage: "gets a zone from a provider (stand-alone)",
Action: func(ctx *cli.Context) error {
if ctx.NArg() < 3 {
return cli.Exit("Arguments should be: credskey providername zone(s) (Ex: r53 ROUTE53 example.com)", 1)
}
args.CredName = ctx.Args().Get(0)
arg1 := ctx.Args().Get(1)
args.ProviderName = arg1
// In v4.0, skip the first args.ZoneNames if it equals "-".
args.ZoneNames = ctx.Args().Slice()[2:]
if arg1 != "" && arg1 != "-" {
// NB(tlim): In v4.0 this "if" can be removed.
fmt.Fprintf(os.Stderr, "WARNING: To retain compatibility in future versions, please change %q to %q. See %q\n",
arg1, "-",
"https://docs.dnscontrol.org/commands/get-zones",
)
}
return exit(GetZone(args))
},
Flags: args.flags(),
UsageText: "dnscontrol get-zones [command options] credkey provider zone [...]",
Description: `Download a zone from a provider. This is a stand-alone utility.
ARGUMENTS:
credkey: The name used in creds.json (first parameter to NewDnsProvider() in dnsconfig.js)
provider: The name of the provider (second parameter to NewDnsProvider() in dnsconfig.js)
zone: One or more zones (domains) to download; or "all".
FORMATS:
--format=js dnsconfig.js format (not perfect, just a decent first draft)
--format=djs js with disco commas (leading commas)
--format=zone BIND zonefile format
--format=tsv TAB separated value (useful for AWK)
--format=nameonly Just print the zone names
The columns in --format=tsv are:
FQDN (the label with the domain)
ShortName (just the label, "@" if it is the naked domain)
TTL
Record Type (A, AAAA, CNAME, etc.)
Target and arguments (quoted like in a zonefile)
Either empty or a comma-separated list of properties like "cloudflare_proxy=true"
The --ttl flag only applies to zone/js/djs formats.
EXAMPLES:
dnscontrol get-zones myr53 ROUTE53 example.com
dnscontrol get-zones gmain GANDI_V5 example.com other.com
dnscontrol get-zones cfmain CLOUDFLAREAPI all
dnscontrol get-zones --format=tsv bind BIND example.com
dnscontrol get-zones --format=djs --out=draft.js gcloud GCLOUD example.com`,
}
}())
// check-creds foo bar
// is the same as
// get-zones --format=nameonly foo bar all
var _ = cmd(catUtils, func() *cli.Command {
var args GetZoneArgs
return &cli.Command{
Name: "check-creds",
Usage: "Do a small operation to verify credentials (stand-alone)",
Action: func(ctx *cli.Context) error {
var arg0, arg1 string
// This takes one or two command-line args.
// Starting in v3.16: Using it with 2 args will generate a warning.
// Starting in v4.0: Using it with 2 args might be an error.
if ctx.NArg() == 1 {
arg0 = ctx.Args().Get(0)
arg1 = ""
} else if ctx.NArg() == 2 {
arg0 = ctx.Args().Get(0)
arg1 = ctx.Args().Get(1)
} else {
return cli.Exit("Arguments should be: credskey [providername] (Ex: r53 ROUTE53)", 1)
}
args.CredName = arg0
args.ProviderName = arg1
args.ZoneNames = []string{"all"}
args.OutputFormat = "nameonly"
return exit(GetZone(args))
},
Flags: args.flags(),
UsageText: "dnscontrol check-creds [command options] credkey provider",
Description: `Do a trivia operation to verify credentials. This is a stand-alone utility.
If successful, a list of zones will be output. If not, hopefully you
see verbose error messages.
ARGUMENTS:
credkey: The name used in creds.json (first parameter to NewDnsProvider() in dnsconfig.js)
provider: The name of the provider (second parameter to NewDnsProvider() in dnsconfig.js)
EXAMPLES:
dnscontrol check-creds myr53 ROUTE53 # Pre v3.16, or pre-v4.0 for backwards-compatibility
dnscontrol check-creds myr53
dnscontrol check-creds --out=/dev/null myr53 && echo Success`,
}
}())
// GetZoneArgs args required for the create-domain subcommand.
type GetZoneArgs struct {
GetCredentialsArgs // Args related to creds.json
CredName string // key in creds.json
ProviderName string // provider type: BIND, GANDI_V5, etc or "-" (NB(tlim): In 4.0, this field goes away.)
ZoneNames []string // The zones to get
OutputFormat string // Output format
OutputFile string // Filename to send output ("" means stdout)
DefaultTTL int // default TTL for providers where it is unknown
}
func (args *GetZoneArgs) flags() []cli.Flag {
flags := args.GetCredentialsArgs.flags()
flags = append(flags, &cli.StringFlag{
Name: "format",
Destination: &args.OutputFormat,
Value: "zone",
Usage: `Output format: js djs zone tsv nameonly`,
})
flags = append(flags, &cli.StringFlag{
Name: "out",
Destination: &args.OutputFile,
Usage: `Instead of stdout, write to this file`,
})
flags = append(flags, &cli.IntFlag{
Name: "ttl",
Destination: &args.DefaultTTL,
Usage: `Default TTL (0 picks the most common TTL)`,
})
return flags
}
// GetZone contains all data/flags needed to run get-zones, independently of CLI.
func GetZone(args GetZoneArgs) error {
var providerConfigs map[string]map[string]string
var err error
// Read it in:
providerConfigs, err = credsfile.LoadProviderConfigs(args.CredsFile)
if err != nil {
return fmt.Errorf("failed GetZone LoadProviderConfigs(%q): %w", args.CredsFile, err)
}
provider, err := providers.CreateDNSProvider(args.ProviderName, providerConfigs[args.CredName], nil)
if err != nil {
return fmt.Errorf("failed GetZone CDP: %w", err)
}
// decide which zones we need to convert
zones := args.ZoneNames
if len(args.ZoneNames) == 1 && args.ZoneNames[0] == "all" {
lister, ok := provider.(providers.ZoneLister)
if !ok {
return fmt.Errorf("provider type %s:%s cannot list zones to use the 'all' feature", args.CredName, args.ProviderName)
}
zones, err = lister.ListZones()
if err != nil {
return fmt.Errorf("failed GetZone LZ: %w", err)
}
}
// first open output stream and print initial header (if applicable)
w := os.Stdout
if args.OutputFile != "" {
w, err = os.Create(args.OutputFile)
}
if err != nil {
return fmt.Errorf("failed GetZone Create(%q): %w", args.OutputFile, err)
}
defer w.Close()
if args.OutputFormat == "nameonly" {
for _, zone := range zones {
fmt.Fprintln(w, zone)
}
return nil
}
// fetch all of the records
zoneRecs := make([]models.Records, len(zones))
for i, zone := range zones {
recs, err := provider.GetZoneRecords(zone,
map[string]string{
models.DomainUniqueName: zone,
})
if err != nil {
return fmt.Errorf("failed GetZone gzr: %w", err)
}
zoneRecs[i] = recs
}
// Write the heading:
dspVariableName := "DSP_" + strings.ToUpper(args.CredName)
if args.OutputFormat == "js" || args.OutputFormat == "djs" {
fmt.Fprintf(w, "// generated by get-zones. This is 'a decent first draft' and requires editing.\n")
fmt.Fprintf(w, "\n")
if args.ProviderName == "-" {
fmt.Fprintf(w, `var %s = NewDnsProvider("%s");`+"\n",
dspVariableName, args.CredName)
} else {
fmt.Fprintf(w, `var %s = NewDnsProvider("%s", "%s");`+"\n",
dspVariableName, args.CredName, args.ProviderName)
}
fmt.Fprintf(w, `var REG_CHANGEME = NewRegistrar("none");`+"\n\n")
}
// print each zone
for i, recs := range zoneRecs {
zoneName := zones[i]
z := prettyzone.PrettySort(recs, zoneName, 0, nil)
switch args.OutputFormat {
case "zone":
fmt.Fprintf(w, "$ORIGIN %s.\n", zoneName)
if err := prettyzone.WriteZoneFileRC(w, z.Records, zoneName, uint32(args.DefaultTTL), nil); err != nil {
return err
}
fmt.Fprintln(w)
case "js", "djs":
sep := ",\n\t" // Commas at EOL
if args.OutputFormat == "djs" {
sep = "\n\t, " // Funky comma mode
}
fmt.Fprintf(w, `D("%s", REG_CHANGEME%s`, zoneName, sep)
var o []string
o = append(o, fmt.Sprintf("DnsProvider(%s)", dspVariableName))
defaultTTL := uint32(args.DefaultTTL)
if defaultTTL == 0 {
defaultTTL = prettyzone.MostCommonTTL(recs)
}
if defaultTTL != models.DefaultTTL && defaultTTL != 0 {
o = append(o, fmt.Sprintf("DefaultTTL(%d)", defaultTTL))
}
for _, rec := range recs {
if (rec.Type == "CNAME") && (rec.Name == "@") {
o = append(o, "// NOTE: CNAME at apex may require manual editing.")
}
o = append(o, formatDsl(rec, defaultTTL))
}
out := strings.Join(o, sep)
// Joining with a comma between each item works great but
// makes comments look terrible. Here we clean them up
// after the fact.
if args.OutputFormat == "djs" {
out = strings.ReplaceAll(out, "\n\t, //", "\n\t//, ") // Fix comments
out = strings.ReplaceAll(out,
"//, NOTE: CNAME at apex may require manual editing.",
"// NOTE: CNAME at apex may require manual editing.",
)
fmt.Fprint(w, out)
fmt.Fprint(w, "\n)\n\n")
} else {
out = out + ","
out = strings.ReplaceAll(out,
"// NOTE: CNAME at apex may require manual editing.,",
"// NOTE: CNAME at apex may require manual editing.",
)
fmt.Fprint(w, out)
fmt.Fprint(w, "\n);\n\n")
}
case "tsv":
for _, rec := range recs {
cfproxy := ""
if cp, ok := rec.Metadata["cloudflare_proxy"]; ok {
if cp == "true" {
cfproxy = "\tcloudflare_proxy=true"
}
}
ty := rec.Type
if rec.Type == "UNKNOWN" {
ty = rec.UnknownTypeName
}
fmt.Fprintf(w, "%s\t%s\t%d\tIN\t%s\t%s%s\n",
rec.NameFQDN, rec.Name, rec.TTL, ty, rec.GetTargetCombinedFunc(nil), cfproxy)
}
default:
return fmt.Errorf("format %q unknown", args.OutputFormat)
}
}
return nil
}
// jsonQuoted returns a properly escaped JSON string (without quotes).
func jsonQuoted(i string) string {
// https://stackoverflow.com/questions/51691901
b, err := json.Marshal(i)
if err != nil {
panic(err)
}
return string(b)
}
func formatDsl(rec *models.RecordConfig, defaultTTL uint32) string {
target := rec.GetTargetCombined()
ttl := uint32(0)
ttlop := ""
if rec.TTL != defaultTTL && rec.TTL != 0 {
ttl = rec.TTL
ttlop = fmt.Sprintf(", TTL(%d)", ttl)
}
cfproxy := ""
if cp, ok := rec.Metadata["cloudflare_proxy"]; ok {
if cp == "true" {
cfproxy = ", CF_PROXY_ON"
}
}
switch rec.Type { // #rtype_variations
case "CAA":
return makeCaa(rec, ttlop)
case "DS":
target = fmt.Sprintf(`%d, %d, %d, "%s"`, rec.DsKeyTag, rec.DsAlgorithm, rec.DsDigestType, rec.DsDigest)
case "DNSKEY":
target = fmt.Sprintf(`%d, %d, %d, "%s"`, rec.DnskeyFlags, rec.DnskeyProtocol, rec.DnskeyAlgorithm, rec.DnskeyPublicKey)
case "MX":
target = fmt.Sprintf(`%d, "%s"`, rec.MxPreference, rec.GetTargetField())
case "NAPTR":
target = fmt.Sprintf(`%d, %d, %s, %s, %s, %s`,
rec.NaptrOrder, // 1
rec.NaptrPreference, // 10
jsonQuoted(rec.NaptrFlags), // U
jsonQuoted(rec.NaptrService), // E2U+sip
jsonQuoted(rec.NaptrRegexp), // regex
jsonQuoted(rec.GetTargetField()), // .
)
case "SMIMEA":
target = fmt.Sprintf(`%d, %d, %d, "%s"`, rec.SmimeaUsage, rec.SmimeaSelector, rec.SmimeaMatchingType, rec.GetTargetField())
case "SSHFP":
target = fmt.Sprintf(`%d, %d, "%s"`, rec.SshfpAlgorithm, rec.SshfpFingerprint, rec.GetTargetField())
case "SOA":
rec.Type = "//SOA"
target = fmt.Sprintf(`"%s", "%s", %d, %d, %d, %d`, rec.GetTargetField(), rec.SoaMbox, rec.SoaRefresh, rec.SoaRetry, rec.SoaExpire, rec.SoaMinttl)
case "SRV":
target = fmt.Sprintf(`%d, %d, %d, "%s"`, rec.SrvPriority, rec.SrvWeight, rec.SrvPort, rec.GetTargetField())
case "SVCB", "HTTPS":
target = fmt.Sprintf(`%d, "%s", "%s"`, rec.SvcPriority, rec.GetTargetField(), rec.SvcParams)
case "TLSA":
target = fmt.Sprintf(`%d, %d, %d, "%s"`, rec.TlsaUsage, rec.TlsaSelector, rec.TlsaMatchingType, rec.GetTargetField())
case "TXT":
target = jsonQuoted(rec.GetTargetTXTJoined())
// TODO(tlim): If this is an SPF record, generate a SPF_BUILDER().
case "LUA":
target = fmt.Sprintf("%q, %s", rec.LuaRType, jsonQuoted(rec.GetTargetTXTJoined()))
case "NS":
// NS records at the apex should be NAMESERVER() records.
// DnsControl uses the API to get this info. NAMESERVER() is just
// to override that when needed.
if rec.Name == "@" {
return fmt.Sprintf(`//NAMESERVER("%s")`, target)
}
target = `"` + target + `"`
case "R53_ALIAS":
return makeR53alias(rec, ttl)
case "UNKNOWN":
return makeUknown(rec, ttl)
default:
target = `"` + target + `"`
}
return fmt.Sprintf(`%s("%s", %s%s%s)`, rec.Type, rec.Name, target, cfproxy, ttlop)
}
func makeCaa(rec *models.RecordConfig, ttlop string) string {
var target string
if rec.CaaFlag == 128 {
target = fmt.Sprintf(`"%s", "%s", CAA_CRITICAL`, rec.CaaTag, rec.GetTargetField())
} else {
target = fmt.Sprintf(`"%s", "%s"`, rec.CaaTag, rec.GetTargetField())
}
return fmt.Sprintf(`%s("%s", %s%s)`, rec.Type, rec.Name, target, ttlop)
// TODO(tlim): Generate a CAA_BUILDER() instead?
}
func makeR53alias(rec *models.RecordConfig, ttl uint32) string {
items := []string{
`"` + rec.Name + `"`,
`"` + rec.R53Alias["type"] + `"`,
`"` + rec.GetTargetField() + `"`,
}
if z, ok := rec.R53Alias["zone_id"]; ok {
items = append(items, `R53_ZONE("`+z+`")`)
}
if e, ok := rec.R53Alias["evaluate_target_health"]; ok && e == "true" {
items = append(items, "R53_EVALUATE_TARGET_HEALTH(true)")
}
if ttl != 0 {
items = append(items, fmt.Sprintf("TTL(%d)", ttl))
}
return rec.Type + "(" + strings.Join(items, ", ") + ")"
}
func makeUknown(rc *models.RecordConfig, ttl uint32) string {
return fmt.Sprintf(`// %s("%s", TTL(%d))`, rc.UnknownTypeName, rc.GetTargetField(), ttl)
}