fix(dns): o painel dizia que faltavam registos que estavam publicados
Tres defeitos na comparacao, todos encontrados contra a zona real. Cada um fazia a consola declarar em falta um registo que existia e estava a funcionar - o que e pior do que nao dizer nada, porque manda o operador procurar um problema que nao ha. MX. A especificacao guarda a prioridade num campo proprio, portanto o valor desejado e so o host. A comparacao era feita contra "10 mail.x." como texto, e por isso o MX de um dominio que recebe correio todos os dias aparecia como inexistente. TXT longo. Um valor acima de 255 bytes e publicado em varias strings, e o resolver devolve-as entre aspas. Tirar as aspas e juntar tudo com espacos punha um espaco no meio de uma chave DKIM: todos os registos DKIM RSA da zona eram dados como em falta. As strings sao agora unidas ponta a ponta, e os espacos dentro de cada uma sobrevivem. Registos atras do proxy. Um CNAME proxied nao e publicado como CNAME - o proxy responde A. Perguntar pelo CNAME devolvia resposta vazia, e o registo era dado como em falta apesar de estar la a servir trafego. A pergunta passa a ser do tipo que o proxy responde de facto. A comparacao passa a ser por tipo em vez de uma normalizacao unica para tudo, que era a origem comum dos tres. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
64f1a2e145
commit
8227fe2b97
4 files changed
+217
-14
No files matched your search
@@ -50,6 +50,12 @@ func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsReco
|
|||||||
// disagree. Presence is all that can be checked.
|
// disagree. Presence is all that can be checked.
|
||||||
checkValue := !record.Proxied
|
checkValue := !record.Proxied
|
||||||
|
|
||||||
|
// And it is not published as the type it was created as: a proxied CNAME
|
||||||
|
// answers A, so asking for the CNAME comes back empty and the record was
|
||||||
|
// reported as missing while it was there and serving traffic. For those,
|
||||||
|
// the question has to be the type the proxy actually answers.
|
||||||
|
qtype = dns.StringToType[queryTypeFor(record.Type, record.Proxied)]
|
||||||
|
|
||||||
var lastDetail string
|
var lastDetail string
|
||||||
for _, resolver := range v.resolvers {
|
for _, resolver := range v.resolvers {
|
||||||
msg := new(dns.Msg)
|
msg := new(dns.Msg)
|
||||||
@@ -67,7 +73,7 @@ func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsReco
|
|||||||
if !checkValue {
|
if !checkValue {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
if answerMatches(reply.Answer, record.Value) {
|
if answerMatches(reply.Answer, record.Type, record.Value) {
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
lastDetail = fmt.Sprintf("%s: published value does not match the desired one", resolver)
|
lastDetail = fmt.Sprintf("%s: published value does not match the desired one", resolver)
|
||||||
@@ -77,11 +83,7 @@ func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
// answerMatches compares the resolver's answer against the desired value.
|
// answerMatches compares the resolver's answer against the desired value.
|
||||||
// Comparison is on the record's own presentation form with the owner name and
|
func answerMatches(answers []dns.RR, recordType, want string) bool {
|
||||||
// TTL stripped, which is what makes it work uniformly across record types.
|
|
||||||
func answerMatches(answers []dns.RR, want string) bool {
|
|
||||||
want = normalizeRdata(want)
|
|
||||||
|
|
||||||
for _, rr := range answers {
|
for _, rr := range answers {
|
||||||
// String() renders "<name>\t<ttl>\t<class>\t<type>\t<rdata>"; the
|
// String() renders "<name>\t<ttl>\t<class>\t<type>\t<rdata>"; the
|
||||||
// rdata is everything after the fourth tab.
|
// rdata is everything after the fourth tab.
|
||||||
@@ -89,18 +91,98 @@ func answerMatches(answers []dns.RR, want string) bool {
|
|||||||
if len(parts) < 5 {
|
if len(parts) < 5 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if normalizeRdata(parts[4]) == want {
|
if answerMatchesValue(recordType, parts[4], want) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeRdata removes the differences that do not change meaning: quoting
|
// answerMatchesValue decides whether one published rdata means the same thing
|
||||||
// of TXT strings, trailing dots on names, case, and repeated whitespace.
|
// as the value that was asked for.
|
||||||
func normalizeRdata(value string) string {
|
//
|
||||||
value = strings.ReplaceAll(value, `"`, "")
|
// The comparison has to be per type. A single normalisation for everything is
|
||||||
|
// what made the console report records as missing while they were published
|
||||||
|
// and correct: an MX answer carries a priority the desired value does not, and
|
||||||
|
// a TXT longer than 255 bytes arrives as several quoted strings that belong
|
||||||
|
// end to end rather than separated.
|
||||||
|
func answerMatchesValue(recordType, published, want string) bool {
|
||||||
|
switch strings.ToUpper(recordType) {
|
||||||
|
case "MX":
|
||||||
|
// The spec keeps the priority in its own field, so the desired value
|
||||||
|
// is the host alone. Comparing it against "10 mail.example.test."
|
||||||
|
// said the MX of a domain receiving mail daily did not exist.
|
||||||
|
return normalizeName(mxHost(published)) == normalizeName(want)
|
||||||
|
case "TXT", "SPF":
|
||||||
|
return joinTxtStrings(published) == joinTxtStrings(want)
|
||||||
|
case "CNAME", "NS", "PTR":
|
||||||
|
return normalizeName(published) == normalizeName(want)
|
||||||
|
default:
|
||||||
|
return normalizeName(published) == normalizeName(want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mxHost drops the preference number an MX answer carries.
|
||||||
|
func mxHost(rdata string) string {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(rdata))
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
return fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(rdata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeName removes the differences that do not change what a name means.
|
||||||
|
func normalizeName(value string) string {
|
||||||
value = strings.Join(strings.Fields(value), " ")
|
value = strings.Join(strings.Fields(value), " ")
|
||||||
|
value = strings.Trim(value, `"`)
|
||||||
value = strings.TrimSuffix(value, ".")
|
value = strings.TrimSuffix(value, ".")
|
||||||
return strings.ToLower(value)
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinTxtStrings puts a TXT value back together.
|
||||||
|
//
|
||||||
|
// A value over 255 bytes is published as several strings, and a resolver
|
||||||
|
// renders them as `"first" "second"`. They are one value end to end: joining
|
||||||
|
// them with a space inserted one into the middle of a DKIM public key, so
|
||||||
|
// every RSA DKIM record was reported as missing. Spaces inside a single
|
||||||
|
// string are meaningful and survive.
|
||||||
|
func joinTxtStrings(value string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
inQuote := false
|
||||||
|
quoted := false
|
||||||
|
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
c := value[i]
|
||||||
|
switch {
|
||||||
|
case c == '\\' && i+1 < len(value) && inQuote:
|
||||||
|
i++
|
||||||
|
out.WriteByte(value[i])
|
||||||
|
case c == '"':
|
||||||
|
inQuote = !inQuote
|
||||||
|
quoted = true
|
||||||
|
case inQuote:
|
||||||
|
out.WriteByte(c)
|
||||||
|
case c == ' ' || c == '\t':
|
||||||
|
// Whitespace between strings is the rendering, not the value.
|
||||||
|
if !quoted {
|
||||||
|
out.WriteByte(c)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryTypeFor is the record type to actually ask a resolver about.
|
||||||
|
//
|
||||||
|
// A proxied CNAME is answered as A by the provider, so asking for the CNAME
|
||||||
|
// returns nothing at all - which is how a record that was published and
|
||||||
|
// serving traffic came back reported as missing.
|
||||||
|
func queryTypeFor(recordType string, proxied bool) string {
|
||||||
|
upper := strings.ToUpper(recordType)
|
||||||
|
if proxied && upper == "CNAME" {
|
||||||
|
return "A"
|
||||||
|
}
|
||||||
|
return upper
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package netdns
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Comparing a desired record against what a resolver actually answers.
|
||||||
|
//
|
||||||
|
// Every case here comes from a real zone where the console reported a record
|
||||||
|
// as missing while it was published and correct. Telling an operator that a
|
||||||
|
// record they can see in their DNS does not exist is worse than saying
|
||||||
|
// nothing: they go looking for a problem that is not there.
|
||||||
|
|
||||||
|
func TestMxIgnoresThePriorityAndTrailingDot(t *testing.T) {
|
||||||
|
// The spec carries the host in Value and the priority in its own field, so
|
||||||
|
// the desired value is the host alone. A resolver answers "10 mail.x." -
|
||||||
|
// comparing the two as strings said the MX of a domain that receives mail
|
||||||
|
// every day did not exist.
|
||||||
|
if !answerMatchesValue("MX", "10 mail.lucasrguerra.dev.br.", "mail.lucasrguerra.dev.br") {
|
||||||
|
t.Error("a published MX was reported as missing")
|
||||||
|
}
|
||||||
|
if !answerMatchesValue("MX", "20 mail.lucasrguerra.dev.br.", "mail.lucasrguerra.dev.br") {
|
||||||
|
t.Error("the priority should not decide whether the host matches")
|
||||||
|
}
|
||||||
|
// A different host is still a real difference.
|
||||||
|
if answerMatchesValue("MX", "10 outro.example.test.", "mail.lucasrguerra.dev.br") {
|
||||||
|
t.Error("a different mail host was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A TXT value longer than 255 bytes is published as several strings, and a
|
||||||
|
// resolver renders them quoted and separated. Joining them with a space - which
|
||||||
|
// is what stripping the quotes and collapsing whitespace did - inserted a space
|
||||||
|
// into the middle of a DKIM public key, so every RSA DKIM record on the zone
|
||||||
|
// was reported as missing.
|
||||||
|
func TestLongTxtIsJoinedWithoutASeparator(t *testing.T) {
|
||||||
|
published := `"v=DKIM1; k=rsa; p=AAAA" "BBBB"`
|
||||||
|
if !answerMatchesValue("TXT", published, "v=DKIM1; k=rsa; p=AAAABBBB") {
|
||||||
|
t.Error("a split TXT record was reported as missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortTxtStillMatches(t *testing.T) {
|
||||||
|
if !answerMatchesValue("TXT", `"v=spf1 mx -all"`, "v=spf1 mx -all") {
|
||||||
|
t.Error("a single-string TXT was reported as missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The spaces inside a policy string are meaningful and must survive.
|
||||||
|
func TestTxtKeepsItsOwnSpaces(t *testing.T) {
|
||||||
|
if !answerMatchesValue("TXT", `"v=spf1 include:_spf.example.test -all"`,
|
||||||
|
"v=spf1 include:_spf.example.test -all") {
|
||||||
|
t.Error("a TXT with real spaces stopped matching")
|
||||||
|
}
|
||||||
|
if answerMatchesValue("TXT", `"v=spf1 mx -all"`, "v=spf1 mx ~all") {
|
||||||
|
t.Error("a genuinely different policy was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCnameIgnoresTheTrailingDot(t *testing.T) {
|
||||||
|
if !answerMatchesValue("CNAME", "mail.lucasrguerra.dev.br.", "mail.lucasrguerra.dev.br") {
|
||||||
|
t.Error("a published CNAME was reported as missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaseDoesNotDecide(t *testing.T) {
|
||||||
|
if !answerMatchesValue("CNAME", "MAIL.LUCASRGUERRA.DEV.BR.", "mail.lucasrguerra.dev.br") {
|
||||||
|
t.Error("a name in another case was reported as missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAValueIsCompared(t *testing.T) {
|
||||||
|
if !answerMatchesValue("A", "203.0.113.10", "203.0.113.10") {
|
||||||
|
t.Error("a matching A record was reported as missing")
|
||||||
|
}
|
||||||
|
if answerMatchesValue("A", "203.0.113.99", "203.0.113.10") {
|
||||||
|
t.Error("a different address was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A record behind the provider's proxy is not published as the type it was
|
||||||
|
// created as: a proxied CNAME answers A, carrying the proxy's own addresses.
|
||||||
|
// Asking for the CNAME therefore comes back empty, and the console reported
|
||||||
|
// the record as missing while it was there and serving traffic.
|
||||||
|
func TestProxiedCnameIsQueriedAsTheTypeTheProxyAnswers(t *testing.T) {
|
||||||
|
proxied := queryTypeFor("CNAME", true)
|
||||||
|
if proxied != "A" {
|
||||||
|
t.Errorf("a proxied CNAME is queried as %s; the proxy answers A", proxied)
|
||||||
|
}
|
||||||
|
// Without the proxy it is still a CNAME question.
|
||||||
|
if plain := queryTypeFor("CNAME", false); plain != "CNAME" {
|
||||||
|
t.Errorf("an ordinary CNAME is queried as %s", plain)
|
||||||
|
}
|
||||||
|
// Other types are unaffected: a proxied A is still an A.
|
||||||
|
if a := queryTypeFor("A", true); a != "A" {
|
||||||
|
t.Errorf("a proxied A is queried as %s", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
{initialsFor(account?.email)}
|
{initialsFor(account?.email)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block break-words text-[12.5px] leading-tight">
|
<span
|
||||||
|
className="block truncate text-[12.5px] leading-tight"
|
||||||
|
title={account?.email ?? undefined}
|
||||||
|
>
|
||||||
{account?.email ?? t("common.loading")}
|
{account?.email ?? t("common.loading")}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
|
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
|
||||||
@@ -154,7 +157,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop rail */}
|
{/* Desktop rail */}
|
||||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||||
{SidebarContent}
|
{SidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -198,3 +198,25 @@ describe("API proxy routing", () => {
|
|||||||
expect(proxy).toContain("return AUTH_SERVICE_URL");
|
expect(proxy).toContain("return AUTH_SERVICE_URL");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the admin rail has the fixes the webmail rail got", () => {
|
||||||
|
const ADMIN_LAYOUT = "src/app/(admin)/admin/layout.tsx";
|
||||||
|
|
||||||
|
// The same defect, in the other surface: break-words split the address
|
||||||
|
// mid-domain and left a lone "br" on its own line.
|
||||||
|
it("truncates the account address instead of breaking it apart", () => {
|
||||||
|
const layout = code(ADMIN_LAYOUT);
|
||||||
|
expect(layout).not.toMatch(/break-words[^"]*>\s*\{?\s*(account|session)?\??\.?email/);
|
||||||
|
expect(layout).toMatch(/truncate[\s\S]{0,200}email/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A rail pinned at 244px is what made the address and the surface switch
|
||||||
|
// too tight to fit in the first place.
|
||||||
|
it("gives the rail room on a wide screen", () => {
|
||||||
|
expect(code(ADMIN_LAYOUT)).toMatch(/xl:w-\[\d+px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the full address reachable in the tooltip", () => {
|
||||||
|
expect(code(ADMIN_LAYOUT)).toMatch(/title=\{[^}]*email/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user