diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8dda5e40..f9b1118a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -299,8 +299,36 @@ let exporter = FileExporter::new(Path::new("/var/log/audit.log"))? {"id":"uuid","timestamp":"2024-01-15T10:30:00Z","event_type":"file_uploaded","session_id":"sess-001","user":"admin","client_ip":"192.168.1.100","path":"/data/report.pdf","bytes":1048576,"result":"success","protocol":"sftp"} ``` +- **OtelExporter**: OpenTelemetry exporter for distributed tracing and observability + - OTLP/gRPC protocol support using tonic + - Event to LogRecord mapping with proper attribute conversion + - Severity level mapping based on event types and results + - Resource attributes including service.name and service.version + - Graceful shutdown and flush methods + - TLS support for secure audit data transmission + +**OtelExporter Usage**: +```rust +use bssh::server::audit::otel::OtelExporter; +use bssh::server::audit::exporter::AuditExporter; +use bssh::server::audit::event::{AuditEvent, EventType}; + +// Create exporter with OTLP endpoint +let exporter = OtelExporter::new("http://localhost:4317")?; + +// Export an audit event +let event = AuditEvent::new( + EventType::AuthSuccess, + "alice".to_string(), + "session-123".to_string(), +); +exporter.export(event).await?; + +// Graceful shutdown +exporter.close().await?; +``` + **Future Exporters** (planned): -- OpenTelemetry exporter for distributed tracing - Logstash exporter for centralized logging ### Server CLI Binary diff --git a/Cargo.lock b/Cargo.lock index 1f09ac22..3b761862 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,51 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -269,6 +314,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -287,7 +338,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e" dependencies = [ - "base64", + "base64 0.22.1", "blowfish", "getrandom 0.2.16", "subtle", @@ -422,6 +473,9 @@ dependencies = [ "mockito", "nix 0.30.1", "once_cell", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", "owo-colors", "rand 0.8.5", "ratatui", @@ -448,6 +502,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-width", + "url", "uuid", "whoami", "zeroize", @@ -906,6 +961,15 @@ dependencies = [ "itertools 0.13.0", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1269,6 +1333,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1717,6 +1792,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1728,8 +1822,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", - "indexmap", + "http 1.4.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -1747,6 +1841,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1855,6 +1955,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1865,6 +1976,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1872,7 +1994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1883,8 +2005,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1909,6 +2031,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1919,9 +2065,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1931,6 +2077,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.32", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1939,9 +2097,9 @@ checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "bytes", "futures-core", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "pin-project-lite", "tokio", ] @@ -1970,12 +2128,124 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1983,7 +2253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -2090,6 +2360,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2140,7 +2419,7 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "portable-atomic", "thiserror 2.0.17", ] @@ -2263,6 +2542,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -2290,7 +2575,7 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -2312,6 +2597,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md5" version = "0.7.0" @@ -2339,6 +2630,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2403,10 +2700,10 @@ dependencies = [ "bytes", "colored", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "log", "pin-project-lite", @@ -2604,6 +2901,84 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opentelemetry" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e32339a5dc40459130b3bd269e9892439f55b33e772d2a9d402a789baaf4e8a" +dependencies = [ + "futures-core", + "futures-sink", + "indexmap 2.13.0", + "js-sys", + "once_cell", + "pin-project-lite", + "thiserror 1.0.69", + "urlencoding", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24cda83b20ed2433c68241f918d0f6fdec8b1d43b7a9590ab4420c5095ca930" +dependencies = [ + "async-trait", + "futures-core", + "http 0.2.12", + "opentelemetry", + "opentelemetry-proto", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "prost", + "thiserror 1.0.69", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2e155ce5cc812ea3d1dffbd1539aed653de4bf4882d60e6e04dcf0901d674e1" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5774f1ef1f982ef2a447f6ee04ec383981a3ab99c8e77a1a7b30182e65bbc84" +dependencies = [ + "opentelemetry", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f16aec8a98a457a52664d69e0091bac3a0abd18ead9b641cb00202ba4e0efe4" +dependencies = [ + "async-trait", + "crossbeam-channel", + "futures-channel", + "futures-executor", + "futures-util", + "once_cell", + "opentelemetry", + "ordered-float", + "percent-encoding", + "rand 0.8.5", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2842,6 +3217,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2969,6 +3364,15 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3050,6 +3454,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quote" version = "1.0.43" @@ -3162,7 +3589,7 @@ checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ "bitflags 2.10.0", "compact_str", - "hashbrown", + "hashbrown 0.16.1", "indoc", "itertools 0.14.0", "kasuari", @@ -3213,7 +3640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ "bitflags 2.10.0", - "hashbrown", + "hashbrown 0.16.1", "indoc", "instability", "itertools 0.14.0", @@ -3647,7 +4074,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -3846,6 +4273,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -3931,6 +4368,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3992,6 +4435,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -4049,7 +4509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "fancy-regex", "filedescriptor", @@ -4163,6 +4623,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -4206,11 +4676,21 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -4257,6 +4737,66 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" +dependencies = [ + "async-trait", + "axum", + "base64 0.21.7", + "bytes", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4318,6 +4858,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -4393,6 +4939,30 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -4442,6 +5012,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4897,6 +5476,12 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "yasna" version = "0.5.2" @@ -4907,6 +5492,29 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.33" @@ -4927,6 +5535,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -4947,6 +5576,39 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "zmij" version = "1.0.12" diff --git a/Cargo.toml b/Cargo.toml index ae049643..a92b7709 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,10 @@ rand = "0.8" ssh-key = { version = "0.6", features = ["std"] } async-compression = { version = "0.4", features = ["tokio", "gzip"] } serde_json = "1.0" +opentelemetry = "0.21" +opentelemetry_sdk = { version = "0.21", features = ["rt-tokio", "logs"] } +opentelemetry-otlp = { version = "0.14", features = ["grpc-tonic", "logs"] } +url = "2.5" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3.5.1" diff --git a/src/server/audit/mod.rs b/src/server/audit/mod.rs index 46369bff..2a0d29f4 100644 --- a/src/server/audit/mod.rs +++ b/src/server/audit/mod.rs @@ -49,6 +49,7 @@ pub mod event; pub mod exporter; pub mod file; +pub mod otel; use anyhow::Result; use std::sync::Arc; @@ -59,6 +60,7 @@ use tokio::task::JoinHandle; pub use event::{AuditEvent, EventResult, EventType}; pub use exporter::{AuditExporter, NullExporter}; pub use file::{FileExporter, RotateConfig}; +pub use otel::OtelExporter; /// Configuration for the audit system. #[derive(Debug, Clone)] @@ -89,8 +91,7 @@ pub enum AuditExporterConfig { /// Path to the audit log file path: String, }, - /// OpenTelemetry exporter (future implementation) - #[allow(dead_code)] + /// OpenTelemetry exporter Otel { /// OTLP endpoint URL endpoint: String, @@ -213,10 +214,9 @@ impl AuditManager { let file_exporter = FileExporter::new(std::path::Path::new(path))?; Arc::new(file_exporter) } - AuditExporterConfig::Otel { .. } => { - // Future implementation - tracing::warn!("OTEL exporter not yet implemented, using null exporter"); - Arc::new(NullExporter::new()) + AuditExporterConfig::Otel { endpoint } => { + let otel_exporter = OtelExporter::new(endpoint)?; + Arc::new(otel_exporter) } AuditExporterConfig::Logstash { .. } => { // Future implementation diff --git a/src/server/audit/otel.rs b/src/server/audit/otel.rs new file mode 100644 index 00000000..6085ad01 --- /dev/null +++ b/src/server/audit/otel.rs @@ -0,0 +1,456 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! OpenTelemetry audit event exporter. +//! +//! This module provides an exporter that sends audit events to an OpenTelemetry +//! Collector using the OTLP protocol over gRPC. This enables integration with +//! observability platforms like Jaeger, Grafana Tempo, and cloud monitoring services. + +use super::event::{AuditEvent, EventResult, EventType}; +use super::exporter::AuditExporter; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use opentelemetry::{ + logs::{AnyValue, LogRecord, Logger, LoggerProvider as _, Severity}, + KeyValue, +}; +use opentelemetry_otlp::{ExportConfig, Protocol, WithExportConfig}; +use opentelemetry_sdk::{ + logs::{Config, LoggerProvider}, + Resource, +}; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// OpenTelemetry audit exporter. +/// +/// Exports audit events as OpenTelemetry log records to an OTLP collector +/// using gRPC protocol. +/// +/// # Example +/// +/// ```no_run +/// use bssh::server::audit::otel::OtelExporter; +/// use bssh::server::audit::exporter::AuditExporter; +/// use bssh::server::audit::event::{AuditEvent, EventType}; +/// +/// # async fn example() -> anyhow::Result<()> { +/// let exporter = OtelExporter::new("http://localhost:4317")?; +/// +/// let event = AuditEvent::new( +/// EventType::AuthSuccess, +/// "alice".to_string(), +/// "session-123".to_string(), +/// ); +/// +/// exporter.export(event).await?; +/// exporter.close().await?; +/// # Ok(()) +/// # } +/// ``` +pub struct OtelExporter { + logger_provider: Arc>, + endpoint: String, +} + +impl OtelExporter { + /// Create a new OpenTelemetry exporter. + /// + /// # Arguments + /// + /// * `endpoint` - OTLP endpoint URL (e.g., "http://localhost:4317" for local development, + /// "https://otel-collector.example.com:4317" for production) + /// + /// # TLS Requirements + /// + /// For production deployments, it is strongly recommended to use HTTPS endpoints to ensure + /// audit data is transmitted securely. HTTP endpoints should only be used for local testing + /// or when the OTLP collector is on the same trusted network. + /// + /// # Errors + /// + /// Returns an error if: + /// - The endpoint URL is invalid + /// - The exporter cannot be initialized + pub fn new(endpoint: &str) -> Result { + // Validate endpoint URL + url::Url::parse(endpoint).context("invalid endpoint URL")?; + + // Warn if not using HTTPS + if !endpoint.starts_with("https://") { + tracing::warn!( + endpoint = %endpoint, + "OpenTelemetry audit exporter is not using HTTPS. \ + Audit data will be transmitted unencrypted. \ + Use HTTPS for production deployments." + ); + } + let export_config = ExportConfig { + endpoint: endpoint.to_string(), + protocol: Protocol::Grpc, + ..Default::default() + }; + + let exporter = opentelemetry_otlp::new_exporter() + .tonic() + .with_export_config(export_config) + .build_log_exporter() + .context("failed to build OTLP log exporter")?; + + let resource = Resource::new(vec![ + KeyValue::new("service.name", "bssh-server"), + KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), + ]); + + let logger_provider = LoggerProvider::builder() + .with_config(Config::default().with_resource(resource)) + .with_simple_exporter(exporter) + .build(); + + Ok(Self { + logger_provider: Arc::new(RwLock::new(logger_provider)), + endpoint: endpoint.to_string(), + }) + } + + /// Convert an audit event to an OpenTelemetry log record. + fn event_to_log_record(&self, event: &AuditEvent) -> LogRecord { + let mut attributes = vec![ + KeyValue::new("event.id", event.id.clone()), + KeyValue::new("event.type", format!("{:?}", event.event_type)), + KeyValue::new("session.id", event.session_id.clone()), + KeyValue::new("user.name", event.user.clone()), + KeyValue::new("result", format!("{:?}", event.result)), + ]; + + if let Some(ref ip) = event.client_ip { + attributes.push(KeyValue::new("client.ip", ip.to_string())); + } + if let Some(ref path) = event.path { + attributes.push(KeyValue::new("file.path", path.display().to_string())); + } + if let Some(ref dest_path) = event.dest_path { + attributes.push(KeyValue::new( + "file.dest_path", + dest_path.display().to_string(), + )); + } + if let Some(bytes) = event.bytes { + attributes.push(KeyValue::new("file.bytes", bytes as i64)); + } + if let Some(ref protocol) = event.protocol { + attributes.push(KeyValue::new("protocol", protocol.clone())); + } + if let Some(ref details) = event.details { + attributes.push(KeyValue::new("details", details.clone())); + } + + let severity = self.event_to_severity(&event.event_type, &event.result); + let body = format!( + "{:?} - {} - {:?}", + event.event_type, event.user, event.result + ); + + LogRecord::builder() + .with_timestamp(event.timestamp.into()) + .with_observed_timestamp(event.timestamp.into()) + .with_severity_number(severity) + .with_severity_text(format!("{:?}", severity)) + .with_body(body.into()) + .with_attributes( + attributes + .into_iter() + .map(|kv| (kv.key, AnyValue::from(kv.value))) + .collect(), + ) + .build() + } + + /// Map event type and result to OpenTelemetry severity level. + fn event_to_severity(&self, event_type: &EventType, result: &EventResult) -> Severity { + // High severity for failures and denied operations + if matches!(result, EventResult::Failure | EventResult::Denied) { + return match event_type { + EventType::AuthFailure | EventType::AuthRateLimited => Severity::Warn, + EventType::TransferDenied | EventType::CommandBlocked => Severity::Warn, + _ => Severity::Error, + }; + } + + // Map event types to severity + match event_type { + // Security events + EventType::SuspiciousActivity | EventType::IpBlocked => Severity::Error, + EventType::AuthFailure | EventType::AuthRateLimited => Severity::Warn, + EventType::TransferDenied | EventType::CommandBlocked => Severity::Warn, + EventType::IpUnblocked => Severity::Info, + + // Authentication and session events + EventType::AuthSuccess | EventType::SessionStart | EventType::SessionEnd => { + Severity::Info + } + + // File and directory operations + EventType::FileOpenRead + | EventType::FileOpenWrite + | EventType::FileRead + | EventType::FileWrite + | EventType::FileClose + | EventType::FileUploaded + | EventType::FileDownloaded + | EventType::FileDeleted + | EventType::FileRenamed + | EventType::DirectoryCreated + | EventType::DirectoryDeleted + | EventType::DirectoryListed => Severity::Info, + + // Command execution + EventType::CommandExecuted => Severity::Info, + + // Transfer allowed + EventType::TransferAllowed => Severity::Debug, + } + } +} + +#[async_trait] +impl AuditExporter for OtelExporter { + async fn export(&self, event: AuditEvent) -> Result<()> { + let log_record = self.event_to_log_record(&event); + let provider = self.logger_provider.read().await; + let logger = provider.logger("bssh-audit"); + + logger.emit(log_record); + + Ok(()) + } + + async fn export_batch(&self, events: &[AuditEvent]) -> Result<()> { + let provider = self.logger_provider.read().await; + let logger = provider.logger("bssh-audit"); + + for event in events { + let log_record = self.event_to_log_record(event); + logger.emit(log_record); + } + + Ok(()) + } + + async fn flush(&self) -> Result<()> { + let provider = self.logger_provider.read().await; + let results = provider.force_flush(); + + // Check if any flush operation failed + for result in results { + result.context("failed to flush OTLP log exporter")?; + } + + Ok(()) + } + + async fn close(&self) -> Result<()> { + let mut provider = self.logger_provider.write().await; + let results = provider.shutdown(); + + // Check if any shutdown operation failed + for result in results { + result.context("failed to shutdown OTLP log exporter")?; + } + + Ok(()) + } +} + +impl std::fmt::Debug for OtelExporter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OtelExporter") + .field("endpoint", &self.endpoint) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::IpAddr; + use std::path::PathBuf; + + #[tokio::test] + async fn test_event_to_severity_security_events() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + + assert_eq!( + exporter.event_to_severity(&EventType::SuspiciousActivity, &EventResult::Success), + Severity::Error + ); + assert_eq!( + exporter.event_to_severity(&EventType::IpBlocked, &EventResult::Success), + Severity::Error + ); + assert_eq!( + exporter.event_to_severity(&EventType::AuthFailure, &EventResult::Failure), + Severity::Warn + ); + assert_eq!( + exporter.event_to_severity(&EventType::AuthRateLimited, &EventResult::Denied), + Severity::Warn + ); + } + + #[tokio::test] + async fn test_event_to_severity_normal_operations() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + + assert_eq!( + exporter.event_to_severity(&EventType::AuthSuccess, &EventResult::Success), + Severity::Info + ); + assert_eq!( + exporter.event_to_severity(&EventType::FileUploaded, &EventResult::Success), + Severity::Info + ); + assert_eq!( + exporter.event_to_severity(&EventType::CommandExecuted, &EventResult::Success), + Severity::Info + ); + } + + #[tokio::test] + async fn test_event_to_log_record_basic() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let event = AuditEvent::new( + EventType::AuthSuccess, + "alice".to_string(), + "session-123".to_string(), + ); + + let log_record = exporter.event_to_log_record(&event); + + assert!(log_record.timestamp.is_some()); + assert_eq!(log_record.severity_number, Some(Severity::Info)); + assert!(log_record.body.is_some()); + assert!(log_record.attributes.is_some()); + + let attributes = log_record.attributes.unwrap(); + assert!(attributes.iter().any(|kv| kv.0.as_str() == "event.id")); + assert!(attributes.iter().any(|kv| { + if kv.0.as_str() == "user.name" { + matches!(&kv.1, AnyValue::String(s) if s.as_ref() == "alice") + } else { + false + } + })); + } + + #[tokio::test] + async fn test_event_to_log_record_with_all_fields() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let ip: IpAddr = "192.168.1.100".parse().unwrap(); + let event = AuditEvent::new( + EventType::FileUploaded, + "bob".to_string(), + "session-456".to_string(), + ) + .with_client_ip(ip) + .with_path(PathBuf::from("/home/bob/file.txt")) + .with_bytes(1024) + .with_protocol("sftp") + .with_details("Upload completed".to_string()); + + let log_record = exporter.event_to_log_record(&event); + let attributes = log_record.attributes.unwrap(); + + assert!(attributes.iter().any(|kv| kv.0.as_str() == "client.ip")); + assert!(attributes.iter().any(|kv| kv.0.as_str() == "file.path")); + assert!(attributes.iter().any(|kv| { + if kv.0.as_str() == "file.bytes" { + matches!(&kv.1, AnyValue::Int(1024)) + } else { + false + } + })); + assert!(attributes.iter().any(|kv| { + if kv.0.as_str() == "protocol" { + matches!(&kv.1, AnyValue::String(s) if s.as_ref() == "sftp") + } else { + false + } + })); + assert!(attributes.iter().any(|kv| { + if kv.0.as_str() == "details" { + matches!(&kv.1, AnyValue::String(s) if s.as_ref() == "Upload completed") + } else { + false + } + })); + } + + #[tokio::test] + async fn test_export_single_event() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let event = AuditEvent::new( + EventType::SessionStart, + "charlie".to_string(), + "session-789".to_string(), + ); + + // Should not fail even if no collector is running + let result = exporter.export(event).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_export_batch() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let events = vec![ + AuditEvent::new( + EventType::AuthSuccess, + "user1".to_string(), + "session-1".to_string(), + ), + AuditEvent::new( + EventType::FileUploaded, + "user2".to_string(), + "session-2".to_string(), + ), + ]; + + let result = exporter.export_batch(&events).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_flush() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let result = exporter.flush().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_close() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let result = exporter.close().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_debug_impl() { + let exporter = OtelExporter::new("http://localhost:4317").unwrap(); + let debug_str = format!("{:?}", exporter); + assert!(debug_str.contains("OtelExporter")); + assert!(debug_str.contains("endpoint")); + } +}