diff --git a/internal/dnsrecord/validator.go b/internal/dnsrecord/validator.go index c0a5c07..4fc6b87 100644 --- a/internal/dnsrecord/validator.go +++ b/internal/dnsrecord/validator.go @@ -23,8 +23,8 @@ type Validator struct { func NewValidator() (*Validator, error) { v := validator.New() - if err := v.RegisterValidation("dns_fqdn", func(fl validator.FieldLevel) bool { - return IsFQDN(fl.Field().String()) + if err := v.RegisterValidation("dns_name", func(fl validator.FieldLevel) bool { + return IsDNSName(fl.Field().String()) }); err != nil { return nil, err } @@ -41,13 +41,13 @@ func (v *Validator) ValidateRRSet(name, recordType string, ttl uint64, contents recordType = strings.ToUpper(strings.TrimSpace(recordType)) if ttl == 0 || ttl > 1<<32-1 { - return pdns.RRSet{}, fmt.Errorf("ttl must be between 1 and 4294967295") + return pdns.RRSet{}, fmt.Errorf("ttl deve estar entre 1 e 4294967295") } - if err := v.validate.Var(name, "required,dns_fqdn"); err != nil { - return pdns.RRSet{}, fmt.Errorf("record name must be a fully qualified domain name") + 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("unsupported record type %q", recordType) + return pdns.RRSet{}, fmt.Errorf("tipo de registro %q não suportado", recordType) } records := make([]pdns.Record, 0, len(contents)) @@ -62,7 +62,7 @@ func (v *Validator) ValidateRRSet(name, recordType string, ttl uint64, contents records = append(records, pdns.Record{Content: content}) } if len(records) == 0 { - return pdns.RRSet{}, fmt.Errorf("at least one record value is required") + return pdns.RRSet{}, fmt.Errorf("é obrigatório ao menos um valor de registro") } return pdns.RRSet{ @@ -81,7 +81,17 @@ func EnsureTrailingDot(value string) string { 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 @@ -99,7 +109,7 @@ func IsFQDN(value string) bool { return false } for _, r := range label { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || (allowUnderscore && r == '_') { continue } return false @@ -113,18 +123,18 @@ func validateContent(recordType, content string) error { case "A": addr, err := netip.ParseAddr(content) if err != nil || !addr.Is4() { - return fmt.Errorf("A record content must be an IPv4 address") + 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("AAAA record content must be an IPv6 address") + 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("%s record content must be a fully qualified domain name", recordType) + return fmt.Errorf("o conteúdo do registro %s precisa ser um domínio totalmente qualificado", recordType) } case "MX": return validateMX(content) @@ -134,7 +144,7 @@ func validateContent(recordType, content string) error { return validateSRV(content) case "TXT": if !txtValuePattern.MatchString(content) { - return fmt.Errorf("TXT record content must be one or more quoted strings") + return fmt.Errorf("o conteúdo do registro TXT precisa ser uma ou mais strings entre aspas") } } return nil @@ -143,13 +153,13 @@ func validateContent(recordType, content string) error { func validateMX(content string) error { fields := strings.Fields(content) if len(fields) != 2 { - return fmt.Errorf("MX record content must be: priority target") + return fmt.Errorf("o conteúdo do registro MX precisa ser: prioridade destino") } if _, err := parseUint(fields[0], 16); err != nil { - return fmt.Errorf("MX priority must be between 0 and 65535") + return fmt.Errorf("a prioridade MX deve estar entre 0 e 65535") } if !IsFQDN(fields[1]) { - return fmt.Errorf("MX target must be a fully qualified domain name") + return fmt.Errorf("o destino MX precisa ser um domínio totalmente qualificado") } return nil } @@ -157,16 +167,16 @@ func validateMX(content string) error { func validateSRV(content string) error { fields := strings.Fields(content) if len(fields) != 4 { - return fmt.Errorf("SRV record content must be: priority weight port target") + return fmt.Errorf("o conteúdo do registro SRV precisa ser: prioridade peso porta destino") } - for i, label := range []string{"priority", "weight", "port"} { + for i, label := range []string{"prioridade", "peso", "porta"} { if _, err := parseUint(fields[i], 16); err != nil { - return fmt.Errorf("SRV %s must be between 0 and 65535", label) + 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("SRV target must be a fully qualified domain name or .") + return fmt.Errorf("o destino SRV precisa ser um domínio totalmente qualificado ou .") } return nil } @@ -174,17 +184,17 @@ func validateSRV(content string) error { func validateSOA(content string) error { fields := strings.Fields(content) if len(fields) != 7 { - return fmt.Errorf("SOA record content must be: primary hostmaster serial refresh retry expire minimum") + 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("SOA primary nameserver must be a fully qualified domain name") + return fmt.Errorf("o servidor de nomes primario do SOA precisa ser um domínio totalmente qualificado") } if !IsFQDN(fields[1]) { - return fmt.Errorf("SOA hostmaster must be a fully qualified domain name") + return fmt.Errorf("o hostmaster do SOA precisa ser um domínio totalmente qualificado") } - for i, label := range []string{"serial", "refresh", "retry", "expire", "minimum"} { + 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 must be between 0 and 4294967295", label) + return fmt.Errorf("SOA %s deve estar entre 0 e 4294967295", label) } } return nil @@ -193,18 +203,18 @@ func validateSOA(content string) error { func validateCAA(content string) error { fields := strings.Fields(content) if len(fields) < 3 { - return fmt.Errorf("CAA record content must be: flags tag \"value\"") + 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("CAA flags must be between 0 and 255") + return fmt.Errorf("o campo flags do CAA deve estar entre 0 e 255") } if !caaTagPattern.MatchString(fields[1]) { - return fmt.Errorf("CAA tag must start with a letter and contain only letters, numbers, and hyphens") + 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("CAA value must be quoted") + return fmt.Errorf("o valor CAA deve estar entre aspas") } return nil } diff --git a/internal/dnsrecord/validator_test.go b/internal/dnsrecord/validator_test.go index 609c097..238ddbd 100644 --- a/internal/dnsrecord/validator_test.go +++ b/internal/dnsrecord/validator_test.go @@ -18,6 +18,7 @@ func TestValidateRRSetAcceptsSupportedRecords(t *testing.T) { {"SOA", "ns1.example.org. hostmaster.example.org. 2026010101 3600 600 604800 300"}, {"SRV", "10 20 443 service.example.org."}, {"TXT", `"v=spf1 -all"`}, + {"TXT", `"v=DMARC1; p=reject; rua=mailto:postmaster@example.org; ruf=mailto:postmaster@example.org"`}, } for _, tc := range cases { @@ -56,6 +57,68 @@ func TestValidateRRSetRejectsInvalidRecords(t *testing.T) { } } +func TestValidateRRSetAcceptsUnderscoresInOwnerNames(t *testing.T) { + v := newTestValidator(t) + + cases := []struct { + name string + recordType string + content string + wantName string + }{ + { + name: "_dmarc.example.org", + recordType: "TXT", + content: `"v=DMARC1; p=reject; rua=mailto:postmaster@example.org; ruf=mailto:postmaster@example.org"`, + wantName: "_dmarc.example.org.", + }, + { + name: "_sip._tcp.example.org", + recordType: "SRV", + content: "10 20 5060 sip.example.org.", + wantName: "_sip._tcp.example.org.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rrset, err := v.ValidateRRSet(tc.name, tc.recordType, 300, []string{tc.content}) + if err != nil { + t.Fatalf("ValidateRRSet returned error: %v", err) + } + if rrset.Name != tc.wantName { + t.Fatalf("unexpected normalized name: %q", rrset.Name) + } + if rrset.Records[0].Content != tc.content { + t.Fatalf("unexpected content: %q", rrset.Records[0].Content) + } + }) + } +} + +func TestValidateRRSetRejectsUnderscoresInHostnameTargets(t *testing.T) { + v := newTestValidator(t) + + cases := []struct { + recordType string + content string + }{ + {"CNAME", "_target.example.org."}, + {"MX", "10 _mail.example.org."}, + {"NS", "_ns.example.org."}, + {"SOA", "_ns.example.org. hostmaster.example.org. 2026010101 3600 600 604800 300"}, + {"SRV", "10 20 5060 _sip.example.org."}, + } + + for _, tc := range cases { + t.Run(tc.recordType, func(t *testing.T) { + if _, err := v.ValidateRRSet("www.example.org.", tc.recordType, 300, []string{tc.content}); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + func TestValidateRRSetRequiresTTL(t *testing.T) { v := newTestValidator(t) diff --git a/internal/server/server.go b/internal/server/server.go index f0c99ca..327845e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -159,14 +159,14 @@ func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) { func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { - http.NotFound(w, r) + http.Error(w, "pagina nao encontrada", http.StatusNotFound) return } server, serverErr := s.client.GetServer(r.Context()) zones, zonesErr := s.client.ListZones(r.Context()) data := pageData{ - Title: "Dashboard", + Title: "Painel", Server: server, Zones: zones, Error: firstNonEmpty(errorText(serverErr), errorText(zonesErr)), @@ -185,7 +185,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { } data := pageData{ - Title: "Login", + Title: "Entrar", Error: r.URL.Query().Get("error"), Next: safeRedirectPath(r.URL.Query().Get("next")), } @@ -198,7 +198,7 @@ func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) { return } if err := r.ParseForm(); err != nil { - http.Redirect(w, r, "/login?error="+url.QueryEscape("invalid form data"), http.StatusSeeOther) + http.Redirect(w, r, "/login?error="+url.QueryEscape("dados do formulario invalidos"), http.StatusSeeOther) return } @@ -207,18 +207,18 @@ func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) { allowed, err := s.auth.Authenticate(r.Context(), username, password) if err != nil { s.logger.Printf("authentication failed for %q: %v", username, err) - http.Redirect(w, r, "/login?error="+url.QueryEscape("authentication backend failed"), http.StatusSeeOther) + http.Redirect(w, r, "/login?error="+url.QueryEscape("falha no servico de autenticacao"), http.StatusSeeOther) return } if !allowed { - http.Redirect(w, r, "/login?error="+url.QueryEscape("invalid username or password"), http.StatusSeeOther) + http.Redirect(w, r, "/login?error="+url.QueryEscape("usuario ou senha invalidos"), http.StatusSeeOther) return } token, err := s.createSession(username) if err != nil { s.logger.Printf("create session: %v", err) - http.Error(w, "session creation failed", http.StatusInternalServerError) + http.Error(w, "falha ao criar sessao", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ @@ -255,7 +255,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { func (s *Server) listZones(w http.ResponseWriter, r *http.Request) { zones, err := s.client.ListZones(r.Context()) data := pageData{ - Title: "Zones", + Title: "Zonas", Zones: zones, Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)), } @@ -264,30 +264,30 @@ func (s *Server) listZones(w http.ResponseWriter, r *http.Request) { func (s *Server) createZone(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - s.redirectZonesError(w, r, "invalid form data") + s.redirectZonesError(w, r, "dados do formulario invalidos") return } zoneName := dnsrecord.EnsureTrailingDot(r.FormValue("name")) if !dnsrecord.IsFQDN(zoneName) { - s.redirectZonesError(w, r, "zone name must be a fully qualified domain name") + s.redirectZonesError(w, r, "o nome da zona deve ser um nome de dominio totalmente qualificado") return } kind := strings.TrimSpace(r.FormValue("kind")) if !validZoneKind(kind) { - s.redirectZonesError(w, r, "zone kind must be Native, Master, or Slave") + s.redirectZonesError(w, r, "o tipo da zona deve ser Native, Master ou Slave") return } - nameservers, err := parseFQDNLines(r.FormValue("nameservers"), "nameserver") + nameservers, err := parseFQDNLines(r.FormValue("nameservers"), "servidor de nomes") if err != nil { s.redirectZonesError(w, r, err.Error()) return } masters := parseLines(r.FormValue("masters")) if kind == "Slave" && len(masters) == 0 { - s.redirectZonesError(w, r, "slave zones require at least one master address") + s.redirectZonesError(w, r, "zonas Slave exigem ao menos um endereco de servidor mestre") return } @@ -317,7 +317,7 @@ func (s *Server) showZone(w http.ResponseWriter, r *http.Request) { zoneID := r.PathValue("zoneID") zone, err := s.client.GetZone(r.Context(), zoneID) data := pageData{ - Title: "Zone " + zoneID, + Title: "Zona " + zoneID, ZoneID: zoneID, Zone: zone, Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)), @@ -329,7 +329,7 @@ func (s *Server) newRRSet(w http.ResponseWriter, r *http.Request) { zoneID := r.PathValue("zoneID") zone, err := s.client.GetZone(r.Context(), zoneID) data := pageData{ - Title: "Add record", + Title: "Adicionar registro", ZoneID: zoneID, Zone: zone, Error: firstNonEmpty(r.URL.Query().Get("error"), errorText(err)), @@ -337,8 +337,8 @@ func (s *Server) newRRSet(w http.ResponseWriter, r *http.Request) { RecordForm: recordForm{ Type: "A", TTL: 300, - Title: "Add record", - SubmitLabel: "Create record", + Title: "Adicionar registro", + SubmitLabel: "Criar registro", }, } s.render(w, r, "record_form.html", data) @@ -356,7 +356,7 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) { rrset, ok := findRRSet(zone, name, recordType) if !ok { - s.redirectZoneError(w, r, zoneID, "record not found") + s.redirectZoneError(w, r, zoneID, "registro nao encontrado") return } @@ -366,11 +366,11 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) { TTL: rrset.TTL, Records: recordValues(rrset), IsEdit: true, - Title: "Edit record", - SubmitLabel: "Save record", + Title: "Editar registro", + SubmitLabel: "Salvar registro", } data := pageData{ - Title: "Edit record", + Title: "Editar registro", ZoneID: zoneID, Zone: zone, Error: r.URL.Query().Get("error"), @@ -383,13 +383,13 @@ func (s *Server) editRRSet(w http.ResponseWriter, r *http.Request) { func (s *Server) saveRRSet(w http.ResponseWriter, r *http.Request) { zoneID := r.PathValue("zoneID") if err := r.ParseForm(); err != nil { - s.redirectZoneError(w, r, zoneID, "invalid form data") + s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos") return } ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32) if err != nil { - s.redirectZoneError(w, r, zoneID, "ttl must be a positive integer") + s.redirectZoneError(w, r, zoneID, "ttl deve ser um numero inteiro positivo") return } @@ -412,18 +412,18 @@ func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") recordType := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("type"))) if name == "" || recordType == "" { - s.redirectZoneError(w, r, zoneID, "record identity is required") + s.redirectZoneError(w, r, zoneID, "a identidade do registro e obrigatoria") return } if err := r.ParseForm(); err != nil { - s.redirectZoneError(w, r, zoneID, "invalid form data") + s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos") return } ttl, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("ttl")), 10, 32) if err != nil { - s.redirectZoneError(w, r, zoneID, "ttl must be a positive integer") + s.redirectZoneError(w, r, zoneID, "ttl deve ser um numero inteiro positivo") return } @@ -443,18 +443,18 @@ func (s *Server) saveEditedRRSet(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request) { zoneID := r.PathValue("zoneID") if err := r.ParseForm(); err != nil { - s.redirectZoneError(w, r, zoneID, "invalid form data") + s.redirectZoneError(w, r, zoneID, "dados do formulario invalidos") return } name := dnsrecord.EnsureTrailingDot(r.FormValue("name")) recordType := strings.ToUpper(strings.TrimSpace(r.FormValue("type"))) if name == "." || recordType == "" { - s.redirectZoneError(w, r, zoneID, "record name and type are required") + s.redirectZoneError(w, r, zoneID, "nome e tipo do registro sao obrigatorios") return } if isSOA(recordType) { - s.redirectZoneError(w, r, zoneID, "SOA records are required for zones and cannot be deleted") + s.redirectZoneError(w, r, zoneID, "registros SOA sao obrigatorios para zonas e nao podem ser excluidos") return } @@ -475,7 +475,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl, ok := s.templates[name] if !ok { - http.Error(w, "template not found", http.StatusInternalServerError) + http.Error(w, "template nao encontrado", http.StatusInternalServerError) return } if err := tmpl.ExecuteTemplate(w, name, data); err != nil { @@ -507,7 +507,7 @@ func (s *Server) withSessionAuth(next http.Handler) http.Handler { return } if isUnsafeMethod(r.Method) && !validCSRFToken(r, sess.CSRFToken) { - http.Error(w, "invalid CSRF token", http.StatusForbidden) + http.Error(w, "token CSRF invalido", http.StatusForbidden) return } next.ServeHTTP(w, r) @@ -664,7 +664,7 @@ func parseFQDNLines(raw, label string) ([]string, error) { for i, value := range values { values[i] = dnsrecord.EnsureTrailingDot(value) if !dnsrecord.IsFQDN(values[i]) { - return nil, fmt.Errorf("%s %q must be a fully qualified domain name", label, value) + return nil, fmt.Errorf("%s %q deve ser um nome de dominio totalmente qualificado", label, value) } } return values, nil diff --git a/internal/server/templates/base.html b/internal/server/templates/base.html index 37f95e8..9a1ba5a 100644 --- a/internal/server/templates/base.html +++ b/internal/server/templates/base.html @@ -1,6 +1,6 @@ {{ define "layout" }} - + @@ -15,16 +15,16 @@

AehooDNS

- diff --git a/internal/server/templates/dashboard.html b/internal/server/templates/dashboard.html index 4b1f743..d21137c 100644 --- a/internal/server/templates/dashboard.html +++ b/internal/server/templates/dashboard.html @@ -1,69 +1,76 @@ {{ define "content" }}