Skip to main content

featherbit/plugins/native/
sls_logger.rs

1//! Alibaba Cloud SLS (Simple Log Service) access-logger (`sls-logger`).
2//!
3//! Ships access-log entries to Alibaba Cloud's Log Service. Each request builds
4//! one log entry (the shared [`build_entry`] shape, or a custom `log_format`)
5//! that is handed to a fire-and-forget [`BatchSink`]; a background task POSTs
6//! batches to the SLS `PutLogs` REST endpoint, signed with an HMAC-SHA1
7//! `Authorization: LOG <id>:<signature>` header.
8//!
9//! ## Deviations from APISIX
10//!
11//! APISIX's `sls-logger` does **not** use the SLS HTTP REST API. It serializes
12//! each entry into an RFC 5424 syslog frame (with the project, logstore, and
13//! access keys embedded as syslog structured-data) and streams it over a raw
14//! **TLS/TCP** socket to the SLS syslog ingress. featherbit has no TCP sink;
15//! its shared infrastructure is HTTP-only. This port therefore targets the
16//! **documented SLS `PutLogs` REST API** instead
17//! (<https://www.alibabacloud.com/help/en/sls/developer-reference/api-putlogs>):
18//!
19//! - The batch is POSTed as JSON `{"__topic__","__source__","__logs__":[…]}`
20//!   to `https://<project>.<host>:<port>/logstores/<logstore>/shards/lb`.
21//! - Requests are signed per the SLS spec: `Content-MD5`, `Date`, the sorted
22//!   `x-log-*` canonical headers, and the canonical resource are HMAC-SHA1
23//!   signed with the access-key secret, base64-encoded, and sent as
24//!   `Authorization: LOG <access_key_id>:<signature>`.
25//! - SLS requires string log values, so non-string entry fields are
26//!   JSON-encoded (mirroring how the syslog path stringifies the entry).
27//!
28//! The signing helper and its primitives (MD5, HMAC-SHA1) are unit-tested
29//! against fixed vectors; no end-to-end SLS handshake is exercised.
30
31use 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, LogFormat};
45use crate::plugins::{Plugin, PluginOutput, PluginResult};
46
47const API_VERSION: &str = "0.6.0";
48const SIGNATURE_METHOD: &str = "hmac-sha1";
49
50/// Node that batches access-log entries and ships them to Alibaba Cloud SLS.
51pub struct SlsLoggerPlugin {
52    sink: BatchSink,
53    log_format: Option<LogFormat>,
54    include_req_body: bool,
55    include_resp_body: bool,
56}
57
58/// Delivers batches to the SLS `PutLogs` REST endpoint.
59struct SlsFlusher {
60    client: Arc<OutboundClient>,
61    /// `<project>.<host>` — the SLS endpoint host, also the URL authority.
62    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    /// Builds the plugin from node config.
72    ///
73    /// Accepted keys (all of `host`/`port`/`project`/`logstore`/
74    /// `access_key_id`/`access_key_secret` are **required**):
75    /// - `host` (string): SLS endpoint, e.g. `cn-hangzhou.log.aliyuncs.com`.
76    /// - `port` (integer): endpoint port, e.g. `443`.
77    /// - `project` (string): SLS project; prefixed to `host` as the request
78    ///   authority `<project>.<host>`.
79    /// - `logstore` (string): destination logstore.
80    /// - `access_key_id` / `access_key_secret` (strings): RAM credentials used
81    ///   to sign each request.
82    /// - `timeout` (integer ms, default `5000`): per-flush HTTP deadline.
83    /// - `include_req_body` / `include_resp_body` (bool, default `false`).
84    /// - `log_format` (object, optional): custom flat entry.
85    /// - Batch tuning (see [`BatchConfig`]).
86    ///
87    /// ```yaml
88    /// type: sls-logger
89    /// config:
90    ///   host: cn-hangzhou.log.aliyuncs.com
91    ///   port: 443
92    ///   project: my-project
93    ///   logstore: gateway
94    ///   access_key_id: ${SLS_KEY_ID}
95    ///   access_key_secret: ${SLS_KEY_SECRET}
96    /// ```
97    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
158/// Builds the SLS `PutLogs` JSON payload. Each entry becomes a log object of
159/// string values (non-string fields JSON-encoded) plus a `__time__` field.
160/// Pure and network-free for testing.
161fn 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
184/// SLS log values must be strings; keep strings as-is, JSON-encode the rest.
185fn stringify(v: &Value) -> String {
186    match v {
187        Value::String(s) => s.clone(),
188        other => other.to_string(),
189    }
190}
191
192/// Builds the SLS request signature and the headers it covers.
193///
194/// Returns `(headers, signature)` where `headers` are the header pairs to send
195/// (including `Authorization`). `body_md5` is the uppercase-hex MD5 of the
196/// request body; `date` is the RFC 1123 GMT timestamp. Split out from the
197/// network path so it is unit-testable with fixed inputs (mirrors the SLS
198/// signing spec).
199fn 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    // Canonical x-log-* headers, sorted by key ascending.
211    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
242/// HMAC-SHA1 via ring, returning the raw 20-byte tag.
243fn 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
248/// Uppercase hex MD5 digest, as required for the SLS `Content-MD5` header.
249fn 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
258/// Self-contained MD5 (RFC 1321). SLS requires a `Content-MD5` header and the
259/// project has no MD5 dependency, so it is implemented here and unit-tested
260/// against the RFC vectors.
261fn 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    // Padding above makes msg.len() a multiple of 64, so the remainder is
294    // always empty.
295    let (chunks, _remainder) = msg.as_chunks::<64>();
296    for chunk in chunks {
297        let mut m = [0u32; 16];
298        for (i, word) in m.iter_mut().enumerate() {
299            *word = u32::from_le_bytes([
300                chunk[i * 4],
301                chunk[i * 4 + 1],
302                chunk[i * 4 + 2],
303                chunk[i * 4 + 3],
304            ]);
305        }
306
307        let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
308        for i in 0..64 {
309            let (f, g) = match i {
310                0..=15 => ((b & c) | (!b & d), i),
311                16..=31 => ((d & b) | (!d & c), (5 * i + 1) % 16),
312                32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
313                _ => (c ^ (b | !d), (7 * i) % 16),
314            };
315            let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
316            a = d;
317            d = c;
318            c = b;
319            b = b.wrapping_add(f.rotate_left(S[i]));
320        }
321        a0 = a0.wrapping_add(a);
322        b0 = b0.wrapping_add(b);
323        c0 = c0.wrapping_add(c);
324        d0 = d0.wrapping_add(d);
325    }
326
327    let mut out = [0u8; 16];
328    out[0..4].copy_from_slice(&a0.to_le_bytes());
329    out[4..8].copy_from_slice(&b0.to_le_bytes());
330    out[8..12].copy_from_slice(&c0.to_le_bytes());
331    out[12..16].copy_from_slice(&d0.to_le_bytes());
332    out
333}
334
335/// Formats a Unix timestamp (seconds) as an RFC 1123 GMT date, as SLS's `Date`
336/// header requires (e.g. `Mon, 03 Jan 2022 04:05:06 GMT`).
337fn format_http_date(unix_secs: u64) -> String {
338    const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
339    const MONTHS: [&str; 12] = [
340        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
341    ];
342    let days = unix_secs / 86400;
343    let secs_of_day = unix_secs % 86400;
344    let (hour, minute, second) = (
345        secs_of_day / 3600,
346        (secs_of_day % 3600) / 60,
347        secs_of_day % 60,
348    );
349    // 1970-01-01 was a Thursday (index 0 in DAYS).
350    let weekday = DAYS[(days % 7) as usize];
351
352    // Convert day count to civil (year, month, day).
353    let mut year = 1970u64;
354    let mut days_left = days;
355    loop {
356        let leap =
357            (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
358        let year_days = if leap { 366 } else { 365 };
359        if days_left < year_days {
360            break;
361        }
362        days_left -= year_days;
363        year += 1;
364    }
365    let leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
366    let month_lengths = [
367        31,
368        if leap { 29 } else { 28 },
369        31,
370        30,
371        31,
372        30,
373        31,
374        31,
375        30,
376        31,
377        30,
378        31,
379    ];
380    let mut month = 0usize;
381    while days_left >= month_lengths[month] {
382        days_left -= month_lengths[month];
383        month += 1;
384    }
385    let day = days_left + 1;
386
387    format!(
388        "{weekday}, {day:02} {mon} {year:04} {hour:02}:{minute:02}:{second:02} GMT",
389        mon = MONTHS[month],
390    )
391}
392
393#[async_trait]
394impl BatchFlusher for SlsFlusher {
395    async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
396        let now = SystemTime::now()
397            .duration_since(UNIX_EPOCH)
398            .map(|d| d.as_secs())
399            .unwrap_or(0);
400        let payload = build_logs_payload(entries, now);
401        let body = serde_json::to_vec(&payload).map_err(|e| FlushError {
402            message: format!("sls payload encode failed: {e}"),
403            first_fail: None,
404        })?;
405
406        let date = format_http_date(now);
407        let (mut headers, _sig) = sign_request(
408            &self.access_key_id,
409            &self.access_key_secret,
410            &self.logstore,
411            &body,
412            &date,
413        );
414        headers.push(("Host".to_string(), self.endpoint_host.clone()));
415
416        let url = format!(
417            "https://{}:{}/logstores/{}/shards/lb",
418            self.endpoint_host, self.port, self.logstore
419        );
420        let req = OutboundRequest {
421            method: http::Method::POST,
422            url,
423            headers,
424            body: Bytes::from(body),
425            timeout: self.timeout,
426            ssl_verify: true,
427            tls: None,
428        };
429
430        match self.client.request(req).await {
431            Ok(resp) if resp.status == 200 => Ok(()),
432            Ok(resp) => Err(FlushError {
433                message: format!(
434                    "sls returned status {}: {}",
435                    resp.status,
436                    String::from_utf8_lossy(&resp.body)
437                ),
438                first_fail: None,
439            }),
440            Err(e) => Err(FlushError {
441                message: format!("sls callout failed: {e}"),
442                first_fail: None,
443            }),
444        }
445    }
446}
447
448#[async_trait]
449impl Plugin for SlsLoggerPlugin {
450    fn plugin_type(&self) -> &str {
451        "sls-logger"
452    }
453
454    fn reads_response_body(&self) -> bool {
455        crate::plugins::util::log_entry::reads_response_body(
456            self.log_format.as_ref(),
457            self.include_resp_body,
458        )
459    }
460
461    async fn execute(&self, ctx: Context) -> PluginResult {
462        let entry = build_entry(
463            &ctx,
464            self.log_format.as_ref(),
465            self.include_req_body,
466            self.include_resp_body,
467        );
468        self.sink.push(entry);
469        Ok(PluginOutput::success(ctx))
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use serde_json::json;
477
478    fn cfg(v: Value) -> HashMap<String, Value> {
479        serde_json::from_value(v).unwrap()
480    }
481
482    fn full_cfg() -> Value {
483        json!({
484            "host": "cn-hangzhou.log.aliyuncs.com",
485            "port": 443,
486            "project": "proj",
487            "logstore": "store",
488            "access_key_id": "id",
489            "access_key_secret": "secret"
490        })
491    }
492
493    #[tokio::test]
494    async fn requires_all_fields() {
495        assert!(SlsLoggerPlugin::from_config(
496            &cfg(json!({ "host": "h", "port": 443, "project": "p", "logstore": "l", "access_key_id": "i" })),
497            &PluginResources::empty()
498        )
499        .is_err());
500        assert!(SlsLoggerPlugin::from_config(&cfg(full_cfg()), &PluginResources::empty()).is_ok());
501    }
502
503    #[test]
504    fn md5_known_vectors() {
505        assert_eq!(md5_hex_upper(b""), "D41D8CD98F00B204E9800998ECF8427E");
506        assert_eq!(md5_hex_upper(b"abc"), "900150983CD24FB0D6963F7D28E17F72");
507        assert_eq!(
508            md5_hex_upper(b"The quick brown fox jumps over the lazy dog"),
509            "9E107D9D372BB6826BD81D3542A419D6"
510        );
511    }
512
513    #[test]
514    fn hmac_sha1_rfc2202_vector() {
515        // RFC 2202 test case 1: key = 20 x 0x0b, data = "Hi There".
516        let key = [0x0bu8; 20];
517        let tag = hmac_sha1(&key, b"Hi There");
518        let hex: String = tag.iter().map(|b| format!("{b:02x}")).collect();
519        assert_eq!(hex, "b617318655057264e28bc0b6fb378c8ef146be00");
520    }
521
522    #[test]
523    fn http_date_formatting() {
524        // 1970-01-01T00:00:00Z was a Thursday.
525        assert_eq!(format_http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
526        // 1234567890 = 2009-02-13T23:31:30Z (a Friday).
527        assert_eq!(
528            format_http_date(1234567890),
529            "Fri, 13 Feb 2009 23:31:30 GMT"
530        );
531    }
532
533    #[test]
534    fn sign_request_is_stable() {
535        let body = br#"{"__topic__":"","__source__":"","__logs__":[]}"#;
536        let (headers, sig) = sign_request(
537            "AKID",
538            "AKSECRET",
539            "store",
540            body,
541            "Mon, 03 Jan 2022 04:05:06 GMT",
542        );
543        // Signing is deterministic for fixed inputs.
544        let (_, sig2) = sign_request(
545            "AKID",
546            "AKSECRET",
547            "store",
548            body,
549            "Mon, 03 Jan 2022 04:05:06 GMT",
550        );
551        assert_eq!(sig, sig2);
552        // Authorization header has the LOG <id>:<sig> shape.
553        let auth = headers
554            .iter()
555            .find(|(k, _)| k == "Authorization")
556            .map(|(_, v)| v.clone())
557            .unwrap();
558        assert_eq!(auth, format!("LOG AKID:{sig}"));
559        // Stable base64 signature (recomputed vector).
560        assert_eq!(sig, "ftz3mY2D1oijCA9FoZlVAx47RzQ=");
561    }
562
563    #[test]
564    fn logs_payload_stringifies_values() {
565        let entries = vec![json!({ "status": 200, "path": "/x" })];
566        let payload = build_logs_payload(&entries, 1000);
567        let log = &payload["__logs__"][0];
568        assert_eq!(log["__time__"], json!(1000));
569        assert_eq!(log["status"], json!("200"));
570        assert_eq!(log["path"], json!("/x"));
571        assert_eq!(payload["__topic__"], json!(""));
572    }
573}