Repository navigation
V3: Bestool should fall back to mTLS when Tailscale identity untrusted - #960
Conversation
| /// | ||
| /// The tailscale probe asks an endpoint that needs no auth, so it cannot tell a | ||
| /// tailnet node canopy trusts from one it does not. When a call over tailscale is | ||
| /// refused for the tailnet identity (`auth-tailnet-node-not-permitted`) and a |
There was a problem hiding this comment.
[Bugs & Correctness] suggestion
The new module doc states unconditionally that when a tailscale call is refused for the tailnet identity "the transport falls back to mTLS and retries the call", but the raw-requests escape hatch ReqwestTransport::request (line 458) hands a RequestBuilder to the caller and can never retry — a raw POST/PUT over tailscale that canopy refuses still surfaces the 403 with no fallback, while raw get does fall back. Suggest scoping the doc claim (e.g. "generated calls and raw GETs fall back; requests started via request do not"), so callers of the escape hatch aren't surprised by the asymmetry.
| && self | ||
| .leave_tailnet() | ||
| .await | ||
| .map_err(|err| Error::transport(std::io::Error::other(err.to_string())))? |
There was a problem hiding this comment.
[Bugs & Correctness] suggestion
Error::transport(std::io::Error::other(err.to_string())) flattens the miette error from leave_tailnet (e.g. a certificate-load failure inside build_mtls_http with its wrap context) into a bare string, dropping the source chain — and this fires at exactly the moment the feature activates and an operator most needs to know why tailscale was abandoned and why mTLS could not be built. Consider a lossless conversion into bes_canopy_api::Error (or at least wrapping the original report so its cause chain survives) instead of to_string().
|
|
||
| let mut req = http | ||
| .request(request.method().clone(), url) | ||
| .headers(request.headers().clone()); |
There was a problem hiding this comment.
[Performance] suggestion
Every call now clones the request's headers (and body) on each attempt, even though a retry only ever happens once and only on the rare tailnet-refusal path. The pre-change code consumed the request via into_parts() and moved the body into the reqwest builder, so the hot path was clone-free; the new send(&CanopyRequest) pays a HeaderMap clone (plus a full body copy if the wire type isn't refcounted) on every single canopy call for the sake of retryability. Restructure instead: consume the request in call (e.g. into_parts() into owned parts passed to a private send(parts)), and only rebuild/clone for the second attempt when a fallback actually fires — keeping the per-call allocation profile the same as before this change.
| .and_then(|kind| kind.as_str()) | ||
| .is_some_and(|kind| { | ||
| kind.trim_end_matches('/') | ||
| .ends_with("/auth-tailnet-node-not-permitted") |
There was a problem hiding this comment.
[Security] suggestion
refuses_tailnet_identity hand-rolls a second parser for canopy's problem+json type field, diverging from the existing problem_slug helper in crates/canopy/src/names.rs:110 which all other problem-type matching goes through. The suffix match ends_with("/auth-tailnet-node-not-permitted") accepts any multi-segment prefix (e.g. /a/b/auth-tailnet-node-not-permitted) but rejects a bare "auth-tailnet-node-not-permitted" slug, whereas problem_slug normalises to the last path segment and would treat both consistently. If canopy ever changes how the type URI is shaped (or emits the bare slug), this duplicated matcher silently stops triggering the mTLS fallback while every other problem-type consumer still works. Extract/reuse the shared slug parsing (e.g. make problem_slug pub(crate) and match problem_slug(...) == Some("auth-tailnet-node-not-permitted"), ideally from a named constant) so the fallback trigger cannot drift from the rest of the codebase's problem-type handling.
|
🦸 Review Hero Summary (round 1) · reviewed Below consensus threshold (4 unique issues not confirmed by majority)
Nitpicks
Local fix prompt (copy to your coding agent) |
🦸 Review Hero