Skip to content

Commit ae7c085

Browse files
cansofgreaseclaude
andcommitted
Say why the exit path fell back, and widen the DNS "good" band
The Connection panel said "default path - your exit-path target didn't resolve" whenever a traceroute could not use the target you configured. That is only one of the two reasons it happens, and it is the less common one. The other is that the target resolved perfectly well and was refused. A target pointing at loopback or a link-local address would trace this machine or the cloud metadata endpoint rather than an exit path, so it is declined on purpose. Telling that operator their name did not resolve sends them to look for a DNS fault that was never there. The reason now travels with the flag, and the panel says which applied. An address on your LAN is still allowed, because tracing a LAN gateway is a reasonable thing to want. Separately: the DNS dot now stays at full strength up to 40ms rather than 30. That probe deliberately defeats every cache, so the resolver has to go and ask rather than answer from memory, and a healthy lookup is a real round trip. Twenty to forty milliseconds is ordinary for one, and the old boundary made normal setups look worse than they were. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 99d3564 commit ae7c085

3 files changed

Lines changed: 111 additions & 12 deletions

File tree

internal/netinfo/exit.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,24 @@ type ExitInfo struct {
9090
NextRTTms float64 `json:"next_rtt_ms,omitempty"`
9191
NextASN string `json:"next_asn,omitempty"`
9292
NextLoc string `json:"next_loc,omitempty"` // city of the handoff hop
93-
// TargetFallback is true when the configured exit target didn't resolve to
94-
// an IPv4 address, so this is the DEFAULT path (1.1.1.1), not the user's.
93+
// TargetFallback is true when the configured exit target could not be used,
94+
// so this is the DEFAULT path (1.1.1.1), not the user's.
9595
TargetFallback bool `json:"target_fallback,omitempty"`
96+
// TargetFallbackWhy says WHICH of the two reasons applied, because they need
97+
// opposite responses and the bool alone sent readers to the wrong one: a
98+
// target that resolves perfectly well but points somewhere internal was
99+
// refused on purpose, and telling that operator it "did not resolve" starts a
100+
// DNS hunt for a decision this code made deliberately.
101+
//
102+
// "unresolved" - no IPv4 address (transient DNS failure, or an IPv6-only
103+
// name the IPv4 traceroute cannot use). Retry or fix the name.
104+
// "internal" - resolved to loopback or link-local, which would trace the
105+
// host itself or the cloud-metadata endpoint rather than an
106+
// exit path. Refused; pick a target outside the machine.
107+
//
108+
// Empty whenever TargetFallback is false. RFC1918 is deliberately NOT a
109+
// reason - tracing a LAN gateway is legitimate (see isInternalTraceTarget).
110+
TargetFallbackWhy string `json:"target_fallback_why,omitempty"`
96111
}
97112

98113
// discoverExit traces toward target (resolved by cachedExit) and locates the
@@ -562,12 +577,12 @@ func (m *Manager) cachedExit(ctx context.Context, ourASN string) *ExitInfo {
562577
// what was actually traced: an unresolvable target (transient DNS failure,
563578
// or an IPv6-only name the IPv4 traceroute can't use) falls back to the
564579
// default path, flagged so the UI doesn't present it as the chosen one.
565-
target, fellBack := traceTarget, false
580+
target, fellBackWhy := traceTarget, ""
566581
if want != "" {
567582
v, ok := resolveIPv4(ctx, want)
568583
switch {
569584
case !ok:
570-
fellBack = true
585+
fellBackWhy = "unresolved"
571586
if ctx.Err() == nil { // don't blame the target when the caller aborted
572587
m.log.Warn("exit target did not resolve to IPv4; tracing the default path", "target", want)
573588
}
@@ -578,15 +593,16 @@ func (m *Manager) cachedExit(ctx context.Context, ourASN string) *ExitInfo {
578593
// and trace the default. RFC1918 stays allowed on purpose: tracing a LAN
579594
// gateway is legitimate and the trust model already equates dashboard
580595
// access with local-network reach.
581-
fellBack = true
596+
fellBackWhy = "internal"
582597
m.log.Warn("exit target resolves to a loopback/link-local address; tracing the default path", "target", want)
583598
default:
584599
target = v
585600
}
586601
}
587602
ex, err := m.discoverExit(ctx, ourASN, target)
588603
if err == nil && ex != nil {
589-
ex.TargetFallback = fellBack
604+
ex.TargetFallback = fellBackWhy != ""
605+
ex.TargetFallbackWhy = fellBackWhy
590606
}
591607
// A trace cut short by the CALLER (browser abort mid-refresh, shutdown) is not
592608
// a real failure: it must neither skew the trace_ok/trace_fail counters nor

internal/netinfo/exit_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package netinfo
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"io"
78
"log/slog"
@@ -398,3 +399,64 @@ func TestRefreshDropsStaleExitOnIPChangeWhenRetraceFails(t *testing.T) {
398399
t.Error("IP change with successful retrace must publish the new network's exit, got nil")
399400
}
400401
}
402+
403+
// The two fallback causes must stay distinguishable on the wire, because they
404+
// need opposite responses from an operator and the bool alone cannot say which.
405+
//
406+
// "unresolved" is a name to retry or fix. "internal" resolved perfectly well and
407+
// was refused on purpose - loopback and link-local would trace this machine or
408+
// the cloud-metadata endpoint rather than an exit path. The dashboard used to
409+
// print "your exit-path target didn't resolve" for both, which sent the second
410+
// operator hunting a DNS fault that did not exist.
411+
//
412+
// The reason is asserted as the literal string the JSON carries: the dashboard
413+
// switches on those exact values (target_fallback_why in index.html), so renaming
414+
// one here without the other silently drops the UI back to its vaguest wording.
415+
//
416+
// SCOPE, so nobody reads more into this than it proves: it pins the wire contract
417+
// - which strings exist, and that the flag and the reason never disagree. It does
418+
// NOT pin which branch of cachedExit assigns which reason. resolveIPv4 is a plain
419+
// function and the surrounding path runs a real traceroute, so covering that would
420+
// mean opening a seam in production code, and a two-line switch does not earn one.
421+
// Swapping the two literals at the assignment would pass this test.
422+
func TestExitTargetFallbackNamesItsReason(t *testing.T) {
423+
// The bool and the reason are one fact recorded twice, so they must never
424+
// disagree: a reason without the flag hides the fallback from the UI's
425+
// existing check, and a flag without a reason is the ambiguity this closes.
426+
for _, c := range []struct {
427+
name string
428+
why string
429+
flag bool
430+
}{
431+
{"target resolved and was used", "", false},
432+
{"no IPv4 address for the name", "unresolved", true},
433+
{"resolved to loopback or link-local", "internal", true},
434+
} {
435+
t.Run(c.name, func(t *testing.T) {
436+
ex := &ExitInfo{TargetFallback: c.why != "", TargetFallbackWhy: c.why}
437+
if ex.TargetFallback != c.flag {
438+
t.Fatalf("TargetFallback = %v, want %v", ex.TargetFallback, c.flag)
439+
}
440+
b, err := json.Marshal(ex)
441+
if err != nil {
442+
t.Fatalf("marshal: %v", err)
443+
}
444+
var got map[string]any
445+
if err := json.Unmarshal(b, &got); err != nil {
446+
t.Fatalf("unmarshal: %v", err)
447+
}
448+
w, present := got["target_fallback_why"]
449+
if c.why == "" {
450+
if present {
451+
t.Errorf("target_fallback_why = %v on a run that used its target, want the "+
452+
"field omitted so a reader cannot read a reason into a normal trace", w)
453+
}
454+
return
455+
}
456+
if !present || w != c.why {
457+
t.Errorf("target_fallback_why = %v (present=%v), want %q - the dashboard switches "+
458+
"on this exact string", w, present, c.why)
459+
}
460+
})
461+
}
462+
}

internal/web/ui/index.html

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4164,8 +4164,13 @@ <h2><span class="drag-handle" title="Drag to reorder" aria-hidden="true"><svg vi
41644164
: ms<10 ? hdot(100,v) : ms<100 ? hdot(45,v) : ms<200 ? hdot(30,v) : hdot(20,v);
41654165
// DNS resolve-time intensity scale, shared by the top-bar DNS pill and the
41664166
// latency-graph DNS pill - mirrors latDot's aggression with DNS-appropriate bands:
4167-
// full <30ms, a hard cliff to 45% under 100ms, then the 20% floor at ≥100ms.
4168-
const dnsPct = ms => ms<30 ? 100 : ms<100 ? 45 : 20;
4167+
// full <40ms, a hard cliff to 45% under 100ms, then the 20% floor at ≥100ms.
4168+
//
4169+
// The full band is 40ms, not the 10ms latDot uses, because this probe cannot be
4170+
// fast by construction: the random label defeats every cache on the path, so the
4171+
// resolver has to go and ask rather than answer from memory. A healthy lookup is
4172+
// therefore a real round trip to an authority, and 20-40ms is ordinary for one.
4173+
const dnsPct = ms => ms<40 ? 100 : ms<100 ? 45 : 20;
41694174
const dnsDot = ms => hdot(dnsPct(ms), '--dot-dns');
41704175
// Pill latency text: 1 decimal under 100 ms, whole ms at 100+ - keeps precision
41714176
// where it's useful without widening the value slot on high/spiky readings.
@@ -6441,10 +6446,26 @@ <h2><span class="drag-handle" title="Drag to reorder" aria-hidden="true"><svg vi
64416446
parts.push(`${esc(ex.next_name||ex.next_ip)}${b.length?` <span class="muted">(${b.join(' · ')})</span>`:''}`);
64426447
}
64436448
let v=parts.join(' <span class="muted">→</span> ');
6444-
// The configured exit-path target didn't resolve over IPv4, so this trace
6445-
// followed the default path (1.1.1.1) - flag it so the row can't silently
6446-
// present the wrong route as the user's target.
6447-
if(ex && ex.target_fallback) v+=' <span class="muted">(default path - your exit-path target didn\'t resolve)</span>';
6449+
// The configured exit-path target could not be used, so this trace followed
6450+
// the default path (1.1.1.1) - flag it so the row can't silently present the
6451+
// wrong route as the user's target.
6452+
//
6453+
// Name the actual reason: the two need opposite responses. "unresolved" is a
6454+
// name to retry or fix; "internal" resolved fine and was refused on purpose,
6455+
// because loopback and link-local targets trace the host or the cloud
6456+
// metadata endpoint instead of an exit path (target_fallback_why, set in
6457+
// internal/netinfo/exit.go). Saying "did not resolve" for the second sent
6458+
// operators hunting a DNS fault that was never there. An older daemon sends
6459+
// the bool without the reason, so an unknown value falls back to the
6460+
// non-committal wording rather than guessing.
6461+
if(ex && ex.target_fallback){
6462+
const why = ex.target_fallback_why==='internal'
6463+
? 'your exit-path target points inside this machine, so it cannot be traced'
6464+
: ex.target_fallback_why==='unresolved'
6465+
? 'your exit-path target did not resolve'
6466+
: 'your exit-path target could not be used';
6467+
v+=' <span class="muted">(default path - '+why+')</span>';
6468+
}
64486469
// Cloudflare PoP (colo) rides the Exit line after a · separator - a separate,
64496470
// always-Cloudflare readout (/cdn-cgi/trace), not the exit-path target (see tooltip).
64506471
if(n.cf_colo) v+=`${v?' <span class="muted">·</span> ':''}Cloudflare PoP <b>${esc(n.cf_colo)}</b>`;

0 commit comments

Comments
 (0)