1use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35use async_trait::async_trait;
36use base64::{engine::general_purpose::STANDARD, Engine};
37use bytes::Bytes;
38use serde_json::Value;
39
40use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
41use crate::context::Context;
42use crate::outbound::{OutboundClient, OutboundRequest};
43use crate::plugins::resources::PluginResources;
44use crate::plugins::util::log_entry::{build_entry, parse_log_format};
45use crate::plugins::{Plugin, PluginOutput, PluginResult};
46
47const API_VERSION: &str = "0.6.0";
48const SIGNATURE_METHOD: &str = "hmac-sha1";
49
50pub struct SlsLoggerPlugin {
52 sink: BatchSink,
53 log_format: Option<HashMap<String, Value>>,
54 include_req_body: bool,
55 include_resp_body: bool,
56}
57
58struct SlsFlusher {
60 client: Arc<OutboundClient>,
61 endpoint_host: String,
63 port: u16,
64 logstore: String,
65 access_key_id: String,
66 access_key_secret: String,
67 timeout: Duration,
68}
69
70impl SlsLoggerPlugin {
71 pub fn from_config(
98 config: &HashMap<String, Value>,
99 resources: &Arc<PluginResources>,
100 ) -> Result<Self, String> {
101 let host = required_string(config, "host")?;
102 let project = required_string(config, "project")?;
103 let logstore = required_string(config, "logstore")?;
104 let access_key_id = required_string(config, "access_key_id")?;
105 let access_key_secret = required_string(config, "access_key_secret")?;
106 let port = config
107 .get("port")
108 .and_then(|v| v.as_u64())
109 .ok_or_else(|| "sls-logger requires 'port'".to_string())? as u16;
110
111 let timeout = Duration::from_millis(
112 config
113 .get("timeout")
114 .and_then(|v| v.as_u64())
115 .unwrap_or(5000),
116 );
117
118 let include_req_body = config
119 .get("include_req_body")
120 .and_then(|v| v.as_bool())
121 .unwrap_or(false);
122 let include_resp_body = config
123 .get("include_resp_body")
124 .and_then(|v| v.as_bool())
125 .unwrap_or(false);
126 let log_format = parse_log_format(config)?;
127
128 let batch_cfg = BatchConfig::from_config(config)?;
129 let flusher = Arc::new(SlsFlusher {
130 client: resources.outbound.clone(),
131 endpoint_host: format!("{project}.{host}"),
132 port,
133 logstore,
134 access_key_id,
135 access_key_secret,
136 timeout,
137 });
138 let sink = BatchSink::spawn("sls-logger", batch_cfg, flusher);
139
140 Ok(Self {
141 sink,
142 log_format,
143 include_req_body,
144 include_resp_body,
145 })
146 }
147}
148
149fn required_string(config: &HashMap<String, Value>, key: &str) -> Result<String, String> {
150 config
151 .get(key)
152 .and_then(|v| v.as_str())
153 .filter(|s| !s.is_empty())
154 .map(|s| s.to_string())
155 .ok_or_else(|| format!("sls-logger requires '{key}'"))
156}
157
158fn build_logs_payload(entries: &[Value], now_unix: u64) -> Value {
162 let logs: Vec<Value> = entries
163 .iter()
164 .map(|entry| {
165 let mut obj = serde_json::Map::new();
166 obj.insert("__time__".to_string(), Value::from(now_unix));
167 if let Some(map) = entry.as_object() {
168 for (k, v) in map {
169 obj.insert(k.clone(), Value::String(stringify(v)));
170 }
171 } else {
172 obj.insert("log".to_string(), Value::String(stringify(entry)));
173 }
174 Value::Object(obj)
175 })
176 .collect();
177 serde_json::json!({
178 "__topic__": "",
179 "__source__": "",
180 "__logs__": logs,
181 })
182}
183
184fn stringify(v: &Value) -> String {
186 match v {
187 Value::String(s) => s.clone(),
188 other => other.to_string(),
189 }
190}
191
192fn sign_request(
200 access_key_id: &str,
201 access_key_secret: &str,
202 logstore: &str,
203 body: &[u8],
204 date: &str,
205) -> (Vec<(String, String)>, String) {
206 let content_md5 = md5_hex_upper(body);
207 let content_type = "application/json";
208 let body_size = body.len().to_string();
209
210 let canonical_headers = format!(
212 "x-log-apiversion:{API_VERSION}\nx-log-bodyrawsize:{body_size}\nx-log-signaturemethod:{SIGNATURE_METHOD}"
213 );
214 let canonical_resource = format!("/logstores/{logstore}/shards/lb");
215
216 let sign_string = format!(
217 "POST\n{content_md5}\n{content_type}\n{date}\n{canonical_headers}\n{canonical_resource}"
218 );
219 let signature = STANDARD.encode(hmac_sha1(
220 access_key_secret.as_bytes(),
221 sign_string.as_bytes(),
222 ));
223
224 let headers = vec![
225 ("Content-Type".to_string(), content_type.to_string()),
226 ("Content-MD5".to_string(), content_md5),
227 ("Date".to_string(), date.to_string()),
228 ("x-log-apiversion".to_string(), API_VERSION.to_string()),
229 (
230 "x-log-signaturemethod".to_string(),
231 SIGNATURE_METHOD.to_string(),
232 ),
233 ("x-log-bodyrawsize".to_string(), body_size),
234 (
235 "Authorization".to_string(),
236 format!("LOG {access_key_id}:{signature}"),
237 ),
238 ];
239 (headers, signature)
240}
241
242fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> {
244 let k = ring::hmac::Key::new(ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key);
245 ring::hmac::sign(&k, msg).as_ref().to_vec()
246}
247
248fn md5_hex_upper(data: &[u8]) -> String {
250 let digest = md5(data);
251 let mut s = String::with_capacity(32);
252 for b in digest {
253 s.push_str(&format!("{b:02X}"));
254 }
255 s
256}
257
258fn md5(input: &[u8]) -> [u8; 16] {
262 const S: [u32; 64] = [
263 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
264 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
265 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
266 ];
267 const K: [u32; 64] = [
268 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613,
269 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193,
270 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d,
271 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
272 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122,
273 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
274 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244,
275 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
276 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb,
277 0xeb86d391,
278 ];
279
280 let mut a0: u32 = 0x67452301;
281 let mut b0: u32 = 0xefcdab89;
282 let mut c0: u32 = 0x98badcfe;
283 let mut d0: u32 = 0x10325476;
284
285 let mut msg = input.to_vec();
286 let bit_len = (input.len() as u64).wrapping_mul(8);
287 msg.push(0x80);
288 while msg.len() % 64 != 56 {
289 msg.push(0);
290 }
291 msg.extend_from_slice(&bit_len.to_le_bytes());
292
293 for chunk in msg.chunks_exact(64) {
294 let mut m = [0u32; 16];
295 for (i, word) in m.iter_mut().enumerate() {
296 *word = u32::from_le_bytes([
297 chunk[i * 4],
298 chunk[i * 4 + 1],
299 chunk[i * 4 + 2],
300 chunk[i * 4 + 3],
301 ]);
302 }
303
304 let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
305 for i in 0..64 {
306 let (f, g) = match i {
307 0..=15 => ((b & c) | (!b & d), i),
308 16..=31 => ((d & b) | (!d & c), (5 * i + 1) % 16),
309 32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
310 _ => (c ^ (b | !d), (7 * i) % 16),
311 };
312 let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
313 a = d;
314 d = c;
315 c = b;
316 b = b.wrapping_add(f.rotate_left(S[i]));
317 }
318 a0 = a0.wrapping_add(a);
319 b0 = b0.wrapping_add(b);
320 c0 = c0.wrapping_add(c);
321 d0 = d0.wrapping_add(d);
322 }
323
324 let mut out = [0u8; 16];
325 out[0..4].copy_from_slice(&a0.to_le_bytes());
326 out[4..8].copy_from_slice(&b0.to_le_bytes());
327 out[8..12].copy_from_slice(&c0.to_le_bytes());
328 out[12..16].copy_from_slice(&d0.to_le_bytes());
329 out
330}
331
332fn format_http_date(unix_secs: u64) -> String {
335 const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
336 const MONTHS: [&str; 12] = [
337 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
338 ];
339 let days = unix_secs / 86400;
340 let secs_of_day = unix_secs % 86400;
341 let (hour, minute, second) = (
342 secs_of_day / 3600,
343 (secs_of_day % 3600) / 60,
344 secs_of_day % 60,
345 );
346 let weekday = DAYS[(days % 7) as usize];
348
349 let mut year = 1970u64;
351 let mut days_left = days;
352 loop {
353 let leap =
354 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
355 let year_days = if leap { 366 } else { 365 };
356 if days_left < year_days {
357 break;
358 }
359 days_left -= year_days;
360 year += 1;
361 }
362 let leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
363 let month_lengths = [
364 31,
365 if leap { 29 } else { 28 },
366 31,
367 30,
368 31,
369 30,
370 31,
371 31,
372 30,
373 31,
374 30,
375 31,
376 ];
377 let mut month = 0usize;
378 while days_left >= month_lengths[month] {
379 days_left -= month_lengths[month];
380 month += 1;
381 }
382 let day = days_left + 1;
383
384 format!(
385 "{weekday}, {day:02} {mon} {year:04} {hour:02}:{minute:02}:{second:02} GMT",
386 mon = MONTHS[month],
387 )
388}
389
390#[async_trait]
391impl BatchFlusher for SlsFlusher {
392 async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
393 let now = SystemTime::now()
394 .duration_since(UNIX_EPOCH)
395 .map(|d| d.as_secs())
396 .unwrap_or(0);
397 let payload = build_logs_payload(entries, now);
398 let body = serde_json::to_vec(&payload).map_err(|e| FlushError {
399 message: format!("sls payload encode failed: {e}"),
400 first_fail: None,
401 })?;
402
403 let date = format_http_date(now);
404 let (mut headers, _sig) = sign_request(
405 &self.access_key_id,
406 &self.access_key_secret,
407 &self.logstore,
408 &body,
409 &date,
410 );
411 headers.push(("Host".to_string(), self.endpoint_host.clone()));
412
413 let url = format!(
414 "https://{}:{}/logstores/{}/shards/lb",
415 self.endpoint_host, self.port, self.logstore
416 );
417 let req = OutboundRequest {
418 method: http::Method::POST,
419 url,
420 headers,
421 body: Bytes::from(body),
422 timeout: self.timeout,
423 ssl_verify: true,
424 tls: None,
425 };
426
427 match self.client.request(req).await {
428 Ok(resp) if resp.status == 200 => Ok(()),
429 Ok(resp) => Err(FlushError {
430 message: format!(
431 "sls returned status {}: {}",
432 resp.status,
433 String::from_utf8_lossy(&resp.body)
434 ),
435 first_fail: None,
436 }),
437 Err(e) => Err(FlushError {
438 message: format!("sls callout failed: {e}"),
439 first_fail: None,
440 }),
441 }
442 }
443}
444
445#[async_trait]
446impl Plugin for SlsLoggerPlugin {
447 fn plugin_type(&self) -> &str {
448 "sls-logger"
449 }
450
451 async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
452 let entry = build_entry(
453 &ctx,
454 self.log_format.as_ref(),
455 self.include_req_body,
456 self.include_resp_body,
457 );
458 self.sink.push(entry);
459 Ok(PluginOutput {
460 context: ctx,
461 named_outputs: HashMap::new(),
462 })
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use serde_json::json;
470
471 fn cfg(v: Value) -> HashMap<String, Value> {
472 serde_json::from_value(v).unwrap()
473 }
474
475 fn full_cfg() -> Value {
476 json!({
477 "host": "cn-hangzhou.log.aliyuncs.com",
478 "port": 443,
479 "project": "proj",
480 "logstore": "store",
481 "access_key_id": "id",
482 "access_key_secret": "secret"
483 })
484 }
485
486 #[tokio::test]
487 async fn requires_all_fields() {
488 assert!(SlsLoggerPlugin::from_config(
489 &cfg(json!({ "host": "h", "port": 443, "project": "p", "logstore": "l", "access_key_id": "i" })),
490 &PluginResources::empty()
491 )
492 .is_err());
493 assert!(SlsLoggerPlugin::from_config(&cfg(full_cfg()), &PluginResources::empty()).is_ok());
494 }
495
496 #[test]
497 fn md5_known_vectors() {
498 assert_eq!(md5_hex_upper(b""), "D41D8CD98F00B204E9800998ECF8427E");
499 assert_eq!(md5_hex_upper(b"abc"), "900150983CD24FB0D6963F7D28E17F72");
500 assert_eq!(
501 md5_hex_upper(b"The quick brown fox jumps over the lazy dog"),
502 "9E107D9D372BB6826BD81D3542A419D6"
503 );
504 }
505
506 #[test]
507 fn hmac_sha1_rfc2202_vector() {
508 let key = [0x0bu8; 20];
510 let tag = hmac_sha1(&key, b"Hi There");
511 let hex: String = tag.iter().map(|b| format!("{b:02x}")).collect();
512 assert_eq!(hex, "b617318655057264e28bc0b6fb378c8ef146be00");
513 }
514
515 #[test]
516 fn http_date_formatting() {
517 assert_eq!(format_http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
519 assert_eq!(
521 format_http_date(1234567890),
522 "Fri, 13 Feb 2009 23:31:30 GMT"
523 );
524 }
525
526 #[test]
527 fn sign_request_is_stable() {
528 let body = br#"{"__topic__":"","__source__":"","__logs__":[]}"#;
529 let (headers, sig) = sign_request(
530 "AKID",
531 "AKSECRET",
532 "store",
533 body,
534 "Mon, 03 Jan 2022 04:05:06 GMT",
535 );
536 let (_, sig2) = sign_request(
538 "AKID",
539 "AKSECRET",
540 "store",
541 body,
542 "Mon, 03 Jan 2022 04:05:06 GMT",
543 );
544 assert_eq!(sig, sig2);
545 let auth = headers
547 .iter()
548 .find(|(k, _)| k == "Authorization")
549 .map(|(_, v)| v.clone())
550 .unwrap();
551 assert_eq!(auth, format!("LOG AKID:{sig}"));
552 assert_eq!(sig, "ftz3mY2D1oijCA9FoZlVAx47RzQ=");
554 }
555
556 #[test]
557 fn logs_payload_stringifies_values() {
558 let entries = vec![json!({ "status": 200, "path": "/x" })];
559 let payload = build_logs_payload(&entries, 1000);
560 let log = &payload["__logs__"][0];
561 assert_eq!(log["__time__"], json!(1000));
562 assert_eq!(log["status"], json!("200"));
563 assert_eq!(log["path"], json!("/x"));
564 assert_eq!(payload["__topic__"], json!(""));
565 }
566}