volto/tunnel/status.rs
1//! Refusing a request, and the RFC 9209 vocabulary a refusal is written in.
2//!
3//! Everything about *answering* a request this server will not tunnel: the
4//! `Proxy-Status` error types, the two failures that end in one of them (the
5//! failure a connect attempt carries out of its address walk, and the failure a
6//! name resolution carries out of its budget), and the handful of helpers that
7//! put a status on the wire under the bound
8//! [`h3api::Stream::respond_within`] sets.
9//!
10//! The choice of which answer to send is not here. It is made where the fact
11//! is known -- in [`super::admit_target`], in each tunnel's connect step, in
12//! [`crate::conn`] -- and this module is what those choices are said in.
13
14use std::time::Duration;
15
16use tracing::debug;
17
18use crate::h3api::{self, FieldValue, Fields, RespondError, Status, Stream};
19
20/// An RFC 9209 proxy error type, for the `Proxy-Status` field of a refusal.
21///
22/// Only registered types (RFC 9209 §2.3.2) appear here. There is no registered
23/// type for "that port is closed by policy", so a denied port is reported as
24/// `http_request_denied` — the registry's general "denied per policy" type —
25/// rather than stretching `destination_ip_prohibited` to cover something that is
26/// not about the address at all.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ProxyError {
29 /// The target name could not be resolved.
30 DnsError,
31 /// The resolver did not answer inside the `[limits] connect_timeout` budget.
32 DnsTimeout,
33 /// Every address the target resolved to is prohibited by policy.
34 DestinationIpProhibited,
35 /// The target is a legal destination but could not be reached.
36 DestinationUnavailable,
37 /// The target actively refused the connection.
38 ConnectionRefused,
39 /// The connection attempt timed out.
40 ConnectionTimeout,
41 /// This host could not spare the resources to reach the target at all.
42 ///
43 /// RFC 9209 §2.3.30 describes the type as "the intermediary encountered an
44 /// internal error unrelated to the origin", which is exactly the case: the
45 /// descriptor, the buffer or the source port ran out here, and nothing at
46 /// all was learned about the destination. See `is_local_exhaustion`.
47 ProxyInternalError,
48 /// This connection already holds as many tunnels as it may.
49 ConnectionLimitReached,
50 /// The request is refused by policy.
51 HttpRequestDenied,
52}
53
54/// A target that could not be reached, and the address the attempt failed on.
55///
56/// The address is what RFC 9209 §2.1.2 calls the `next-hop`: "the intermediary or
57/// origin server selected (and used, if contacted) to obtain this response".
58/// Carried out of the connect helpers rather than recomputed, because with
59/// several resolved addresses only they know which one was tried last.
60pub(crate) struct Unreachable {
61 /// The last address attempted, or `None` if there was nothing to attempt.
62 pub(crate) next_hop: Option<std::net::SocketAddr>,
63 /// Why that attempt failed.
64 pub(crate) error: std::io::Error,
65}
66
67impl Unreachable {
68 /// The failure of a target that offered nothing to attempt.
69 ///
70 /// Both tunnel types walk a list of addresses and keep the last failure, so
71 /// both need an answer for a list that was empty. Neither can actually reach
72 /// it — [`super::admit_target`] never hands back an empty list — so this is the
73 /// empty-list arm written out rather than asserted, and there is no hop to
74 /// name in it.
75 pub(crate) fn no_addresses() -> Self {
76 Self {
77 next_hop: None,
78 error: std::io::Error::new(std::io::ErrorKind::InvalidInput, "no addresses to try"),
79 }
80 }
81}
82
83/// A name that could not be turned into addresses, and why not.
84///
85/// The two cases are reported differently, so the callers have to be able to
86/// tell them apart: a resolver that answered "no" is a 502, while one that did
87/// not answer at all inside the `[limits] connect_timeout` budget is a 504.
88#[derive(Debug)]
89pub(crate) enum ResolveFailure {
90 /// The resolver answered, unsuccessfully.
91 Failed(std::io::Error),
92 /// The budget expired before the resolver answered.
93 TimedOut(Duration),
94}
95
96impl ResolveFailure {
97 /// The RFC 9209 type this failure is reported as.
98 pub(crate) fn proxy_error(&self) -> ProxyError {
99 match self {
100 Self::Failed(_) => ProxyError::DnsError,
101 Self::TimedOut(_) => ProxyError::DnsTimeout,
102 }
103 }
104}
105
106impl std::fmt::Display for ResolveFailure {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 Self::Failed(error) => write!(f, "{error}"),
110 Self::TimedOut(budget) => write!(f, "no answer within {budget:?}"),
111 }
112 }
113}
114
115impl ProxyError {
116 /// The complete `Proxy-Status` field value.
117 ///
118 /// RFC 9209 §2 shape: an identifier for this proxy, then parameters. Kept as
119 /// static strings so building a refusal cannot itself fail.
120 fn field_value(self) -> &'static str {
121 match self {
122 Self::DnsError => "volto; error=dns_error",
123 Self::DnsTimeout => "volto; error=dns_timeout",
124 Self::DestinationIpProhibited => "volto; error=destination_ip_prohibited",
125 Self::DestinationUnavailable => "volto; error=destination_unavailable",
126 Self::ConnectionRefused => "volto; error=connection_refused",
127 Self::ConnectionTimeout => "volto; error=connection_timeout",
128 Self::ProxyInternalError => "volto; error=proxy_internal_error",
129 Self::ConnectionLimitReached => "volto; error=connection_limit_reached",
130 Self::HttpRequestDenied => "volto; error=http_request_denied",
131 }
132 }
133
134 /// This error as the field lines of a response.
135 pub fn fields(self) -> Fields {
136 let mut fields = Fields::new();
137 fields.append(PROXY_STATUS, FieldValue::from_static(self.field_value()));
138 fields
139 }
140
141 /// Whether this error may name the address it happened on.
142 ///
143 /// Only the three failures that are *about reaching a specific hop* qualify.
144 /// RFC 9209 §2.1.2 allows the parameter anywhere, so this list is a privacy
145 /// judgement rather than a syntactic one, and every exclusion is deliberate:
146 ///
147 /// * `dns_error` and `dns_timeout` — there is no hop to name. The lookup is
148 /// what failed, so any address in the response would be invented.
149 /// * `destination_ip_prohibited` and `http_request_denied` — the request was
150 /// refused *by this proxy*, and echoing the resolved address would turn
151 /// every refusal into a lookup oracle: a client that cannot reach an
152 /// internal resolver could read this server's view of a name straight out
153 /// of the refusal. The policy exists to keep exactly that reachable-only-
154 /// from-here information in, so the refusal must not carry it out.
155 /// * `connection_limit_reached`, and the 407 path, say nothing about a
156 /// target: they are verdicts on the client.
157 /// * `proxy_internal_error` — nothing was contacted. The allocation this
158 /// host could not make failed before the first packet, so naming the
159 /// address would be reporting a hop that was never tried, and would hand
160 /// a client this server's resolution of a name it never reached (D89).
161 fn discloses_next_hop(self) -> bool {
162 matches!(
163 self,
164 Self::ConnectionRefused | Self::ConnectionTimeout | Self::DestinationUnavailable
165 )
166 }
167
168 /// This error as the field lines of a response, naming the hop it happened on.
169 ///
170 /// The address is dropped unless `Self::discloses_next_hop` allows it, so a
171 /// caller cannot leak one by passing it to the wrong error type.
172 pub fn fields_with_next_hop(self, next_hop: Option<std::net::SocketAddr>) -> Fields {
173 let Some(address) = next_hop.filter(|_| self.discloses_next_hop()) else {
174 return self.fields();
175 };
176
177 // `<identifier>; error=<type>; next-hop="<address>"`. RFC 9209 §2.1.2
178 // accepts a String or a Token; an IPv6 address needs its brackets, which
179 // no Token may contain, so the String form is used for both families.
180 let value = format!(
181 "{}; next-hop={}",
182 self.field_value(),
183 sf_string(&address.to_string())
184 );
185
186 let mut fields = Fields::new();
187 fields.append(
188 PROXY_STATUS,
189 // A rendered socket address is printable ASCII, so this cannot fail;
190 // falling back to the parameterless value keeps that from being a
191 // panic if it ever somehow did, which is the property the whole
192 // refusal path is built on: refusing must never itself fail.
193 FieldValue::parse(value.as_bytes())
194 .unwrap_or_else(|| FieldValue::from_static(self.field_value())),
195 );
196 fields
197 }
198
199 /// The error type that best describes a failure to reach a target.
200 ///
201 /// The local failures are separated out first, because everything below
202 /// them is a statement *about the target* and they are not one: see
203 /// `is_local_exhaustion`.
204 pub fn from_connect_error(error: &std::io::Error) -> Self {
205 if is_local_exhaustion(error) {
206 return Self::ProxyInternalError;
207 }
208
209 match error.kind() {
210 std::io::ErrorKind::ConnectionRefused => Self::ConnectionRefused,
211 std::io::ErrorKind::TimedOut => Self::ConnectionTimeout,
212 _ => Self::DestinationUnavailable,
213 }
214 }
215
216 /// The HTTP status code RFC 9209 §2.3.2 recommends for this error type.
217 ///
218 /// The registry pairs each type with a status, and following it costs
219 /// nothing while telling an operator reading a log which failure it was: a
220 /// 504 is a target that never answered, a 503 is one that could not be
221 /// reached at all, and a 502 is one that actively refused.
222 ///
223 /// One deliberate departure: `destination_ip_prohibited` is recommended as
224 /// 502, and this server answers 403. Decision D11 made both policy refusals
225 /// — denied port and denied address — a 403, because they are refusals by
226 /// this proxy rather than reports about an upstream hop, and a client that
227 /// sees 502 would reasonably retry.
228 ///
229 /// One address refusal never arrives here at all (decision D49, a carve-out
230 /// from D11 rather than a revision of it): a target whose every resolved
231 /// address is the unspecified one is a name the upstream resolver filtered,
232 /// so it is answered with a 200 that closes on the spot — see
233 /// `accept_then_close` — and carries no `Proxy-Status` field, because
234 /// nothing about it is this proxy's verdict. Every refusal that is actually
235 /// sent still follows the table below.
236 pub fn recommended_status(self) -> Status {
237 match self {
238 Self::DnsError | Self::ConnectionRefused => Status::BAD_GATEWAY,
239 Self::DnsTimeout | Self::ConnectionTimeout => Status::GATEWAY_TIMEOUT,
240 Self::DestinationUnavailable | Self::ConnectionLimitReached => {
241 Status::SERVICE_UNAVAILABLE
242 }
243 Self::DestinationIpProhibited | Self::HttpRequestDenied => Status::FORBIDDEN,
244 Self::ProxyInternalError => Status::INTERNAL_SERVER_ERROR,
245 }
246 }
247}
248
249/// Whether a socket could not be opened because *this host* had nothing left.
250///
251/// The distinction the RFC 9209 registry draws, and the one D89 is about.
252/// `destination_unavailable` is defined as "the intermediary considers the next
253/// hop to be unavailable; e.g., recent attempts to communicate with it may have
254/// failed, or a health check may indicate that it is down" (§2.3.4) — a
255/// statement about the target. None of the errors below is one. They are raised
256/// by the allocation itself, before a single packet is addressed anywhere, so
257/// what they report is that this process ran out of something:
258///
259/// * `EMFILE` / `ENFILE` — no descriptor, per process or system-wide. This is
260/// the one an operator meets: `max_connections` x `max_targets_per_conn`
261/// sockets is what `crate::quic`'s startup check sizes `RLIMIT_NOFILE`
262/// against, and a host that is over it fails every `socket()` at once.
263/// * `ENOBUFS` / `ENOMEM` — no kernel buffer or memory for another socket.
264/// * `EADDRNOTAVAIL` — no source address left to bind, which on a connect to a
265/// remote address is the ephemeral port range exhausted. A target address
266/// that is itself unassignable cannot reach here: the unspecified address is
267/// answered by decision D49 before any dial, and every other refusal by the
268/// destination policy.
269///
270/// Reported as `proxy_internal_error` — "the intermediary encountered an
271/// internal error unrelated to the origin" (§2.3.30) — which is the registry's
272/// own name for this, rather than as a healthy destination being down.
273///
274/// Deliberately *not* here: `EACCES` and `EPERM`. A local firewall refusing the
275/// route to one target is a fact about reaching that target, so it keeps
276/// `destination_unavailable`; running out of descriptors is not.
277///
278/// A plain function over the OS error number, for the reason
279/// `udp::is_per_packet_error` gives for the same shape: `std` maps none of
280/// these onto an `ErrorKind` that is stable to match on, and both hosts this
281/// server builds for define every constant.
282fn is_local_exhaustion(error: &std::io::Error) -> bool {
283 matches!(
284 error.raw_os_error(),
285 Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::EADDRNOTAVAIL)
286 )
287}
288
289/// The field an RFC 9209 refusal explains itself in.
290const PROXY_STATUS: &str = "proxy-status";
291
292/// Renders `value` as a structured field String (RFC 8941 §3.3.3).
293///
294/// A String is DQUOTE-delimited, and inside it only DQUOTE and backslash are
295/// escaped. A rendered socket address contains neither, so this never actually
296/// escapes anything today — it exists so that the one field value this server
297/// builds at runtime is correct by construction rather than by argument about its
298/// inputs.
299fn sf_string(value: &str) -> String {
300 let mut quoted = String::with_capacity(value.len() + 2);
301 quoted.push('"');
302 for character in value.chars() {
303 if character == '"' || character == '\\' {
304 quoted.push('\\');
305 }
306 quoted.push(character);
307 }
308 quoted.push('"');
309 quoted
310}
311
312/// What became of a response written under [`h3api::Stream::respond_within`]'s
313/// bound.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub(crate) enum Responded {
316 /// The response is on the stream.
317 Sent,
318 /// The write failed outright, and [`respond`] has already reported it.
319 Failed,
320 /// The write did not complete within its bound, and the stream has already
321 /// been reset with H3_REQUEST_CANCELLED.
322 ///
323 /// [`Responded::landed`] is the follow-up all four callers share: it writes
324 /// the line and answers that the write did not land. What a caller has left
325 /// to clean up stays with the caller; `tcp::run` aborts the target
326 /// connection RFC 9114 section 4.4 asks it to, for a lapse and for an
327 /// outright failure alike.
328 Expired,
329}
330
331impl Responded {
332 /// Whether the response reached the stream, reporting a lapse as `gave_up`.
333 ///
334 /// The follow-up is the same at every call site: a sent response is what
335 /// the caller asked for, a failed one has already been reported by
336 /// [`respond`], and a lapsed one owes one line. Written here so a fifth
337 /// caller cannot answer it a fifth way.
338 ///
339 /// What a lapse leaves behind is still the caller's business. `false` says
340 /// to stop; anything past that stays where the thing to undo is.
341 pub(crate) fn landed(self, stream_id: u64, status: Status, gave_up: &'static str) -> bool {
342 match self {
343 Self::Sent => true,
344 Self::Failed => false,
345 Self::Expired => {
346 debug!(stream_id, status = status.as_str(), "{gave_up}");
347 false
348 }
349 }
350 }
351}
352
353/// Sends `status` with `fields` under the bound
354/// [`h3api::Stream::respond_within`] carries, reporting a write that fails
355/// outright.
356///
357/// Every response this server sends with no tunnel behind it is written this
358/// way, and for the reason the bound exists: a peer that grants no flow-control
359/// credit never takes even the few bytes of a status line, and nothing else
360/// would ever end the wait — whatever the caller does next does not exist until
361/// this write returns. It is also why the count of authentication failures that
362/// is meant to cost a guesser a handshake is recorded by the caller before this
363/// call rather than after it (review H1/H2).
364///
365/// `failed` is the caller's own wording for a write that failed, kept a literal
366/// at the call site so what this server can say still reads out of `src/`.
367pub(crate) async fn respond(
368 stream: &mut Stream,
369 status: Status,
370 fields: Fields,
371 failed: &'static str,
372) -> Responded {
373 let stream_id = stream.id();
374
375 match stream.respond_within(status, fields).await {
376 Ok(()) => Responded::Sent,
377 Err(RespondError::Failed(error)) => {
378 debug!(stream_id, %error, "{failed}");
379 Responded::Failed
380 }
381 Err(RespondError::Expired) => Responded::Expired,
382 }
383}
384
385/// Answers a request we will not serve, then closes the stream tidily.
386///
387/// Any request body is unwanted, so the client is told to stop sending before
388/// the status goes out.
389pub(crate) async fn refuse(stream: &mut Stream, status: Status) {
390 refuse_with(stream, status, Fields::new()).await;
391}
392
393/// Refuses a request, explaining why in an RFC 9209 `Proxy-Status` field.
394///
395/// The status is the error's own (`recommended_status`), so the table that
396/// argues for each pairing — including D11's departure from the registry — is
397/// the only thing that decides one.
398pub(crate) async fn refuse_because(stream: &mut Stream, error: ProxyError) {
399 refuse_with(stream, error.recommended_status(), error.fields()).await;
400}
401
402/// Refuses a request whose target could not be reached, naming the failed hop.
403///
404/// Both tunnel types end up here from their connect step, so the mapping from an
405/// `io::Error` to a status and an RFC 9209 field is written once: the type
406/// decides the status (`recommended_status`) and whether the address may be
407/// disclosed (`discloses_next_hop`).
408pub(crate) async fn refuse_unreachable(stream: &mut Stream, failure: &Unreachable) {
409 let error = ProxyError::from_connect_error(&failure.error);
410 refuse_with(
411 stream,
412 error.recommended_status(),
413 error.fields_with_next_hop(failure.next_hop),
414 )
415 .await;
416}
417
418/// Refuses a request with an explicit set of response fields.
419///
420/// The write is bounded by one QUIC idle timeout and a lapsed one is abandoned
421/// with a reset; [`respond`] carries the reasoning.
422pub(crate) async fn refuse_with(stream: &mut Stream, status: Status, fields: Fields) {
423 let stream_id = stream.id();
424 stream.stop_receiving(h3api::NO_ERROR);
425
426 if !respond(stream, status, fields, "failed to send error response")
427 .await
428 .landed(
429 stream_id,
430 status,
431 "gave up on an error response the peer would not take",
432 )
433 {
434 return;
435 }
436
437 if let Err(error) = stream.finish() {
438 debug!(stream_id, %error, "failed to finish error response");
439 }
440}
441
442/// Accepts a request with a 200 and closes the tunnel again immediately.
443///
444/// **Not a refusal, and deliberately not named like one.** The response carries
445/// no `Proxy-Status` field and says nothing about a failure; `fields` is
446/// whatever an accepted response of that tunnel type has to carry — nothing for
447/// a TCP tunnel, the RFC 9297 `Capsule-Protocol` field for CONNECT-UDP.
448///
449/// Exactly one case uses it (decision D49): a target whose every resolved
450/// address is the unspecified one, which is how a filtering resolver upstream
451/// says "this name is blocked". That block is not this proxy's verdict, and an
452/// error status makes the client attribute it here; a tunnel that opens and
453/// closes at once is instead what every transport without an in-band refusal
454/// channel shows for such a name, and what a target that accepts a connection
455/// and hangs up immediately looks like on the wire.
456///
457/// Mechanically the close is the tidy one, and it is the same one an
458/// established session ends with: STOP_SENDING with H3_NO_ERROR for anything
459/// the client is still sending, then a FIN on the response stream. **Never a
460/// reset** — RFC 9114 §4.4 reserves an abruptly terminated stream for a failure
461/// of the target connection (H3_CONNECT_ERROR), which this is not, and a reset
462/// would also be the one signal a client is entitled to read as "the proxy
463/// broke".
464///
465/// The FIN that follows is this server's own choice rather than a rule read off
466/// a spec. RFC 9297 §3.3 mentions a cleanly terminated capsule stream only to
467/// rule on what a truncated last capsule means then — "If the receive side of a
468/// stream carrying Capsules is terminated cleanly (for example, in HTTP/3 this
469/// is defined as receiving a QUIC STREAM frame with the FIN bit set) and the
470/// last Capsule on the stream was truncated, this MUST be treated as if it were
471/// a malformed or incomplete message" — and says nothing about ending one on
472/// purpose. RFC 9298 §3.1 ties the request stream to a socket that exists: a
473/// UDP proxy "MUST keep the socket open while the request stream is open", and
474/// when it closes a socket it "MUST close the request stream". No socket is
475/// ever opened here, so neither sentence reaches this case. So this is this
476/// server's own, and the FIN is what it picks: the ending an established
477/// session gets, and the only one that says nothing went wrong.
478///
479/// The STOP_SENDING up front is the shape to keep. RFC 9114 §4.1 leaves the
480/// choice open — a server may abort reading the request, or leave the client to
481/// finish and close it — and the other shape was tried once (D59): 200 and FIN
482/// only, then read and discard until the client closed, on the theory that a
483/// stop landing on a client still writing its first bytes was what made one
484/// client stack answer with a transport-level PROTOCOL_VIOLATION. A frame-level
485/// A/B against that very stack showed the two shapes are indistinguishable to it
486/// (it resets within a round trip either way) and production kept failing under
487/// the drain, so the simpler shape came back. That fault is on the client's
488/// side and does not depend on what this close sends.
489pub(crate) async fn accept_then_close(stream: &mut Stream, fields: Fields) {
490 let stream_id = stream.id();
491 stream.stop_receiving(h3api::NO_ERROR);
492
493 let sent = respond(
494 stream,
495 Status::OK,
496 fields,
497 "failed to send 200 for a tunnel closed on the spot",
498 )
499 .await;
500
501 if !sent.landed(
502 stream_id,
503 Status::OK,
504 "gave up on a 200 the peer would not take for a tunnel closed on the spot",
505 ) {
506 return;
507 }
508
509 if let Err(error) = stream.finish() {
510 debug!(stream_id, %error, "failed to close a tunnel after its 200");
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn proxy_status_values_are_rfc_9209_shaped() {
520 for (error, expected) in [
521 (ProxyError::DnsError, "dns_error"),
522 (ProxyError::DnsTimeout, "dns_timeout"),
523 (
524 ProxyError::DestinationIpProhibited,
525 "destination_ip_prohibited",
526 ),
527 (
528 ProxyError::ConnectionLimitReached,
529 "connection_limit_reached",
530 ),
531 (ProxyError::HttpRequestDenied, "http_request_denied"),
532 (ProxyError::ProxyInternalError, "proxy_internal_error"),
533 ] {
534 let value = error.field_value();
535 // `<identifier>; error=<type>`: the identifier names this proxy, the
536 // parameter names the failure.
537 assert_eq!(value, format!("volto; error={expected}"));
538
539 let fields = error.fields();
540 assert_eq!(
541 fields.get(PROXY_STATUS).and_then(FieldValue::to_str),
542 Some(value)
543 );
544 }
545 }
546
547 /// RFC 9209 §2.3.2 pairs each registered type with a recommended status.
548 /// Every pairing here is the registry's, except the documented D11 choice of
549 /// 403 for a destination the policy refuses.
550 #[test]
551 fn every_error_type_carries_its_recommended_status() {
552 for (error, expected) in [
553 (ProxyError::DnsError, Status::BAD_GATEWAY),
554 (ProxyError::DnsTimeout, Status::GATEWAY_TIMEOUT),
555 (ProxyError::ConnectionRefused, Status::BAD_GATEWAY),
556 (ProxyError::ConnectionTimeout, Status::GATEWAY_TIMEOUT),
557 (
558 ProxyError::DestinationUnavailable,
559 Status::SERVICE_UNAVAILABLE,
560 ),
561 (
562 ProxyError::ConnectionLimitReached,
563 Status::SERVICE_UNAVAILABLE,
564 ),
565 (ProxyError::HttpRequestDenied, Status::FORBIDDEN),
566 (ProxyError::DestinationIpProhibited, Status::FORBIDDEN),
567 (
568 ProxyError::ProxyInternalError,
569 Status::INTERNAL_SERVER_ERROR,
570 ),
571 ] {
572 assert_eq!(
573 error.recommended_status(),
574 expected,
575 "{error:?} must answer {expected}"
576 );
577 }
578 }
579
580 /// A failure of *this host* is not a report about the target (D89).
581 ///
582 /// Every errno here is raised by the allocation itself, before anything is
583 /// addressed anywhere, so none of them can be evidence that "the next hop
584 /// [is] unavailable" — RFC 9209 §2.3.4's definition of the type they used
585 /// to be reported as. `proxy_internal_error` is §2.3.30's "internal error
586 /// unrelated to the origin", which is what happened.
587 #[test]
588 fn a_local_resource_failure_does_not_blame_the_target() {
589 for code in [
590 libc::EMFILE,
591 libc::ENFILE,
592 libc::ENOBUFS,
593 libc::ENOMEM,
594 libc::EADDRNOTAVAIL,
595 ] {
596 let error = std::io::Error::from_raw_os_error(code);
597 assert_eq!(
598 ProxyError::from_connect_error(&error),
599 ProxyError::ProxyInternalError,
600 "errno {code} ({error}) is this host's failure, not the target's"
601 );
602 assert_eq!(
603 ProxyError::from_connect_error(&error).recommended_status(),
604 Status::INTERNAL_SERVER_ERROR
605 );
606 }
607 }
608
609 /// The other side of that line: a failure that really is about reaching
610 /// this target keeps saying so.
611 ///
612 /// `EACCES` and `EPERM` are the pair worth naming — a local firewall
613 /// refusing the route to one destination is a fact about that destination,
614 /// however local the component enforcing it is.
615 #[test]
616 fn a_failure_to_reach_the_target_still_blames_the_target() {
617 for code in [
618 libc::ECONNREFUSED,
619 libc::ETIMEDOUT,
620 libc::EHOSTUNREACH,
621 libc::ENETUNREACH,
622 libc::EACCES,
623 libc::EPERM,
624 ] {
625 let error = std::io::Error::from_raw_os_error(code);
626 assert_ne!(
627 ProxyError::from_connect_error(&error),
628 ProxyError::ProxyInternalError,
629 "errno {code} ({error}) is about the target"
630 );
631 }
632 }
633
634 /// The three failures `from_connect_error` distinguishes must stay
635 /// distinguishable in the response, which is the point of computing them.
636 #[test]
637 fn connect_failures_do_not_collapse_onto_one_status() {
638 use std::io::{Error, ErrorKind};
639
640 let statuses: Vec<Status> = [
641 ErrorKind::ConnectionRefused,
642 ErrorKind::TimedOut,
643 ErrorKind::PermissionDenied,
644 ]
645 .into_iter()
646 .map(|kind| ProxyError::from_connect_error(&Error::from(kind)).recommended_status())
647 .collect();
648
649 assert_eq!(
650 statuses,
651 vec![
652 Status::BAD_GATEWAY,
653 Status::GATEWAY_TIMEOUT,
654 Status::SERVICE_UNAVAILABLE
655 ]
656 );
657 }
658
659 #[test]
660 fn connect_errors_map_onto_registered_types() {
661 use std::io::{Error, ErrorKind};
662
663 assert_eq!(
664 ProxyError::from_connect_error(&Error::from(ErrorKind::ConnectionRefused)),
665 ProxyError::ConnectionRefused
666 );
667 assert_eq!(
668 ProxyError::from_connect_error(&Error::from(ErrorKind::TimedOut)),
669 ProxyError::ConnectionTimeout
670 );
671 assert_eq!(
672 ProxyError::from_connect_error(&Error::from(ErrorKind::PermissionDenied)),
673 ProxyError::DestinationUnavailable
674 );
675 }
676
677 /// Reads the `Proxy-Status` field out of a field list.
678 fn proxy_status(fields: &Fields) -> String {
679 fields
680 .get(PROXY_STATUS)
681 .and_then(FieldValue::to_str)
682 .expect("every refusal carries a Proxy-Status field")
683 .to_owned()
684 }
685
686 /// RFC 9209 §2.1.2's `next-hop` "identifies the intermediary or origin server
687 /// selected (and used, if contacted) to obtain this response" — so it belongs
688 /// on the failures that are about reaching one, and nowhere else.
689 #[test]
690 fn only_failures_to_reach_a_hop_name_it() {
691 let hop: std::net::SocketAddr = "192.0.2.7:443".parse().expect("address");
692
693 for error in [
694 ProxyError::ConnectionRefused,
695 ProxyError::ConnectionTimeout,
696 ProxyError::DestinationUnavailable,
697 ] {
698 assert_eq!(
699 proxy_status(&error.fields_with_next_hop(Some(hop))),
700 format!("{}; next-hop=\"192.0.2.7:443\"", error.field_value())
701 );
702 }
703 }
704
705 /// The exclusions, and the reason for the middle two: a policy refusal that
706 /// echoed the resolved address would hand the client this server's view of a
707 /// name it is not allowed to reach — an internal DNS mapping, read straight
708 /// out of the refusal. `dns_error` has no hop to name at all.
709 #[test]
710 fn refusals_that_are_not_about_a_hop_never_echo_an_address() {
711 let hop: std::net::SocketAddr = "10.1.2.3:53".parse().expect("address");
712
713 for error in [
714 ProxyError::DnsError,
715 ProxyError::DnsTimeout,
716 ProxyError::DestinationIpProhibited,
717 ProxyError::HttpRequestDenied,
718 ProxyError::ConnectionLimitReached,
719 // Nothing was contacted, so there is no hop this one could name
720 // even though it is produced by the connect step (D89).
721 ProxyError::ProxyInternalError,
722 ] {
723 let value = proxy_status(&error.fields_with_next_hop(Some(hop)));
724 assert_eq!(
725 value,
726 error.field_value(),
727 "{error:?} must stay exactly as it is without a next-hop"
728 );
729 assert!(
730 !value.contains("10.1.2.3") && !value.contains("next-hop"),
731 "{error:?} leaked an address: {value}"
732 );
733 }
734 }
735
736 /// No address to report — an empty candidate list — leaves the field as the
737 /// plain one, byte for byte.
738 #[test]
739 fn a_missing_address_leaves_the_field_untouched() {
740 for error in [
741 ProxyError::ConnectionRefused,
742 ProxyError::ConnectionTimeout,
743 ProxyError::DestinationUnavailable,
744 ] {
745 assert_eq!(
746 error.fields_with_next_hop(None).get(PROXY_STATUS),
747 error.fields().get(PROXY_STATUS)
748 );
749 }
750 }
751
752 /// RFC 9209 §2.1.2 accepts a String or a Token, and RFC 8941 §3.3.3 defines
753 /// the String: DQUOTE-delimited, with DQUOTE and backslash escaped. An IPv6
754 /// hop is the case that decides it — its brackets are not Token characters.
755 #[test]
756 fn a_next_hop_is_a_quoted_structured_field_string() {
757 let ipv6: std::net::SocketAddr = "[2001:db8::1]:53".parse().expect("address");
758 assert_eq!(
759 proxy_status(&ProxyError::ConnectionRefused.fields_with_next_hop(Some(ipv6))),
760 "volto; error=connection_refused; next-hop=\"[2001:db8::1]:53\""
761 );
762
763 // The escaping rule itself, on inputs a socket address cannot produce but
764 // the encoder must still handle.
765 assert_eq!(sf_string("192.0.2.7:443"), "\"192.0.2.7:443\"");
766 assert_eq!(sf_string("[2001:db8::1]:53"), "\"[2001:db8::1]:53\"");
767 assert_eq!(sf_string(r#"a"b"#), r#""a\"b""#);
768 assert_eq!(sf_string(r"a\b"), r#""a\\b""#);
769 assert_eq!(sf_string(""), "\"\"");
770 }
771
772 /// The mapping the two resolution failures are told apart by.
773 #[test]
774 fn a_resolver_timeout_is_a_different_type_from_a_resolver_failure() {
775 use std::io::{Error, ErrorKind};
776
777 let failed = ResolveFailure::Failed(Error::from(ErrorKind::NotFound));
778 assert_eq!(failed.proxy_error(), ProxyError::DnsError);
779 assert_eq!(
780 failed.proxy_error().recommended_status(),
781 Status::BAD_GATEWAY
782 );
783
784 let timed_out = ResolveFailure::TimedOut(Duration::from_secs(10));
785 assert_eq!(timed_out.proxy_error(), ProxyError::DnsTimeout);
786 assert_eq!(
787 timed_out.proxy_error().recommended_status(),
788 Status::GATEWAY_TIMEOUT
789 );
790 }
791}