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};
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<HashMap<String, Value>>,
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    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
332/// Formats a Unix timestamp (seconds) as an RFC 1123 GMT date, as SLS's `Date`
333/// header requires (e.g. `Mon, 03 Jan 2022 04:05:06 GMT`).
334fn 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    // 1970-01-01 was a Thursday (index 0 in DAYS).
347    let weekday = DAYS[(days % 7) as usize];
348
349    // Convert day count to civil (year, month, day).
350    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        // RFC 2202 test case 1: key = 20 x 0x0b, data = "Hi There".
509        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        // 1970-01-01T00:00:00Z was a Thursday.
518        assert_eq!(format_http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
519        // 1234567890 = 2009-02-13T23:31:30Z (a Friday).
520        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        // Signing is deterministic for fixed inputs.
537        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        // Authorization header has the LOG <id>:<sig> shape.
546        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        // Stable base64 signature (recomputed vector).
553        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}