Documentation
¶
Overview ¶
Package probe dials one endpoint with one client profile and reports what happened, in enough detail to tell two very different refusals apart.
The distinction the whole tool rests on: a peer that answers a post-quantum ClientHello with a TLS alert has *understood* it and declined the group — a clean, survivable "no". A peer that resets the connection, times out, or closes without a record has choked on the ClientHello itself, and every client that offers ML-KEM will fail against it regardless of whether that client would have been happy with X25519. Only the second is an outage waiting for a CDN to flip a default.
Index ¶
- func HasEgress(network string) bool
- func LookupECHConfig(ctx context.Context, at, name string) ([]byte, error)
- func NetName(n string) string
- func Nets() []string
- func ParseECHConfigList(s string) ([]byte, error)
- func ResolverAt(at string) *net.Resolver
- func StartTLSProtocols() []string
- func ValidNet(n string) bool
- func ValidStartTLS(proto string) bool
- type Cert
- type Dialer
- type Kind
- type Resolver
- type Result
- type Target
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func HasEgress ¶ added in v0.32.0
HasEgress reports whether this machine has a route for an address family (PQ-47).
A UDP "dial" is a route lookup and a local bind: no packet is sent, nothing is contacted, and the address it is made against is a documentation prefix that is routed nowhere. That matters twice over — this is a tool whose contract is that it never sends anything it did not say it would, and the answer has to be available on a fleet run where forty endpoints have just failed for what is probably one local reason.
It answers only for a pinned family. "tcp" is not a question that has an answer here, and guessing one would be worse than the silence.
func LookupECHConfig ¶ added in v0.34.0
LookupECHConfig returns the ECHConfigList published for name, asking at.
at is a `host:port` resolver; empty means the ones this machine uses. A name with no HTTPS record, or one without an `ech=` parameter, is an error naming the name — most of the internet is in that state today, and a fleet run says so per target rather than silently probing without ECH.
func NetName ¶ added in v0.30.0
NetName is the family in the words an operator uses. It exists so no renderer has to translate "tcp6" itself, and so the two spellings cannot drift.
func Nets ¶ added in v0.30.0
func Nets() []string
Nets is what --net accepts: the two address families Go dials, in the order the help lists them.
func ParseECHConfigList ¶ added in v0.52.0
ParseECHConfigList decodes the base64 ECHConfigList an endpoint publishes as the `ech=` value of its HTTPS DNS record (PQ-50), and refuses anything that is not one.
It lives here rather than in the CLI because the public API takes the same value (PQ-71), and two copies of a validation is how the binary and the library come to disagree about what they accept — the one thing this repository treats as worse than a missing feature.
It is checked before anything is dialled on purpose: a paste that is not a config list would otherwise fail inside the handshake, where the report reads as though the endpoint had done something wrong.
func ResolverAt ¶ added in v0.37.0
ResolverAt returns the resolver every lookup in a run should use (PQ-58).
An empty address means the machine's own, which is a nil *net.Resolver — the zero value every net API already understands as "the default". A pinned one is Go's own resolver rather than the system's, because the cgo path asks whatever the host is configured with and would ignore the flag.
It is used for *every* question pqprobe asks, the dialler's own name resolution included: a run that asked one resolver about ECH and another about addresses would be reporting on two different networks, and from inside one where the interesting answer is the internal one that is not a preference but a wrong answer.
func StartTLSProtocols ¶ added in v0.24.0
func StartTLSProtocols() []string
StartTLSProtocols is what --starttls accepts, in the order the help lists them.
func ValidNet ¶ added in v0.30.0
ValidNet reports whether n is one of them. The empty string is valid and is the default: whatever the resolver hands over, which is what every run did before PQ-46 — and which is exactly why a dual-stack name could answer on one stack and die on the other without the report ever saying which was dialled.
func ValidStartTLS ¶ added in v0.24.0
ValidStartTLS reports whether the protocol is one of them. The empty string is valid and means no negotiation: implicit TLS, which is every other port.
Types ¶
type Cert ¶
type Cert struct {
// Bytes is the DER length the peer sent for this certificate. Post-quantum
// *authentication* is the next migration and its failure will be a size
// failure again — an ML-DSA signature is around 3.3 KB where an ECDSA one
// is 64 — so what a chain costs today is worth having in hand.
Bytes int `json:"bytes,omitempty"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotAfter time.Time `json:"not_after"`
NotBefore time.Time `json:"not_before"`
DNSNames []string `json:"dns_names,omitempty"`
IsCA bool `json:"is_ca"`
// KeyBytes and SigBytes are what this certificate's subjectPublicKeyInfo
// and signature weigh in the DER the peer sent (PQ-72). They are the two
// numbers a post-quantum projection needs: an ML-DSA certificate is the
// same certificate with those two fields replaced, and without them the
// projection has to assume what comes *out*, which is how a number ends up
// in a ticket with nobody able to say where it came from.
KeyBytes int `json:"key_bytes,omitempty"`
SigBytes int `json:"sig_bytes,omitempty"`
// KeyAlg, KeyBits and SigAlg are the inventory a certificate migration is
// planned against (PQ-73): what this certificate's key is, how big, and
// what signed it. Recorded rather than judged — whether RSA-2048 is good
// enough is a configuration opinion, and configuration opinions belong to
// testssl.sh.
KeyAlg string `json:"key_alg,omitempty"`
KeyBits int `json:"key_bits,omitempty"`
SigAlg string `json:"sig_alg,omitempty"`
}
Cert is the part of a peer certificate a report can use.
type Dialer ¶
type Dialer struct {
Timeout time.Duration
ALPN []string
// StartTLS, when set, is the protocol whose plaintext negotiation gets to
// TLS before the handshake: smtp, imap or postgres. Implicit TLS on any
// port needs none of this — `host:465` is already just TLS.
StartTLS string
// Socks5, when set, is the `host:port` of a SOCKS5 proxy every connection
// goes through. SOCKS5 and nothing else: HTTP CONNECT is a *request*, and
// sending one would trade away the property that makes this binary safe to
// point at production. The target name is passed to the proxy unresolved,
// because inside a network that is often the only place it resolves.
Socks5 string
// Net pins the address family every connection uses: "tcp4", "tcp6", or ""
// for whatever the resolver hands over. Pinning it is what makes an
// IPv6-only failure reproducible on demand — unpinned, a dual-stack name
// answers on whichever address the resolver felt like handing over, and two
// runs can disagree with nothing having changed on the endpoint.
Net string
// Resolver is the resolver used to turn a target name into an address; nil
// means the machine's own. It is set from --dns, so that one flag governs
// every question a run asks (PQ-58).
Resolver *net.Resolver
// Confirm re-dials an abrupt failure once before it is believed. See
// DoConfirmed.
Confirm bool
// ConfirmDelay is the pause between the two dials. Zero means a short
// default; tests set it to nothing.
ConfirmDelay time.Duration
// Now is the clock used for expiry arithmetic; tests set it.
Now func() time.Time
}
Dialer performs one handshake. It exists so tests can drive the classifier without a network, and so a caller can inject a proxy-aware dialler later.
func (Dialer) DoConfirmed ¶ added in v0.5.0
DoConfirmed dials once and, when the failure was abrupt and Confirm is set, dials a second time before the result is believed.
pq-intolerant is the finding somebody takes to a CDN vendor, and one reset is not only a middlebox: it is also a stale conntrack entry, a load balancer mid-reconfiguration, a node that had just been drained. Those flap; a wall does not. So the second dial decides which word the report gets to use, and the result carries both attempts either way — Reproduced when the refusal happened twice, Flapped when the retry connected.
Only abrupt failures are retried. An alert is an answer the peer chose to give, and re-dialling it would double the connections against every endpoint with a group policy while proving nothing.
type Kind ¶
type Kind string
Kind classifies how a handshake ended.
const ( KindOK Kind = "ok" // KindDNS: the name did not resolve. Nothing about TLS was learned. KindDNS Kind = "dns" // KindRefused: nothing is listening. KindRefused Kind = "refused" // KindStartTLS: the plaintext upgrade never reached TLS — the peer does not // advertise STARTTLS, or refused it. Never abrupt: the peer refused *TLS*, // not a post-quantum client, and reading it as abrupt would file a relay // with TLS switched off as pq-intolerant. KindStartTLS Kind = "starttls" // KindProxy: the SOCKS5 proxy failed before the endpoint saw anything — it // wanted credentials, refused, or was not there. Never abrupt: it is not the // peer cutting us off, and treating it as one would put somebody else's // endpoint in the pq-intolerant bucket for a fault on this side. KindProxy Kind = "proxy" // KindUnroutable: this host has no route to the address. It says nothing // about the endpoint — an AAAA record probed from a machine with no IPv6 // egress is the usual case — so it must never be reported as a property of // the peer. KindUnroutable Kind = "unroutable" // KindTimeout: no answer inside the deadline. On a profile with a large // ClientHello this is a classic intolerance symptom — a middlebox that // drops the second TCP segment leaves the handshake hanging forever. KindTimeout Kind = "timeout" // KindReset: the peer killed the connection. KindReset Kind = "reset" // KindEOF: the peer closed without saying anything. KindEOF Kind = "eof" // KindECHReject: the peer declined Encrypted Client Hello and answered with // a retry config. Never abrupt: it parsed the hello, found no key of its own // for it, and said so — an endpoint that simply does not do ECH, which is // most of them, and not one that chokes on a larger hello. KindECHReject Kind = "ech-reject" // KindAlert: a TLS alert. The peer parsed the ClientHello and said no. KindAlert Kind = "alert" // KindRecord: the answer was not a TLS record at all (a plaintext HTTP // error page on 443 is the usual case). KindRecord Kind = "record" KindOther Kind = "other" )
func Classify ¶ added in v0.25.0
classify turns a dial or handshake error into a Kind plus the message a report shows. The order matters: a timeout arrives wrapped in an OpError, and a reset arrives wrapped twice, so the specific tests come before the general ones. Classify is classify, exported so the public facade can offer it to an embedder that dials with its own TLS stack (PQ-10). The judgement of what an error means has to stay in one place; a fingerprint probe holding its own error must reach *this* answer, not a second opinion.
type Resolver ¶ added in v0.7.0
Resolver is the DNS lookup ExpandAddresses needs. *net.Resolver satisfies it; the tests pass a table, because DNS is flaky and belongs to somebody else.
type Result ¶
type Result struct {
Profile string `json:"profile"`
OK bool `json:"ok"`
Kind Kind `json:"kind"`
Err string `json:"error,omitempty"`
Version string `json:"tls_version,omitempty"`
Group string `json:"group,omitempty"`
Cipher string `json:"cipher,omitempty"`
ALPN string `json:"alpn,omitempty"`
Elapsed time.Duration `json:"elapsed_ns,omitempty"`
// PQ is true when the negotiated key exchange was a post-quantum hybrid.
PQ bool `json:"pq,omitempty"`
// Chain is the certificate chain as the peer sent it, leaf first. It is
// recorded on every successful handshake and read once, from the baseline.
Chain []Cert `json:"chain,omitempty"`
// ChainVerified is the result of verifying that chain against the system
// roots, done here rather than by the dialler — see clientprofile.TLSConfig.
ChainVerified bool `json:"chain_verified"`
ChainError string `json:"chain_error,omitempty"`
// Attempts is how many handshakes this result is based on: two when an
// abrupt failure was re-dialled to confirm it. See DoConfirmed.
Attempts int `json:"attempts,omitempty"`
// FirstKind is how the first attempt ended, when there was more than one.
FirstKind Kind `json:"first_kind,omitempty"`
// Reproduced is true when both attempts ended abruptly: the refusal is a
// wall rather than a flap, and the finding may say so.
Reproduced bool `json:"reproduced,omitempty"`
// Flapped is true when the first attempt ended abruptly and the second
// connected. The endpoint works and is unstable, which is a third state and
// must not render as either of the other two.
Flapped bool `json:"flapped,omitempty"`
// ECHAccepted is true when the peer accepted Encrypted Client Hello. Read
// from the connection state and never inferred: a server that ignores ECH
// completes the handshake too, and calling that acceptance would be a claim
// about privacy that is not true (PQ-50).
ECHAccepted bool `json:"ech_accepted,omitempty"`
// HelloBytes is the size on the wire of the first ClientHello record,
// measured rather than estimated. It is the number the whole
// size-intolerance conversation turns on: the hybrid hello is roughly
// 1.2 KB larger than the classical one, which is what stops it fitting a
// single TCP segment.
HelloBytes int `json:"hello_bytes,omitempty"`
// HelloCount is how many ClientHellos went out. Two means the peer sent a
// HelloRetryRequest.
HelloCount int `json:"hello_count,omitempty"`
// HRR is true when the peer answered with a HelloRetryRequest: it did not
// take the key share offered and asked for another group, which costs a
// round trip and is a different state from never having seen ML-KEM.
HRR bool `json:"hrr,omitempty"`
// ClientCertRequested is true when the peer sent a CertificateRequest: this
// endpoint is mutual TLS. On TLS 1.3 that does not stop the handshake —
// the peer's objection arrives after the client is finished, and pqprobe
// never reads — so the key exchange answer stands and this is the note that
// keeps "pq-ready" from being read as "usable". On TLS 1.2 client auth
// happens inside the handshake, and then this is the only thing that tells
// the failure apart from "no mutually supported group".
ClientCertRequested bool `json:"client_cert_requested,omitempty"`
// ChainBytes is what the whole chain cost on the wire, in bytes of DER. It
// is the headroom number for post-quantum authentication: a chain that is
// already 6 KB has a different future from one that is 2.
ChainBytes int `json:"chain_bytes,omitempty"`
// PeerChainLen is how many certificates the peer sent. One means the peer
// sent the leaf alone: browsers with a cached intermediate will be fine and
// a fresh client will not, which is the most confusing class of bug there
// is.
PeerChainLen int `json:"peer_chain_len,omitempty"`
}
Result is one (target, profile) handshake attempt.
type Target ¶
type Target struct {
Host string
Port string
SNI string
// PortWritten records that the target named its own port, so `--port` —
// documented as the default "for targets written without one" — cannot
// replace it. Without this the two cases are indistinguishable at 443, and
// `--port 8443 origin.example:443` probed an endpoint nobody named.
PortWritten bool
}
Target is one endpoint to probe. SNI defaults to Host but is kept separate: probing an origin by IP while sending the public hostname is exactly what a CDN does, and it is the only way to reproduce a CDN-only failure from a workstation.
func ExpandAddresses ¶ added in v0.7.0
func ExpandAddresses(ctx context.Context, r Resolver, targets []Target, network string) ([]Target, []error)
ExpandAddresses turns each named target into one target per A/AAAA record, dialling the address while still sending the name (PQ-12).
A hostname behind six records is six stacks, and the failure that survives a manual check is one of them being different: probe the name and you hit whichever address the resolver felt like handing over, six times if you are unlucky, and the broken node stays invisible. This is the `1.2.3.4=origin.example` form the tool already had, applied automatically.
An address literal is left alone — there is nothing to resolve, and looking it up would be a DNS query nobody asked for. A name that does not resolve keeps its target and the error is returned as well: the dialler then reports it as a DNS failure in the usual words, and no endpoint silently disappears from a fleet report. network is the family the run is pinned to (PQ-46): "tcp4", "tcp6", or "" for both. Records outside it are dropped here rather than dialled and reported as failures — a run pinned to one family that still probes the other produces a page of results the operator has to learn to read past, which is the same noise --per-address was built to cut.
func (Target) ServerName ¶
ServerName is what goes in the SNI extension.