Fix SNI check failing when one IP family is undetectable
runSNICheck wired each family's getIP failure through a shared
context.WithCancelCause, so a single family's detection failure (for
example tcp6 on an IPv4-only-egress server) made the whole check return
an error even when the other family was detected and matched. Both
callers treat that error as fatal, so a server that is fine on IPv4
failed the SNI check outright -- the exact audience of #529.
Mirror the graceful per-family handling access.go already uses: discard
the per-family getIP error and report an undetectable family through an
empty OurIP4/OurIP6, which both callers already surface via their
"cannot detect public IP address" branch. The error return is now
reserved for genuine DNS-resolution failure. Removing the shared cancel
also makes the two families independent, so a fast-failing family can no
longer abort the other family's in-flight detection.
Add a regression test that drives the real runSNICheck over a loopback
DNS fake and an IPv4-only-egress network fake.
This PR has an intention of resolving URLs by using multiple endpoints
that identify an IP address of the service. This is handy if one service
is blocked for some reason.
The detection mechanism follows this logic:
1. It tries to access all services in parallel
2. If service respond with some error (like, no route to host for IPv6),
then we accurately collect those errors and return a merged one
3. In case of the first IP resolved, we immediately return it.
Also, this PR refactors how access and SNI check are performed.
`doctor`'s checkSecretHost and the proxy-startup warnSNIMismatch each
carried their own copy of the same logic: resolve the secret hostname,
determine the server's public IPv4/IPv6 (config first, getIP fallback),
and compare the two sets.
Extract that data-gathering into runSNICheck (internal/cli/sni_check.go),
returning an sniCheckResult. The success decision stays with each caller
because the rules genuinely differ — `doctor` reports OK when any family
matches, while the startup warning requires every detected family to
match — so only the gathering is shared, not the verdict.
No behavior change: both callers produce byte-identical output and the
same return values as before.