1use crate::api::clash_api::{SingBoxClashApiClient, SingBoxCoreVersionProvider};
4use crate::api::delay::{DelayAnswer, MAX_SINGBOX_DELAY_TIMEOUT_MS, refusal_code};
5use crate::api::scope_fence::{engine_id, rejected, verify_live_scope, verify_owner_scope};
6use lurker_core_contract::error::{ErrorCode, Failure};
7use lurker_core_contract::identity::CoreScope;
8use lurker_core_contract::outbound::details::{
9 OutboundDetailsSnapshot, OutboundDetailsState, OutboundSnapshot,
10};
11use lurker_core_contract::outbound::identity::{OutboundId, SnapshotRevision};
12use lurker_core_contract::outbound::inventory::{
13 OutboundInventorySnapshot, OutboundInventoryState, OutboundObservationSnapshot,
14 OutboundProbeSnapshot, OutboundProbeState, OutboundSelectionSet, OutboundSelectionState,
15 SelectionAvailability,
16};
17use lurker_core_ports::core_scope::CoreScopeSource;
18use lurker_core_ports::error::PortError;
19use lurker_core_ports::outbound::{OutboundPort, OutboundProbeRequest, ProbeTarget};
20use serde::Deserialize;
21use std::collections::BTreeMap;
22use std::sync::Arc;
23use std::time::Duration;
24use tokio::sync::Mutex;
25use tokio::time::timeout;
26
27const DETAILS_REVISION: SnapshotRevision = SnapshotRevision::new(1);
33
34const DELAY_DEADLINE_GRACE: Duration = Duration::from_secs(2);
41
42const DELAY_TRANSPORT_SLACK: Duration = Duration::from_secs(1);
48
49pub struct SingBoxOutboundAdapter {
61 api: SingBoxClashApiClient,
62 scope_source: Arc<dyn CoreScopeSource>,
63 version_provider: SingBoxCoreVersionProvider,
64 observation: Mutex<OutboundObservationState>,
65}
66
67impl SingBoxOutboundAdapter {
68 pub fn new(api: SingBoxClashApiClient, scope_source: Arc<dyn CoreScopeSource>) -> Self {
70 Self {
71 version_provider: SingBoxCoreVersionProvider::new(api.clone()),
72 api,
73 scope_source,
74 observation: Mutex::new(OutboundObservationState::default()),
75 }
76 }
77
78 async fn read_inventory(&self) -> Result<OutboundInventoryState, PortError> {
79 let response: ClashProxiesResponse = self.api.get_json(&["proxies"]).await?;
80 map_inventory(response)
81 }
82
83 async fn preflight_revision(
84 &self,
85 scope: &CoreScope,
86 expected_revision: SnapshotRevision,
87 state: &mut OutboundObservationState,
88 ) -> Result<OutboundInventoryState, PortError> {
89 verify_live_scope(&self.version_provider, self.scope_source.as_ref(), scope).await?;
90 let cached = match &state.state {
91 OutboundObservationPhase::Ready {
92 scope: observed_scope,
93 revision,
94 inventory,
95 } if observed_scope == scope && *revision == expected_revision => inventory.clone(),
96 OutboundObservationPhase::Ready { .. } | OutboundObservationPhase::Unobserved => {
97 return Err(stale_revision());
98 }
99 };
100 let current = self.read_inventory().await?;
101 verify_live_scope(&self.version_provider, self.scope_source.as_ref(), scope).await?;
102 state.observe(scope, current)?;
103 let current = current_inventory(state, scope)?;
104 if current.inventory() != &cached || current.revision() != expected_revision {
105 return Err(stale_revision());
106 }
107 Ok(current.inventory().clone())
108 }
109
110 async fn measure_delay(
116 &self,
117 outbound_id: &OutboundId,
118 target: &str,
119 timeout_ms: u32,
120 ) -> Result<DelayAnswer, PortError> {
121 let deadline = Duration::from_millis(u64::from(timeout_ms)) + DELAY_DEADLINE_GRACE;
122 let transport_timeout = deadline + DELAY_TRANSPORT_SLACK;
123 match timeout(
124 deadline,
125 self.api
126 .get_delay(outbound_id.value(), target, timeout_ms, transport_timeout),
127 )
128 .await
129 {
130 Ok(result) => result,
131 Err(_elapsed) => Ok(DelayAnswer::TimedOut),
132 }
133 }
134}
135
136#[async_trait::async_trait]
137impl OutboundPort for SingBoxOutboundAdapter {
138 async fn inventory(&self, scope: CoreScope) -> Result<OutboundInventorySnapshot, PortError> {
139 verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
140 let mut state = self.observation.lock().await;
141 let inventory = self.read_inventory().await?;
142 verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
143 state.observe(&scope, inventory)?;
144 current_inventory(&state, &scope)
145 }
146
147 async fn observation(
148 &self,
149 scope: CoreScope,
150 ) -> Result<OutboundObservationSnapshot, PortError> {
151 let inventory = self.inventory(scope.clone()).await?;
152 let details = OutboundDetailsSnapshot::new(
153 scope,
154 DETAILS_REVISION,
155 OutboundDetailsState::Unsupported,
156 )
157 .map_err(|error| PortError::Failed(error.to_string()))?;
158 OutboundObservationSnapshot::new(inventory, details)
159 .map_err(|error| PortError::Failed(error.to_string()))
160 }
161
162 async fn details(
163 &self,
164 scope: CoreScope,
165 expected_revision: SnapshotRevision,
166 ) -> Result<OutboundDetailsSnapshot, PortError> {
167 let mut state = self.observation.lock().await;
168 self.preflight_revision(&scope, expected_revision, &mut state)
169 .await?;
170 OutboundDetailsSnapshot::new(scope, DETAILS_REVISION, OutboundDetailsState::Unsupported)
171 .map_err(|error| PortError::Failed(error.to_string()))
172 }
173
174 async fn select(
175 &self,
176 scope: CoreScope,
177 expected_revision: SnapshotRevision,
178 collection_id: OutboundId,
179 outbound_id: OutboundId,
180 ) -> Result<OutboundInventorySnapshot, PortError> {
181 verify_id_engine(&scope, &collection_id)?;
182 verify_id_engine(&scope, &outbound_id)?;
183 let mut state = self.observation.lock().await;
184 let inventory = self
185 .preflight_revision(&scope, expected_revision, &mut state)
186 .await?;
187 verify_selectable_member(&inventory, &collection_id, &outbound_id)?;
188 verify_owner_scope(self.scope_source.as_ref(), &scope)?;
189
190 let put_result = self
191 .api
192 .put_json(
193 &["proxies", collection_id.value()],
194 serde_json::json!({"name": outbound_id.value()}),
195 )
196 .await;
197 let observed = self.read_inventory().await;
198 verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
199 let observed = observed?;
200 state.observe(&scope, observed)?;
201 let snapshot = current_inventory(&state, &scope)?;
202 if selected_member(snapshot.inventory(), &collection_id) == Some(&outbound_id) {
203 return Ok(snapshot);
204 }
205 put_result?;
206 Err(rejected(
207 ErrorCode::InvalidState,
208 "sing-box selector readback did not confirm the requested outbound",
209 ))
210 }
211
212 async fn probe(
213 &self,
214 scope: CoreScope,
215 expected_revision: SnapshotRevision,
216 outbound_id: OutboundId,
217 request: OutboundProbeRequest,
218 ) -> Result<OutboundProbeSnapshot, PortError> {
219 verify_id_engine(&scope, &outbound_id)?;
220 let (target, timeout_ms) = singbox_probe_values(&request)?;
221 let mut state = self.observation.lock().await;
222 let inventory = self
223 .preflight_revision(&scope, expected_revision, &mut state)
224 .await?;
225 if !inventory_contains(&inventory, &outbound_id) {
226 return Err(rejected(
227 ErrorCode::InvalidState,
228 "probe outbound is absent from the observed sing-box inventory",
229 ));
230 }
231
232 let answer = self.measure_delay(&outbound_id, &target, timeout_ms).await;
235
236 let observed = self.read_inventory().await;
240 verify_live_scope(&self.version_provider, self.scope_source.as_ref(), &scope).await?;
241 let observed = observed?;
242 state.observe(&scope, observed)?;
243 let current = current_inventory(&state, &scope)?;
244
245 let probe_state = match answer {
246 Ok(answer) => map_delay_answer(answer),
247 Err(error) => OutboundProbeState::Failed {
248 failure: Failure::from(error),
249 },
250 };
251 OutboundProbeSnapshot::new(
252 scope,
253 current.revision(),
254 DETAILS_REVISION,
255 outbound_id,
256 probe_state,
257 )
258 .map_err(|error| PortError::Failed(error.to_string()))
259 }
260}
261
262fn map_delay_answer(answer: DelayAnswer) -> OutboundProbeState {
269 match answer {
270 DelayAnswer::Measured { delay_ms } => OutboundProbeState::Measured { delay_ms },
271 DelayAnswer::TimedOut => OutboundProbeState::TimedOut,
272 DelayAnswer::Refused { status, reason } => OutboundProbeState::Failed {
273 failure: refusal_failure(status, reason),
274 },
275 DelayAnswer::NotProvided => OutboundProbeState::Unsupported,
276 DelayAnswer::Malformed => OutboundProbeState::Failed {
277 failure: Failure::new(
278 ErrorCode::InvalidState,
279 "the sing-box delay endpoint answered with a body that is not a measurement",
280 false,
281 ),
282 },
283 }
284}
285
286fn refusal_failure(status: u16, reason: Option<String>) -> Failure {
288 let message = match reason {
289 Some(reason) => {
290 format!("the sing-box delay endpoint refused the request with HTTP {status}: {reason}")
291 }
292 None => format!("the sing-box delay endpoint refused the request with HTTP {status}"),
293 };
294 Failure::new(refusal_code(status), message, false)
295}
296
297fn singbox_probe_values(request: &OutboundProbeRequest) -> Result<(String, u32), PortError> {
302 let target = ProbeTarget::http_url(request.target().as_url().to_owned())
303 .map_err(|error| rejected(ErrorCode::InvalidInput, error.to_string()))?;
304 let timeout_ms = u32::try_from(request.timeout_ms()).map_err(|_| {
305 rejected(
306 ErrorCode::InvalidInput,
307 "the sing-box delay endpoint carries the timeout as a millisecond count",
308 )
309 })?;
310 if timeout_ms == 0 {
311 return Err(rejected(
312 ErrorCode::InvalidInput,
313 "the sing-box delay timeout must be greater than zero",
314 ));
315 }
316 if timeout_ms > MAX_SINGBOX_DELAY_TIMEOUT_MS {
317 return Err(rejected(
318 ErrorCode::InvalidInput,
319 "the sing-box delay timeout exceeds the endpoint's signed 16-bit millisecond limit",
320 ));
321 }
322 Ok((target.as_url().to_owned(), timeout_ms))
323}
324
325#[derive(Default)]
326struct OutboundObservationState {
327 state: OutboundObservationPhase,
328}
329
330#[derive(Default)]
331enum OutboundObservationPhase {
332 #[default]
333 Unobserved,
334 Ready {
335 scope: CoreScope,
336 revision: SnapshotRevision,
337 inventory: OutboundInventoryState,
338 },
339}
340
341impl OutboundObservationState {
342 fn observe(
343 &mut self,
344 scope: &CoreScope,
345 inventory: OutboundInventoryState,
346 ) -> Result<(), PortError> {
347 self.state = match &self.state {
348 OutboundObservationPhase::Unobserved => OutboundObservationPhase::Ready {
349 scope: scope.clone(),
350 revision: SnapshotRevision::new(1),
351 inventory,
352 },
353 OutboundObservationPhase::Ready {
354 scope: previous_scope,
355 ..
356 } if previous_scope != scope => OutboundObservationPhase::Ready {
357 scope: scope.clone(),
358 revision: SnapshotRevision::new(1),
359 inventory,
360 },
361 OutboundObservationPhase::Ready {
362 scope: previous_scope,
363 revision,
364 inventory: previous_inventory,
365 } if previous_inventory != &inventory => OutboundObservationPhase::Ready {
366 scope: previous_scope.clone(),
367 revision: SnapshotRevision::new(revision.value().checked_add(1).ok_or_else(
368 || PortError::Failed("sing-box inventory revision exhausted".to_owned()),
369 )?),
370 inventory,
371 },
372 OutboundObservationPhase::Ready {
373 scope: previous_scope,
374 revision,
375 ..
376 } => OutboundObservationPhase::Ready {
377 scope: previous_scope.clone(),
378 revision: *revision,
379 inventory,
380 },
381 };
382 Ok(())
383 }
384}
385
386fn current_inventory(
387 state: &OutboundObservationState,
388 scope: &CoreScope,
389) -> Result<OutboundInventorySnapshot, PortError> {
390 match &state.state {
391 OutboundObservationPhase::Ready {
392 scope: observed_scope,
393 revision,
394 inventory,
395 } if observed_scope == scope => {
396 OutboundInventorySnapshot::new(scope.clone(), *revision, inventory.clone())
397 .map_err(|error| PortError::Failed(error.to_string()))
398 }
399 OutboundObservationPhase::Ready { .. } => Err(stale_revision()),
400 OutboundObservationPhase::Unobserved => Err(rejected(
401 ErrorCode::NotReady,
402 "sing-box outbound inventory has not been observed",
403 )),
404 }
405}
406
407#[derive(Deserialize)]
408struct ClashProxiesResponse {
409 proxies: BTreeMap<String, ClashProxy>,
410}
411
412#[derive(Deserialize)]
413struct ClashProxy {
414 #[serde(rename = "type")]
415 proxy_type: String,
416 name: String,
417 #[serde(default)]
418 now: Option<String>,
419 #[serde(default)]
420 all: Option<Vec<String>>,
421}
422
423fn map_inventory(response: ClashProxiesResponse) -> Result<OutboundInventoryState, PortError> {
424 let engine = engine_id();
425 let mut proxies = Vec::new();
426 for (key, proxy) in response.proxies {
427 if key == "GLOBAL" && proxy.name == "GLOBAL" && proxy.proxy_type == "Fallback" {
430 continue;
431 }
432 if key != proxy.name {
433 return Err(rejected(
434 ErrorCode::InvalidState,
435 "sing-box /proxies key did not match its reported proxy name",
436 ));
437 }
438 proxies.push((proxy.proxy_type, proxy.name, proxy.now, proxy.all));
439 }
440
441 if proxies.is_empty() {
442 return Ok(OutboundInventoryState::Empty);
443 }
444
445 let mut entries = Vec::with_capacity(proxies.len());
446 let mut ids = BTreeMap::new();
447 for (_, name, _, _) in &proxies {
448 let id = OutboundId::new(engine.clone(), name.clone())
449 .map_err(|error| rejected(ErrorCode::InvalidState, error.to_string()))?;
450 ids.insert(name.clone(), id.clone());
451 entries.push(OutboundSnapshot {
452 id,
453 display_name: Some(name.clone()),
454 });
455 }
456
457 let mut collections = Vec::new();
458 for (proxy_type, name, now, all) in proxies {
459 let is_known_group = matches!(proxy_type.as_str(), "Selector" | "URLTest");
460 if !is_known_group && all.is_none() && now.is_none() {
461 continue;
462 }
463 let members = match all {
464 Some(names) => names
465 .iter()
466 .map(|member| {
467 ids.get(member).cloned().ok_or_else(|| {
468 rejected(
469 ErrorCode::InvalidState,
470 format!("sing-box selector references missing outbound {member}"),
471 )
472 })
473 })
474 .collect::<Result<Vec<_>, _>>()?,
475 None => Vec::new(),
476 };
477 let collection_id = ids
478 .get(&name)
479 .cloned()
480 .ok_or_else(|| rejected(ErrorCode::InvalidState, "sing-box group ID was lost"))?;
481 let selected = match now {
482 Some(selected) => ids.get(&selected).cloned().map(|selected| {
483 if members.contains(&selected) {
484 OutboundSelectionState::Ready { selected }
485 } else {
486 OutboundSelectionState::Unknown
487 }
488 }),
489 None => None,
490 }
491 .unwrap_or(OutboundSelectionState::Unknown);
492 let availability = match proxy_type.as_str() {
493 "Selector"
494 if !members.is_empty()
495 && matches!(&selected, OutboundSelectionState::Ready { .. }) =>
496 {
497 SelectionAvailability::Selectable
498 }
499 "URLTest" => SelectionAvailability::Automatic,
500 "Selector" => SelectionAvailability::Unknown,
501 _ => SelectionAvailability::Unknown,
502 };
503 collections.push(OutboundSelectionSet {
504 id: collection_id,
505 member_ids: members,
506 availability,
507 selection: selected,
508 });
509 }
510
511 Ok(OutboundInventoryState::Ready {
512 entries,
513 collections,
514 })
515}
516
517fn verify_selectable_member(
518 inventory: &OutboundInventoryState,
519 collection_id: &OutboundId,
520 outbound_id: &OutboundId,
521) -> Result<(), PortError> {
522 let OutboundInventoryState::Ready { collections, .. } = inventory else {
523 return Err(rejected(
524 ErrorCode::NotReady,
525 "sing-box inventory is not ready",
526 ));
527 };
528 let collection = collections
529 .iter()
530 .find(|collection| &collection.id == collection_id)
531 .ok_or_else(|| {
532 rejected(
533 ErrorCode::InvalidState,
534 "sing-box selector was not observed",
535 )
536 })?;
537 if collection.availability != SelectionAvailability::Selectable {
538 return Err(PortError::Rejected(Failure::unsupported(
539 "sing-box only exposes selection writes for observed Selector groups",
540 )));
541 }
542 if !collection.member_ids.contains(outbound_id) {
543 return Err(rejected(
544 ErrorCode::InvalidState,
545 "requested outbound is not a member of the observed sing-box selector",
546 ));
547 }
548 Ok(())
549}
550
551fn selected_member<'a>(
552 inventory: &'a OutboundInventoryState,
553 collection_id: &OutboundId,
554) -> Option<&'a OutboundId> {
555 let OutboundInventoryState::Ready { collections, .. } = inventory else {
556 return None;
557 };
558 collections
559 .iter()
560 .find(|collection| &collection.id == collection_id)
561 .and_then(|collection| match &collection.selection {
562 OutboundSelectionState::Ready { selected } => Some(selected),
563 OutboundSelectionState::Unknown
564 | OutboundSelectionState::Empty
565 | OutboundSelectionState::Unsupported
566 | OutboundSelectionState::Failed { .. } => None,
567 })
568}
569
570fn inventory_contains(inventory: &OutboundInventoryState, outbound_id: &OutboundId) -> bool {
571 match inventory {
572 OutboundInventoryState::Ready { entries, .. } => {
573 entries.iter().any(|entry| &entry.id == outbound_id)
574 }
575 OutboundInventoryState::Unknown
576 | OutboundInventoryState::Empty
577 | OutboundInventoryState::Unsupported
578 | OutboundInventoryState::Failed { .. } => false,
579 }
580}
581
582fn verify_id_engine(scope: &CoreScope, id: &OutboundId) -> Result<(), PortError> {
583 if id.engine_id() == &scope.identity.engine && id.engine_id() == &engine_id() {
584 Ok(())
585 } else {
586 Err(rejected(
587 ErrorCode::InvalidState,
588 "sing-box outbound request used a foreign engine ID",
589 ))
590 }
591}
592
593fn stale_revision() -> PortError {
594 rejected(
595 ErrorCode::InvalidState,
596 "sing-box outbound inventory revision is stale",
597 )
598}
599
600#[cfg(test)]
601#[path = "outbound_tests.rs"]
602mod tests;