234 lines
6.5 KiB
Go
234 lines
6.5 KiB
Go
package dnsrecord
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
|
|
"pdns_admin/internal/pdns"
|
|
)
|
|
|
|
var (
|
|
txtValuePattern = regexp.MustCompile(`^"([^"\\]|\\.)*"( "([^"\\]|\\.)*")*$`)
|
|
caaTagPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`)
|
|
)
|
|
|
|
type Validator struct {
|
|
validate *validator.Validate
|
|
}
|
|
|
|
func NewValidator() (*Validator, error) {
|
|
v := validator.New()
|
|
if err := v.RegisterValidation("dns_name", func(fl validator.FieldLevel) bool {
|
|
return IsDNSName(fl.Field().String())
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Validator{validate: v}, nil
|
|
}
|
|
|
|
func SupportedTypes() []string {
|
|
return []string{"A", "AAAA", "CAA", "CNAME", "MX", "NS", "SOA", "SRV", "TXT"}
|
|
}
|
|
|
|
func (v *Validator) ValidateRRSet(name, recordType string, ttl uint64, contents []string) (pdns.RRSet, error) {
|
|
name = EnsureTrailingDot(name)
|
|
recordType = strings.ToUpper(strings.TrimSpace(recordType))
|
|
|
|
if ttl == 0 || ttl > 1<<32-1 {
|
|
return pdns.RRSet{}, fmt.Errorf("ttl deve estar entre 1 e 4294967295")
|
|
}
|
|
if err := v.validate.Var(name, "required,dns_name"); err != nil {
|
|
return pdns.RRSet{}, fmt.Errorf("o nome do registro precisa ser um domínio totalmente qualificado")
|
|
}
|
|
if !supported(recordType) {
|
|
return pdns.RRSet{}, fmt.Errorf("tipo de registro %q não suportado", recordType)
|
|
}
|
|
|
|
records := make([]pdns.Record, 0, len(contents))
|
|
for _, content := range contents {
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
continue
|
|
}
|
|
if err := validateContent(recordType, content); err != nil {
|
|
return pdns.RRSet{}, err
|
|
}
|
|
records = append(records, pdns.Record{Content: content})
|
|
}
|
|
if len(records) == 0 {
|
|
return pdns.RRSet{}, fmt.Errorf("é obrigatório ao menos um valor de registro")
|
|
}
|
|
|
|
return pdns.RRSet{
|
|
Name: name,
|
|
Type: recordType,
|
|
TTL: uint32(ttl),
|
|
Records: records,
|
|
}, nil
|
|
}
|
|
|
|
func EnsureTrailingDot(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || strings.HasSuffix(value, ".") {
|
|
return value
|
|
}
|
|
return value + "."
|
|
}
|
|
|
|
// IsFQDN reports whether value is an absolute hostname-style DNS name.
|
|
func IsFQDN(value string) bool {
|
|
return isDomainName(value, false)
|
|
}
|
|
|
|
// IsDNSName reports whether value is an absolute DNS owner name.
|
|
func IsDNSName(value string) bool {
|
|
return isDomainName(value, true)
|
|
}
|
|
|
|
func isDomainName(value string, allowUnderscore bool) bool {
|
|
value = strings.TrimSpace(value)
|
|
if value == "." || value == "" || !strings.HasSuffix(value, ".") || len(value) > 253 {
|
|
return false
|
|
}
|
|
|
|
labels := strings.Split(strings.TrimSuffix(value, "."), ".")
|
|
for i, label := range labels {
|
|
if label == "" || len(label) > 63 {
|
|
return false
|
|
}
|
|
if label == "*" && i == 0 {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
|
return false
|
|
}
|
|
for _, r := range label {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || (allowUnderscore && r == '_') {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateContent(recordType, content string) error {
|
|
switch recordType {
|
|
case "A":
|
|
addr, err := netip.ParseAddr(content)
|
|
if err != nil || !addr.Is4() {
|
|
return fmt.Errorf("o conteúdo do registro A precisa ser um endereco IPv4")
|
|
}
|
|
case "AAAA":
|
|
addr, err := netip.ParseAddr(content)
|
|
if err != nil || !addr.Is6() {
|
|
return fmt.Errorf("o conteúdo do registro AAAA precisa ser um endereco IPv6")
|
|
}
|
|
case "CAA":
|
|
return validateCAA(content)
|
|
case "CNAME", "NS":
|
|
if !IsFQDN(content) {
|
|
return fmt.Errorf("o conteúdo do registro %s precisa ser um domínio totalmente qualificado", recordType)
|
|
}
|
|
case "MX":
|
|
return validateMX(content)
|
|
case "SOA":
|
|
return validateSOA(content)
|
|
case "SRV":
|
|
return validateSRV(content)
|
|
case "TXT":
|
|
if !txtValuePattern.MatchString(content) {
|
|
return fmt.Errorf("o conteúdo do registro TXT precisa ser uma ou mais strings entre aspas")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateMX(content string) error {
|
|
fields := strings.Fields(content)
|
|
if len(fields) != 2 {
|
|
return fmt.Errorf("o conteúdo do registro MX precisa ser: prioridade destino")
|
|
}
|
|
if _, err := parseUint(fields[0], 16); err != nil {
|
|
return fmt.Errorf("a prioridade MX deve estar entre 0 e 65535")
|
|
}
|
|
if !IsFQDN(fields[1]) {
|
|
return fmt.Errorf("o destino MX precisa ser um domínio totalmente qualificado")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSRV(content string) error {
|
|
fields := strings.Fields(content)
|
|
if len(fields) != 4 {
|
|
return fmt.Errorf("o conteúdo do registro SRV precisa ser: prioridade peso porta destino")
|
|
}
|
|
for i, label := range []string{"prioridade", "peso", "porta"} {
|
|
if _, err := parseUint(fields[i], 16); err != nil {
|
|
return fmt.Errorf("o campo SRV %s deve estar entre 0 e 65535", label)
|
|
}
|
|
}
|
|
target := strings.TrimSpace(fields[3])
|
|
if target != "." && !IsFQDN(target) {
|
|
return fmt.Errorf("o destino SRV precisa ser um domínio totalmente qualificado ou .")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSOA(content string) error {
|
|
fields := strings.Fields(content)
|
|
if len(fields) != 7 {
|
|
return fmt.Errorf("o conteúdo do registro SOA precisa ser: primario hostmaster serial atualizacao nova-tentativa expiracao minimo")
|
|
}
|
|
if !IsFQDN(fields[0]) {
|
|
return fmt.Errorf("o servidor de nomes primario do SOA precisa ser um domínio totalmente qualificado")
|
|
}
|
|
if !IsFQDN(fields[1]) {
|
|
return fmt.Errorf("o hostmaster do SOA precisa ser um domínio totalmente qualificado")
|
|
}
|
|
for i, label := range []string{"serial", "atualizacao", "nova-tentativa", "expiracao", "minimo"} {
|
|
if _, err := parseUint(fields[i+2], 32); err != nil {
|
|
return fmt.Errorf("SOA %s deve estar entre 0 e 4294967295", label)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCAA(content string) error {
|
|
fields := strings.Fields(content)
|
|
if len(fields) < 3 {
|
|
return fmt.Errorf("o conteúdo do registro CAA precisa ser: flags tag \"valor\"")
|
|
}
|
|
flags, err := parseUint(fields[0], 8)
|
|
if err != nil || flags > 255 {
|
|
return fmt.Errorf("o campo flags do CAA deve estar entre 0 e 255")
|
|
}
|
|
if !caaTagPattern.MatchString(fields[1]) {
|
|
return fmt.Errorf("a tag CAA deve começar com uma letra e conter apenas letras, numeros e hífens")
|
|
}
|
|
value := strings.Join(fields[2:], " ")
|
|
if !txtValuePattern.MatchString(value) {
|
|
return fmt.Errorf("o valor CAA deve estar entre aspas")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseUint(value string, bits int) (uint64, error) {
|
|
return strconv.ParseUint(value, 10, bits)
|
|
}
|
|
|
|
func supported(recordType string) bool {
|
|
for _, candidate := range SupportedTypes() {
|
|
if candidate == recordType {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|