Skip to content

feat(prober): hourly blackhole sweep, and fetch the credential instead of hand-carrying it - #5

Merged
Ryanmello07 merged 6 commits into
urnetwork:mainfrom
Ryanmello07:feat/prober-blackhole-and-credential-upstream
Aug 22, 2026
Merged

feat(prober): hourly blackhole sweep, and fetch the credential instead of hand-carrying it#5
Ryanmello07 merged 6 commits into
urnetwork:mainfrom
Ryanmello07:feat/prober-blackhole-and-credential-upstream

Conversation

@Ryanmello07

Copy link
Copy Markdown
Collaborator

Two changes that go together, because the second's wiring sits in code the first adds.

Blackhole sweep

The full pass spends minutes per provider, so it sweeps a fleet over hours to days. In that window a provider that stops carrying traffic keeps its last passing measurement — it stays connected and keeps accepting clients, so nothing else reveals it. On beta, 12 of 12 providers sampled from the stalest cohort answered ok=0/131 while still being advertised.

So this asks the cheapest question that still means something — did ANY traffic get through — of every provider hourly, on its own loop. It draws 3 connectivity destinations and passes on the first success, reusing fetch so the table's headers, body caps and Verify contracts all apply; a captive portal answering 200 with its own body fails here exactly as it fails a full run. The sample is redrawn per run, so a provider cannot learn which three to keep alive.

BlackholeHosts feeds the startup confinement self-check: if the prober could reach these directly, a provider carrying nothing would still be recorded ok.

Two defects found in live testing, both fixed here: the sweep reused -probe-timeout (2m20s), which on a mostly-dark fleet made the cheap loop as slow as the full pass — it now has its own 15s deadline; and a pass took one batch and slept, covering under a fifth of the fleet per hour — it now drains the queue.

Credential fetch

Pairs with GET /network/prober-credential (urnetwork/server#441). When UR_PROBER_BY_JWT is empty the prober fetches its jwt with the operator secret it already holds, instead of an operator hand-carrying one. An explicitly supplied jwt still wins, so existing deployments are unaffected — that is pinned by a test.

404 means "not ready, keep polling" with backoff rather than a fatal exit, since the server-side bootstrap task runs on its own schedule. A 401 stops the prober loudly: this system already lost 8 hours to a rejected credential that surfaced as every provider reporting no_consensus, and a silent retry loop would reproduce exactly that. Fetched jwts go through the same startup checkCredential that was added for that incident.

Merged with upstream/main; the two conflicts were combined, not chosen between — upstream's -interval help-text correction is kept alongside our blackhole flags, and both sets of credential tests are retained.

go build ./... and go test -race ./... clean across all 8 packages.

Ryanmello07 and others added 6 commits August 12, 2026 05:41
The full pass spends minutes per provider -- ~131 destinations, geolocation
consensus, optionally bandwidth -- so it sweeps a fleet over hours to days. In
that window a provider that silently stops forwarding keeps its last passing
measurement, and every consumer of that measurement keeps believing it. On beta
that meant 98.6% of advertised providers were judged on evidence over six hours
old, and twelve sampled from the stalest cohort answered ok=0/131 while still
being handed to clients.

Nothing about being dark looks different from outside: the provider stays
connected and goes on accepting clients. Only asking it to carry something
finds out.

So this is the cheapest question that still means something -- did ANY traffic
get through -- asked of every provider hourly, on its own loop.

  - egresshealth.Blackhole draws up to 3 connectivity destinations and passes on
    the FIRST success. Three, not one, so a single destination having a bad
    minute cannot condemn a provider; ANY, not all, because a provider reaching
    some destinations is degraded rather than dark, and degradation is Check's
    department. It reuses fetch, so the table's headers, body caps and Verify
    contracts all apply -- a captive portal answering 200 with its own body
    fails here exactly as it fails a full run, which is what makes "something
    got through" mean anything.

  - The sample is drawn fresh per run for the same anti-gaming reason the full
    check samples: a provider that knew the three addresses could carry those
    and blackhole everything else.

  - A tunnel that cannot be opened counts as dark, under its own failure class.
    From a client's point of view a provider it cannot build a circuit through
    is exactly as useless as one that carries nothing, and not advertising those
    is the whole point.

  - BlackholeHosts feeds the startup confinement self-check. If the prober could
    reach these directly, a provider carrying nothing would still be recorded ok
    -- the check would confirm the prober's own connectivity, remove nothing
    forever, and look healthy doing it.

  - The sweeper stops on ErrBlackholeUnsupported and ErrUnauthorized, and only
    those. An old server will never grow the endpoint mid-run, and a rejected
    secret is a broken deployment; everything else is retried next tick, so a
    momentary server outage cannot silently end blackhole detection for the life
    of the process.

Results are submitted as one batch per pass: a sweep produces hundreds of
one-bit answers and a request each would spend more on http than on the checks.

Defaults: -blackhole-interval 1h, -blackhole-limit 500, -blackhole-concurrency
32. Concurrency is higher than the full pass because a check is one round trip
through the tunnel rather than a 131-destination sweep. 0 disables the sweep.
The sweeper reused -probe-timeout (2m20s on beta) as its per-request deadline,
which defeats the point of the check. A dark provider burns the full timeout on
every destination, so a 500-provider batch at concurrency 32 takes ~37 minutes
rather than ~4 -- on a fleet that is mostly dark, which is exactly the fleet
this exists to detect, the cheap loop runs as slowly as the full pass it was
meant to complement.

15s: a provider that carries traffic answers a 204 probe in well under that,
and one that does not must fail fast for the sweep to stay hourly.
A pass took one batch and slept for the interval. -blackhole-limit is the
server's per-request ceiling, not the size of the fleet: at 500 per request
against ~2,700 eligible providers that covers under a fifth of them per hour,
so the oldest evidence ages out faster than the sweep reaches it -- and the
requirement is that the whole fleet is checked every interval.

Now a pass keeps requesting until the queue returns nothing, bounded at 40
rounds so a server that keeps handing back work cannot hold a pass open
forever and starve the interval.
The prober required UR_PROBER_BY_JWT to be provisioned and placed by hand.
The server now mints the prober's network client identity in a bootstrap
task and serves it at GET /network/prober-credential, authenticated with
the same X-UR-Operator-Secret the other operator endpoints use -- so the
last hand-placed secret can go.

ingest.Client.ProberCredential fetches it, with three disjoint outcomes so
a caller cannot take the wrong branch: 404 is ErrCredentialNotReady ("not
yet", keep asking), 401 is a bare ErrUnauthorized (a wrong secret must be
loud and must never be retried forever), everything else is
ErrCredentialUnavailable (transient, retry). A 200 carrying no usable
by_client_jwt is refused rather than returned empty -- decodable is not
usable, the same lesson GeolocationPins learned about a nil pin map.

cmd/egress-prober fills an empty -by-jwt from it, with envFallback's
precedence: an explicitly supplied jwt wins and the endpoint is never
contacted, so the running deployment is untouched and gains no dependency
on it. The fetch sits above parseByJwtClientId, so a fetched credential
takes the identical path a supplied one does and a token this process
cannot use fails at startup instead of becoming a silent outage. The
bootstrap task runs every 6h, so a 404 is a wait on a backoff capped at
5m, ended only by ctx -- an interrupt there exits zero, since a prober
interrupted while waiting is a shutdown, not a broken deployment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
encoding/json populates the fields it could before returning an
UnmarshalTypeError, so a body whose client_id is not a string yields a
usable-looking jwt alongside the error. Every other unusable body is
backstopped by the emptiness check; this is the only case that fails if
the decode error is ignored.
# Conflicts:
#	cmd/egress-prober/credential_test.go
#	cmd/egress-prober/main.go
@Ryanmello07
Ryanmello07 merged commit 54e634a into urnetwork:main Aug 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant