Skip to main content

singbox_driver/api/
delay.rs

1//! The typed wire semantics of the sing-box Clash API delay endpoint.
2//!
3//! sing-box answers `GET /proxies/{name}/delay?url={url}&timeout={ms}` with
4//! one of its own typed answers. This module owns that classification, so the
5//! outbound adapter never reads a status code or a message on its own and
6//! never turns an answer the engine did not produce into a measurement.
7//!
8//! The answers of sing-box 1.13 and 1.14 are:
9//!
10//! | Status | Body | Meaning |
11//! | --- | --- | --- |
12//! | `200` | `{"delay": N}` | the engine measured a round trip through the outbound |
13//! | `400` | `{"message": "Body invalid"}` | the engine refused the request |
14//! | `401` / `403` | `{"message": ...}` | the controller refused the secret |
15//! | `404` | `{"message": "Resource not found"}` | the engine does not know the outbound |
16//! | `503` | `{"message": "An error occurred in the delay test"}` | the delay test failed |
17//! | `504` | `{"message": "Timeout"}` | the engine's own delay test exceeded its deadline |
18//!
19//! The endpoint parses the timeout as a signed 16-bit millisecond count and
20//! measures HTTPS targets only: a plain-HTTP target is replaced by the
21//! engine's own URL test target. The driver therefore sends the requested
22//! target unchanged and reports the measurement the engine publishes, and it
23//! refuses a timeout the endpoint cannot carry before the request is sent.
24
25use lurker_core_contract::error::ErrorCode;
26use serde::Deserialize;
27
28/// The largest delay timeout the sing-box delay endpoint accepts, in
29/// milliseconds.
30///
31/// The endpoint parses the timeout as a signed 16-bit millisecond count, so a
32/// larger value is refused before the request is sent.
33pub const MAX_SINGBOX_DELAY_TIMEOUT_MS: u32 = i16::MAX as u32;
34
35/// The status the engine answers with when its own delay test exceeded the
36/// deadline it was given.
37const ENGINE_TIMEOUT_STATUS: u16 = 504;
38
39/// The status the engine answers with when the delay test itself failed.
40const ENGINE_TEST_FAILED_STATUS: u16 = 503;
41
42/// The status the engine answers with when it does not know the outbound.
43const ENGINE_UNKNOWN_OUTBOUND_STATUS: u16 = 404;
44
45/// The status the engine answers with when it refuses the request.
46const ENGINE_REFUSED_STATUS: u16 = 400;
47
48/// The status the engine answers with when the controller refuses the secret.
49const ENGINE_UNAUTHORIZED_STATUS: u16 = 401;
50
51/// The status the engine answers with when the controller forbids the request.
52const ENGINE_FORBIDDEN_STATUS: u16 = 403;
53
54/// The status the engine answers with when its own handler failed.
55const ENGINE_INTERNAL_ERROR_STATUS: u16 = 500;
56
57/// The typed answer of the sing-box delay endpoint.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub enum DelayAnswer {
60    /// The engine measured a round-trip delay through the outbound.
61    Measured {
62        /// The measured delay in milliseconds.
63        delay_ms: u32,
64    },
65    /// The engine's own delay test exceeded the deadline it was given.
66    TimedOut,
67    /// The engine refused the request.
68    Refused {
69        /// The status the engine answered with.
70        status: u16,
71        /// The engine's own reason, when it sent one.
72        reason: Option<String>,
73    },
74    /// The engine's answer is not one of the answers its delay endpoint
75    /// produces, so the driver claims no measurement, timeout or refusal.
76    NotProvided,
77    /// The engine answered with a measurement the driver could not read.
78    Malformed,
79}
80
81/// Classifies the engine's answer to one delay request.
82///
83/// A `200` answer carries the measurement; every other answer of the engine
84/// carries its own typed error body. An answer the engine's delay endpoint
85/// does not produce is never read as a measurement, a timeout or a refusal.
86pub fn classify(status: u16, body: &[u8]) -> DelayAnswer {
87    match status {
88        200 => match serde_json::from_slice::<DelayResponse>(body) {
89            Ok(response) => DelayAnswer::Measured {
90                delay_ms: response.delay,
91            },
92            Err(_) => DelayAnswer::Malformed,
93        },
94        ENGINE_TIMEOUT_STATUS => DelayAnswer::TimedOut,
95        ENGINE_REFUSED_STATUS
96        | ENGINE_UNAUTHORIZED_STATUS
97        | ENGINE_FORBIDDEN_STATUS
98        | ENGINE_UNKNOWN_OUTBOUND_STATUS
99        | ENGINE_INTERNAL_ERROR_STATUS
100        | ENGINE_TEST_FAILED_STATUS => {
101            let reason = serde_json::from_slice::<EngineError>(body)
102                .ok()
103                .map(|error| error.message);
104            if status == ENGINE_UNKNOWN_OUTBOUND_STATUS && reason.is_none() {
105                // The engine's delay route did not answer: the body is not one
106                // of the engine's own typed error bodies.
107                return DelayAnswer::NotProvided;
108            }
109            DelayAnswer::Refused { status, reason }
110        }
111        _ => DelayAnswer::NotProvided,
112    }
113}
114
115/// The neutral failure code one refused delay answer maps to.
116///
117/// The mapping follows the engine's own answers: a malformed request is the
118/// caller's input, a refused secret is an authentication failure, an unknown
119/// outbound is a stale observation and every other refusal is a network
120/// failure of the engine's delay test.
121pub fn refusal_code(status: u16) -> ErrorCode {
122    match status {
123        ENGINE_REFUSED_STATUS => ErrorCode::InvalidInput,
124        ENGINE_UNAUTHORIZED_STATUS | ENGINE_FORBIDDEN_STATUS => ErrorCode::Authentication,
125        ENGINE_UNKNOWN_OUTBOUND_STATUS => ErrorCode::InvalidState,
126        _ => ErrorCode::Network,
127    }
128}
129
130/// The measurement the engine published.
131#[derive(Deserialize)]
132struct DelayResponse {
133    delay: u32,
134}
135
136/// The typed error body every refusal of the engine carries.
137#[derive(Deserialize)]
138struct EngineError {
139    message: String,
140}
141
142#[cfg(test)]
143#[path = "delay_tests.rs"]
144mod tests;