1use crate::network_access::NetworkAccessList;
9use crate::system_allowlist::SystemAllowlist;
10use async_trait::async_trait;
11use futures::{Stream, StreamExt};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::Duration;
17use thiserror::Error;
18
19const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
20const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
21
22pub type EgressResult<T> = std::result::Result<T, EgressError>;
23pub type EgressByteStream = Pin<Box<dyn Stream<Item = EgressResult<Vec<u8>>> + Send>>;
24
25#[derive(Debug, Error)]
26pub enum EgressError {
27 #[error("Invalid egress request: {0}")]
28 InvalidRequest(String),
29
30 #[error("Outbound request blocked by network access policy: {url}")]
31 NetworkAccessDenied { url: String },
32
33 #[error("Outbound request signing is not configured")]
34 SigningUnavailable,
35
36 #[error("Outbound transport error: {0}")]
37 Transport(String),
38}
39
40impl EgressError {
41 fn invalid(message: impl Into<String>) -> Self {
42 Self::InvalidRequest(message.into())
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum EgressRequestKind {
50 Provider,
51 Capability,
52 Integration,
53 SystemEmail,
54 UtilityLlm,
55 Mcp,
56 Other(String),
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum EgressSigning {
63 Disabled,
64 PlatformDefault,
65 Required,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct EgressRequest {
71 pub method: String,
72 pub url: String,
73 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
74 pub headers: BTreeMap<String, String>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub body: Vec<u8>,
77 pub kind: EgressRequestKind,
78 #[serde(default = "default_signing")]
79 pub signing: EgressSigning,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub network_access: Option<NetworkAccessList>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub timeout_ms: Option<u64>,
84 #[serde(default)]
87 pub dns_pinning_required: bool,
88 #[serde(skip)]
95 pub pinned_addrs: Option<(String, Vec<std::net::SocketAddr>)>,
96}
97
98impl EgressRequest {
99 pub fn new(method: impl Into<String>, url: impl Into<String>, kind: EgressRequestKind) -> Self {
100 Self {
101 method: method.into(),
102 url: url.into(),
103 headers: BTreeMap::new(),
104 body: Vec::new(),
105 kind,
106 signing: EgressSigning::Disabled,
107 network_access: None,
108 timeout_ms: None,
109 dns_pinning_required: false,
110 pinned_addrs: None,
111 }
112 }
113
114 pub fn require_dns_pinning(mut self) -> Self {
117 self.dns_pinning_required = true;
118 self
119 }
120
121 pub fn pinned_addrs(
131 mut self,
132 host: impl Into<String>,
133 addrs: Vec<std::net::SocketAddr>,
134 ) -> Self {
135 if !addrs.is_empty() {
136 self.pinned_addrs = Some((host.into(), addrs));
137 }
138 self
139 }
140
141 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
142 self.headers.insert(name.into(), value.into());
143 self
144 }
145
146 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
147 self.body = body.into();
148 self
149 }
150
151 pub fn signing(mut self, signing: EgressSigning) -> Self {
152 self.signing = signing;
153 self
154 }
155
156 pub fn network_access(mut self, network_access: Option<NetworkAccessList>) -> Self {
157 self.network_access = network_access;
158 self
159 }
160
161 pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
162 self.timeout_ms = Some(timeout_ms);
163 self
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct EgressResponse {
170 pub status: u16,
171 pub headers: BTreeMap<String, String>,
172 pub body: Vec<u8>,
173}
174
175pub struct EgressStreamResponse {
176 pub status: u16,
177 pub headers: BTreeMap<String, String>,
178 pub body: EgressByteStream,
179}
180
181#[async_trait]
182pub trait EgressService: Send + Sync {
183 async fn send(&self, request: EgressRequest) -> EgressResult<EgressResponse>;
184
185 async fn send_stream(&self, request: EgressRequest) -> EgressResult<EgressStreamResponse>;
186
187 fn name(&self) -> &'static str {
188 "EgressService"
189 }
190}
191
192#[derive(Debug, Clone, Default)]
193pub struct DisabledEgressService;
194
195#[async_trait]
196impl EgressService for DisabledEgressService {
197 async fn send(&self, _request: EgressRequest) -> EgressResult<EgressResponse> {
198 Err(EgressError::Transport(
199 "outbound egress service is disabled".to_string(),
200 ))
201 }
202
203 async fn send_stream(&self, _request: EgressRequest) -> EgressResult<EgressStreamResponse> {
204 Err(EgressError::Transport(
205 "outbound egress service is disabled".to_string(),
206 ))
207 }
208
209 fn name(&self) -> &'static str {
210 "DisabledEgressService"
211 }
212}
213
214#[derive(Clone)]
215pub struct DirectEgressService {
216 client: reqwest::Client,
217 system_allowlist: Option<Arc<SystemAllowlist>>,
221}
222
223impl std::fmt::Debug for DirectEgressService {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.debug_struct("DirectEgressService")
226 .finish_non_exhaustive()
227 }
228}
229
230impl Default for DirectEgressService {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236impl DirectEgressService {
237 pub fn new() -> Self {
238 Self {
239 client: reqwest::Client::builder()
240 .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
241 .timeout(DEFAULT_REQUEST_TIMEOUT)
242 .redirect(reqwest::redirect::Policy::none())
243 .build()
244 .expect("build direct egress HTTP client"),
245 system_allowlist: None,
246 }
247 }
248
249 pub fn with_client(client: reqwest::Client) -> Self {
250 Self {
251 client,
252 system_allowlist: None,
253 }
254 }
255
256 pub fn for_runtime_traffic_from_env() -> Self {
261 Self::from_env()
262 }
263
264 pub fn from_env() -> Self {
272 Self::new().with_system_allowlist(SystemAllowlist::from_env())
273 }
274
275 pub fn with_system_allowlist(mut self, system_allowlist: Option<Arc<SystemAllowlist>>) -> Self {
277 self.system_allowlist = system_allowlist;
278 self
279 }
280
281 fn validate_request(&self, request: &EgressRequest) -> EgressResult<()> {
282 if request.method.trim().is_empty() {
283 return Err(EgressError::invalid("method is required"));
284 }
285 let parsed = reqwest::Url::parse(&request.url)
286 .map_err(|error| EgressError::invalid(format!("invalid URL: {error}")))?;
287 match parsed.scheme() {
288 "http" | "https" => {}
289 scheme => {
290 return Err(EgressError::invalid(format!(
291 "URL must use http or https, got '{scheme}'"
292 )));
293 }
294 }
295 if let Some(acl) = &request.network_access
296 && !acl.is_url_allowed(&request.url)
297 {
298 return Err(EgressError::NetworkAccessDenied {
299 url: request.url.clone(),
300 });
301 }
302 if let Some(allowlist) = &self.system_allowlist
306 && !allowlist.is_url_allowed(&request.url)
307 {
308 return Err(EgressError::NetworkAccessDenied {
309 url: request.url.clone(),
310 });
311 }
312 Ok(())
313 }
314
315 async fn prepare_request(&self, mut request: EgressRequest) -> EgressResult<EgressRequest> {
316 self.validate_request(&request)?;
317 if request.signing == EgressSigning::Required {
318 return Err(EgressError::SigningUnavailable);
319 }
320 if request.dns_pinning_required {
321 let (validated_url, resolved_addrs) =
322 crate::url_validation::validate_url_dns_pinned(&request.url)
323 .await
324 .map_err(|error| EgressError::NetworkAccessDenied {
325 url: format!("{} ({error})", request.url),
326 })?;
327 let pin_host = validated_url.host_str().unwrap_or("").to_string();
328 request = request.pinned_addrs(pin_host, resolved_addrs);
329 }
330 Ok(request)
331 }
332
333 fn build_request(&self, request: EgressRequest) -> EgressResult<reqwest::RequestBuilder> {
334 let EgressRequest {
335 method,
336 url,
337 headers,
338 body,
339 timeout_ms,
340 pinned_addrs,
341 ..
342 } = request;
343
344 let method = reqwest::Method::from_bytes(method.as_bytes())
345 .map_err(|error| EgressError::invalid(format!("invalid HTTP method: {error}")))?;
346
347 let mut builder = if let Some((ref host, ref addrs)) = pinned_addrs {
351 reqwest::Client::builder()
352 .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
353 .redirect(reqwest::redirect::Policy::none())
354 .resolve_to_addrs(host, addrs)
355 .build()
356 .map_err(|e| EgressError::invalid(format!("pinned client build failed: {e}")))?
357 .request(method, &url)
358 } else {
359 self.client.request(method, &url)
360 };
361
362 for (name, value) in headers {
363 builder = builder.header(name, value);
364 }
365 if let Some(timeout_ms) = timeout_ms {
366 builder = builder.timeout(Duration::from_millis(timeout_ms));
367 }
368 if !body.is_empty() {
369 builder = builder.body(body);
370 }
371 Ok(builder)
372 }
373}
374
375#[async_trait]
376impl EgressService for DirectEgressService {
377 async fn send(&self, request: EgressRequest) -> EgressResult<EgressResponse> {
378 let request = self.prepare_request(request).await?;
379 let response = self
380 .build_request(request)?
381 .send()
382 .await
383 .map_err(|error| EgressError::Transport(error.to_string()))?;
384 let status = response.status().as_u16();
385 let headers = response
386 .headers()
387 .iter()
388 .filter_map(|(name, value)| {
389 value
390 .to_str()
391 .ok()
392 .map(|value| (name.as_str().to_string(), value.to_string()))
393 })
394 .collect();
395 let body = response
396 .bytes()
397 .await
398 .map_err(|error| EgressError::Transport(error.to_string()))?
399 .to_vec();
400
401 Ok(EgressResponse {
402 status,
403 headers,
404 body,
405 })
406 }
407
408 async fn send_stream(&self, request: EgressRequest) -> EgressResult<EgressStreamResponse> {
409 let request = self.prepare_request(request).await?;
410 let response = self
411 .build_request(request)?
412 .send()
413 .await
414 .map_err(|error| EgressError::Transport(error.to_string()))?;
415 let status = response.status().as_u16();
416 let headers = response
417 .headers()
418 .iter()
419 .filter_map(|(name, value)| {
420 value
421 .to_str()
422 .ok()
423 .map(|value| (name.as_str().to_string(), value.to_string()))
424 })
425 .collect();
426 let body = response.bytes_stream().map(|chunk| {
427 chunk
428 .map(|bytes| bytes.to_vec())
429 .map_err(|error| EgressError::Transport(error.to_string()))
430 });
431
432 Ok(EgressStreamResponse {
433 status,
434 headers,
435 body: Box::pin(body),
436 })
437 }
438
439 fn name(&self) -> &'static str {
440 "DirectEgressService"
441 }
442}
443
444fn default_signing() -> EgressSigning {
445 EgressSigning::Disabled
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use futures::StreamExt;
452 use serde_json::json;
453 use wiremock::matchers::{body_json, header, method, path};
454 use wiremock::{Mock, MockServer, ResponseTemplate};
455
456 #[tokio::test]
457 async fn direct_service_sends_json_request() {
458 let server = MockServer::start().await;
459 Mock::given(method("POST"))
460 .and(path("/v1/test"))
461 .and(header("Authorization", "Bearer test"))
462 .and(body_json(json!({"ok": true})))
463 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
464 "id": "response_123"
465 })))
466 .expect(1)
467 .mount(&server)
468 .await;
469
470 let response = DirectEgressService::new()
471 .send(
472 EgressRequest::new(
473 "POST",
474 format!("{}/v1/test", server.uri()),
475 EgressRequestKind::Capability,
476 )
477 .header("Authorization", "Bearer test")
478 .header("Content-Type", "application/json")
479 .body(serde_json::to_vec(&json!({"ok": true})).unwrap()),
480 )
481 .await
482 .unwrap();
483
484 assert_eq!(response.status, 201);
485 assert_eq!(
486 serde_json::from_slice::<serde_json::Value>(&response.body).unwrap()["id"],
487 "response_123"
488 );
489 }
490
491 #[tokio::test]
492 async fn direct_service_enforces_network_access() {
493 let error = DirectEgressService::new()
494 .send(
495 EgressRequest::new(
496 "GET",
497 "https://blocked.example.com/path",
498 EgressRequestKind::Capability,
499 )
500 .network_access(Some(NetworkAccessList::allow_only(["allowed.example.com"]))),
501 )
502 .await
503 .unwrap_err();
504
505 assert!(matches!(error, EgressError::NetworkAccessDenied { .. }));
506 }
507
508 #[tokio::test]
509 async fn system_allowlist_blocks_unlisted_hosts() {
510 use crate::system_allowlist::SystemAllowlist;
511 let server = MockServer::start().await;
512 Mock::given(method("GET"))
513 .and(path("/blocked"))
514 .respond_with(ResponseTemplate::new(200))
515 .expect(0)
516 .mount(&server)
517 .await;
518 let allowlist = SystemAllowlist::from_toml(
519 r#"
520 [groups.test]
521 allowed = ["allowed.example.com"]
522 "#,
523 )
524 .unwrap();
525
526 let service = DirectEgressService::new().with_system_allowlist(Some(Arc::new(allowlist)));
527 let error = service
528 .send(EgressRequest::new(
529 "GET",
530 format!("{}/blocked", server.uri()),
531 EgressRequestKind::Capability,
532 ))
533 .await
534 .unwrap_err();
535
536 assert!(matches!(error, EgressError::NetworkAccessDenied { .. }));
537 }
538
539 #[tokio::test]
540 async fn system_allowlist_cannot_be_overridden_by_request_acl() {
541 use crate::system_allowlist::SystemAllowlist;
542 let allowlist = SystemAllowlist::from_toml(
546 r#"
547 [groups.test]
548 allowed = ["allowed.example.com"]
549 "#,
550 )
551 .unwrap();
552
553 let service = DirectEgressService::new().with_system_allowlist(Some(Arc::new(allowlist)));
554 let error = service
555 .send(
556 EgressRequest::new(
557 "GET",
558 "https://blocked.example.com/path",
559 EgressRequestKind::Capability,
560 )
561 .network_access(Some(NetworkAccessList::allow_only(["blocked.example.com"]))),
563 )
564 .await
565 .unwrap_err();
566
567 assert!(matches!(error, EgressError::NetworkAccessDenied { .. }));
568 }
569
570 #[tokio::test]
571 async fn system_allowlist_permits_listed_hosts() {
572 use crate::system_allowlist::SystemAllowlist;
573 let server = MockServer::start().await;
574 let host = reqwest::Url::parse(&server.uri())
575 .unwrap()
576 .host_str()
577 .unwrap()
578 .to_string();
579 Mock::given(method("GET"))
580 .and(path("/ok"))
581 .respond_with(ResponseTemplate::new(200))
582 .expect(1)
583 .mount(&server)
584 .await;
585
586 let allowlist =
587 SystemAllowlist::from_toml(&format!("[groups.test]\nallowed = [\"{host}\"]\n"))
588 .unwrap();
589 let service = DirectEgressService::new().with_system_allowlist(Some(Arc::new(allowlist)));
590 let response = service
591 .send(EgressRequest::new(
592 "GET",
593 format!("{}/ok", server.uri()),
594 EgressRequestKind::Capability,
595 ))
596 .await
597 .unwrap();
598
599 assert_eq!(response.status, 200);
600 }
601
602 #[tokio::test]
603 async fn dns_pinning_blocks_loopback_after_request_policy_passes() {
604 let error = DirectEgressService::new()
605 .send(
606 EgressRequest::new(
607 "GET",
608 "http://127.0.0.1/latest/meta-data",
609 EgressRequestKind::Capability,
610 )
611 .network_access(Some(NetworkAccessList::allow_only(["127.0.0.1"])))
612 .require_dns_pinning(),
613 )
614 .await
615 .unwrap_err();
616
617 assert!(matches!(error, EgressError::NetworkAccessDenied { .. }));
618 assert!(error.to_string().contains("private/internal address"));
619 }
620
621 #[tokio::test]
622 async fn dns_pinning_does_not_run_before_system_allowlist_denial() {
623 use crate::system_allowlist::SystemAllowlist;
624 let allowlist = SystemAllowlist::from_toml(
625 r#"
626 [groups.test]
627 allowed = ["allowed.example.com"]
628 "#,
629 )
630 .unwrap();
631
632 let blocked_url = "https://blocked.invalid/path";
633 let error = DirectEgressService::new()
634 .with_system_allowlist(Some(Arc::new(allowlist)))
635 .send(
636 EgressRequest::new("GET", blocked_url, EgressRequestKind::Capability)
637 .network_access(Some(NetworkAccessList::allow_only(["blocked.invalid"])))
638 .require_dns_pinning(),
639 )
640 .await
641 .unwrap_err();
642
643 assert!(
644 matches!(error, EgressError::NetworkAccessDenied { ref url } if url == blocked_url)
645 );
646 }
647
648 #[tokio::test]
649 async fn required_signing_fails_when_no_signer_is_configured() {
650 let error = DirectEgressService::new()
651 .send(
652 EgressRequest::new("GET", "https://example.com", EgressRequestKind::Capability)
653 .signing(EgressSigning::Required),
654 )
655 .await
656 .unwrap_err();
657
658 assert!(matches!(error, EgressError::SigningUnavailable));
659 }
660
661 #[tokio::test]
662 async fn direct_service_does_not_follow_redirects() {
663 let redirect_server = MockServer::start().await;
664 Mock::given(method("GET"))
665 .and(path("/secret"))
666 .respond_with(ResponseTemplate::new(200).set_body_string("secret"))
667 .expect(0)
668 .mount(&redirect_server)
669 .await;
670
671 let server = MockServer::start().await;
672 Mock::given(method("GET"))
673 .and(path("/start"))
674 .respond_with(
675 ResponseTemplate::new(302)
676 .insert_header("Location", format!("{}/secret", redirect_server.uri())),
677 )
678 .expect(1)
679 .mount(&server)
680 .await;
681
682 let response = DirectEgressService::new()
683 .send(EgressRequest::new(
684 "GET",
685 format!("{}/start", server.uri()),
686 EgressRequestKind::Capability,
687 ))
688 .await
689 .unwrap();
690
691 assert_eq!(response.status, 302);
692 }
693
694 #[tokio::test]
695 async fn direct_service_streams_response_body() {
696 let server = MockServer::start().await;
697 Mock::given(method("GET"))
698 .and(path("/stream"))
699 .respond_with(ResponseTemplate::new(200).set_body_string("data: one\n\ndata: two\n\n"))
700 .expect(1)
701 .mount(&server)
702 .await;
703
704 let mut response = DirectEgressService::new()
705 .send_stream(EgressRequest::new(
706 "GET",
707 format!("{}/stream", server.uri()),
708 EgressRequestKind::Capability,
709 ))
710 .await
711 .unwrap();
712
713 assert_eq!(response.status, 200);
714 let mut body = Vec::new();
715 while let Some(chunk) = response.body.next().await {
716 body.extend(chunk.unwrap());
717 }
718 assert_eq!(
719 String::from_utf8(body).unwrap(),
720 "data: one\n\ndata: two\n\n"
721 );
722 }
723}