fronted/scanner: client-side CDN front discovery (draft)#488
Draft
myleshorton wants to merge 21 commits into
Draft
fronted/scanner: client-side CDN front discovery (draft)#488myleshorton wants to merge 21 commits into
myleshorton wants to merge 21 commits into
Conversation
Adds a probe-based scanner that turns the existing fronted.yaml.gz masquerades — plus opportunistic CloudFront-range samples and Akamai hostname-regex draws — into a ranked list of (IP, outer SNI, inner Host) tuples that work from the client's network position. Why this exists at all: censorship in IR moves fast enough that a config push isn't a tight enough loop, and the working fronts are per-(ISP, geography, time-of-day) per Samim Mirhosseini. The scanner runs client-side and reports per-client truth. Pieces: - scanner.go: Candidate / Result / Probe / Scan / RankWorking. Probe does TCP + uTLS handshake + HTTPS GET to TestURL with the inner Host header. Only OK on a 2xx. - candidates.go: CandidatesFromConfig flattens domainfront.Config into the primary probe pool. SNIsForProvider extracts the masquerade-domain pool for use with CloudFrontCandidates. - cloudfront.go: 204 CloudFront IPv4 prefixes embedded; weighted random sampling pairs IPs with caller-supplied outer SNIs. - akamai.go: SystemResolver (OS/ISP resolver — the ISP is the right source in IR). Akamai candidates leave SNI empty matching fronted.yaml.gz and verify against AkamaiCertHostname for every entry. GenerateAkamaiHostnames produces the Psiphon/MahsaNG regex pattern. 22 unit tests, plus opt-in (SCANNER_INTEGRATION=1) live-network tests. Akamai integration: ~100% hit rate against the canonical edge hostname.
Adds the layer on top of the probe primitives: a Service that runs scans on a schedule, persists working fronts to disk, exposes a round-robin Pick API for consumers, and re-scans when consumers report failures. Lifecycle: - NewService(cfg) loads any prior cache (filtered by CacheTTL so stale entries don't seed the live pool with already-blocked IPs) - Start(ctx) runs the periodic refresh loop until ctx is canceled or Close is called - Working() returns the current ranked list; Pick() returns the next one round-robin so all working fronts get traffic rather than every dial pinning to the lowest-latency entry - ReportFailure(c) tracks per-front failures; after two failures within a refresh cycle the front is dropped, and if the working list falls below MinWorkingFronts a refresh is signaled - Refresh() is a manual trigger BuildPool composes candidates from the three feeders (known masquerades from fronted.yaml.gz, regex-generated Akamai hostnames resolved via SystemResolver, random CloudFront IPs paired with masquerade SNIs). Sample sizes <= 0 disable a feeder. Cache schema is versioned JSON written atomically (write tmp + rename). Missing file is not an error — first-boot loads nothing and proceeds to the first scan. Defaults: RefreshInterval 1h, CacheTTL 6h (matches Samim's "time-of-day" observation that working fronts shift on roughly that timescale), MinWorkingFronts 3. Tests: 11 new (cache save/load/TTL/missing/version + service round-robin/empty/failure-removal/low-water-signal/cache-restore/ no-config-is-error + BuildPool known-only and CloudFront paths).
Adds the consumer layer that converts the scanner.Service's working list into []FrontSpec entries ready for the lantern-box meek outbound's JSON configuration. Provider owns the Service lifecycle, wires the bypass dialer so probes don't loop through the active VPN TUN, and uses TrustedCAsPool from the loaded domainfront config so cert validation matches production. FrontSpec is a local mirror of lantern-box/option.FrontSpec — same JSON shape, kept local to avoid version-coupling radiance to lantern-box's release cadence (the meek option type lands in lantern-box#265 and isn't published yet). Service lifecycle fix: Close no longer hangs when Start was never called. NewProvider returns an error for nil Config instead of panicking inside TrustedCAsPool. Adds a live-network timing benchmark (TestLive_TimeToFirstWorking, gated on SCANNER_INTEGRATION=1) that loads the production fronted.yaml.gz, builds a 70+ candidate pool, runs a full scan, and reports time-to-first-working / total scan time / per-feeder hit rate / per-probe latency p50/p90. On a sample run from a US dev network: - pool: 72 candidates (50 known + Akamai-DNS-resolved + 10 CloudFront-random) - time to first working front: 205ms - scan complete: 35/72 working in 8.79s - akamai: 35/36 working (97%) - cloudfront: 0/36 working (0%) — fronted.yaml.gz cloudfront testurl is stale - per-probe latency: p50=218ms, p90=1.47s, min=142ms Sub-second time-to-usability means a cold-boot client gets a working front before the user notices. CloudFront's 0% is the known POP-vs-distribution issue (#3525); production deployment with a fresh, globally-served test URL would lift that.
Flips the default candidate pool composition so per-scan-fresh IPs from the AWS CloudFront prefix list and DNS-resolved Akamai edges are the primary discovery source, with the pre-resolved IPs in fronted.yaml.gz reduced to opt-in via KnownSample > 0. Why: the YAML's pre-resolved IPs are the same baked list every user gets and don't move per (ISP, location, time-of-day). The raw-range feeders self-heal as CDN edges rotate and produce per-user-fresh candidates — matching Samim Mirhosseini's observation that the working fronts vary across all three dimensions. BuildPool semantic change: KnownSample <= 0 now skips the known feeder entirely (previously it meant "use all known"). Callers explicitly opt in by passing KnownSample > 0. Provider defaults: KnownSample removed from defaults() (defaults to 0 → skip), CloudFrontSample=30, AkamaiSample=3 (4 hostnames after adding canonical → typically ~8 unique IPs after DNS dedup). Re-ran the live timing benchmark with new defaults from a US dev network against the production fronted.yaml.gz: - pool: 38 candidates (30 CloudFront-raw + 8 Akamai-DNS-resolved) - time to first working front: 154ms (was 205ms) - scan complete: 8/38 working in 10.7s - akamai: 8/8 working (100%) - cloudfront: 0/30 working (0%) — stale testurl in YAML - per-probe latency: p50=244ms p90=292ms min=154ms Tail latency tightened (p90 1.47s → 292ms) because the working pool is now uniformly fresh rather than mixing pre-resolved IPs of varying age. CloudFront's 0% is a fixable production deployment issue (fresh globally-served distribution), not a discovery flaw. Sub-200ms time-to-first-working means cold-boot clients have a working front before the user notices.
http.Transport routes via DialTLSContext (our pre-opened fronted TLS conn) only for https URLs. With an http:// TestURL the request fell through to plain DNS + port 80, bypassing the front entirely — every probe was effectively a direct-DNS plaintext request to the inner hostname instead of a fronted request via the chosen CDN edge. Akamai's TestURL in fronted.yaml.gz is https:// so its probes were fine; CloudFront's is http:// so its probes were structurally broken. The fix surfaces a separate finding: even with probes routed correctly, CloudFront returns HTTP 421 "Misdirected Request" for every (random IP × masquerade SNI) pair AND for every pre-validated pair in fronted.yaml.gz. AWS now strictly enforces SNI/Host match, killing the cross-distribution Host header routing technique our YAML attempts. CloudFront fronting via this scheme is not just stale data — it's structurally disabled at the AWS layer. Workable CloudFront fronting requires alternate-domain-names on the same distribution (outer SNI and inner Host both belong to one CloudFront distribution AWS owns the cert for), which is a different deployment than fronted.yaml.gz uses today. Tracking as follow-up.
CloudFront fronting works when the client sends no SNI extension and keeps the inner Host in the request. The TLS handshake completes with CloudFront's default *.cloudfront.net cert (or a customer cert pinned to that edge); CloudFront then routes by inner Host alone since no SNI claims a different distribution. Sending a non-empty SNI triggered HTTP 421 "Misdirected Request" because CloudFront strictly enforces SNI/Host match — exactly the behavior the earlier 0% hit rate exposed. Production's fronted.yaml.gz CloudFront masquerades have always shipped with sni: "" for the same reason; the bug was in my scanner's CloudFrontCandidates setting SNI = masquerade-domain. Two changes in CloudFrontCandidates: - SNI: "" (was masquerade-domain) — sidesteps 421 enforcement. - VerifyHostname: InnerHost (was masquerade-domain) — when no SNI, CloudFront serves either the *.cloudfront.net default cert (which wildcards the inner Host) or a customer-pinned cert. Verifying against InnerHost filters to the former, where cross-distribution Host routing actually reaches our backend. Verifying against the masquerade-domain rejected the wildcard cert and lost the working cases. Live-network results after the fix: - CloudFront random sampling: 1-3/30 working (3-8%) — was 0/30. The hit rate is structural (POP-vs-distribution coverage); each hit is an edge that genuinely routes to our distribution. - Akamai: 100% unchanged. - Time to first working front: 149ms.
Adds the radiance-side wiring that takes a FrontSpec list (from the fronted/scanner Service via kindling/meek.Provider) and turns it into a sing-box outbound the live tunnel can route through. Two pieces: 1. kindling/meek.BuildOutbound(tag, url, fronts) constructs a sing-box O.Outbound with Type="meek" and a local MeekOutboundOptions struct whose JSON shape mirrors lantern-box/option.MeekOutboundOptions exactly. The local copy sidesteps the lantern-box version-coupling: lantern-box v0.0.82 doesn't have the meek outbound type registered, so we can't import lbO.MeekOutboundOptions today. Once the lantern-box bump lands the local copy + MeekOutboundType constant can be replaced one-for-one with the upstream symbols. Returns ok=false when fronts is empty so callers skip injection when the scanner hasn't produced anything yet. 2. vpn.BoxOptions gains an optional MeekOutbound *O.Outbound field. buildOptions injects it into Outbounds and appends its Tag to the selector tags list immediately after mergeAndCollectTags (and before the auto/manual selector outbounds are built) so the meek outbound participates in routing alongside API-supplied ones. Nil = no-op, no behavior change for callers that don't set it. Until lantern-box's meek type is registered in radiance's pinned version, setting MeekOutbound is a no-op end-to-end — libbox will reject the unknown "meek" type at config unmarshal. The wiring is ready; activation flips when (a) lantern-box bumps and (b) the caller (whoever owns the VPNClient) populates MeekOutbound from a meek.Provider's FrontSpecs. Tests: 2 new in kindling/meek (BuildOutbound empty-fronts/shape), 2 new in vpn (MeekInjection/MeekOmittedWhenNil) confirming the selector tag list includes the meek tag and Outbounds is augmented correctly.
Single source of truth for the meek-server URL the production wiring will dial through Akamai. End-to-end verified 2026-05-23: domain-fronted POST returns the echoed payload in ~470ms.
Meek is heavier than other transports, so it is opt-in by region. The detection runs without network access since the Lantern API may be unreachable exactly when an Iranian user starts the app. Decision: network MCC (Android TelephonyManager.getNetworkOperator()) when available is authoritative — it reflects which cell tower the device is currently camped on, real-time and unspoofable. This is the strongest available "in Iran right now" signal and correctly classifies an Iranian-diaspora user keeping Asia/Tehran on their phone in Berlin as not-in-Iran (their network MCC is 262, Germany). When MCC is unavailable (WiFi-only, no signal, iOS 16+ where Apple deprecated CTCarrier.mobileCountryCode) the function falls back to tzName == "Asia/Tehran" alone. The host plumbing for MCC (Flutter → backend.Options or settings) is deferred — this commit ships the pure-Go detection plus tests so the contract is reviewable independently.
Adds device-local opt-in for the meek transport. The pure-Go classifier
shipped earlier (kindling/iran) is now consulted at backend startup and,
when it returns true, the meek front scanner is launched in the
background.
Plumbing:
- backend.Options gains an MCC field; the host (Flutter) passes the
network MCC string it reads from TelephonyManager. Empty on
WiFi-only, between-tower, or iOS 16+ (where Apple deprecated
CTCarrier-based MCC access). Stored under settings.MCCKey.
- kindling/fronted exposes LoadCachedConfig so the meek provider can
consume the *domainfront.Config independently of a full
domainfront.Client lifecycle. Skips the live fetch and falls back
to the embedded copy — the meek path must work even when the
Lantern API is unreachable.
- LocalBackend.maybeStartMeek runs the classifier; on a positive
result it loads the fronted config, constructs a meek.Provider
with default sampling, starts its scanner, and stores the
provider on an atomic pointer. Shutdown is wired through
shutdownFuncs.
- getBoxOptions reads the provider atomically; when present it
builds the meek outbound (tag "meek-fronted", URL
meek.DefaultURL, 3 fronts) and sets BoxOptions.MeekOutbound,
which the existing vpn/boxoptions.go injection point appends to
the sing-box outbounds + selector tags.
A slow scanner startup cannot block VPN connects: until the provider
populates, getBoxOptions simply omits the meek outbound and the
bandit will pick it up on the next reconnect.
5 tasks
Lets a developer bypass the iran.LikelyIran heuristic and route ALL
VPN traffic through meek, useful for testing the transport from
outside Iran. When set:
- maybeStartMeek launches the scanner regardless of MCC/TZ
- getBoxOptions returns a stripped-down config with meek as the
sole outbound and InitialServer pinned to its tag — API-provided
outbounds, bandit selector arms, and smart routing are all
omitted, so traffic must traverse meek or fail
Usage:
RADIANCE_FORCE_MEEK_ONLY=1 ./<radiance-binary>
If the scanner hasn't found working fronts yet (typical for a few
seconds after launch), getBoxOptions returns empty options and the
connect fails; retry after a few seconds.
Exercises the full client path without VPN/host-app coupling:
scanner (Akamai-only sampling)
→ fronted HTTPClient (random front per dial, TLS verifies against
front.VerifyHostname not URL host)
→ lantern-box meek.Conn
→ SOCKS5 method-select + CONNECT httpbin.org:80 + HTTP GET /ip
→ assert response body contains the Linode public IP
Verified 2026-05-24: HTTP 200 with {"origin": "139.162.181.47"}
returned in ~20s (scanner ~14s + transport handshakes ~3s).
Bumps lantern-box to fisk/meek-outbound HEAD so the import resolves
(v0.0.82 does not yet register the meek outbound). Roll the bump
forward to a tagged release before merge.
Caveat: lantern-box meek.Conn.SetReadDeadline does not unblock a
parked Read (the underlying condvar Wait isn't broadcast on deadline
expiry); the test reads in a goroutine and times out from outside.
lantern-box meek.Conn.SetReadDeadline now properly unblocks a parked Read via a deadline timer (lantern-box c467035). The smoke test calls SetReadDeadline + Read directly instead of reading in a goroutine and timing out from outside. Bumps lantern-box pin to that commit. Verified end-to-end against the deployed server: httpbin reports "origin": "139.162.181.47".
AkamaiCandidates previously emitted one Candidate per IP with empty SNI (no ServerName in ClientHello). That matches the dominant working strategy from Psiphon's in-country data (~majority of successful Akamai dials show <NULL> SNI in Iran), but leaves us with no fallback for the periods when DPI clamps down on bare SNI. Extend AkamaiCandidates to accept an SNI pool. For each IP it now emits the existing bare-SNI candidate first, followed by up to 3 candidates with SNIs drawn at random (without replacement) from the pool. VerifyHostname stays a248.e.akamai.net for all — Akamai's edge serves the same default cert regardless of incoming SNI, so named SNIs are pure DPI cover. pool.go now passes SNIsForProvider(cfg, "akamai") to feed the pool; when the config has no Akamai masquerades with Domain populated, behavior is unchanged. Working Akamai SNIs from Keith (Psiphon ops, 2026-05-24) to be added to the akamai provider's masquerade list in getlantern/fronted as a follow-up: python.org, pypi.org, www.python.org, www.pypi.org, files.pythonhosted.org, registry.npmjs.org, google.com, www.google.com, snapp.ir, varzesh3.com, aparat.com, bmi.ir, digikala.com, go.microsoft.com.
…SNIs Mirrors the getlantern/fronted PR that adds the in-country-validated Akamai SNIs to the masquerade pool. The embedded copy is used as a last-resort fallback when the live fetch and on-disk cache both fail, so it needs to stay in sync to give offline-first clients the same SNI diversity. Smoke test confirms the new SNIs feed through SNIsForProvider into AkamaiCandidates.
This was referenced May 25, 2026
getlantern/fronted is deprecated; the kindling path uses
getlantern/domainfront for the fronted config now. Both:
- flip configURL from the fronted repo's raw to the domainfront
repo's raw fronted.yaml.gz
- resync the embedded fallback from domainfront's main, which
includes the new in-country-validated Akamai SNIs (Keith,
Psiphon ops, 2026-05-24)
Smoke test against the deployed meek server continues to pass.
The daily upstream pipeline regenerates the Masquerades list from a
fresh CDN-IP scan; manual additions to Masquerades are wiped on the
next refresh. FrontingSNIs.<group>.ArbitrarySNIs is the durable
home — copied verbatim from provider_map.yaml into each daily
fronted.yaml.gz — and is where curated cover names (e.g. the new
IR-validated SNIs in akamai.frontingsnis.ir) belong.
SNIsForProvider now returns the union of:
- FrontingSNIs across all groups where UseArbitrarySNIs is true
- Masquerade.Domain fields (existing behavior; preserves
CloudFront's per-masquerade-domain SNI strategy)
The cn group has UseArbitrarySNIs=false and is correctly excluded;
new test asserts this and the union semantics.
Also flips the refresh-fronted-config workflow + kindling configURL
to getlantern/domainfront (getlantern/fronted is the deprecated Go
module). Embedded fronted.yaml.gz resynced from domainfront@main
with the IR-targeted SNIs inlined into akamai.frontingsnis.ir
(mirrors lantern-cloud PR; will reconcile once the upstream
pipeline pushes the same change).
domainfront#8 fixes the yaml tag mismatch that left Config.Providers[*].FrontingSNIs silently empty for every parser. The runtime fronting client's arbitrary-SNI cover code path was dead because of this; the scanner's new FrontingSNIs path (SNIsForProvider in this PR) needs the fix to see the data. Local verification with the bumped dep: SNIsForProvider pool size: 4652 (was 1 with broken tags) All 14 IR-validated SNIs visible in the pool AkamaiCandidates emits Candidates with those SNIs populated scanner.go sets tlsConfig.ServerName from Candidate.SNI when non-empty → ClientHello SNI on the wire matches
Once the domainfront FrontingSNIs yaml-tag fix landed, SNIsForProvider returns ~4650 SNIs instead of 1, so AkamaiCandidates emits 4 candidates per IP (1 bare + 3 named). At AkamaiSample=50 that's ~330 candidates, which the 8-way Service scan can't clear inside the smoke test's 30s readiness poll. AkamaiSample=3 matches the production meek provider default and keeps the candidate count (~60) scannable within the window. Verified: scan completes in ~19s with 32 working fronts; top-ranked working front uses a named SNI (turkanime.co), confirming the FrontingSNIs cover pool is exercised end-to-end.
Previously Service.refresh blocked on the full Scan (wg.Wait over every candidate) before assigning s.working, so Working/Pick — and therefore meek availability — were gated on the slowest probe. With the FrontingSNIs pool now ~4600 SNIs, a high AkamaiSample produces hundreds of candidates and the 8-way scan took 40s+, during which meek had no usable fronts. Add Options.OnResult, invoked per probe as it completes (concurrently across probe goroutines). Service.refresh uses it to insert each OK result into s.working immediately, sorted by latency. The first hit of a scan supersedes the previous cycle's list, so a re-scan keeps serving the old fronts until a new one lands and never drops to empty mid-scan. The post-scan bulk assign remains the canonical end state and handles the zero-working case (clears a stale list). Measured: with 334 candidates, FrontSpecs(3) returns in ~1s instead of waiting ~40s for the full scan. Tests: OnResult fires once per candidate; insertSortedLocked keeps latency order. Reverts the smoke test's AkamaiSample workaround back to the production default of 3 — incremental availability makes the workaround unnecessary.
domainfront#8 merged. Re-pin from the branch-HEAD pseudo-version to the merged-main commit so the dependency no longer references a branch that may be deleted. No behavior change — same yaml-tag fix. lantern-box is still pinned at the fisk/meek-outbound branch HEAD; re-pin that once lantern-box#265 merges.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — companion to lantern-box#TBD (meek outbound).
Summary
Adds a client-side probe-based scanner that turns the existing
fronted.yaml.gzmasquerades — plus opportunistic CloudFront-range samples and Akamai hostname-regex draws — into a ranked list of(IP, outer SNI, inner Host)tuples that work from this client's network position right now.Why this can't be a server-curated list: censorship in IR moves faster than our config-push cadence, and per Samim Mirhosseini (developer behind patterniha/MITM-DomainFronting, in this Slack thread) the working fronts are "different for each person depending on what ISP they use, their location and time of day". The right discovery loop is per-client, not per-deploy.
Architecture
Layer 1 — client-side probe (this PR)
Probe(ctx, Candidate, Options) Resultperforms the full check that any one front actually works for this client:Candidate.IPAddress:443via the suppliedDialerServerName = Candidate.SNI(empty = no SNI sent, Akamai style; non-empty = sent verbatim, CloudFront style)Candidate.VerifyHostnameCandidate.TestURLwithHost: Candidate.InnerHostScan(ctx, []Candidate, Options) []Resultruns Probe concurrently with configurable budget.Layer 2 — candidate generation
Three feeders into the candidate pool, each suited to a different CDN's edge model:
CandidatesFromConfig(*domainfront.Config)flattens the existingfronted.yaml.gzmasquerades. Pre-validated(IP, SNI)pairs; this is the primary input.CloudFrontCandidates(n, snis, ...)for discovering CloudFront edges beyond the curated list. Embedded snapshot of AWS's 204 CloudFront IPv4 prefixes (cloudfront_prefixes.txt); weighted random sampling pairs IPs with caller-supplied outer SNIs. Expected hit rate is partial — each CloudFront edge serves a subset of distributions per POP, so the probe filters mismatches. Acceptable for discovery.AkamaiCandidates(ctx, hostnames, SystemResolver{}, ...)for discovering Akamai edges via the OS/ISP resolver. Critical: this is the correct path even in IR — the ISP returns real Akamai IPs (Akamai isn't blocked, hosts too much Iranian critical infra) and those IPs are geographically near the client's network. DoH endpoints themselves are blocked in IR.GenerateAkamaiHostnames(n)produces draws froma([1-9]|1[0-9])([0-9]{2})\.(dsc)?(b|d|g|g2|na|r|w7)\.akamai\.net— same regex pattern shipped in Psiphon's server entries and adopted by MahsaNG / Shir-o-Khorshid. ~3,500 hostnames in the regex space, all resolve through the same Akamai general edge property.For Akamai,
VerifyHostnameis always set to the canonicala248.e.akamai.netregardless of which regex hostname was used to discover the IP — the regex hostnames aren't in the cert's SAN list, but the edge's default cert always validates againsta248.e.akamai.net. This was a non-obvious bug in the initial draft; live-network testing exposed it (cert-mismatch failures with regex-generated VerifyHostnames).Sequence
sequenceDiagram participant App as radiance client participant Scn as fronted/scanner participant SYS as System Resolver (ISP) participant CDN as CDN edge (Akamai/CloudFront) Note over App: needs working front App->>Scn: Scan(candidates) par per candidate Scn->>SYS: LookupHost(a248.e.akamai.net) [Akamai feeder] SYS-->>Scn: real edge IPs end loop concurrent probes Scn->>CDN: TCP + uTLS(SNI=⟂ or masquerade) Scn->>CDN: GET TestURL with Host: api.iantem.io CDN-->>Scn: 200 OK / 403 / TLS-mismatch end Scn-->>App: RankWorking() — sorted by latencyTest coverage
22 unit tests, all green:
CandidatesFromConfigflatteningCloudFrontPrefixesweighted samplingPlus opt-in (
SCANNER_INTEGRATION=1) live-network tests:TestLive_AkamaiSystemResolver: ~100% hit rate (16/16 in latest run; IPs spanning 3 different POP clusters)TestLive_CloudFrontRandomIPs: diagnostic only — hit rate is partial as expectedTestLive_CloudFrontKnownMasquerades: diagnostic — confirms howfronted.yaml.gzstale entries filter outWhat's NOT in this PR
kindling/domainfront(refresh its working-pool from scanner output) or the lantern-box meek outbound (feed scanner-discovered fronts asFrontsconfig) happens separately.Reference
🤖 Generated with Claude Code