diff --git a/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden b/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden new file mode 100644 index 0000000000..bdb5219ede --- /dev/null +++ b/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden @@ -0,0 +1,54 @@ +🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 +πŸŸ₯πŸŸ₯πŸŸ₯ STDERR️️ πŸŸ₯πŸŸ₯πŸŸ₯️ +Import DNS records into a zone that uses Scaleway default name servers. + +The DNS zone is the only positional argument; pass the path to the file as file=PATH. + +Two formats are supported: + - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + Supported types: A, AAAA, CNAME, TXT, MX, NS, PTR, SRV, CAA. For other types (e.g. TLSA, SSHFP, DS), use format=json. + - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + Accepts all types supported by the Scaleway DNS API. + +SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. + +Use "replace=true" to delete all existing records in the zone before importing (equivalent to "scw dns record clear" followed by adds). + +For a full zone file replacement at once, prefer "scw dns zone import". + +USAGE: + scw dns record import [arg=value ...] + +EXAMPLES: + Import BIND records from a file + scw dns record import my-domain.tld file=./zone.txt + + Import JSON and replace existing records + scw dns record import my-domain.tld file=./records.json format=json replace=true + +ARGS: + dns-zone DNS zone to import records into + file Path to the zone file (bind) or JSON file + [format=bind] File format: "bind" or "json" (bind | json) + [dry-run=false] Parse the file and print a summary without calling the API + [replace=false] Clear all records in the zone before importing + +FLAGS: + -h, --help help for import + --list-sub-commands List all subcommands + +GLOBAL FLAGS: + -c, --config string The path to the config file + -D, --debug Enable debug mode + -o, --output string Output format: json or human, see 'scw help output' for more info (default "human") + -p, --profile string The config profile to use + +SEE ALSO: + # Import a full raw DNS zone + dns zone import + + # Low-level record changes + dns record bulk-update + + # Delete all records in a zone + dns record clear diff --git a/cmd/scw/testdata/test-all-usage-dns-record-usage.golden b/cmd/scw/testdata/test-all-usage-dns-record-usage.golden index 8e65d9b68a..22b6753af2 100644 --- a/cmd/scw/testdata/test-all-usage-dns-record-usage.golden +++ b/cmd/scw/testdata/test-all-usage-dns-record-usage.golden @@ -10,6 +10,7 @@ AVAILABLE COMMANDS: bulk-update Update records within a DNS zone clear Clear records within a DNS zone delete Delete a DNS record + import Import many DNS records from a file list List records within a DNS zone list-nameservers List name servers within a DNS zone set Update a DNS record diff --git a/docs/commands/dns.md b/docs/commands/dns.md index 0424c19280..8ed425d50d 100644 --- a/docs/commands/dns.md +++ b/docs/commands/dns.md @@ -12,6 +12,7 @@ This API allows you to manage your domains, DNS zones and records. - [Update records within a DNS zone](#update-records-within-a-dns-zone) - [Clear records within a DNS zone](#clear-records-within-a-dns-zone) - [Delete a DNS record](#delete-a-dns-record) + - [Import many DNS records from a file](#import-many-dns-records-from-a-file) - [List records within a DNS zone](#list-records-within-a-dns-zone) - [List name servers within a DNS zone](#list-name-servers-within-a-dns-zone) - [Update a DNS record](#update-a-dns-record) @@ -316,6 +317,58 @@ scw dns record delete my-domain.tld data=1.2.3.4 name=vpn type=A +### Import many DNS records from a file + +Import DNS records into a zone that uses Scaleway default name servers. + +The DNS zone is the only positional argument; pass the path to the file as file=PATH. + +Two formats are supported: + - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + Supported types: A, AAAA, CNAME, TXT, MX, NS, PTR, SRV, CAA. For other types (e.g. TLSA, SSHFP, DS), use format=json. + - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + Accepts all types supported by the Scaleway DNS API. + +SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. + +Use "replace=true" to delete all existing records in the zone before importing (equivalent to "scw dns record clear" followed by adds). + +For a full zone file replacement at once, prefer "scw dns zone import". + +**Usage:** + +``` +scw dns record import [arg=value ...] +``` + + +**Args:** + +| Name | | Description | +|------|---|-------------| +| dns-zone | Required | DNS zone to import records into | +| file | Required | Path to the zone file (bind) or JSON file | +| format | Default: `bind`
One of: `bind`, `json` | File format: "bind" or "json" | +| dry-run | Default: `false` | Parse the file and print a summary without calling the API | +| replace | Default: `false` | Clear all records in the zone before importing | + + +**Examples:** + + +Import BIND records from a file +``` +scw dns record import my-domain.tld file=./zone.txt +``` + +Import JSON and replace existing records +``` +scw dns record import my-domain.tld file=./records.json format=json replace=true +``` + + + + ### List records within a DNS zone Retrieve a list of DNS records within a DNS zone that has default name servers. diff --git a/go.mod b/go.mod index e8e8620915..63acbc89d4 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/karrick/tparse/v2 v2.8.2 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.21 + github.com/miekg/dns v1.1.72 github.com/moby/buildkit v0.29.0 github.com/moby/go-archive v0.2.0 github.com/opencontainers/go-digest v1.0.0 @@ -204,11 +205,11 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect diff --git a/go.sum b/go.sum index 5671dcd32c..b95fc8fa51 100644 --- a/go.sum +++ b/go.sum @@ -364,6 +364,8 @@ github.com/mattn/go-tty v0.0.4 h1:NVikla9X8MN0SQAqCYzpGyXv0jY7MNl3HOWD2dkle7E= github.com/mattn/go-tty v0.0.4/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e h1:Qa6dnn8DlasdXRnacluu8HzPts0S1I9zvvUPDbBnXFI= @@ -604,8 +606,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -618,8 +620,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -686,8 +688,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/namespaces/domain/v2beta1/custom.go b/internal/namespaces/domain/v2beta1/custom.go index a757a839e0..9740379442 100644 --- a/internal/namespaces/domain/v2beta1/custom.go +++ b/internal/namespaces/domain/v2beta1/custom.go @@ -44,6 +44,7 @@ func GetCommands() *core.Commands { dnsRecordAddCommand(), dnsRecordSetCommand(), dnsRecordDeleteCommand(), + dnsRecordImportCommand(), )) cmds.MustFind("dns", "zone", "import").ArgSpecs.GetByName("bind-source.content").CanLoadFile = true diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go new file mode 100644 index 0000000000..86589e79ee --- /dev/null +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -0,0 +1,218 @@ +package domain + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/scaleway/scaleway-cli/v2/core" + domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" +) + +const dnsImportBatchSize = 200 + +type dnsRecordImportArgs struct { + DNSZone string + File string + Format string + DryRun bool + Replace bool +} + +// dnsRecordImportResult is returned by dns record import for human and JSON output. +type dnsRecordImportResult struct { + DryRun bool `json:"dry_run"` + RecordCount int `json:"record_count"` + APIRequests int `json:"api_requests"` + ReplacedZone bool `json:"replaced_zone"` +} + +func (r dnsRecordImportResult) String() string { + switch { + case r.DryRun: + return fmt.Sprintf( + "dry-run: parsed %d record(s); would perform %d API request(s) (replace=%v)", + r.RecordCount, r.APIRequests, r.ReplacedZone, + ) + case r.RecordCount == 0: + return "no records imported" + default: + return fmt.Sprintf( + "imported %d record(s) in %d API request(s)", + r.RecordCount, + r.APIRequests, + ) + } +} + +func dnsRecordImportCommand() *core.Command { + return &core.Command{ + Short: `Import many DNS records from a file`, + Long: strings.TrimSpace(` +Import DNS records into a zone that uses Scaleway default name servers. + +The DNS zone is the only positional argument; pass the path to the file as file=PATH. + +Two formats are supported: + - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + Supported types: A, AAAA, CNAME, TXT, MX, NS, PTR, SRV, CAA. For other types (e.g. TLSA, SSHFP, DS), use format=json. + - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + Accepts all types supported by the Scaleway DNS API. + +SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. + +Use "replace=true" to delete all existing records in the zone before importing (equivalent to "scw dns record clear" followed by adds). + +For a full zone file replacement at once, prefer "scw dns zone import". +`), + Namespace: "dns", + Resource: "record", + Verb: "import", + ArgsType: reflect.TypeOf(dnsRecordImportArgs{}), + ArgSpecs: core.ArgSpecs{ + { + Name: "dns-zone", + Short: "DNS zone to import records into", + Required: true, + Positional: true, + }, + { + Name: "file", + Short: "Path to the zone file (bind) or JSON file", + Required: true, + Positional: false, + }, + { + Name: "format", + Short: `File format: "bind" or "json"`, + Required: false, + Default: core.DefaultValueSetter("bind"), + EnumValues: []string{"bind", "json"}, + }, + { + Name: "dry-run", + Short: "Parse the file and print a summary without calling the API", + Required: false, + Default: core.DefaultValueSetter("false"), + }, + { + Name: "replace", + Short: "Clear all records in the zone before importing", + Required: false, + Default: core.DefaultValueSetter("false"), + }, + }, + Run: dnsRecordImportRun, + Examples: []*core.Example{ + { + Short: "Import BIND records from a file", + Raw: "scw dns record import my-domain.tld file=./zone.txt", + }, + { + Short: "Import JSON and replace existing records", + Raw: "scw dns record import my-domain.tld file=./records.json format=json replace=true", + }, + }, + SeeAlsos: []*core.SeeAlso{ + {Command: "dns zone import", Short: "Import a full raw DNS zone"}, + {Command: "dns record bulk-update", Short: "Low-level record changes"}, + {Command: "dns record clear", Short: "Delete all records in a zone"}, + }, + } +} + +func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { + args := argsI.(*dnsRecordImportArgs) + zone := strings.TrimSpace(args.DNSZone) + if zone == "" { + return nil, errors.New("dns-zone is required") + } + path := strings.TrimSpace(args.File) + if path == "" { + return nil, errors.New("file is required") + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve file path: %w", err) + } + raw, err := os.ReadFile(abs) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + + format := strings.ToLower(strings.TrimSpace(args.Format)) + if format == "" { + format = "bind" + } + + var records []*domain.Record + switch format { + case "bind": + records, err = parseImportBind(string(raw), zone) + case "json": + records, err = parseImportJSON(string(raw)) + default: + return nil, fmt.Errorf("unsupported format %q (use bind or json)", format) + } + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, errors.New( + "no records to import after parsing " + + "(SOA/apex NS are skipped in bind format)", + ) + } + + apiCalls := 0 + if args.Replace { + apiCalls++ + } + apiCalls += (len(records) + dnsImportBatchSize - 1) / dnsImportBatchSize + + if args.DryRun { + return dnsRecordImportResult{ + DryRun: true, + RecordCount: len(records), + APIRequests: apiCalls, + ReplacedZone: args.Replace, + }, nil + } + + client := core.ExtractClient(ctx) + api := domain.NewAPI(client) + + if args.Replace { + _, err = api.ClearDNSZoneRecords(&domain.ClearDNSZoneRecordsRequest{DNSZone: zone}) + if err != nil { + return nil, fmt.Errorf("clear zone before import: %w", err) + } + } + + for i := 0; i < len(records); i += dnsImportBatchSize { + end := min(i+dnsImportBatchSize, len(records)) + chunk := records[i:end] + req := &domain.UpdateDNSZoneRecordsRequest{ + DNSZone: zone, + DisallowNewZoneCreation: true, + Changes: []*domain.RecordChange{ + {Add: &domain.RecordChangeAdd{Records: chunk}}, + }, + } + _, err = api.UpdateDNSZoneRecords(req) + if err != nil { + return nil, fmt.Errorf("import records (batch starting at index %d): %w", i, err) + } + } + + return dnsRecordImportResult{ + DryRun: false, + RecordCount: len(records), + APIRequests: apiCalls, + ReplacedZone: args.Replace, + }, nil +} diff --git a/internal/namespaces/domain/v2beta1/record_import_parse.go b/internal/namespaces/domain/v2beta1/record_import_parse.go new file mode 100644 index 0000000000..550a53f4c2 --- /dev/null +++ b/internal/namespaces/domain/v2beta1/record_import_parse.go @@ -0,0 +1,297 @@ +package domain + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + "github.com/miekg/dns" + domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" +) + +const dnsImportDefaultTTL = uint32(3600) + +// jsonImportFile is the expected shape for format=json imports. +type jsonImportFile struct { + Records []jsonImportRecord `json:"records"` +} + +type jsonImportRecord struct { + Name string `json:"name"` + Type string `json:"type"` + TTL uint32 `json:"ttl"` + Data string `json:"data"` + Priority *uint32 `json:"priority,omitempty"` +} + +func validateZoneDirectives(content string) error { + scanner := bufio.NewScanner(strings.NewReader(content)) + lineNum := 0 + const maxScan = 32 * 1024 * 1024 // 32 MiB lines are unexpected; avoid unbounded buffer + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxScan) + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, ";") { + continue + } + upper := strings.ToUpper(line) + if strings.HasPrefix(upper, "$INCLUDE") { + return fmt.Errorf( + "line %d: $INCLUDE is not supported; expand includes before importing", + lineNum, + ) + } + if strings.HasPrefix(upper, "$GENERATE") { + return fmt.Errorf( + "line %d: $GENERATE is not supported; expand generated records before importing", + lineNum, + ) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read zone file: %w", err) + } + + return nil +} + +func parseImportJSON(content string) ([]*domain.Record, error) { + var doc jsonImportFile + if err := json.Unmarshal([]byte(content), &doc); err != nil { + return nil, fmt.Errorf("parse JSON: %w", err) + } + if len(doc.Records) == 0 { + return nil, errors.New(`no records found in JSON (expected a top-level "records" array)`) + } + records := make([]*domain.Record, 0, len(doc.Records)) + for i, r := range doc.Records { + rec, err := jsonRecordToDomain(r, i) + if err != nil { + return nil, err + } + records = append(records, rec) + } + + return records, nil +} + +func jsonRecordToDomain(r jsonImportRecord, index int) (*domain.Record, error) { + typ := strings.TrimSpace(strings.ToUpper(r.Type)) + if typ == "" { + return nil, fmt.Errorf("records[%d]: missing type", index) + } + if !isAllowedImportRecordType(typ) { + return nil, fmt.Errorf("records[%d]: unsupported type %q", index, r.Type) + } + data := strings.TrimSpace(r.Data) + if data == "" { + return nil, fmt.Errorf("records[%d]: missing data", index) + } + ttl := r.TTL + if ttl == 0 { + ttl = dnsImportDefaultTTL + } + name := strings.TrimSpace(r.Name) + if name == "@" { + name = "" + } + if err := validateRecordOwnerName(name); err != nil { + return nil, fmt.Errorf("records[%d]: %w", index, err) + } + rt := domain.RecordType(typ) + rec := &domain.Record{ + Data: data, + Name: name, + TTL: ttl, + Type: rt, + Priority: 0, + } + if r.Priority != nil { + rec.Priority = *r.Priority + } + if typ == "MX" && r.Priority == nil { + return nil, fmt.Errorf("records[%d]: MX records require priority", index) + } + + return rec, nil +} + +func validateRecordOwnerName(name string) error { + if name == "" || name == "@" { + return nil + } + if strings.Contains(name, "..") || strings.ContainsAny(name, " \t") { + return fmt.Errorf("invalid owner name %q", name) + } + // Reject absolute FQDNs: we expect short names relative to the zone (like the rest of scw dns record). + if dns.IsFqdn(name) { + return fmt.Errorf( + "owner name %q looks like an FQDN; use a relative name (e.g. www) or @ for apex", + name, + ) + } + + return nil +} + +func isAllowedImportRecordType(typ string) bool { + return slices.Contains(domainTypes, typ) +} + +func parseImportBind(content, dnsZone string) ([]*domain.Record, error) { + if err := validateZoneDirectives(content); err != nil { + return nil, err + } + origin := dns.Fqdn(dnsZone) + zp := dns.NewZoneParser(strings.NewReader(content), origin, "") + var records []*domain.Record + for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { + recs, err := dnsRRToRecords(rr, dnsZone) + if err != nil { + return nil, err + } + records = append(records, recs...) + } + if err := zp.Err(); err != nil { + return nil, fmt.Errorf("parse BIND zone: %w", err) + } + + return records, nil +} + +func dnsRRToRecords(rr dns.RR, dnsZone string) ([]*domain.Record, error) { + switch hdr := rr.Header(); hdr.Rrtype { + case dns.TypeSOA: + return nil, nil + case dns.TypeNS: + name, err := relativeOwnerName(hdr.Name, dnsZone) + if err != nil { + return nil, err + } + if name == "" { + // Apex NS are managed by Scaleway for zones with default name servers. + return nil, nil + } + ns, ok := rr.(*dns.NS) + if !ok { + return nil, errors.New("internal error: expected NS record") + } + + return []*domain.Record{{ + Data: targetToData(ns.Ns), + Name: name, + TTL: ttlOrDefault(hdr.Ttl), + Type: domain.RecordTypeNS, + Priority: 0, + }}, nil + default: + rec, err := dnsRRToRecord(rr, dnsZone) + if err != nil { + return nil, err + } + if rec == nil { + return nil, nil + } + + return []*domain.Record{rec}, nil + } +} + +func dnsRRToRecord(rr dns.RR, dnsZone string) (*domain.Record, error) { + hdr := rr.Header() + name, err := relativeOwnerName(hdr.Name, dnsZone) + if err != nil { + return nil, err + } + ttl := ttlOrDefault(hdr.Ttl) + + switch v := rr.(type) { + case *dns.A: + return &domain.Record{ + Data: v.A.String(), + Name: name, TTL: ttl, Type: domain.RecordTypeA, + }, nil + case *dns.AAAA: + return &domain.Record{ + Data: v.AAAA.String(), + Name: name, TTL: ttl, Type: domain.RecordTypeAAAA, + }, nil + case *dns.CNAME: + return &domain.Record{ + Data: targetToData(v.Target), + Name: name, TTL: ttl, Type: domain.RecordTypeCNAME, + }, nil + case *dns.TXT: + return &domain.Record{ + Data: strings.Join(v.Txt, ""), + Name: name, TTL: ttl, Type: domain.RecordTypeTXT, + }, nil + case *dns.MX: + return &domain.Record{ + Data: targetToData(v.Mx), + Name: name, + TTL: ttl, + Type: domain.RecordTypeMX, + Priority: uint32(v.Preference), + }, nil + case *dns.PTR: + return &domain.Record{ + Data: targetToData(v.Ptr), + Name: name, TTL: ttl, Type: domain.RecordTypePTR, + }, nil + case *dns.SRV: + return &domain.Record{ + Data: fmt.Sprintf("%d %d %d %s", v.Priority, v.Weight, v.Port, targetToData(v.Target)), + Name: name, TTL: ttl, Type: domain.RecordTypeSRV, + }, nil + case *dns.CAA: + return &domain.Record{ + Data: fmt.Sprintf("%d %s %s", v.Flag, v.Tag, v.Value), + Name: name, TTL: ttl, Type: domain.RecordTypeCAA, + }, nil + default: + typeName := dns.TypeToString[hdr.Rrtype] + if typeName == "" { + typeName = fmt.Sprintf("TYPE%d", hdr.Rrtype) + } + + return nil, fmt.Errorf( + "unsupported record type %s for %s in BIND format; "+ + "use format=json with a raw data field instead", + typeName, hdr.Name, + ) + } +} + +func ttlOrDefault(ttl uint32) uint32 { + if ttl == 0 { + return dnsImportDefaultTTL + } + + return ttl +} + +func targetToData(target string) string { + return strings.TrimSuffix(target, ".") +} + +func relativeOwnerName(ownerFQN, zone string) (string, error) { + owner := strings.TrimSuffix(ownerFQN, ".") + z := strings.TrimSuffix(dns.Fqdn(zone), ".") + if owner == z { + return "", nil + } + suf := "." + z + rel, ok := strings.CutSuffix(owner, suf) + if !ok { + return "", fmt.Errorf("owner %q is not under DNS zone %q", ownerFQN, zone) + } + + return rel, nil +}