+ {!expandHintDismissed && logs.length > 0 && (
+
+
+ Tap any entry to see full request details.
+
+
+
+
+ )}
{logs.map((log, index) => {
const isLast = index === logs.length - 1;
return (
@@ -417,6 +481,9 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
lastLogRef={isLast ? lastLogRef : undefined}
onQuickRule={handleOpenQuickRule}
quickRuleRestricted={isRestricted}
+ blocklistNames={blocklistNames}
+ serviceNames={serviceNames}
+ onExpand={dismissExpandHint}
/>
);
})}
diff --git a/app/src/pages/logs/QueryLogCard.tsx b/app/src/pages/logs/QueryLogCard.tsx
index 59832b95..ea240db2 100644
--- a/app/src/pages/logs/QueryLogCard.tsx
+++ b/app/src/pages/logs/QueryLogCard.tsx
@@ -1,4 +1,4 @@
-import { useState, type JSX } from "react";
+import { useId, useState, type JSX } from "react";
import { useScreenDetector } from "@/hooks/useScreenDetector";
import { formatDistanceToNow, parseISO, format } from "date-fns";
import { Clock, ShieldPlus } from "lucide-react";
@@ -6,6 +6,8 @@ import { Clock, ShieldPlus } from "lucide-react";
import { Badge } from "@/components/ui/badge"; // still used for Blocked status only
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";
+import { ReasonBadges } from "@/components/ui/ReasonBadges";
+import { cn, INTERACTIVE_CARD } from "@/lib/utils";
import type { ModelQueryLog } from "@/api/client";
interface QueryLogCardProps {
@@ -14,9 +16,13 @@ interface QueryLogCardProps {
lastLogRef?: (node: HTMLDivElement | null) => void;
onQuickRule?: (domain?: string, defaultAction?: "denylist" | "allowlist") => void;
quickRuleRestricted?: boolean;
+ blocklistNames?: Record
;
+ serviceNames?: Record;
+ /** Called the first time this row is expanded (used to dismiss the one-time mobile hint). */
+ onExpand?: () => void;
}
-const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted }: QueryLogCardProps): JSX.Element | null => {
+const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: QueryLogCardProps): JSX.Element | null => {
// If domain logging is disabled, dns_request.domain may be absent. Provide a placeholder.
const rawDomain = log.dns_request?.domain;
const normalizedDomain = rawDomain ? rawDomain.replace(/\.$/, "") : undefined;
@@ -38,6 +44,8 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
: isProcessed
? "bg-[var(--tailwind-colors-slate-800)] text-[var(--tailwind-colors-slate-100)] hover:!bg-[var(--tailwind-colors-red-600)] hover:!text-[var(--tailwind-colors-slate-50)]"
: "bg-[var(--tailwind-colors-rdns-600)] text-[var(--tailwind-colors-slate-900)] hover:!bg-[var(--tailwind-colors-slate-900)] hover:!text-[var(--tailwind-colors-rdns-600)]";
+ // Quick-rule is the ONLY control excluded from the whole-card expand overlay; its wrapper
+ // sits above the overlay (relative z-20) so it stays clickable.
const renderQuickRuleButton = (wrapperClassName: string) => (
@@ -50,21 +58,28 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
aria-label="Quick custom rule"
onClick={handleQuickRule}
disabled={quickRuleDisabled}
- className={`h-9 w-9 lg:min-h-0 p-0 aspect-square rounded-full disabled:opacity-40 ${quickRuleButtonClasses}`}
+ className={`h-11 w-11 md:h-9 md:w-9 min-h-0 p-0 aspect-square rounded-full disabled:opacity-40 ${quickRuleButtonClasses}`}
data-testid="logs-quick-rule-button"
>
-
+
);
- // Track timestamp expansion to increase card height smoothly on mobile
- const [timestampExpanded, setTimestampExpanded] = useState(false);
-
- // Expansion state for mobile tap-to-expand of truncated domain (device id no longer truncates)
- const [showFullDomainMobile, setShowFullDomainMobile] = useState(false);
+ // Whole-card expand: every row is expandable (blocked and processed, with or without reasons).
+ // There is no visible chevron — expandability is signalled by the hover lift (desktop),
+ // the press/active feedback (both), and a one-time hint (mobile, owned by the Logs page).
+ const reasons = log.reasons ?? [];
+ const hasReasons = reasons.length > 0;
+ const [expanded, setExpanded] = useState(false);
+ const panelId = useId();
+ const toggleExpanded = () => setExpanded(v => {
+ const next = !v;
+ if (next) onExpand?.();
+ return next;
+ });
// Device ID: backend allows up to 36 chars; truncate only for mobile (<=768px)
const { isMobile } = useScreenDetector();
@@ -75,24 +90,86 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
else deviceIdOrIp = rawDeviceId.slice(0, 36);
const DOMAIN_TRUNCATE_THRESHOLD = 65; // existing logic threshold
- const MOBILE_EXPANDED_DOMAIN_LIMIT = 50;
- const TIMESTAMP_COLLAPSED_MAX_HEIGHT = 24;
- const TIMESTAMP_EXPANDED_MAX_HEIGHT = 48;
const isDomainTruncatable = displayDomain ? displayDomain.length > DOMAIN_TRUNCATE_THRESHOLD : false;
const truncatedDomain = displayDomain && isDomainTruncatable ? displayDomain.slice(0, DOMAIN_TRUNCATE_THRESHOLD) + '…' : displayDomain;
- const mobileExpandedDomain = displayDomain
- ? displayDomain.length > MOBILE_EXPANDED_DOMAIN_LIMIT
- ? displayDomain.slice(0, MOBILE_EXPANDED_DOMAIN_LIMIT) + '…'
- : displayDomain
- : undefined;
const protocolLabel = log?.protocol ? log.protocol.toUpperCase() : '—';
+ // DNSSEC status shown inline next to the protocol as a plain text badge (styled like the
+ // protocol label — no outline/background):
+ // - validated (AD bit true) -> brand-coloured "DNSSEC"
+ // - failed (bogus/misconfigured) -> red "DNSSEC" (recursor SERVFAILed on validation)
+ // Neither shows for domains without a DNSSEC signal.
+ const dnssecValidated = log.dns_request?.dnssec === true;
+ const dnssecFailed = reasons.includes('dnssec_failed');
+ const dnssecShown = dnssecValidated || dnssecFailed;
+ // When reserveWhenHidden is set (desktop), the badge is always rendered — invisible
+ // when there's no DNSSEC — so it reserves a constant slot and the protocol label
+ // never shifts depending on whether DNSSEC is shown. The testid/color are only
+ // applied when actually shown.
+ const renderDnssecBadge = (className?: string, reserveWhenHidden = false) => {
+ if (!dnssecShown && !reserveWhenHidden) return null;
+ return (
+
+ DNSSEC
+
+ );
+ };
+
+ // Detail-grid field: uppercase micro-label + selectable value (optionally coloured).
+ const renderDetailField = (label: string, value: string, testid: string, valueClassName?: string) => (
+
+
{label}
+ {value}
+
+ );
+
+ // DNSSEC has three distinct states — keep them clearly worded and colour-coded:
+ // failed (bogus) -> "Validation failed" (red) — signatures broken
+ // validated (AD=1) -> "Validated" (brand/green) — authentic
+ // unsigned -> "No DNSSEC" (muted) — domain isn't signed
+ const dnssecDetail = dnssecFailed
+ ? { text: 'Validation failed', className: 'text-[var(--tailwind-colors-red-600)]' }
+ : dnssecValidated
+ ? { text: 'Validated', className: 'text-[var(--tailwind-colors-rdns-600)]' }
+ : { text: 'No DNSSEC', className: 'text-[var(--tailwind-colors-slate-200)]' };
+
return (
-
+ {/* Whole-card expand/collapse trigger: a real button (native keyboard/focus/aria).
+ Spans the ENTIRE card (absolute inset-0 on the card root), so clicking anywhere —
+ the header row OR the expanded detail panel — toggles it. Collapsed, the panel is
+ 0-height so the button only covers the header. Quick-rule (z-20) stays above it. */}
+
+
@@ -100,14 +177,12 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{displayDomain ? (
-
-
- {isDomainTruncatable ? truncatedDomain : displayDomain}
-
-
+
+ {isDomainTruncatable ? truncatedDomain : displayDomain}
+
) : (
'-'
)}
@@ -120,6 +195,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{protocolLabel}
+ {dnssecShown && renderDnssecBadge()}
{isBlocked && (
{deviceIdOrIp}
- {renderQuickRuleButton("flex-shrink-0")}
+ {renderQuickRuleButton("flex-shrink-0 relative z-20")}
-
-
+
+
{displayDomain ? (
- timestampExpanded ? (
-
- {mobileExpandedDomain}
-
- ) : isDomainTruncatable ? (
- setShowFullDomainMobile(v => !v)}
- className="truncate focus:outline-none active:scale-[0.98] transition-transform text-left"
- data-testid={showFullDomainMobile ? 'querylog-domain-full' : 'querylog-domain-truncated'}
- >
- {showFullDomainMobile ? displayDomain : truncatedDomain}
-
- ) : (
- {displayDomain}
- )
+
+ {isDomainTruncatable ? truncatedDomain : displayDomain}
+
) : (
'-'
)}
-
@@ -185,6 +240,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{protocolLabel}
+ {renderDnssecBadge("order-2 md:order-2", true)}
Blocked
@@ -198,46 +254,66 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{deviceIdOrIp}
-
+
- {renderQuickRuleButton("flex items-center justify-center")}
+ {renderQuickRuleButton("flex items-center justify-center relative z-20")}
)}
+
+
+
+
+ {normalizedDomain !== undefined
+ ? renderDetailField("Domain", normalizedDomain, "querylog-detail-domain")
+ : (
+
+
Domain
+ Domain logging disabled
+
+ )}
+ {log.dns_request?.query_type && renderDetailField("Query type", log.dns_request.query_type, "querylog-detail-query-type")}
+ {log.dns_request?.response_code && renderDetailField("Response code", log.dns_request.response_code, "querylog-detail-response-code")}
+ {(log.dns_request?.dnssec !== undefined || dnssecFailed) && renderDetailField("DNSSEC", dnssecDetail.text, "querylog-detail-dnssec", dnssecDetail.className)}
+ {renderDetailField("Protocol", protocolLabel, "querylog-detail-protocol")}
+ {log.client_ip && renderDetailField("Client IP", log.client_ip, "querylog-detail-client-ip")}
+ {log.device_id && renderDetailField("Device ID", log.device_id, "querylog-detail-device-id")}
+ {renderDetailField("Time", log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—", "querylog-detail-timestamp")}
+
+ {hasReasons && (
+
+ {(isBlocked || dnssecFailed) ? "Block reason" : "Allow reason"}
+
+
+ )}
+
+
+
);
};
-interface TimestampDisplayProps { timestamp?: string; onToggle?: (expanded: boolean) => void }
+interface TimestampDisplayProps { timestamp?: string }
-const TimestampDisplay = ({ timestamp, onToggle }: TimestampDisplayProps) => {
- const [expanded, setExpanded] = useState(false);
+// Static relative-time label (Clock icon + "x ago"). The absolute timestamp moves to the panel.
+const TimestampDisplay = ({ timestamp }: TimestampDisplayProps) => {
if (!timestamp) return null;
- const date = parseISO(timestamp);
- const relative = formatDistanceToNow(date, { addSuffix: true });
- const absolute = format(date, "MMMM d, yyyy 'at' hh:mm:ss a");
+ const relative = formatDistanceToNow(parseISO(timestamp), { addSuffix: true });
return (
-
setExpanded(e => { const next = !e; onToggle?.(next); return next; })}
- className={`group relative w-fit font-text-xs-leading-5-normal font-[number:var(--text-xs-leading-5-normal-font-weight)] text-[var(--tailwind-colors-slate-100)] text-[length:var(--text-xs-leading-5-normal-font-size)] tracking-[var(--text-xs-leading-5-normal-letter-spacing)] leading-[var(--text-xs-leading-5-normal-line-height)] whitespace-nowrap [font-style:var(--text-xs-leading-5-normal-font-style)] inline-flex items-center gap-1 focus:outline-none cursor-pointer select-text transition-all duration-300 ease-out ${expanded ? 'mt-0.5' : ''}`}
- title={expanded ? 'Show relative time' : 'Show full timestamp'}
+
-
-
-
- {relative}
-
-
- {absolute}
-
-
-
+
+
{relative}
+
);
};
diff --git a/app/src/pages/setup/SetupScreen.tsx b/app/src/pages/setup/SetupScreen.tsx
index f5508682..afb3edee 100644
--- a/app/src/pages/setup/SetupScreen.tsx
+++ b/app/src/pages/setup/SetupScreen.tsx
@@ -24,6 +24,7 @@ import VerificationBanner from '@/pages/setup/VerificationBanner';
import modDNSLogoDarkTheme from '@/assets/logos/modDNS-dark-theme.svg';
import modDNSLogoLightTheme from '@/assets/logos/modDNS-light-theme.svg';
import { useTheme } from "@/components/theme-provider";
+import { cn, INTERACTIVE_CARD } from "@/lib/utils";
import SetupGuidePanel from './RightPanelGuide';
@@ -235,13 +236,18 @@ export default function Setup({ profiles }: SetupProps): JSX.Element {
handlePlatformClick(platform.name)}
>
@@ -259,10 +265,13 @@ export default function Setup({ profiles }: SetupProps): JSX.Element {
{/* Device Identification Card - full width */}
handlePlatformClick('Device Identification')}
>
@@ -300,10 +309,13 @@ export default function Setup({ profiles }: SetupProps): JSX.Element {
handlePlatformClick(platform.name)}
>
@@ -318,10 +330,13 @@ export default function Setup({ profiles }: SetupProps): JSX.Element {
))}
handlePlatformClick('Device Identification')}
>
diff --git a/app/src/store/general.ts b/app/src/store/general.ts
index e4d01fd0..98e25502 100644
--- a/app/src/store/general.ts
+++ b/app/src/store/general.ts
@@ -21,6 +21,8 @@ interface AppState {
setBlocklistsAlertDismissed: (dismissed: boolean) => void;
customRulesAlertDismissed: boolean; // session-only dismissal (not persisted)
setCustomRulesAlertDismissed: (dismissed: boolean) => void;
+ logsExpandHintDismissed: boolean; // persisted dismissal of the one-time "tap a row" logs hint
+ setLogsExpandHintDismissed: (dismissed: boolean) => void;
passkeys: ModelCredential[];
setPasskeys: (passkeys: ModelCredential[]) => void;
subscriptionStatus: string | null;
@@ -79,6 +81,8 @@ export const useAppStore = create()(
setBlocklistsAlertDismissed: (dismissed) => set({ blocklistsAlertDismissed: dismissed }),
customRulesAlertDismissed: false,
setCustomRulesAlertDismissed: (dismissed) => set({ customRulesAlertDismissed: dismissed }),
+ logsExpandHintDismissed: false,
+ setLogsExpandHintDismissed: (dismissed) => set({ logsExpandHintDismissed: dismissed }),
passkeys: [],
setPasskeys: (passkeys) => set({ passkeys }),
subscriptionStatus: null,
@@ -104,6 +108,7 @@ export const useAppStore = create()(
connectionStatusVisible: state.connectionStatusVisible,
announcementsLastSeenAt: state.announcementsLastSeenAt,
customRulesCollapsed: state.customRulesCollapsed,
+ logsExpandHintDismissed: state.logsExpandHintDismissed,
}),
}
)
diff --git a/proxy/cache/memory/serialization_test.go b/proxy/cache/memory/serialization_test.go
index b77a4053..48d8f147 100644
--- a/proxy/cache/memory/serialization_test.go
+++ b/proxy/cache/memory/serialization_test.go
@@ -40,6 +40,10 @@ func TestRequestContextSerialization(t *testing.T) {
assert.Equal(t, "test-profile", reqCtx.LoggerConfig.ProfileID, "Logger config should have correct profile ID")
assert.False(t, reqCtx.LoggerConfig.Enabled, "Logger config should show enabled=false")
+ // UpstreamName must survive the cache round-trip — EmitQueryLog needs it to pick
+ // the recursor for the DNSSEC-failure CD probe. Regression guard: it was json:"-".
+ reqCtx.UpstreamName = "knot"
+
// Test serialization by setting in cache
requestID := "test-request-123"
err = profileIDCache.SetRequestCtx(requestID, reqCtx)
@@ -55,6 +59,7 @@ func TestRequestContextSerialization(t *testing.T) {
assert.Equal(t, map[string]string{"privacy": "setting"}, retrievedCtx.PrivacySettings, "Privacy settings should be preserved")
assert.Equal(t, map[string]string{"dnssec": "enabled"}, retrievedCtx.DNSSECSettings, "DNSSEC settings should be preserved")
assert.Equal(t, map[string]string{"advanced": "setting"}, retrievedCtx.AdvancedSettings, "Advanced settings should be preserved")
+ assert.Equal(t, "knot", retrievedCtx.UpstreamName, "UpstreamName must survive the cache round-trip (needed by the DNSSEC-failure probe)")
// Verify the logger is recreated correctly
require.NotNil(t, retrievedCtx.Logger, "Logger should be recreated")
diff --git a/proxy/internal/dnssec/dnssec.go b/proxy/internal/dnssec/dnssec.go
new file mode 100644
index 00000000..57178a8a
--- /dev/null
+++ b/proxy/internal/dnssec/dnssec.go
@@ -0,0 +1,132 @@
+// Package dnssec holds the proxy's DNSSEC request/response helpers: setting the
+// request flags that make recursors return the Authenticated Data flag and
+// Extended DNS Errors, and capturing/classifying those EDE codes so a DNSSEC
+// validation failure can be surfaced on the query log.
+package dnssec
+
+import (
+ "sync"
+
+ "github.com/AdguardTeam/dnsproxy/upstream"
+ "github.com/miekg/dns"
+)
+
+// ReasonFailed is appended to a query log's reasons when the recursor reports a
+// DNSSEC validation failure via an Extended DNS Error (RFC 8914). The frontend
+// renders it as a "DNSSEC validation failed" chip.
+const ReasonFailed = "dnssec_failed"
+
+// ApplyRequestFlags configures the upstream request's DNSSEC-related bits.
+//
+// The logged DNSSEC-validation status (QueryLog.DNSRequest.DNSSEC, sourced from the
+// response AD bit) is deliberately decoupled from the client-facing send_do_bit
+// setting: validation happens at the recursor regardless of whether DNSSEC RRs are
+// returned to the end device.
+// - validation enabled -> set the request AD bit so the recursor returns, and the
+// dnsproxy library preserves (filterMsg keeps AD when the request's AD or DO bit
+// is set), the Authenticated Data flag — even when the DO bit is not sent.
+// - validation disabled -> set CD (CheckingDisabled) so the recursor skips validation.
+//
+// EDNS(0) is attached whenever validation is enabled — so the recursor can return
+// Extended DNS Errors (carried in the OPT record) on validation failure, which
+// happens whenever the query carries EDNS, independent of the DO bit — or when the
+// client asked for DNSSEC RRs (sendDoBit). The DO bit, set to sendDoBit, governs
+// returning RRSIG/DNSKEY records to the client.
+func ApplyRequestFlags(req *dns.Msg, dnssecEnabled, sendDoBit bool) {
+ req.Extra = make([]dns.RR, 0)
+ if dnssecEnabled {
+ req.AuthenticatedData = true
+ } else {
+ req.CheckingDisabled = true
+ }
+
+ if dnssecEnabled || sendDoBit {
+ req.SetEdns0(2048, sendDoBit)
+ }
+}
+
+// IsFailureEDE reports whether an EDE InfoCode denotes a DNSSEC *validation
+// failure* (bogus zone), as opposed to merely insecure/indeterminate. RFC 8914:
+//
+// 6 DNSSEC Bogus, 7 Signature Expired, 8 Signature Not Yet Valid,
+// 9 DNSKEY Missing, 10 RRSIGs Missing, 11 No Zone Key Bit Set, 12 NSEC Missing.
+//
+// Codes 1/2/5 (unsupported algorithm/digest, indeterminate) mean the zone is
+// treated as insecure, not failed, so they are deliberately excluded — an
+// unsigned/insecure domain must never be flagged. Verified against sdns and
+// knot-resolver v6.4.0, which both emit codes in this range on SERVFAIL.
+func IsFailureEDE(code uint16) bool {
+ return code >= 6 && code <= 12
+}
+
+// FailureEDE returns the first DNSSEC-failure EDE InfoCode found in msg's OPT
+// record, if any.
+func FailureEDE(msg *dns.Msg) (uint16, bool) {
+ if msg == nil {
+ return 0, false
+ }
+ opt := msg.IsEdns0()
+ if opt == nil {
+ return 0, false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok && IsFailureEDE(ede.InfoCode) {
+ return ede.InfoCode, true
+ }
+ }
+ return 0, false
+}
+
+// EDEStore correlates a captured DNSSEC-failure EDE code with the request that
+// produced it, keyed by the request *dns.Msg pointer. dnsproxy passes the same
+// dctx.Req pointer to the upstream Exchange and later exposes it to EmitQueryLog,
+// so the pointer is a stable per-request key. Entries are set by CapturingUpstream
+// at exchange time and drained by EmitQueryLog. Only DNSSEC-failure responses store
+// an entry, so the map stays tiny and short-lived.
+type EDEStore struct{ m sync.Map }
+
+// Set records the EDE code for req.
+func (s *EDEStore) Set(req *dns.Msg, code uint16) {
+ if s == nil {
+ return
+ }
+ s.m.Store(req, code)
+}
+
+// Take returns and removes the stored EDE code for req. Nil-safe so a caller
+// constructed without an EDEStore (e.g. in unit tests) is a harmless no-op.
+func (s *EDEStore) Take(req *dns.Msg) (uint16, bool) {
+ if s == nil {
+ return 0, false
+ }
+ v, ok := s.m.LoadAndDelete(req)
+ if !ok {
+ return 0, false
+ }
+ return v.(uint16), true
+}
+
+// CapturingUpstream wraps an upstream to capture DNSSEC-failure EDE codes from
+// responses BEFORE dnsproxy's filterMsg strips the OPT record (which happens
+// before the query log is emitted, so the EDE is otherwise unavailable at log
+// time). Address()/Close() come from the embedded upstream; only Exchange is
+// intercepted.
+type CapturingUpstream struct {
+ upstream.Upstream
+ store *EDEStore
+}
+
+// NewCapturingUpstream wraps u so DNSSEC-failure EDE codes are captured into store.
+func NewCapturingUpstream(u upstream.Upstream, store *EDEStore) *CapturingUpstream {
+ return &CapturingUpstream{Upstream: u, store: store}
+}
+
+func (u *CapturingUpstream) Exchange(req *dns.Msg) (*dns.Msg, error) {
+ resp, err := u.Upstream.Exchange(req)
+ if err == nil {
+ if code, ok := FailureEDE(resp); ok {
+ u.store.Set(req, code)
+ }
+ }
+ return resp, err
+}
diff --git a/proxy/internal/dnssec/dnssec_test.go b/proxy/internal/dnssec/dnssec_test.go
new file mode 100644
index 00000000..5b93154b
--- /dev/null
+++ b/proxy/internal/dnssec/dnssec_test.go
@@ -0,0 +1,185 @@
+package dnssec
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+)
+
+// mockUpstream implements upstream.Upstream for wrapper tests.
+type mockUpstream struct {
+ resp *dns.Msg
+ err error
+ gotReq *dns.Msg
+}
+
+func (m *mockUpstream) Exchange(req *dns.Msg) (*dns.Msg, error) {
+ m.gotReq = req
+ return m.resp, m.err
+}
+func (m *mockUpstream) Address() string { return "mock" }
+func (m *mockUpstream) Close() error { return nil }
+
+// msgWithEDE builds a response carrying an OPT record with the given EDE InfoCode.
+func msgWithEDE(rcode int, code uint16) *dns.Msg {
+ m := new(dns.Msg)
+ m.SetQuestion(dns.Fqdn("dnssec-failed.org"), dns.TypeA)
+ m.Rcode = rcode
+ opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
+ opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code})
+ m.Extra = append(m.Extra, opt)
+ return m
+}
+
+func newReq() *dns.Msg {
+ req := new(dns.Msg)
+ req.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
+ // seed Extra to confirm it is reset
+ req.Extra = []dns.RR{&dns.TXT{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeTXT}, Txt: []string{"seed"}}}
+ return req
+}
+
+// ApplyRequestFlags decouples logged validation status from the client-facing
+// send_do_bit and always attaches EDNS when validation is enabled so the recursor
+// can return EDE.
+func TestApplyRequestFlags(t *testing.T) {
+ t.Run("enabled, send_do_bit off: AD set, no CD, EDNS present but DO=0", func(t *testing.T) {
+ req := newReq()
+ ApplyRequestFlags(req, true, false)
+ assert.True(t, req.AuthenticatedData, "AD bit must be set so validation is logged")
+ assert.False(t, req.CheckingDisabled)
+ if o := req.IsEdns0(); assert.NotNil(t, o, "EDNS(0) must be present so EDE can be returned") {
+ assert.False(t, o.Do(), "DO must be off when send_do_bit is off")
+ }
+ })
+
+ t.Run("enabled, send_do_bit on: AD set and DO set", func(t *testing.T) {
+ req := newReq()
+ ApplyRequestFlags(req, true, true)
+ assert.True(t, req.AuthenticatedData)
+ assert.False(t, req.CheckingDisabled)
+ if o := req.IsEdns0(); assert.NotNil(t, o) {
+ assert.True(t, o.Do())
+ }
+ })
+
+ t.Run("disabled: CD set, AD not set, no EDNS", func(t *testing.T) {
+ req := newReq()
+ ApplyRequestFlags(req, false, false)
+ assert.True(t, req.CheckingDisabled, "CD must be set so the recursor skips validation")
+ assert.False(t, req.AuthenticatedData)
+ assert.Nil(t, req.IsEdns0(), "no EDNS when validation is disabled")
+ })
+
+ t.Run("disabled, send_do_bit on: CD set, DO set, AD not set", func(t *testing.T) {
+ req := newReq()
+ ApplyRequestFlags(req, false, true)
+ assert.True(t, req.CheckingDisabled)
+ assert.False(t, req.AuthenticatedData)
+ if o := req.IsEdns0(); assert.NotNil(t, o) {
+ assert.True(t, o.Do())
+ }
+ })
+
+ t.Run("Extra is reset (seed cleared)", func(t *testing.T) {
+ req := newReq()
+ ApplyRequestFlags(req, true, false)
+ for _, rr := range req.Extra {
+ _, isTXT := rr.(*dns.TXT)
+ assert.False(t, isTXT, "seeded/stale RRs must be cleared")
+ }
+ })
+}
+
+func TestIsFailureEDE(t *testing.T) {
+ // DNSSEC validation-failure codes 6..12 are failures.
+ for _, c := range []uint16{6, 7, 8, 9, 10, 11, 12} {
+ assert.True(t, IsFailureEDE(c), "code %d should be a DNSSEC failure", c)
+ }
+ // Insecure/indeterminate/other codes must NOT be treated as failures
+ // (so unsigned domains are never flagged).
+ for _, c := range []uint16{0, 1, 2, 3, 4, 5, 13, 29} {
+ assert.False(t, IsFailureEDE(c), "code %d should NOT be a DNSSEC failure", c)
+ }
+}
+
+// tableRef: logs-reason-display-behaviour #13
+func TestFailureEDE(t *testing.T) {
+ t.Run("SERVFAIL with EDE 9 -> detected", func(t *testing.T) {
+ code, ok := FailureEDE(msgWithEDE(dns.RcodeServerFailure, 9))
+ assert.True(t, ok)
+ assert.Equal(t, uint16(9), code)
+ })
+ t.Run("EDE 5 (indeterminate) -> not a failure", func(t *testing.T) {
+ _, ok := FailureEDE(msgWithEDE(dns.RcodeServerFailure, 5))
+ assert.False(t, ok)
+ })
+ t.Run("no OPT/EDE -> not a failure", func(t *testing.T) {
+ m := new(dns.Msg)
+ m.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
+ _, ok := FailureEDE(m)
+ assert.False(t, ok)
+ _, ok = FailureEDE(nil)
+ assert.False(t, ok)
+ })
+}
+
+func TestEDEStore(t *testing.T) {
+ s := &EDEStore{}
+ req := new(dns.Msg)
+ req.SetQuestion(dns.Fqdn("x.org"), dns.TypeA)
+
+ _, ok := s.Take(req)
+ assert.False(t, ok, "empty store returns nothing")
+
+ s.Set(req, 9)
+ code, ok := s.Take(req)
+ assert.True(t, ok)
+ assert.Equal(t, uint16(9), code)
+
+ _, ok = s.Take(req)
+ assert.False(t, ok, "Take must remove the entry")
+
+ // nil-safe
+ var ns *EDEStore
+ ns.Set(req, 9)
+ _, ok = ns.Take(req)
+ assert.False(t, ok)
+}
+
+func TestCapturingUpstream(t *testing.T) {
+ req := new(dns.Msg)
+ req.SetQuestion(dns.Fqdn("dnssec-failed.org"), dns.TypeA)
+
+ t.Run("captures DNSSEC-failure EDE keyed by request pointer", func(t *testing.T) {
+ store := &EDEStore{}
+ u := NewCapturingUpstream(&mockUpstream{resp: msgWithEDE(dns.RcodeServerFailure, 9)}, store)
+ _, err := u.Exchange(req)
+ assert.NoError(t, err)
+ code, ok := store.Take(req)
+ assert.True(t, ok, "EDE must be captured for the exact request")
+ assert.Equal(t, uint16(9), code)
+ })
+
+ t.Run("no capture for a clean response", func(t *testing.T) {
+ store := &EDEStore{}
+ clean := new(dns.Msg)
+ clean.SetQuestion(dns.Fqdn("cloudflare.com"), dns.TypeA)
+ clean.Rcode = dns.RcodeSuccess
+ u := NewCapturingUpstream(&mockUpstream{resp: clean}, store)
+ _, _ = u.Exchange(req)
+ _, ok := store.Take(req)
+ assert.False(t, ok)
+ })
+
+ t.Run("no capture on exchange error", func(t *testing.T) {
+ store := &EDEStore{}
+ u := NewCapturingUpstream(&mockUpstream{err: errors.New("timeout")}, store)
+ _, err := u.Exchange(req)
+ assert.Error(t, err)
+ _, ok := store.Take(req)
+ assert.False(t, ok)
+ })
+}
diff --git a/proxy/requestcontext/request_context.go b/proxy/requestcontext/request_context.go
index 6ca34ca9..f9bf2272 100644
--- a/proxy/requestcontext/request_context.go
+++ b/proxy/requestcontext/request_context.go
@@ -23,7 +23,7 @@ type RequestContext struct {
Logger logging.LoggerInterface `json:"-"`
LoggerConfig logging.LoggingConfig `json:"logger_config"`
StartTime time.Time `json:"-"`
- UpstreamName string `json:"-"`
+ UpstreamName string `json:"upstream_name"`
}
func NewRequestContext(ctx context.Context, p *proxy.Proxy, profileId string, deviceId string, privacySettings, logsSettings, dnssecSettings, advancedSettings map[string]string, logger logging.LoggerInterface) *RequestContext {
diff --git a/proxy/server/proxy.go b/proxy/server/proxy.go
index e7717ef1..bec3d9f8 100644
--- a/proxy/server/proxy.go
+++ b/proxy/server/proxy.go
@@ -11,6 +11,7 @@ import (
"github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/service"
"github.com/ivpn/dns/proxy/config"
+ "github.com/ivpn/dns/proxy/internal/dnssec"
"github.com/rs/zerolog/log"
)
@@ -67,9 +68,12 @@ func (s *Server) newProxyConfig(serverConfig *config.Config) (*proxy.Config, err
if err != nil {
return nil, fmt.Errorf("failed to create upstream: %w", err)
}
+ // Wrap the upstream so we can read the DNSSEC-failure EDE code from the
+ // response before dnsproxy's filterMsg strips the OPT (see EmitQueryLog).
+ wrappedUps := dnssec.NewCapturingUpstream(ups, s.edeStore)
upCfg := &proxy.UpstreamConfig{
Upstreams: []upstream.Upstream{
- ups,
+ wrappedUps,
},
}
customUpstreamConfig := proxy.NewCustomUpstreamConfig(
diff --git a/proxy/server/query_logs.go b/proxy/server/query_logs.go
index a52b55d2..de16f2c8 100644
--- a/proxy/server/query_logs.go
+++ b/proxy/server/query_logs.go
@@ -6,14 +6,27 @@ import (
"github.com/AdguardTeam/dnsproxy/proxy"
"github.com/getsentry/sentry-go"
+ "github.com/ivpn/dns/proxy/internal/dnssec"
"github.com/ivpn/dns/proxy/model"
"github.com/ivpn/dns/proxy/requestcontext"
"github.com/miekg/dns"
)
+// appendReason returns a new slice with r appended, without mutating existing
+// (which is shared with the request context's FilterResult).
+func appendReason(existing []string, r string) []string {
+ out := make([]string, len(existing), len(existing)+1)
+ copy(out, existing)
+ return append(out, r)
+}
+
func (s *Server) EmitQueryLog(reqCtx *requestcontext.RequestContext, dctx *proxy.DNSContext) {
defer sentry.Recover()
+ // Drain any captured DNSSEC-failure EDE for this request unconditionally (even
+ // if logging is disabled) so the edeStore never leaks entries.
+ _, dnssecFailed := s.edeStore.Take(dctx.Req)
+
// Use the contextual logger from the request context
logger := reqCtx.Logger
@@ -59,6 +72,10 @@ func (s *Server) EmitQueryLog(reqCtx *requestcontext.RequestContext, dctx *proxy
queryLog.DNSRequest.ResponseCode = dns.RcodeToString[dctx.Res.Rcode]
queryLog.DNSRequest.DNSSEC = dctx.Res.AuthenticatedData
}
+
+ if dnssecFailed {
+ queryLog.Reasons = appendReason(queryLog.Reasons, dnssec.ReasonFailed)
+ }
retention := model.Retention(logsSettings["retention"])
// send event to channel
if sendErr := s.CollectorChannels[model.TYPE_QUERY_LOGS].Send(
diff --git a/proxy/server/server.go b/proxy/server/server.go
index f40656bb..3b2e4d5e 100644
--- a/proxy/server/server.go
+++ b/proxy/server/server.go
@@ -19,6 +19,7 @@ import (
"github.com/ivpn/dns/proxy/config"
"github.com/ivpn/dns/proxy/filter"
"github.com/ivpn/dns/proxy/internal/asnlookup"
+ "github.com/ivpn/dns/proxy/internal/dnssec"
"github.com/ivpn/dns/proxy/internal/metrics"
"github.com/ivpn/dns/proxy/internal/ratelimit"
"github.com/ivpn/dns/proxy/model"
@@ -40,9 +41,12 @@ type RequestManager interface {
}
type Server struct {
- Config *config.Config
- Proxy *proxy.Proxy // service.Interface
- Upstreams map[string]*proxy.CustomUpstreamConfig
+ Config *config.Config
+ Proxy *proxy.Proxy // service.Interface
+ Upstreams map[string]*proxy.CustomUpstreamConfig
+ // edeStore holds DNSSEC-failure Extended DNS Error codes captured from upstream
+ // responses (by dnssec.CapturingUpstream), drained per-request by EmitQueryLog.
+ edeStore *dnssec.EDEStore
DomainFilter filter.Filter
IPFilter filter.Filter
Cache cache.Cache
@@ -96,6 +100,7 @@ func NewServer(serverConfig *config.Config, collectorChannels map[string]channel
ProfileSettingsCache: profileSettingsCache,
CollectorChannels: collectorChannels,
Upstreams: make(map[string]*proxy.CustomUpstreamConfig, 0),
+ edeStore: &dnssec.EDEStore{},
LoggerFactory: loggerFactory,
RateLimiter: rl,
Metrics: metrics.NewServerMetrics(prometheus.DefaultRegisterer),
@@ -303,16 +308,7 @@ func (s *Server) HandleBefore(p *proxy.Proxy, dctx *proxy.DNSContext) (err error
return err
}
- dctx.Req.Extra = make([]dns.RR, 0)
- if !dnssecEnabled {
- dctx.Req.CheckingDisabled = true
- }
-
- if sendDoBit {
- // Enable EDNS0 with a reasonable UDP buffer size and DO=1
- // This sets a proper OPT RR instead of constructing one manually.
- dctx.Req.SetEdns0(2048, true)
- }
+ dnssec.ApplyRequestFlags(dctx.Req, dnssecEnabled, sendDoBit)
}
return nil