1use async_trait::async_trait;
31use ring::{digest, hmac};
32use std::collections::HashMap;
33use std::sync::Arc;
34use std::time::{Duration, SystemTime, UNIX_EPOCH};
35
36use crate::context::{Context, GatewayError};
37use crate::outbound::{OutboundClient, OutboundError, OutboundRequest, OutboundResponse};
38use crate::plugins::resources::PluginResources;
39use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
40use crate::vars::template::Template;
41
42#[derive(Clone)]
44struct IamAuth {
45 access_key: String,
46 secret_key: String,
47 region: String,
48 service: String,
49 session_token: Option<String>,
50}
51
52#[derive(Clone)]
54enum Authorization {
55 None,
57 ApiKey(String),
59 Iam(IamAuth),
61}
62
63pub struct AwsLambdaPlugin {
65 function_uri: Template,
68 authorization: Authorization,
69 ssl_verify: bool,
70 timeout: Duration,
71 client: Arc<OutboundClient>,
72}
73
74impl AwsLambdaPlugin {
75 pub fn from_config(
107 config: &HashMap<String, serde_json::Value>,
108 resources: &Arc<PluginResources>,
109 ) -> Result<Self, String> {
110 let function_uri = config
111 .get("function_uri")
112 .and_then(|v| v.as_str())
113 .filter(|s| !s.is_empty())
114 .ok_or_else(|| "aws-lambda plugin requires 'function_uri'".to_string())?;
115 let function_uri = Template::parse(function_uri).0;
118
119 let authorization = match config.get("authorization").and_then(|v| v.as_object()) {
120 None => Authorization::None,
121 Some(authz) => {
122 if let Some(iam) = authz.get("iam").and_then(|v| v.as_object()) {
123 let access_key = iam
124 .get("accesskey")
125 .and_then(|v| v.as_str())
126 .filter(|s| !s.is_empty())
127 .ok_or_else(|| {
128 "aws-lambda plugin: authorization.iam requires 'accesskey'".to_string()
129 })?
130 .to_string();
131 let secret_key = iam
132 .get("secretkey")
133 .and_then(|v| v.as_str())
134 .filter(|s| !s.is_empty())
135 .ok_or_else(|| {
136 "aws-lambda plugin: authorization.iam requires 'secretkey'".to_string()
137 })?
138 .to_string();
139 let region = iam
140 .get("aws_region")
141 .and_then(|v| v.as_str())
142 .filter(|s| !s.is_empty())
143 .unwrap_or("us-east-1")
144 .to_string();
145 let service = iam
146 .get("aws_service")
147 .and_then(|v| v.as_str())
148 .filter(|s| !s.is_empty())
149 .unwrap_or("lambda")
150 .to_string();
151 let session_token = iam
152 .get("session_token")
153 .and_then(|v| v.as_str())
154 .filter(|s| !s.is_empty())
155 .map(String::from);
156 Authorization::Iam(IamAuth {
157 access_key,
158 secret_key,
159 region,
160 service,
161 session_token,
162 })
163 } else if let Some(apikey) = authz.get("apikey").and_then(|v| v.as_str()) {
164 Authorization::ApiKey(apikey.to_string())
165 } else {
166 Authorization::None
167 }
168 }
169 };
170
171 let ssl_verify = config
172 .get("ssl_verify")
173 .and_then(|v| v.as_bool())
174 .unwrap_or(false);
175
176 let timeout = Duration::from_millis(
177 config
178 .get("timeout")
179 .and_then(|v| v.as_u64())
180 .unwrap_or(3000),
181 );
182
183 Ok(Self {
184 function_uri,
185 authorization,
186 ssl_verify,
187 timeout,
188 client: resources.outbound.clone(),
189 })
190 }
191
192 fn build_request(&self, ctx: &Context, amz_time: u64) -> Result<OutboundRequest, String> {
197 let function_uri = self.function_uri.render(ctx);
198 let parsed: http::Uri = function_uri
199 .parse()
200 .map_err(|e| format!("aws-lambda: invalid function_uri: {}", e))?;
201 let host = parsed
202 .authority()
203 .map(|a| a.as_str().to_string())
204 .ok_or_else(|| "aws-lambda: function_uri has no host".to_string())?;
205 let canonical_uri = normalize_path(parsed.path());
206 let canonical_query = canonical_query_string(&ctx.request.query_params);
207
208 let mut headers: Vec<(String, String)> = Vec::new();
211 for (name, values) in &ctx.request.headers {
212 let lname = name.to_ascii_lowercase();
213 if matches!(
214 lname.as_str(),
215 "host" | "connection" | "content-length" | "x-amz-date" | "authorization"
216 ) {
217 continue;
218 }
219 for value in values {
220 headers.push((lname.clone(), value.clone()));
221 }
222 }
223 headers.push(("host".to_string(), host.clone()));
224
225 match &self.authorization {
226 Authorization::None => {}
227 Authorization::ApiKey(key) => {
228 if !ctx
229 .request
230 .headers
231 .keys()
232 .any(|k| k.eq_ignore_ascii_case("x-api-key"))
233 {
234 headers.push(("x-api-key".to_string(), key.clone()));
235 }
236 }
237 Authorization::Iam(iam) => {
238 let amz_date = format_amz_date(amz_time);
239 let datestamp = amz_date[..8].to_string();
240 headers.push(("x-amz-date".to_string(), amz_date.clone()));
241 if let Some(token) = &iam.session_token {
242 headers.push(("x-amz-security-token".to_string(), token.clone()));
243 }
244 let mut signed: Vec<(String, String)> = vec![
246 ("host".to_string(), host.clone()),
247 ("x-amz-date".to_string(), amz_date.clone()),
248 ];
249 if let Some(token) = &iam.session_token {
250 signed.push(("x-amz-security-token".to_string(), token.clone()));
251 }
252 let sig = sign_v4(&SigV4Input {
253 method: &ctx.request.method,
254 canonical_uri: &canonical_uri,
255 canonical_query: &canonical_query,
256 headers: &signed,
257 payload: &ctx.request.body,
258 access_key: &iam.access_key,
259 secret_key: &iam.secret_key,
260 region: &iam.region,
261 service: &iam.service,
262 amz_date: &amz_date,
263 datestamp: &datestamp,
264 });
265 headers.push(("authorization".to_string(), sig.authorization));
266 }
267 }
268
269 let scheme = parsed.scheme_str().unwrap_or("https");
270 let url = if canonical_query.is_empty() {
271 format!("{}://{}{}", scheme, host, canonical_uri)
272 } else {
273 format!("{}://{}{}?{}", scheme, host, canonical_uri, canonical_query)
274 };
275
276 let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::POST);
277
278 Ok(OutboundRequest {
279 method,
280 url,
281 headers,
282 body: ctx.request.body.clone(),
283 timeout: self.timeout,
284 ssl_verify: self.ssl_verify,
285 tls: None,
286 })
287 }
288}
289
290fn apply_response(ctx: &mut Context, response: OutboundResponse) {
292 ctx.response.status_code = response.status;
293 ctx.response.headers = response.headers;
294 ctx.response.body = response.body;
295}
296
297#[async_trait]
298impl Plugin for AwsLambdaPlugin {
299 fn plugin_type(&self) -> &str {
300 "aws-lambda"
301 }
302
303 async fn execute(&self, mut ctx: Context) -> PluginResult {
304 let now = SystemTime::now()
305 .duration_since(UNIX_EPOCH)
306 .map(|d| d.as_secs())
307 .unwrap_or(0);
308 let request = match self.build_request(&ctx, now) {
309 Ok(req) => req,
310 Err(message) => {
311 return Err(reject(ctx, 502, message));
312 }
313 };
314
315 match self.client.request(request).await {
316 Ok(response) => {
317 apply_response(&mut ctx, response);
318 Ok(PluginOutput::success(ctx))
319 }
320 Err(e) => {
321 let (status, message) = match &e {
322 OutboundError::Timeout(d) => {
323 (504, format!("aws-lambda callout timed out after {:?}", d))
324 }
325 OutboundError::InvalidRequest(m) => {
326 (502, format!("aws-lambda request build error: {}", m))
327 }
328 OutboundError::Transport(m) => {
329 (503, format!("aws-lambda callout failed: {}", m))
330 }
331 };
332 Err(reject(ctx, status, message))
333 }
334 }
335 }
336}
337
338fn reject(mut ctx: Context, status: u16, message: String) -> PluginExecutionError {
340 ctx.response.status_code = status;
341 PluginExecutionError {
342 context: ctx,
343 error: GatewayError {
344 node_id: String::new(),
345 code: "AWS_LAMBDA_CALLOUT_ERROR".to_string(),
346 message,
347 metadata: HashMap::new(),
348 },
349 }
350}
351
352struct SigV4Input<'a> {
360 method: &'a str,
361 canonical_uri: &'a str,
362 canonical_query: &'a str,
363 headers: &'a [(String, String)],
364 payload: &'a [u8],
365 access_key: &'a str,
366 secret_key: &'a str,
367 region: &'a str,
368 service: &'a str,
369 amz_date: &'a str,
370 datestamp: &'a str,
371}
372
373struct SigV4Output {
375 authorization: String,
377 #[allow(dead_code)]
379 signed_headers: String,
380 #[allow(dead_code)]
382 signature: String,
383 #[allow(dead_code)]
385 canonical_request: String,
386 #[allow(dead_code)]
388 string_to_sign: String,
389}
390
391const ALGO: &str = "AWS4-HMAC-SHA256";
392
393fn sign_v4(input: &SigV4Input) -> SigV4Output {
400 let mut headers: Vec<(String, String)> = input
402 .headers
403 .iter()
404 .map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string()))
405 .collect();
406 headers.sort_by(|a, b| a.0.cmp(&b.0));
407
408 let canonical_headers: String = headers
409 .iter()
410 .map(|(k, v)| format!("{}:{}\n", k, v))
411 .collect();
412 let signed_headers = headers
413 .iter()
414 .map(|(k, _)| k.as_str())
415 .collect::<Vec<_>>()
416 .join(";");
417
418 let payload_hash = hex(sha256(input.payload).as_ref());
419
420 let canonical_request = format!(
421 "{}\n{}\n{}\n{}\n{}\n{}",
422 input.method.to_uppercase(),
423 input.canonical_uri,
424 input.canonical_query,
425 canonical_headers,
426 signed_headers,
427 payload_hash
428 );
429
430 let credential_scope = format!(
431 "{}/{}/{}/aws4_request",
432 input.datestamp, input.region, input.service
433 );
434 let string_to_sign = format!(
435 "{}\n{}\n{}\n{}",
436 ALGO,
437 input.amz_date,
438 credential_scope,
439 hex(sha256(canonical_request.as_bytes()).as_ref())
440 );
441
442 let signing_key = derive_signing_key(
443 input.secret_key,
444 input.datestamp,
445 input.region,
446 input.service,
447 );
448 let signature = hex(hmac_sha256(&signing_key, string_to_sign.as_bytes()).as_ref());
449
450 let authorization = format!(
451 "{} Credential={}/{}, SignedHeaders={}, Signature={}",
452 ALGO, input.access_key, credential_scope, signed_headers, signature
453 );
454
455 SigV4Output {
456 authorization,
457 signed_headers,
458 signature,
459 canonical_request,
460 string_to_sign,
461 }
462}
463
464fn derive_signing_key(secret: &str, datestamp: &str, region: &str, service: &str) -> Vec<u8> {
466 let k_secret = format!("AWS4{}", secret);
467 let k_date = hmac_sha256(k_secret.as_bytes(), datestamp.as_bytes());
468 let k_region = hmac_sha256(k_date.as_ref(), region.as_bytes());
469 let k_service = hmac_sha256(k_region.as_ref(), service.as_bytes());
470 let k_signing = hmac_sha256(k_service.as_ref(), b"aws4_request");
471 k_signing.as_ref().to_vec()
472}
473
474fn hmac_sha256(key: &[u8], msg: &[u8]) -> hmac::Tag {
476 let key = hmac::Key::new(hmac::HMAC_SHA256, key);
477 hmac::sign(&key, msg)
478}
479
480fn sha256(data: &[u8]) -> digest::Digest {
482 digest::digest(&digest::SHA256, data)
483}
484
485fn hex(bytes: &[u8]) -> String {
487 let mut s = String::with_capacity(bytes.len() * 2);
488 for b in bytes {
489 s.push_str(&format!("{:02x}", b));
490 }
491 s
492}
493
494fn uri_encode(s: &str) -> String {
497 let mut out = String::with_capacity(s.len());
498 for b in s.bytes() {
499 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
500 out.push(b as char);
501 } else {
502 out.push_str(&format!("%{:02X}", b));
503 }
504 }
505 out
506}
507
508fn normalize_path(path: &str) -> String {
511 if path.is_empty() {
512 return "/".to_string();
513 }
514 if path != "/" && path.ends_with('/') {
515 return path.trim_end_matches('/').to_string();
516 }
517 path.to_string()
518}
519
520fn canonical_query_string(query: &HashMap<String, Vec<String>>) -> String {
523 let mut pairs: Vec<(String, String)> = Vec::new();
524 for (name, values) in query {
525 let name = uri_encode(name);
526 for value in values {
527 pairs.push((name.clone(), uri_encode(value)));
528 }
529 }
530 pairs.sort();
531 pairs
532 .iter()
533 .map(|(k, v)| format!("{}={}", k, v))
534 .collect::<Vec<_>>()
535 .join("&")
536}
537
538fn format_amz_date(secs: u64) -> String {
540 let days = (secs / 86400) as i64;
541 let rem = secs % 86400;
542 let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
543 let (year, month, day) = civil_from_days(days);
544 format!(
545 "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
546 year, month, day, hour, minute, second
547 )
548}
549
550fn civil_from_days(days: i64) -> (i64, u32, u32) {
553 let z = days + 719468;
554 let era = if z >= 0 { z } else { z - 146096 } / 146097;
555 let doe = z - era * 146097;
556 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
557 let y = yoe + era * 400;
558 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
559 let mp = (5 * doy + 2) / 153;
560 let d = doy - (153 * mp + 2) / 5 + 1;
561 let m = if mp < 10 { mp + 3 } else { mp - 9 };
562 let year = if m <= 2 { y + 1 } else { y };
563 (year, m as u32, d as u32)
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
570 use bytes::Bytes;
571
572 fn ctx_with(method: &str, path: &str) -> Context {
573 Context {
574 request: GatewayRequest {
575 method: method.to_string(),
576 path: path.to_string(),
577 host: "gw".to_string(),
578 scheme: "http".to_string(),
579 headers: HashMap::new(),
580 query_params: HashMap::new(),
581 body: Bytes::new(),
582 remote_addr: "1.2.3.4:5".to_string(),
583 protocol: Protocol::Http1,
584 },
585 response: GatewayResponse {
586 status_code: 0,
587 headers: HashMap::new(),
588 body: Bytes::new(),
589 stream: None,
590 },
591 message: HashMap::new(),
592 errors: Vec::new(),
593 }
594 }
595
596 fn plugin(config: serde_json::Value) -> AwsLambdaPlugin {
597 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
598 AwsLambdaPlugin::from_config(&map, &PluginResources::empty()).unwrap()
599 }
600
601 #[test]
602 fn test_requires_function_uri() {
603 assert!(AwsLambdaPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
604 }
605
606 #[test]
607 fn test_iam_requires_keys() {
608 let map: HashMap<String, serde_json::Value> = serde_json::from_value(serde_json::json!({
609 "function_uri": "https://x.on.aws/",
610 "authorization": { "iam": { "accesskey": "AK" } }
611 }))
612 .unwrap();
613 assert!(AwsLambdaPlugin::from_config(&map, &PluginResources::empty()).is_err());
614 }
615
616 #[test]
617 fn test_iam_defaults() {
618 let p = plugin(serde_json::json!({
619 "function_uri": "https://x.on.aws/",
620 "authorization": { "iam": { "accesskey": "AK", "secretkey": "SK" } }
621 }));
622 match p.authorization {
623 Authorization::Iam(ref iam) => {
624 assert_eq!(iam.region, "us-east-1");
625 assert_eq!(iam.service, "lambda");
626 }
627 _ => panic!("expected IAM auth"),
628 }
629 assert!(!p.ssl_verify);
631 }
632
633 #[test]
636 fn test_sigv4_get_vanilla_vector() {
637 let headers = vec![
638 ("host".to_string(), "example.amazonaws.com".to_string()),
639 ("x-amz-date".to_string(), "20150830T123600Z".to_string()),
640 ];
641 let out = sign_v4(&SigV4Input {
642 method: "GET",
643 canonical_uri: "/",
644 canonical_query: "",
645 headers: &headers,
646 payload: b"",
647 access_key: "AKIDEXAMPLE",
648 secret_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
649 region: "us-east-1",
650 service: "service",
651 amz_date: "20150830T123600Z",
652 datestamp: "20150830",
653 });
654 assert_eq!(
655 out.signature,
656 "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
657 );
658 assert_eq!(out.signed_headers, "host;x-amz-date");
659 assert_eq!(
660 out.authorization,
661 "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
662 SignedHeaders=host;x-amz-date, \
663 Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
664 );
665 assert!(out.string_to_sign.starts_with(
668 "AWS4-HMAC-SHA256\n20150830T123600Z\n20150830/us-east-1/service/aws4_request\n"
669 ));
670 assert!(out.canonical_request.starts_with("GET\n/\n\n"));
672 assert!(out
673 .canonical_request
674 .ends_with("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
675 }
676
677 #[test]
678 fn test_sigv4_is_deterministic() {
679 let headers = vec![("host".to_string(), "h".to_string())];
680 let mk = || {
681 sign_v4(&SigV4Input {
682 method: "POST",
683 canonical_uri: "/f",
684 canonical_query: "a=1&b=2",
685 headers: &headers,
686 payload: b"body",
687 access_key: "AK",
688 secret_key: "SK",
689 region: "us-east-1",
690 service: "lambda",
691 amz_date: "20240101T000000Z",
692 datestamp: "20240101",
693 })
694 .signature
695 };
696 assert_eq!(mk(), mk());
697 }
698
699 #[test]
700 fn test_canonical_query_sorted_and_encoded() {
701 let mut q = HashMap::new();
702 q.insert("b".to_string(), vec!["2".to_string()]);
703 q.insert("a".to_string(), vec!["hello world".to_string()]);
704 assert_eq!(canonical_query_string(&q), "a=hello%20world&b=2");
705 }
706
707 #[test]
708 fn test_format_amz_date_vector() {
709 assert_eq!(format_amz_date(1440938160), "20150830T123600Z");
711 }
712
713 #[test]
714 fn test_apikey_sets_header() {
715 let p = plugin(serde_json::json!({
716 "function_uri": "https://x.on.aws/fn",
717 "authorization": { "apikey": "secret-key" }
718 }));
719 let req = p
720 .build_request(&ctx_with("POST", "/ignored"), 1440938160)
721 .unwrap();
722 let hdr = req
723 .headers
724 .iter()
725 .find(|(k, _)| k == "x-api-key")
726 .map(|(_, v)| v.as_str());
727 assert_eq!(hdr, Some("secret-key"));
728 assert!(req.url.starts_with("https://x.on.aws/fn"));
729 }
730
731 #[test]
732 fn test_function_uri_renders_template() {
733 let p = plugin(serde_json::json!({
734 "function_uri": "https://x.on.aws/{{request.headers.x-fn}}"
735 }));
736 let mut c = ctx_with("POST", "/ignored");
737 c.request
738 .headers
739 .insert("x-fn".to_string(), vec!["fn".to_string()]);
740 let req = p.build_request(&c, 1440938160).unwrap();
741 assert!(req.url.starts_with("https://x.on.aws/fn"));
742 }
743
744 #[test]
745 fn test_iam_build_request_adds_signature_headers() {
746 let p = plugin(serde_json::json!({
747 "function_uri": "https://example.amazonaws.com/",
748 "authorization": {
749 "iam": { "accesskey": "AK", "secretkey": "SK", "session_token": "TOK" }
750 }
751 }));
752 let req = p.build_request(&ctx_with("GET", "/"), 1440938160).unwrap();
753 let has = |name: &str| req.headers.iter().any(|(k, _)| k == name);
754 assert!(has("x-amz-date"));
755 assert!(has("x-amz-security-token"));
756 let authz = req
757 .headers
758 .iter()
759 .find(|(k, _)| k == "authorization")
760 .map(|(_, v)| v.clone())
761 .unwrap();
762 assert!(authz.starts_with("AWS4-HMAC-SHA256 Credential=AK/"));
763 assert!(authz.contains("x-amz-security-token"));
765 }
766
767 #[tokio::test]
768 async fn test_callout_failure_routes_error() {
769 let p = plugin(serde_json::json!({
771 "function_uri": "http://127.0.0.1:1/fn",
772 "timeout": 200
773 }));
774 let err = p.execute(ctx_with("POST", "/")).await.unwrap_err();
775 assert_eq!(err.error.code, "AWS_LAMBDA_CALLOUT_ERROR");
776 assert!(err.context.response.status_code >= 502);
777 }
778
779 #[test]
780 fn test_apply_response_maps_fields() {
781 let mut ctx = ctx_with("GET", "/");
782 let mut headers = HashMap::new();
783 headers.insert(
784 "content-type".to_string(),
785 vec!["application/json".to_string()],
786 );
787 apply_response(
788 &mut ctx,
789 OutboundResponse {
790 status: 201,
791 headers,
792 body: Bytes::from_static(b"{\"ok\":true}"),
793 },
794 );
795 assert_eq!(ctx.response.status_code, 201);
796 assert_eq!(ctx.response.body, Bytes::from_static(b"{\"ok\":true}"));
797 assert_eq!(
798 ctx.response.headers.get("content-type"),
799 Some(&vec!["application/json".to_string()])
800 );
801 }
802}