From b8aa8214e2656ce625b2d2ed6e3ccabb1fd90d54 Mon Sep 17 00:00:00 2001 From: yxrxy <1532529704@qq.com> Date: Wed, 31 Dec 2025 09:01:15 +0800 Subject: [PATCH] Feat/ftps&sftp (#1308) [feat] ftp / sftp --- .../observability/prometheus-data/.gitignore | 0 Cargo.lock | 1478 ++++++++++++++++- Cargo.toml | 11 + crates/e2e_test/Cargo.toml | 8 + crates/e2e_test/src/common.rs | 6 +- crates/e2e_test/src/lib.rs | 3 + crates/e2e_test/src/protocols/README.md | 44 + crates/e2e_test/src/protocols/ftps_core.rs | 211 +++ crates/e2e_test/src/protocols/mod.rs | 19 + crates/e2e_test/src/protocols/test_env.rs | 72 + crates/e2e_test/src/protocols/test_runner.rs | 171 ++ crates/iam/src/manager.rs | 22 + crates/iam/src/sys.rs | 13 + crates/policy/src/auth/mod.rs | 21 + rustfs/Cargo.toml | 8 + rustfs/src/auth.rs | 12 + rustfs/src/config/mod.rs | 41 + rustfs/src/init.rs | 119 ++ rustfs/src/main.rs | 25 +- rustfs/src/protocols/README.md | 159 ++ rustfs/src/protocols/client/mod.rs | 15 + rustfs/src/protocols/client/s3.rs | 281 ++++ rustfs/src/protocols/ftps/driver.rs | 910 ++++++++++ rustfs/src/protocols/ftps/mod.rs | 18 + rustfs/src/protocols/ftps/server.rs | 329 ++++ rustfs/src/protocols/gateway/action.rs | 110 ++ rustfs/src/protocols/gateway/adapter.rs | 85 + rustfs/src/protocols/gateway/authorize.rs | 97 ++ rustfs/src/protocols/gateway/error.rs | 76 + rustfs/src/protocols/gateway/mod.rs | 21 + rustfs/src/protocols/gateway/restrictions.rs | 56 + rustfs/src/protocols/mod.rs | 19 + rustfs/src/protocols/session/context.rs | 54 + rustfs/src/protocols/session/mod.rs | 18 + rustfs/src/protocols/session/principal.rs | 35 + rustfs/src/protocols/sftp/handler.rs | 929 +++++++++++ rustfs/src/protocols/sftp/mod.rs | 17 + rustfs/src/protocols/sftp/server.rs | 706 ++++++++ 38 files changed, 6162 insertions(+), 57 deletions(-) mode change 100644 => 100755 .docker/observability/prometheus-data/.gitignore create mode 100644 crates/e2e_test/src/protocols/README.md create mode 100644 crates/e2e_test/src/protocols/ftps_core.rs create mode 100644 crates/e2e_test/src/protocols/mod.rs create mode 100644 crates/e2e_test/src/protocols/test_env.rs create mode 100644 crates/e2e_test/src/protocols/test_runner.rs create mode 100644 rustfs/src/protocols/README.md create mode 100644 rustfs/src/protocols/client/mod.rs create mode 100644 rustfs/src/protocols/client/s3.rs create mode 100644 rustfs/src/protocols/ftps/driver.rs create mode 100644 rustfs/src/protocols/ftps/mod.rs create mode 100644 rustfs/src/protocols/ftps/server.rs create mode 100644 rustfs/src/protocols/gateway/action.rs create mode 100644 rustfs/src/protocols/gateway/adapter.rs create mode 100644 rustfs/src/protocols/gateway/authorize.rs create mode 100644 rustfs/src/protocols/gateway/error.rs create mode 100644 rustfs/src/protocols/gateway/mod.rs create mode 100644 rustfs/src/protocols/gateway/restrictions.rs create mode 100644 rustfs/src/protocols/mod.rs create mode 100644 rustfs/src/protocols/session/context.rs create mode 100644 rustfs/src/protocols/session/mod.rs create mode 100644 rustfs/src/protocols/session/principal.rs create mode 100644 rustfs/src/protocols/sftp/handler.rs create mode 100644 rustfs/src/protocols/sftp/mod.rs create mode 100644 rustfs/src/protocols/sftp/server.rs diff --git a/.docker/observability/prometheus-data/.gitignore b/.docker/observability/prometheus-data/.gitignore old mode 100644 new mode 100755 diff --git a/Cargo.lock b/Cargo.lock index 440b86e0..f5bd2365 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array 0.14.7", +] + [[package]] name = "aead" version = "0.6.0-rc.3" @@ -47,6 +57,21 @@ dependencies = [ "cfg-if", "cipher 0.5.0-rc.2", "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", ] [[package]] @@ -55,12 +80,13 @@ version = "0.11.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f5c07f414d7dc0755870f84c7900425360288d24e0eae4836f9dee19a30fa5f" dependencies = [ - "aead", + "aead 0.6.0-rc.3", "aes 0.9.0-rc.2", "cipher 0.5.0-rc.2", - "ctr", - "ghash", + "ctr 0.10.0-rc.2", + "ghash 0.6.0-rc.3", "subtle", + "zeroize", ] [[package]] @@ -223,6 +249,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2 0.10.6", + "cpufeatures", + "password-hash 0.5.0", +] + [[package]] name = "argon2" version = "0.6.0-rc.5" @@ -232,7 +270,7 @@ dependencies = [ "base64ct", "blake2 0.11.0-rc.3", "cpufeatures", - "password-hash", + "password-hash 0.6.0-rc.7", ] [[package]] @@ -470,6 +508,45 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "astral-tokio-tar" version = "0.5.6" @@ -486,6 +563,17 @@ dependencies = [ "xattr", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -516,13 +604,60 @@ dependencies = [ "zstd-safe", ] +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] @@ -538,6 +673,32 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -560,6 +721,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -652,6 +819,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1162,6 +1330,17 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2 0.12.2", + "sha2 0.10.9", +] + [[package]] name = "bigdecimal" version = "0.4.10" @@ -1238,7 +1417,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -1248,6 +1427,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" dependencies = [ "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher 0.4.4", ] [[package]] @@ -1436,6 +1648,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.2.51" @@ -1460,6 +1681,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures", +] + [[package]] name = "chacha20" version = "0.10.0-rc.5" @@ -1470,6 +1702,7 @@ dependencies = [ "cipher 0.5.0-rc.2", "cpufeatures", "rand_core 0.10.0-rc-2", + "zeroize", ] [[package]] @@ -1478,10 +1711,10 @@ version = "0.11.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c662d31454533832974f2b2b3fcbd552ed3cde94c95e614a5039d297dd97076f" dependencies = [ - "aead", - "chacha20", + "aead 0.6.0-rc.3", + "chacha20 0.10.0-rc.5", "cipher 0.5.0-rc.2", - "poly1305", + "poly1305 0.9.0-rc.3", ] [[package]] @@ -1554,6 +1787,7 @@ dependencies = [ "block-buffer 0.11.0", "crypto-common 0.2.0-rc.5", "inout 0.2.2", + "zeroize", ] [[package]] @@ -1743,6 +1977,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444" +dependencies = [ + "hax-lib", + "pastey 0.1.1", + "rand 0.9.2", +] + [[package]] name = "cpp_demangle" version = "0.4.5" @@ -1897,7 +2142,7 @@ version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", @@ -1909,7 +2154,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", @@ -1934,7 +2179,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] @@ -1981,6 +2226,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctr" version = "0.10.0-rc.2" @@ -2170,6 +2424,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "datafusion" version = "51.0.0" @@ -2833,6 +3093,17 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "der" version = "0.6.1" @@ -2865,6 +3136,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.5" @@ -2948,6 +3233,38 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.111", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.9.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512ca722eff02fa73c43e5136f440c46f861d41f9dd7761c1f2817a5ca5d9ad7" +dependencies = [ + "cipher 0.5.0-rc.2", +] + [[package]] name = "diff" version = "0.1.13" @@ -3010,6 +3327,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "doxygen-rs" version = "0.4.2" @@ -3035,6 +3358,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" name = "e2e_test" version = "0.0.5" dependencies = [ + "anyhow", "async-trait", "aws-config", "aws-sdk-s3", @@ -3043,19 +3367,26 @@ dependencies = [ "chrono", "flatbuffers", "futures", - "md5", + "md5 0.8.0", + "native-tls", "rand 0.10.0-rc.5", + "rcgen", "reqwest", "rmp-serde", "rustfs-common", "rustfs-ecstore", "rustfs-filemeta", + "rustfs-iam", "rustfs-lock", "rustfs-madmin", "rustfs-protos", + "rustls 0.23.35", + "rustls-pemfile", "serde", "serde_json", "serial_test", + "ssh2", + "suppaftp", "tokio", "tonic", "tracing", @@ -3108,6 +3439,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -3131,7 +3463,7 @@ dependencies = [ "der 0.6.1", "digest 0.10.7", "ff 0.12.1", - "generic-array", + "generic-array 0.14.7", "group 0.12.1", "pkcs8 0.9.0", "rand_core 0.6.4", @@ -3150,7 +3482,7 @@ dependencies = [ "crypto-bigint 0.5.5", "digest 0.10.7", "ff 0.13.1", - "generic-array", + "generic-array 0.14.7", "group 0.13.0", "hkdf", "pem-rfc7468 0.7.0", @@ -3182,6 +3514,18 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "enumset" version = "1.1.10" @@ -3248,6 +3592,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + [[package]] name = "erased-serde" version = "0.4.9" @@ -3269,6 +3622,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.4.1" @@ -3286,7 +3645,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] @@ -3423,6 +3782,18 @@ dependencies = [ "spin 0.9.8", ] +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3441,6 +3812,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3523,6 +3909,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -3581,6 +3980,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -3632,13 +4042,23 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + [[package]] name = "ghash" version = "0.6.0-rc.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "333de57ed9494a40df4bbb866752b100819dde0d18f2264c48f5a08a85fe673d" dependencies = [ - "polyval", + "polyval 0.7.0-rc.3", ] [[package]] @@ -3653,6 +4073,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "google-cloud-auth" version = "1.3.0" @@ -3821,7 +4253,7 @@ dependencies = [ "http-body 1.0.1", "hyper 1.8.1", "lazy_static", - "md5", + "md5 0.8.0", "mime", "percent-encoding", "pin-project", @@ -3991,6 +4423,43 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hax-lib" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "heapless" version = "0.8.0" @@ -4057,6 +4526,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hex-simd" version = "0.8.0" @@ -4189,6 +4664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" dependencies = [ "typenum", + "zeroize", ] [[package]] @@ -4532,7 +5008,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "block-padding", + "generic-array 0.14.7", ] [[package]] @@ -4550,6 +5027,34 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.11+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a77eae781ed6a7709fb15b64862fcca13d886b07c7e2786f5ed34e5e2b9187" +dependencies = [ + "argon2 0.5.3", + "bcrypt-pbkdf", + "ecdsa 0.16.9", + "ed25519-dalek", + "hex", + "hmac 0.12.1", + "num-bigint-dig", + "p256 0.13.2", + "p384", + "p521", + "rand_core 0.6.4", + "rsa 0.9.9", + "sec1 0.7.3", + "sha1 0.10.6", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher 0.2.0", + "ssh-encoding 0.2.0", + "subtle", + "zeroize", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -4702,6 +5207,38 @@ dependencies = [ "libc", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy-regex" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.111", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -4780,6 +5317,72 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libcrux-intrinsics" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.9.2", + "tls_codec", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034" +dependencies = [ + "libcrux-secrets", + "rand 0.9.2", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4817,6 +5420,20 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libsystemd" version = "0.7.2" @@ -4827,7 +5444,7 @@ dependencies = [ "libc", "log", "nix 0.29.0", - "nom", + "nom 8.0.0", "once_cell", "serde", "sha2 0.10.9", @@ -4835,6 +5452,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "libunftp" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8270fae0f77279620962f533153fa727a9cf9485dbb79d47eed3086d42b17264" +dependencies = [ + "async-trait", + "bitflags 2.10.0", + "bytes", + "chrono", + "dashmap", + "derive_more", + "futures-util", + "getrandom 0.3.4", + "lazy_static", + "libc", + "md-5 0.10.6", + "moka", + "nix 0.29.0", + "prometheus", + "proxy-protocol", + "rustls 0.23.35", + "rustls-pemfile", + "slog", + "slog-stdlog", + "thiserror 2.0.17", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tracing", + "tracing-attributes", + "uuid", + "x509-parser 0.17.0", +] + [[package]] name = "libz-rs-sys" version = "0.5.5" @@ -4844,6 +5496,18 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5022,6 +5686,12 @@ dependencies = [ "digest 0.11.0-rc.4", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "md5" version = "0.8.0" @@ -5087,6 +5757,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5120,7 +5796,7 @@ dependencies = [ "crossbeam-epoch", "crossbeam-utils", "equivalent", - "event-listener", + "event-listener 5.4.1", "futures-util", "parking_lot", "portable-atomic", @@ -5135,6 +5811,23 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "neli" version = "0.7.3" @@ -5210,6 +5903,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -5295,6 +5998,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "rand 0.8.5", ] [[package]] @@ -5488,6 +6192,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -5506,12 +6219,56 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "openssl-probe" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -5674,6 +6431,20 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + [[package]] name = "page_size" version = "0.6.0" @@ -5684,6 +6455,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "pageant" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b537f975f6d8dcf48db368d7ec209d583b015713b5df0f5d92d2631e4ff5595" +dependencies = [ + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.8.5", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "windows 0.62.2", + "windows-strings 0.5.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -5750,6 +6540,17 @@ dependencies = [ "zstd", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "password-hash" version = "0.6.0-rc.7" @@ -5766,6 +6567,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.1" @@ -5975,6 +6782,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -5996,6 +6814,21 @@ dependencies = [ "spki 0.8.0-rc.4", ] +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes 0.8.4", + "cbc", + "der 0.7.10", + "pbkdf2 0.12.2", + "scrypt", + "sha2 0.10.9", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.9.0" @@ -6013,6 +6846,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", + "pkcs5", + "rand_core 0.6.4", "spki 0.7.3", ] @@ -6060,12 +6895,37 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "pollster" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "poly1305" version = "0.9.0-rc.3" @@ -6073,7 +6933,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c0749ae91cfe6e68c77c4d48802d9720ee06aed3f7100a38975fb0962d50bc" dependencies = [ "cpufeatures", - "universal-hash", + "universal-hash 0.6.0-rc.3", + "zeroize", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash 0.5.1", ] [[package]] @@ -6084,7 +6957,7 @@ checksum = "1ad60831c19edda4b20878a676595c357e93a9b4e6dca2ba98d75b01066b317b" dependencies = [ "cfg-if", "cpufeatures", - "universal-hash", + "universal-hash 0.6.0-rc.3", ] [[package]] @@ -6231,6 +7104,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "thiserror 2.0.17", +] + [[package]] name = "prost" version = "0.13.5" @@ -6359,6 +7246,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "proxy-protocol" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e50c72c21c738f5c5f350cc33640aee30bf7cd20f9d9da20ed41bce2671d532" +dependencies = [ + "bytes", + "snafu 0.6.10", +] + [[package]] name = "psm" version = "0.1.28" @@ -6516,7 +7413,7 @@ version = "0.10.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be866deebbade98028b705499827ad6967c8bb1e21f96a2609913c8c076e9307" dependencies = [ - "chacha20", + "chacha20 0.10.0-rc.5", "getrandom 0.3.4", "rand_core 0.10.0-rc-2", "serde", @@ -6589,6 +7486,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.0", + "yasna", +] + [[package]] name = "readme-rustdocifier" version = "0.1.1" @@ -6804,7 +7715,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -6818,7 +7729,7 @@ dependencies = [ "base64", "chrono", "futures", - "pastey", + "pastey 0.2.1", "pin-project-lite", "rmcp-macros", "schemars 1.2.0", @@ -6876,6 +7787,7 @@ dependencies = [ "pkcs1 0.7.5", "pkcs8 0.10.2", "rand_core 0.6.4", + "sha2 0.10.9", "signature 2.2.0", "spki 0.7.3", "subtle", @@ -6951,6 +7863,110 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "russh" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b4d036bb45d7bbe99dbfef4ec60eaeb614708d22ff107124272f8ef6b54548" +dependencies = [ + "aes 0.8.4", + "aws-lc-rs", + "bitflags 2.10.0", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr 0.9.2", + "curve25519-dalek", + "data-encoding", + "delegate", + "der 0.7.10", + "digest 0.10.7", + "ecdsa 0.16.9", + "ed25519-dalek", + "elliptic-curve 0.13.8", + "enum_dispatch", + "flate2", + "futures", + "generic-array 1.3.5", + "getrandom 0.2.16", + "hex-literal", + "hmac 0.12.1", + "home", + "inout 0.1.4", + "internal-russh-forked-ssh-key", + "libcrux-ml-kem", + "log", + "md5 0.7.0", + "num-bigint", + "p256 0.13.2", + "p384", + "p521", + "pageant", + "pbkdf2 0.12.2", + "pkcs1 0.7.5", + "pkcs5", + "pkcs8 0.10.2", + "rand 0.8.5", + "rand_core 0.6.4", + "rsa 0.9.9", + "russh-cryptovec", + "russh-util", + "sec1 0.7.3", + "sha1 0.10.6", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "ssh-encoding 0.2.0", + "subtle", + "thiserror 1.0.69", + "tokio", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb0ed583ff0f6b4aa44c7867dd7108df01b30571ee9423e250b4cc939f8c6cf" +dependencies = [ + "libc", + "log", + "nix 0.29.0", + "ssh-encoding 0.2.0", + "winapi", +] + +[[package]] +name = "russh-sftp" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "chrono", + "flurry", + "log", + "serde", + "thiserror 2.0.17", + "tokio", + "tokio-util", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-embed" version = "8.9.0" @@ -7035,16 +8051,20 @@ dependencies = [ "hyper-util", "jemalloc_pprof", "libsystemd", + "libunftp", "matchit 0.9.1", - "md5", + "md5 0.8.0", "metrics", "mimalloc", "mime_guess", "moka", "pin-project-lite", "pprof", + "rand 0.8.5", "reqwest", "rmp-serde", + "russh", + "russh-sftp", "rust-embed", "rustfs-ahm", "rustfs-appauth", @@ -7077,6 +8097,7 @@ dependencies = [ "serial_test", "shadow-rs", "socket2 0.6.1", + "ssh-key", "subtle", "sysctl", "sysinfo", @@ -7217,8 +8238,8 @@ dependencies = [ name = "rustfs-crypto" version = "0.0.5" dependencies = [ - "aes-gcm", - "argon2", + "aes-gcm 0.11.0-rc.2", + "argon2 0.6.0-rc.5", "cfg-if", "chacha20poly1305", "jsonwebtoken", @@ -7235,7 +8256,7 @@ dependencies = [ name = "rustfs-ecstore" version = "0.0.5" dependencies = [ - "async-channel", + "async-channel 2.5.0", "async-recursion", "async-trait", "aws-config", @@ -7366,12 +8387,12 @@ dependencies = [ name = "rustfs-kms" version = "0.0.5" dependencies = [ - "aes-gcm", + "aes-gcm 0.11.0-rc.2", "async-trait", "base64", "chacha20poly1305", "chrono", - "md5", + "md5 0.8.0", "moka", "rand 0.10.0-rc.5", "reqwest", @@ -7543,7 +8564,7 @@ dependencies = [ name = "rustfs-rio" version = "0.0.5" dependencies = [ - "aes-gcm", + "aes-gcm 0.11.0-rc.2", "base64", "bytes", "crc-fast", @@ -7586,7 +8607,7 @@ dependencies = [ "rustfs-common", "rustfs-ecstore", "s3s", - "snafu", + "snafu 0.8.9", "tokio", "tokio-util", "tracing", @@ -7606,7 +8627,7 @@ dependencies = [ "parking_lot", "rustfs-s3select-api", "s3s", - "snafu", + "snafu 0.8.9", "tokio", "tracing", ] @@ -7711,6 +8732,15 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustify" version = "0.6.1" @@ -7808,7 +8838,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -7837,7 +8867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7848,7 +8878,7 @@ checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7860,7 +8890,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7902,7 +8932,7 @@ dependencies = [ "md-5 0.11.0-rc.3", "memchr", "mime", - "nom", + "nom 8.0.0", "numeric_cast", "pin-project-lite", "quick-xml 0.37.5", @@ -7924,6 +8954,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7995,6 +9034,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sct" version = "0.7.1" @@ -8002,7 +9052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -8019,7 +9069,7 @@ checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" dependencies = [ "base16ct 0.1.1", "der 0.6.1", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.9.0", "subtle", "zeroize", @@ -8033,12 +9083,35 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct 0.2.0", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dff52f6118bc9f0ac974a54a639d499ac26a6cad7a6e39bc0990c19625e793b" +dependencies = [ + "base16ct 0.3.0", + "hybrid-array", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -8062,6 +9135,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "semver" version = "1.0.27" @@ -8397,6 +9476,40 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde 0.3.31", + "rustversion", + "serde_core", +] + +[[package]] +name = "slog-scope" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f95a4b4c3274cd2869549da82b57ccc930859bdbf5bcea0424bc5f140b3c786" +dependencies = [ + "arc-swap", + "lazy_static", + "slog", +] + +[[package]] +name = "slog-stdlog" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6706b2ace5bbae7291d3f8d2473e2bfab073ccd7d03670946197aec98471fa3e" +dependencies = [ + "log", + "slog", + "slog-scope", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -8417,6 +9530,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "snafu" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab12d3c261b2308b0d80c26fffb58d17eba81a4be97890101f416b478c79ca7" +dependencies = [ + "doc-comment", + "snafu-derive 0.6.10", +] + [[package]] name = "snafu" version = "0.8.9" @@ -8424,7 +9547,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" dependencies = [ "backtrace", - "snafu-derive", + "snafu-derive 0.8.9", +] + +[[package]] +name = "snafu-derive" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1508efa03c362e23817f96cde18abed596a25219a8b2c66e8db33c03543d315b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -8535,6 +9669,92 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes 0.8.4", + "aes-gcm 0.10.3", + "cbc", + "chacha20 0.9.1", + "cipher 0.4.4", + "ctr 0.9.2", + "poly1305 0.8.0", + "ssh-encoding 0.2.0", + "subtle", +] + +[[package]] +name = "ssh-cipher" +version = "0.3.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361de425e489d5fe3f1ecfd91531c8fe91ededbbc567e24b77a560d503309bf9" +dependencies = [ + "aes 0.9.0-rc.2", + "aes-gcm 0.11.0-rc.2", + "chacha20 0.10.0-rc.5", + "cipher 0.5.0-rc.2", + "des", + "poly1305 0.9.0-rc.3", + "ssh-encoding 0.3.0-rc.3", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad6a09263583e83e934fcd436b7e3bb9d69602e2feef3787adb615c1fe3a343" +dependencies = [ + "base64ct", + "digest 0.11.0-rc.4", + "pem-rfc7468 1.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faefb89d4a5304e31238913d1f7f164e22494276ed58cd84d5058ba7b04911f" +dependencies = [ + "rand_core 0.10.0-rc-2", + "sec1 0.8.0-rc.10", + "sha2 0.11.0-rc.3", + "signature 3.0.0-rc.5", + "ssh-cipher 0.3.0-rc.4", + "ssh-encoding 0.3.0-rc.3", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.10.0", + "libc", + "libssh2-sys", + "parking_lot", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8628,6 +9848,24 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "suppaftp" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8928c89e226be233f0eb1594e9bd023f72a948dc06581c0d908387f57de1de" +dependencies = [ + "async-std", + "async-trait", + "chrono", + "futures-lite", + "lazy-regex", + "log", + "native-tls", + "pin-project", + "rustls 0.23.35", + "thiserror 2.0.17", +] + [[package]] name = "sval" version = "2.16.0" @@ -8818,7 +10056,7 @@ dependencies = [ "objc2-core-foundation", "objc2-io-kit", "rayon", - "windows", + "windows 0.61.3", ] [[package]] @@ -9071,6 +10309,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tokio" version = "1.48.0" @@ -9561,6 +10820,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.0-rc.3" @@ -9571,6 +10840,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -9654,7 +10929,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5" dependencies = [ - "erased-serde", + "erased-serde 0.4.9", "serde_core", "serde_fmt", ] @@ -9694,6 +10969,12 @@ dependencies = [ "url", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -9898,11 +11179,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -9914,6 +11207,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -9948,7 +11250,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -9995,6 +11308,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -10120,6 +11443,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -10249,6 +11581,41 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -10286,6 +11653,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" @@ -10416,7 +11792,7 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "generic-array", + "generic-array 0.14.7", "getrandom 0.3.4", "hmac 0.12.1", "indexmap 2.12.1", diff --git a/Cargo.toml b/Cargo.toml index 107c6cf5..7cf2bacc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -266,6 +266,17 @@ opentelemetry_sdk = { version = "0.31.0" } opentelemetry-semantic-conventions = { version = "0.31.0", features = ["semconv_experimental"] } opentelemetry-stdout = { version = "0.31.0" } +# FTP and SFTP +libunftp = "0.21.0" +russh = "0.55.0" +russh-sftp = "2.1.1" +russh-keys = "0.49.2" +ssh-key = "0.7.0-rc.4" +ssh2 = "0.9" +suppaftp = { version = "7.0.7", features = ["async-std", "rustls", "native-tls"] } +rcgen = "0.14.6" +native-tls = "0.2.14" + # Performance Analysis and Memory Profiling mimalloc = "0.1" # Use tikv-jemallocator as memory allocator and enable performance analysis diff --git a/crates/e2e_test/Cargo.toml b/crates/e2e_test/Cargo.toml index e1fcbe8a..e1e9f41b 100644 --- a/crates/e2e_test/Cargo.toml +++ b/crates/e2e_test/Cargo.toml @@ -26,6 +26,7 @@ workspace = true [dependencies] rustfs-ecstore.workspace = true rustfs-common.workspace = true +rustfs-iam.workspace = true flatbuffers.workspace = true futures.workspace = true rustfs-lock.workspace = true @@ -51,3 +52,10 @@ base64 = { workspace = true } rand = { workspace = true } chrono = { workspace = true } md5 = { workspace = true } +ssh2.workspace = true +suppaftp.workspace = true +rcgen.workspace = true +anyhow.workspace = true +native-tls.workspace = true +rustls.workspace = true +rustls-pemfile.workspace = true \ No newline at end of file diff --git a/crates/e2e_test/src/common.rs b/crates/e2e_test/src/common.rs index 9fecad3c..ce220289 100644 --- a/crates/e2e_test/src/common.rs +++ b/crates/e2e_test/src/common.rs @@ -34,8 +34,8 @@ use tracing::{error, info, warn}; use uuid::Uuid; // Common constants for all E2E tests -pub const DEFAULT_ACCESS_KEY: &str = "minioadmin"; -pub const DEFAULT_SECRET_KEY: &str = "minioadmin"; +pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin"; +pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin"; pub const TEST_BUCKET: &str = "e2e-test-bucket"; pub fn workspace_root() -> PathBuf { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -165,7 +165,7 @@ impl RustFSTestEnvironment { } /// Find an available port for the test - async fn find_available_port() -> Result> { + pub async fn find_available_port() -> Result> { use std::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0")?; let port = listener.local_addr()?.port(); diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index 70831fcf..cef028e5 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -40,3 +40,6 @@ mod content_encoding_test; // Policy variables tests #[cfg(test)] mod policy; + +#[cfg(test)] +mod protocols; diff --git a/crates/e2e_test/src/protocols/README.md b/crates/e2e_test/src/protocols/README.md new file mode 100644 index 00000000..4a2fafe3 --- /dev/null +++ b/crates/e2e_test/src/protocols/README.md @@ -0,0 +1,44 @@ +# Protocol E2E Tests + +FTPS and SFTP protocol end-to-end tests for RustFS. + +## Prerequisites + +### Required Tools + +```bash +# Ubuntu/Debian +sudo apt-get install sshpass ssh-keygen + +# RHEL/CentOS +sudo yum install sshpass openssh-clients + +# macOS +brew install sshpass openssh +``` + +## Running Tests + +Run all protocol tests: +```bash +cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture +``` + +Run only FTPS tests: +```bash +cargo test --package e2e_test test_ftps_core_operations -- --test-threads=1 --nocapture +``` + +## Test Coverage + +### FTPS Tests +- mkdir bucket +- cd to bucket +- put file +- ls list objects +- cd . (stay in current directory) +- cd / (return to root) +- cd nonexistent bucket (should fail) +- delete object +- cdup +- rmdir delete bucket \ No newline at end of file diff --git a/crates/e2e_test/src/protocols/ftps_core.rs b/crates/e2e_test/src/protocols/ftps_core.rs new file mode 100644 index 00000000..1e70d0de --- /dev/null +++ b/crates/e2e_test/src/protocols/ftps_core.rs @@ -0,0 +1,211 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Core FTPS tests + +use crate::common::rustfs_binary_path; +use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment}; +use anyhow::Result; +use native_tls::TlsConnector; +use rcgen::generate_simple_self_signed; +use std::io::Cursor; +use std::path::PathBuf; +use suppaftp::NativeTlsConnector; +use suppaftp::NativeTlsFtpStream; +use tokio::process::Command; +use tracing::info; + +// Fixed FTPS port for testing +const FTPS_PORT: u16 = 9021; +const FTPS_ADDRESS: &str = "127.0.0.1:9021"; + +/// Test FTPS: put, ls, mkdir, rmdir, delete operations +pub async fn test_ftps_core_operations() -> Result<()> { + let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?; + + // Generate and write certificate + let cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])?; + let cert_path = PathBuf::from(&env.temp_dir).join("ftps.crt"); + let key_path = PathBuf::from(&env.temp_dir).join("ftps.key"); + + let cert_pem = cert.cert.pem(); + let key_pem = cert.signing_key.serialize_pem(); + tokio::fs::write(&cert_path, &cert_pem).await?; + tokio::fs::write(&key_path, &key_pem).await?; + + // Start server manually + info!("Starting FTPS server on {}", FTPS_ADDRESS); + let binary_path = rustfs_binary_path(); + let mut server_process = Command::new(&binary_path) + .args([ + "--ftps-enable", + "--ftps-address", + FTPS_ADDRESS, + "--ftps-certs-file", + cert_path.to_str().unwrap(), + "--ftps-key-file", + key_path.to_str().unwrap(), + &env.temp_dir, + ]) + .spawn()?; + + // Ensure server is cleaned up even on failure + let result = async { + // Wait for server to be ready + ProtocolTestEnvironment::wait_for_port_ready(FTPS_PORT, 30) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Create native TLS connector that accepts the certificate + let tls_connector = TlsConnector::builder().danger_accept_invalid_certs(true).build()?; + + // Wrap in suppaftp's NativeTlsConnector + let tls_connector = NativeTlsConnector::from(tls_connector); + + // Connect to FTPS server + let ftp_stream = NativeTlsFtpStream::connect(FTPS_ADDRESS).map_err(|e| anyhow::anyhow!("Failed to connect: {}", e))?; + + // Upgrade to secure connection + let mut ftp_stream = ftp_stream + .into_secure(tls_connector, "127.0.0.1") + .map_err(|e| anyhow::anyhow!("Failed to upgrade to TLS: {}", e))?; + ftp_stream.login(DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY)?; + + info!("Testing FTPS: mkdir bucket"); + let bucket_name = "testbucket"; + ftp_stream.mkdir(bucket_name)?; + info!("PASS: mkdir bucket '{}' successful", bucket_name); + + info!("Testing FTPS: cd to bucket"); + ftp_stream.cwd(bucket_name)?; + info!("PASS: cd to bucket '{}' successful", bucket_name); + + info!("Testing FTPS: put file"); + let filename = "test.txt"; + let content = "Hello, FTPS!"; + ftp_stream.put_file(filename, &mut Cursor::new(content.as_bytes()))?; + info!("PASS: put file '{}' ({} bytes) successful", filename, content.len()); + + info!("Testing FTPS: ls list objects in bucket"); + let list = ftp_stream.list(None)?; + assert!(list.iter().any(|line| line.contains(filename)), "File should appear in list"); + info!("PASS: ls command successful, file '{}' found in bucket", filename); + + info!("Testing FTPS: ls . (list current directory)"); + let list_dot = ftp_stream.list(Some(".")).unwrap_or_else(|_| ftp_stream.list(None).unwrap()); + assert!(list_dot.iter().any(|line| line.contains(filename)), "File should appear in ls ."); + info!("PASS: ls . successful, file '{}' found", filename); + + info!("Testing FTPS: ls / (list root directory)"); + let list_root = ftp_stream.list(Some("/")).unwrap(); + assert!(list_root.iter().any(|line| line.contains(bucket_name)), "Bucket should appear in ls /"); + assert!(!list_root.iter().any(|line| line.contains(filename)), "File should not appear in ls /"); + info!( + "PASS: ls / successful, bucket '{}' found, file '{}' not found in root", + bucket_name, filename + ); + + info!("Testing FTPS: ls /. (list root directory with /.)"); + let list_root_dot = ftp_stream + .list(Some("/.")) + .unwrap_or_else(|_| ftp_stream.list(Some("/")).unwrap()); + assert!( + list_root_dot.iter().any(|line| line.contains(bucket_name)), + "Bucket should appear in ls /." + ); + info!("PASS: ls /. successful, bucket '{}' found", bucket_name); + + info!("Testing FTPS: ls /bucket (list bucket by absolute path)"); + let list_bucket = ftp_stream.list(Some(&format!("/{}", bucket_name))).unwrap(); + assert!(list_bucket.iter().any(|line| line.contains(filename)), "File should appear in ls /bucket"); + info!("PASS: ls /{} successful, file '{}' found", bucket_name, filename); + + info!("Testing FTPS: cd . (stay in current directory)"); + ftp_stream.cwd(".")?; + info!("PASS: cd . successful (stays in current directory)"); + + info!("Testing FTPS: ls after cd . (should still see file)"); + let list_after_dot = ftp_stream.list(None)?; + assert!( + list_after_dot.iter().any(|line| line.contains(filename)), + "File should still appear in list after cd ." + ); + info!("PASS: ls after cd . successful, file '{}' still found in bucket", filename); + + info!("Testing FTPS: cd / (go to root directory)"); + ftp_stream.cwd("/")?; + info!("PASS: cd / successful (back to root directory)"); + + info!("Testing FTPS: ls after cd / (should see bucket only)"); + let root_list_after = ftp_stream.list(None)?; + assert!( + !root_list_after.iter().any(|line| line.contains(filename)), + "File should not appear in root ls" + ); + assert!( + root_list_after.iter().any(|line| line.contains(bucket_name)), + "Bucket should appear in root ls" + ); + info!("PASS: ls after cd / successful, file not in root, bucket '{}' found in root", bucket_name); + + info!("Testing FTPS: cd back to bucket"); + ftp_stream.cwd(bucket_name)?; + info!("PASS: cd back to bucket '{}' successful", bucket_name); + + info!("Testing FTPS: delete object"); + ftp_stream.rm(filename)?; + info!("PASS: delete object '{}' successful", filename); + + info!("Testing FTPS: ls verify object deleted"); + let list_after = ftp_stream.list(None)?; + assert!(!list_after.iter().any(|line| line.contains(filename)), "File should be deleted"); + info!("PASS: ls after delete successful, file '{}' is not found", filename); + + info!("Testing FTPS: cd up to root directory"); + ftp_stream.cdup()?; + info!("PASS: cd up to root directory successful"); + + info!("Testing FTPS: cd to nonexistent bucket (should fail)"); + let nonexistent_bucket = "nonexistent-bucket"; + let cd_result = ftp_stream.cwd(nonexistent_bucket); + assert!(cd_result.is_err(), "cd to nonexistent bucket should fail"); + info!("PASS: cd to nonexistent bucket '{}' failed as expected", nonexistent_bucket); + + info!("Testing FTPS: ls verify bucket exists in root"); + let root_list = ftp_stream.list(None)?; + assert!(root_list.iter().any(|line| line.contains(bucket_name)), "Bucket should exist in root"); + info!("PASS: ls root successful, bucket '{}' found in root", bucket_name); + + info!("Testing FTPS: rmdir delete bucket"); + ftp_stream.rmdir(bucket_name)?; + info!("PASS: rmdir bucket '{}' successful", bucket_name); + + info!("Testing FTPS: ls verify bucket deleted"); + let root_list_after = ftp_stream.list(None)?; + assert!(!root_list_after.iter().any(|line| line.contains(bucket_name)), "Bucket should be deleted"); + info!("PASS: ls root after delete successful, bucket '{}' is not found", bucket_name); + + ftp_stream.quit()?; + + info!("FTPS core tests passed"); + Ok(()) + } + .await; + + // Always cleanup server process + let _ = server_process.kill().await; + let _ = server_process.wait().await; + + result +} diff --git a/crates/e2e_test/src/protocols/mod.rs b/crates/e2e_test/src/protocols/mod.rs new file mode 100644 index 00000000..19f7ab90 --- /dev/null +++ b/crates/e2e_test/src/protocols/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Protocol tests for FTPS and SFTP + +pub mod ftps_core; +pub mod test_env; +pub mod test_runner; diff --git a/crates/e2e_test/src/protocols/test_env.rs b/crates/e2e_test/src/protocols/test_env.rs new file mode 100644 index 00000000..4ab480e5 --- /dev/null +++ b/crates/e2e_test/src/protocols/test_env.rs @@ -0,0 +1,72 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Protocol test environment for FTPS and SFTP + +use std::net::TcpStream; +use std::time::Duration; +use tokio::time::sleep; +use tracing::{info, warn}; + +/// Default credentials +pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin"; +pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin"; + +/// Custom test environment that doesn't automatically stop servers +pub struct ProtocolTestEnvironment { + pub temp_dir: String, +} + +impl ProtocolTestEnvironment { + /// Create a new test environment + /// This environment won't stop any server when dropped + pub fn new() -> Result> { + let temp_dir = format!("/tmp/rustfs_protocol_test_{}", uuid::Uuid::new_v4()); + std::fs::create_dir_all(&temp_dir)?; + + Ok(Self { temp_dir }) + } + + /// Wait for server to be ready + pub async fn wait_for_port_ready(port: u16, max_attempts: u32) -> Result<(), Box> { + let address = format!("127.0.0.1:{}", port); + + info!("Waiting for server to be ready on {}", address); + + for i in 0..max_attempts { + if TcpStream::connect(&address).is_ok() { + info!("Server is ready after {} s", i + 1); + return Ok(()); + } + + if i == max_attempts - 1 { + return Err(format!("Server did not become ready within {} s", max_attempts).into()); + } + + sleep(Duration::from_secs(1)).await; + } + + Ok(()) + } +} + +// Implement Drop trait that doesn't stop servers +impl Drop for ProtocolTestEnvironment { + fn drop(&mut self) { + // Clean up temp directory only, don't stop any server + if let Err(e) = std::fs::remove_dir_all(&self.temp_dir) { + warn!("Failed to clean up temp directory {}: {}", self.temp_dir, e); + } + } +} diff --git a/crates/e2e_test/src/protocols/test_runner.rs b/crates/e2e_test/src/protocols/test_runner.rs new file mode 100644 index 00000000..8492fa55 --- /dev/null +++ b/crates/e2e_test/src/protocols/test_runner.rs @@ -0,0 +1,171 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Protocol test runner + +use crate::common::init_logging; +use crate::protocols::ftps_core::test_ftps_core_operations; +use std::time::Instant; +use tokio::time::{Duration, sleep}; +use tracing::{error, info}; + +/// Test result +#[derive(Debug, Clone)] +pub struct TestResult { + pub test_name: String, + pub success: bool, + pub error_message: Option, +} + +impl TestResult { + pub fn success(test_name: String) -> Self { + Self { + test_name, + success: true, + error_message: None, + } + } + + pub fn failure(test_name: String, error: String) -> Self { + Self { + test_name, + success: false, + error_message: Some(error), + } + } +} + +/// Protocol test suite +pub struct ProtocolTestSuite { + tests: Vec, +} + +#[derive(Debug, Clone)] +struct TestDefinition { + name: String, +} + +impl ProtocolTestSuite { + /// Create default test suite + pub fn new() -> Self { + let tests = vec![ + TestDefinition { + name: "test_ftps_core_operations".to_string(), + }, + // TestDefinition { name: "test_sftp_core_operations".to_string() }, + ]; + + Self { tests } + } + + /// Run test suite + pub async fn run_test_suite(&self) -> Vec { + init_logging(); + info!("Starting Protocol test suite"); + + let start_time = Instant::now(); + let mut results = Vec::new(); + + info!("Scheduled {} tests", self.tests.len()); + + // Run tests + for (i, test_def) in self.tests.iter().enumerate() { + let test_description = match test_def.name.as_str() { + "test_ftps_core_operations" => { + info!("=== Starting FTPS Module Test ==="); + "FTPS core operations (put, ls, mkdir, rmdir, delete)" + } + "test_sftp_core_operations" => { + info!("=== Starting SFTP Module Test ==="); + "SFTP core operations (put, ls, mkdir, rmdir, delete)" + } + _ => "", + }; + + info!("Test {}/{} - {}", i + 1, self.tests.len(), test_description); + info!("Running: {}", test_def.name); + + let test_start = Instant::now(); + + let result = self.run_single_test(test_def).await; + let test_duration = test_start.elapsed(); + + match result { + Ok(_) => { + info!("Test passed: {} ({:.2}s)", test_def.name, test_duration.as_secs_f64()); + results.push(TestResult::success(test_def.name.clone())); + } + Err(e) => { + error!("Test failed: {} ({:.2}s): {}", test_def.name, test_duration.as_secs_f64(), e); + results.push(TestResult::failure(test_def.name.clone(), e.to_string())); + } + } + + // Delay between tests to avoid resource conflicts + if i < self.tests.len() - 1 { + sleep(Duration::from_secs(2)).await; + } + } + + // Print summary + self.print_summary(&results, start_time.elapsed()); + + results + } + + /// Run a single test + async fn run_single_test(&self, test_def: &TestDefinition) -> Result<(), Box> { + match test_def.name.as_str() { + "test_ftps_core_operations" => test_ftps_core_operations().await.map_err(|e| e.into()), + // "test_sftp_core_operations" => test_sftp_core_operations().await.map_err(|e| e.into()), + _ => Err(format!("Test {} not implemented", test_def.name).into()), + } + } + + /// Print test summary + fn print_summary(&self, results: &[TestResult], total_duration: Duration) { + info!("=== Test Suite Summary ==="); + info!("Total duration: {:.2}s", total_duration.as_secs_f64()); + info!("Total tests: {}", results.len()); + + let passed = results.iter().filter(|r| r.success).count(); + let failed = results.len() - passed; + let success_rate = (passed as f64 / results.len() as f64) * 100.0; + + info!("Passed: {} | Failed: {}", passed, failed); + info!("Success rate: {:.1}%", success_rate); + + if failed > 0 { + error!("Failed tests:"); + for result in results.iter().filter(|r| !r.success) { + error!(" - {}: {}", result.test_name, result.error_message.as_ref().unwrap()); + } + } + } +} + +/// Test suite +#[tokio::test] +async fn test_protocol_core_suite() -> Result<(), Box> { + let suite = ProtocolTestSuite::new(); + let results = suite.run_test_suite().await; + + let failed = results.iter().filter(|r| !r.success).count(); + if failed > 0 { + return Err(format!("Protocol tests failed: {failed} failures").into()); + } + + info!("All protocol tests passed"); + Ok(()) +} diff --git a/crates/iam/src/manager.rs b/crates/iam/src/manager.rs index c4b8ef71..cf4b0b28 100644 --- a/crates/iam/src/manager.rs +++ b/crates/iam/src/manager.rs @@ -1258,6 +1258,28 @@ where self.update_user_with_claims(access_key, u) } + /// Add SSH public key for a user (for SFTP authentication) + pub async fn add_user_ssh_public_key(&self, access_key: &str, public_key: &str) -> Result<()> { + if access_key.is_empty() || public_key.is_empty() { + return Err(Error::InvalidArgument); + } + + let users = self.cache.users.load(); + let u = match users.get(access_key) { + Some(u) => u, + None => return Err(Error::NoSuchUser(access_key.to_string())), + }; + + let mut user_identity = u.clone(); + user_identity.add_ssh_public_key(public_key); + + self.api + .save_user_identity(access_key, UserType::Reg, user_identity.clone(), None) + .await?; + + self.update_user_with_claims(access_key, user_identity) + } + pub async fn set_user_status(&self, access_key: &str, status: AccountStatus) -> Result { if access_key.is_empty() { return Err(Error::InvalidArgument); diff --git a/crates/iam/src/sys.rs b/crates/iam/src/sys.rs index 6b6648c1..1173c873 100644 --- a/crates/iam/src/sys.rs +++ b/crates/iam/src/sys.rs @@ -637,6 +637,19 @@ impl IamSys { self.store.update_user_secret_key(access_key, secret_key).await } + /// Add SSH public key for a user (for SFTP authentication) + pub async fn add_user_ssh_public_key(&self, access_key: &str, public_key: &str) -> Result<()> { + if !is_access_key_valid(access_key) { + return Err(IamError::InvalidAccessKeyLength); + } + + if public_key.is_empty() { + return Err(IamError::InvalidArgument); + } + + self.store.add_user_ssh_public_key(access_key, public_key).await + } + pub async fn check_key(&self, access_key: &str) -> Result<(Option, bool)> { if let Some(sys_cred) = get_global_action_cred() { if sys_cred.access_key == access_key { diff --git a/crates/policy/src/auth/mod.rs b/crates/policy/src/auth/mod.rs index cfd5b8e1..7da06ab4 100644 --- a/crates/policy/src/auth/mod.rs +++ b/crates/policy/src/auth/mod.rs @@ -18,6 +18,8 @@ pub use credentials::*; use rustfs_credentials::Credentials; use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; use time::OffsetDateTime; #[derive(Serialize, Deserialize, Clone, Debug, Default)] @@ -42,6 +44,25 @@ impl UserIdentity { update_at: Some(OffsetDateTime::now_utc()), } } + + /// Add an SSH public key to user identity for SFTP authentication + pub fn add_ssh_public_key(&mut self, public_key: &str) { + self.credentials + .claims + .get_or_insert_with(HashMap::new) + .insert("ssh_public_keys".to_string(), json!([public_key])); + } + + /// Get all SSH public keys for user identity + pub fn get_ssh_public_keys(&self) -> Vec { + self.credentials + .claims + .as_ref() + .and_then(|claims| claims.get("ssh_public_keys")) + .and_then(|keys| keys.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).map(String::from).collect()) + .unwrap_or_default() + } } impl From for UserIdentity { diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 6e6d66ec..2c66b696 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -115,6 +115,8 @@ md5.workspace = true mime_guess = { workspace = true } moka = { workspace = true } pin-project-lite.workspace = true +# rand = "0.8" is pinned due to dependency conflicts with workspace version +rand = "0.8" rust-embed = { workspace = true, features = ["interpolate-folder-path"] } s3s.workspace = true shadow-rs = { workspace = true, features = ["build", "metadata"] } @@ -129,6 +131,12 @@ zip = { workspace = true } # Observability and Metrics metrics = { workspace = true } +# FTP and SFTP Libraries +libunftp = { workspace = true } +russh = { workspace = true } +russh-sftp = { workspace = true } +ssh-key = { workspace = true } + [target.'cfg(any(target_os = "macos", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies] sysctl = { workspace = true } diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index 03a39361..01fc0570 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -91,10 +91,22 @@ pub enum AuthType { StreamingUnsignedTrailer, } +#[derive(Debug)] pub struct IAMAuth { simple_auth: SimpleAuth, } +impl Clone for IAMAuth { + fn clone(&self) -> Self { + // Since SimpleAuth doesn't implement Clone, we create a new one + // This is a simplified implementation - in a real scenario, you might need + // to store the credentials separately to properly clone + Self { + simple_auth: SimpleAuth::new(), + } + } +} + impl IAMAuth { pub fn new(ak: impl Into, sk: impl Into) -> Self { let simple_auth = SimpleAuth::from_single(ak, sk); diff --git a/rustfs/src/config/mod.rs b/rustfs/src/config/mod.rs index 00d068ba..64d15c4a 100644 --- a/rustfs/src/config/mod.rs +++ b/rustfs/src/config/mod.rs @@ -135,6 +135,47 @@ pub struct Opt { /// Options: GeneralPurpose, AiTraining, DataAnalytics, WebWorkload, IndustrialIoT, SecureStorage #[arg(long, default_value_t = String::from("GeneralPurpose"), env = "RUSTFS_BUFFER_PROFILE")] pub buffer_profile: String, + + /// Enable FTPS server + #[arg(long, default_value_t = false, env = "RUSTFS_FTPS_ENABLE")] + pub ftps_enable: bool, + + /// FTPS server bind address + #[arg(long, default_value_t = String::from("0.0.0.0:21"), env = "RUSTFS_FTPS_ADDRESS")] + pub ftps_address: String, + + /// FTPS server certificate file path + #[arg(long, env = "RUSTFS_FTPS_CERTS_FILE")] + pub ftps_certs_file: Option, + + /// FTPS server private key file path + #[arg(long, env = "RUSTFS_FTPS_KEY_FILE")] + pub ftps_key_file: Option, + + /// FTPS server passive ports range (e.g., "40000-50000") + #[arg(long, env = "RUSTFS_FTPS_PASSIVE_PORTS")] + pub ftps_passive_ports: Option, + + /// FTPS server external IP address for passive mode (auto-detected if not specified) + #[arg(long, env = "RUSTFS_FTPS_EXTERNAL_IP")] + pub ftps_external_ip: Option, + + /// Enable SFTP server + #[arg(long, default_value_t = false, env = "RUSTFS_SFTP_ENABLE")] + pub sftp_enable: bool, + + /// SFTP server bind address + #[arg(long, default_value_t = String::from("0.0.0.0:22"), env = "RUSTFS_SFTP_ADDRESS")] + pub sftp_address: String, + + /// SFTP server host key file path + #[arg(long, env = "RUSTFS_SFTP_HOST_KEY")] + pub sftp_host_key: Option, + + /// Path to authorized SSH public keys file for SFTP authentication + /// Each line should contain an OpenSSH public key: ssh-rsa AAAA... comment + #[arg(long, env = "RUSTFS_SFTP_AUTHORIZED_KEYS")] + pub sftp_authorized_keys: Option, } // lazy_static::lazy_static! { diff --git a/rustfs/src/init.rs b/rustfs/src/init.rs index 1db6eca7..a415a96a 100644 --- a/rustfs/src/init.rs +++ b/rustfs/src/init.rs @@ -309,3 +309,122 @@ pub(crate) fn init_buffer_profile_system(opt: &config::Opt) { info!("Buffer profiling system initialized successfully"); } } + +/// Initialize the FTPS system +/// +/// This function initializes the FTPS server if enabled in the configuration. +/// It sets up the FTPS server with the appropriate configuration and starts +/// the server in a background task. +/// +/// MINIO CONSTRAINT: FTPS server MUST follow the same lifecycle management +/// as other services and MUST integrate with the global shutdown system. +#[instrument(skip_all)] +pub async fn init_ftp_system( + opt: &crate::config::Opt, + shutdown_tx: tokio::sync::broadcast::Sender<()>, +) -> Result<(), Box> { + use crate::protocols::ftps::server::{FtpsConfig, FtpsServer}; + use std::net::SocketAddr; + + // Check if FTPS is enabled + if !opt.ftps_enable { + debug!("FTPS system is disabled"); + return Ok(()); + } + + // Parse FTPS address + let addr: SocketAddr = opt + .ftps_address + .parse() + .map_err(|e| format!("Invalid FTPS address '{}': {}", opt.ftps_address, e))?; + + // Create FTPS configuration + let config = FtpsConfig { + bind_addr: addr, + passive_ports: opt.ftps_passive_ports.clone(), + external_ip: opt.ftps_external_ip.clone(), + ftps_required: true, + cert_file: opt.ftps_certs_file.clone(), + key_file: opt.ftps_key_file.clone(), + }; + + // Create FTPS server + let server = FtpsServer::new(config).await?; + + // Log server configuration + info!( + "FTPS server configured on {} with passive ports {:?}", + server.config().bind_addr, + server.config().passive_ports + ); + + // Start FTPS server in background task + let shutdown_rx = shutdown_tx.subscribe(); + tokio::spawn(async move { + if let Err(e) = server.start(shutdown_rx).await { + error!("FTPS server error: {}", e); + } + }); + + info!("FTPS system initialized successfully"); + Ok(()) +} + +/// Initialize the SFTP system +/// +/// This function initializes the SFTP server if enabled in the configuration. +/// It sets up the SFTP server with the appropriate configuration and starts +/// the server in a background task. +/// +/// MINIO CONSTRAINT: SFTP server MUST follow the same lifecycle management +/// as other services and MUST integrate with the global shutdown system. +#[instrument(skip_all)] +pub async fn init_sftp_system( + opt: &config::Opt, + shutdown_tx: tokio::sync::broadcast::Sender<()>, +) -> Result<(), Box> { + use crate::protocols::sftp::server::{SftpConfig, SftpServer}; + use std::net::SocketAddr; + + // Check if SFTP is enabled + if !opt.sftp_enable { + debug!("SFTP system is disabled"); + return Ok(()); + } + + // Parse SFTP address + let addr: SocketAddr = opt + .sftp_address + .parse() + .map_err(|e| format!("Invalid SFTP address '{}': {}", opt.sftp_address, e))?; + + // Create SFTP configuration + let config = SftpConfig { + bind_addr: addr, + require_key_auth: false, // TODO: Add key auth configuration + cert_file: None, // CA certificates for client certificate authentication + key_file: opt.sftp_host_key.clone(), // SFTP server host key + authorized_keys_file: opt.sftp_authorized_keys.clone(), // Pre-loaded authorized SSH public keys + }; + + // Create SFTP server + let server = SftpServer::new(config)?; + + // Log server configuration + info!( + "SFTP server configured on {} with key auth requirement: {}", + server.config().bind_addr, + server.config().require_key_auth + ); + + // Start SFTP server in background task + let shutdown_rx = shutdown_tx.subscribe(); + tokio::spawn(async move { + if let Err(e) = server.start(shutdown_rx).await { + error!("SFTP server error: {}", e); + } + }); + + info!("SFTP system initialized successfully"); + Ok(()) +} diff --git a/rustfs/src/main.rs b/rustfs/src/main.rs index 9a993d00..db42f8f1 100644 --- a/rustfs/src/main.rs +++ b/rustfs/src/main.rs @@ -19,6 +19,7 @@ mod error; mod init; mod license; mod profiling; +mod protocols; mod server; mod storage; mod update; @@ -26,7 +27,8 @@ mod version; // Ensure the correct path for parse_license is imported use crate::init::{ - add_bucket_notification_configuration, init_buffer_profile_system, init_kms_system, init_update_check, print_server_info, + add_bucket_notification_configuration, init_buffer_profile_system, init_ftp_system, init_kms_system, init_sftp_system, + init_update_check, print_server_info, }; use crate::server::{ SHUTDOWN_TIMEOUT, ServiceState, ServiceStateManager, ShutdownSignal, init_cert, init_event_notifier, shutdown_event_notifier, @@ -266,6 +268,19 @@ async fn run(opt: config::Opt) -> Result<()> { // Initialize KMS system if enabled init_kms_system(&opt).await?; + // Create a shutdown channel for FTP/SFTP services + let (ftp_sftp_shutdown_tx, _) = tokio::sync::broadcast::channel(1); + + // Initialize FTP system if enabled + init_ftp_system(&opt, ftp_sftp_shutdown_tx.clone()) + .await + .map_err(Error::other)?; + + // Initialize SFTP system if enabled + init_sftp_system(&opt, ftp_sftp_shutdown_tx.clone()) + .await + .map_err(Error::other)?; + // Initialize buffer profiling system init_buffer_profile_system(&opt); @@ -364,11 +379,11 @@ async fn run(opt: config::Opt) -> Result<()> { match wait_for_shutdown().await { #[cfg(unix)] ShutdownSignal::CtrlC | ShutdownSignal::Sigint | ShutdownSignal::Sigterm => { - handle_shutdown(&state_manager, s3_shutdown_tx, console_shutdown_tx, ctx.clone()).await; + handle_shutdown(&state_manager, s3_shutdown_tx, console_shutdown_tx, ftp_sftp_shutdown_tx, ctx.clone()).await; } #[cfg(not(unix))] ShutdownSignal::CtrlC => { - handle_shutdown(&state_manager, s3_shutdown_tx, console_shutdown_tx, ctx.clone()).await; + handle_shutdown(&state_manager, s3_shutdown_tx, console_shutdown_tx, ftp_sftp_shutdown_tx, ctx.clone()).await; } } @@ -381,6 +396,7 @@ async fn handle_shutdown( state_manager: &ServiceStateManager, s3_shutdown_tx: Option>, console_shutdown_tx: Option>, + ftp_sftp_shutdown_tx: tokio::sync::broadcast::Sender<()>, ctx: CancellationToken, ) { ctx.cancel(); @@ -447,6 +463,9 @@ async fn handle_shutdown( // Wait for the worker thread to complete the cleaning work tokio::time::sleep(SHUTDOWN_TIMEOUT).await; + // Send shutdown signal to FTP/SFTP services + let _ = ftp_sftp_shutdown_tx.send(()); + // the last updated status is stopped state_manager.update(ServiceState::Stopped); info!( diff --git a/rustfs/src/protocols/README.md b/rustfs/src/protocols/README.md new file mode 100644 index 00000000..6537ee16 --- /dev/null +++ b/rustfs/src/protocols/README.md @@ -0,0 +1,159 @@ +# RustFS Protocols + +RustFS provides multiple protocol interfaces for accessing object storage, including: + +- **FTPS** - File Transfer Protocol over TLS for traditional file transfers +- **SFTP** - SSH File Transfer Protocol for secure file transfers with key-based authentication + +## Quick Start + +### Enable Protocols on Startup + +```bash +# Start RustFS with all protocols enabled +rustfs \ + --address 0.0.0.0:9000 \ + --access-key rustfsadmin \ + --secret-key rustfsadmin \ + --ftps-enable \ + --ftps-address 0.0.0.0:21 \ + --ftps-certs-file /path/to/cert.pem \ + --ftps-key-file /path/to/key.pem \ + --ftps-passive-ports "40000-41000" \ + --sftp-enable \ + --sftp-address 0.0.0.0:22 \ + --sftp-host-key /path/to/host_key \ + --sftp-authorized-keys /path/to/authorized_keys \ + /data +``` + +## Protocol Details + +### FTPS +- **Port**: 21 +- **Protocol**: FTP over TLS (FTPS) +- **Authentication**: Access Key / Secret Key (same as S3) +- **Features**: + - File upload/download + - Directory listing + - File deletion + - Passive mode data connections +- **Limitations**: + - Cannot create/delete buckets (use S3 API) + - No file rename/copy operations + - No multipart upload + +### SFTP +- **Port**: 22 +- **Protocol**: SSH File Transfer Protocol +- **Authentication**: + - Password (Access Key / Secret Key) + - SSH Public Key (recommended) + - SSH Certificate (optional) +- **Features**: + - File upload/download + - Directory listing and manipulation + - File deletion + - Bucket creation/deletion via mkdir/rmdir +- **Limitations**: + - No file rename/copy operations + - No multipart upload + - No symlinks or file attributes modification + +- **Documentation: [SFTP README](./sftp/README.md)** + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ RustFS Client │ +│ (FTPS Client, SFTP Client) │ +└─────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────┐ +│ Protocol Gateway Layer │ +│ - Action Mapping │ +│ - Authorization (IAM Policy) │ +│ - Operation Support Check │ +└─────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────┐ +│ Internal S3 Client │ +│ (ProtocolS3Client) │ +└─────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────┐ +│ Storage Layer (ECStore) │ +│ - Object Storage │ +│ - Erasure Coding │ +│ - Metadata Management │ +└─────────────────────────────────────────┘ +``` + +## Security + +### Encryption +- **FTPS**: TLS 1.2/1.3 for all control and data connections +- **SFTP**: SSH2 protocol with modern cipher suites (Ed25519, RSA, ECDSA) + +### Authentication +All protocols share the same IAM-based authentication system: +- **Access Key**: Username identifier +- **Secret Key**: Password/API key +- **SSH Public Keys**: For SFTP key-based authentication +- **IAM Policies**: Fine-grained access control + +### Authorization +Unified authorization based on IAM policies: +- Supports `s3:*` action namespace +- Condition-based policies (IP, time, etc.) +- Bucket-level and object-level permissions + +## Troubleshooting + +### FTPS Connection Issues +```bash +# Check TLS certificate +openssl s_client -connect localhost:21 -starttls ftp + +# Test with lftp +lftp -u username,password -e "set ssl:verify-certificate no; ls; bye" ftps://localhost +``` + +### SFTP Connection Issues +```bash +# Verbose SFTP connection +sftp -vvv -o StrictHostKeyChecking=no -o LogLevel=DEBUG3 user@localhost + +# Check SSH host key +ssh-keygen -l -f /path/to/host_key +``` + +## Configuration Reference + +### FTPS Configuration + +| Option | Environment Variable | Description | Default | +|--------|---------------------|-------------|---------| +| `--ftps-enable` | `RUSTFS_FTPS_ENABLE` | Enable FTPS server | `false` | +| `--ftps-address` | `RUSTFS_FTPS_ADDRESS` | FTPS bind address | `0.0.0.0:21` | +| `--ftps-certs-file` | `RUSTFS_FTPS_CERTS_FILE` | TLS certificate file | - | +| `--ftps-key-file` | `RUSTFS_FTPS_KEY_FILE` | TLS private key file | - | +| `--ftps-passive-ports` | `RUSTFS_FTPS_PASSIVE_PORTS` | Passive port range | - | +| `--ftps-external-ip` | `RUSTFS_FTPS_EXTERNAL_IP` | External IP for NAT | - | + +### SFTP Configuration + +| Option | Environment Variable | Description | Default | +|--------|---------------------|-------------|---------| +| `--sftp-enable` | `RUSTFS_SFTP_ENABLE` | Enable SFTP server | `false` | +| `--sftp-address` | `RUSTFS_SFTP_ADDRESS` | SFTP bind address | `0.0.0.0:22` | +| `--sftp-host-key` | `RUSTFS_SFTP_HOST_KEY` | SSH host key file | - | +| `--sftp-authorized-keys` | `RUSTFS_SFTP_AUTHORIZED_KEYS` | Authorized keys file | - | + +## See Also + +- [FTPS README](./ftps/README.md) - Detailed FTPS usage +- [SFTP README](./sftp/README.md) - Detailed SFTP usage +- [RustFS Documentation](https://rustfs.com/docs/) +- [IAM Policy Reference](https://rustfs.com/docs/iam-policies) \ No newline at end of file diff --git a/rustfs/src/protocols/client/mod.rs b/rustfs/src/protocols/client/mod.rs new file mode 100644 index 00000000..b88e013c --- /dev/null +++ b/rustfs/src/protocols/client/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2024 RustFS Team +// +// 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. + +pub mod s3; diff --git a/rustfs/src/protocols/client/s3.rs b/rustfs/src/protocols/client/s3.rs new file mode 100644 index 00000000..361951b8 --- /dev/null +++ b/rustfs/src/protocols/client/s3.rs @@ -0,0 +1,281 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use crate::storage::ecfs::FS; +use http::{HeaderMap, Method}; +use rustfs_credentials; +use s3s::dto::*; +use s3s::{S3, S3Request, S3Result}; +use tokio_stream::Stream; +use tracing::trace; + +/// S3 client for internal protocol use +pub struct ProtocolS3Client { + /// FS instance for internal operations + fs: FS, + /// Access key for the client + access_key: String, +} + +impl ProtocolS3Client { + /// Create a new protocol S3 client + pub fn new(fs: FS, access_key: String) -> Self { + Self { fs, access_key } + } + + /// Get object - maps to S3 GetObject + pub async fn get_object(&self, input: GetObjectInput) -> S3Result { + trace!( + "Protocol S3 client GetObject request: bucket={}, key={:?}, access_key={}", + input.bucket, input.key, self.access_key + ); + // Go through standard S3 API path + let uri: http::Uri = format!("/{}{}", input.bucket, input.key.as_str()).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::GET, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.get_object(req).await?; + Ok(resp.output) + } + + /// Put object - maps to S3 PutObject + pub async fn put_object(&self, input: PutObjectInput) -> S3Result { + trace!( + "Protocol S3 client PutObject request: bucket={}, key={:?}, access_key={}", + input.bucket, input.key, self.access_key + ); + let uri: http::Uri = format!("/{}{}", input.bucket, input.key.as_str()).parse().unwrap_or_default(); + + // Set required headers for put operation + let mut headers = HeaderMap::default(); + if let Some(ref body) = input.body { + let (lower, upper) = body.size_hint(); + if let Some(len) = upper { + headers.insert("content-length", len.to_string().parse().unwrap()); + } else if lower > 0 { + headers.insert("content-length", lower.to_string().parse().unwrap()); + } + } + + let req = S3Request { + input, + method: Method::PUT, + uri, + headers, + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.put_object(req).await?; + Ok(resp.output) + } + + /// Delete object - maps to S3 DeleteObject + pub async fn delete_object(&self, input: DeleteObjectInput) -> S3Result { + trace!( + "Protocol S3 client DeleteObject request: bucket={}, key={:?}, access_key={}", + input.bucket, input.key, self.access_key + ); + let uri: http::Uri = format!("/{}{}", input.bucket, input.key.as_str()).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::DELETE, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.delete_object(req).await?; + Ok(resp.output) + } + + /// Head object - maps to S3 HeadObject + pub async fn head_object(&self, input: HeadObjectInput) -> S3Result { + trace!( + "Protocol S3 client HeadObject request: bucket={}, key={:?}, access_key={}", + input.bucket, input.key, self.access_key + ); + let uri: http::Uri = format!("/{}{}", input.bucket, input.key.as_str()).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::HEAD, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.head_object(req).await?; + Ok(resp.output) + } + + /// Head bucket - maps to S3 HeadBucket + pub async fn head_bucket(&self, input: HeadBucketInput) -> S3Result { + trace!( + "Protocol S3 client HeadBucket request: bucket={}, access_key={}", + input.bucket, self.access_key + ); + let uri: http::Uri = format!("/{}", input.bucket).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::HEAD, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.head_bucket(req).await?; + Ok(resp.output) + } + + /// List objects v2 - maps to S3 ListObjectsV2 + pub async fn list_objects_v2(&self, input: ListObjectsV2Input) -> S3Result { + trace!( + "Protocol S3 client ListObjectsV2 request: bucket={}, access_key={}", + input.bucket, self.access_key + ); + let uri: http::Uri = format!("/{}?list-type=2", input.bucket).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::GET, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.list_objects_v2(req).await?; + Ok(resp.output) + } + + /// List buckets - maps to S3 ListBuckets + /// Note: This requires credentials and ReqInfo because list_buckets performs credential validation + pub async fn list_buckets(&self, input: ListBucketsInput, secret_key: &str) -> S3Result { + trace!("Protocol S3 client ListBuckets request: access_key={}", self.access_key); + + // Create proper credentials with the real secret key from authentication + let credentials = Some(s3s::auth::Credentials { + access_key: self.access_key.clone(), + secret_key: secret_key.to_string().into(), + }); + + // Check if user is the owner (admin) + let is_owner = if let Some(global_cred) = rustfs_credentials::get_global_action_cred() { + self.access_key == global_cred.access_key + } else { + false + }; + + // Create ReqInfo for authorization (required by list_buckets) + let mut extensions = http::Extensions::default(); + extensions.insert(crate::storage::access::ReqInfo { + cred: Some(rustfs_credentials::Credentials { + access_key: self.access_key.clone(), + secret_key: secret_key.to_string(), + session_token: String::new(), + expiration: None, + status: String::new(), + parent_user: String::new(), + groups: None, + claims: None, + name: None, + description: None, + }), + is_owner, + bucket: None, + object: None, + version_id: None, + region: None, + }); + + let req = S3Request { + input, + method: Method::GET, + uri: http::Uri::from_static("/"), + headers: HeaderMap::default(), + extensions, + credentials, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.list_buckets(req).await?; + Ok(resp.output) + } + + /// Create bucket - maps to S3 CreateBucket + pub async fn create_bucket(&self, input: CreateBucketInput) -> S3Result { + trace!( + "Protocol S3 client CreateBucket request: bucket={:?}, access_key={}", + input.bucket, self.access_key + ); + let bucket_str = input.bucket.as_str(); + let uri: http::Uri = format!("/{}", bucket_str).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::PUT, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.create_bucket(req).await?; + Ok(resp.output) + } + + /// Delete bucket - maps to S3 DeleteBucket + pub async fn delete_bucket(&self, input: DeleteBucketInput) -> S3Result { + trace!( + "Protocol S3 client DeleteBucket request: bucket={}, access_key={}", + input.bucket, self.access_key + ); + let uri: http::Uri = format!("/{}", input.bucket).parse().unwrap_or_default(); + let req = S3Request { + input, + method: Method::DELETE, + uri, + headers: HeaderMap::default(), + extensions: http::Extensions::default(), + credentials: None, + region: None, + service: None, + trailing_headers: None, + }; + let resp = self.fs.delete_bucket(req).await?; + Ok(resp.output) + } +} diff --git a/rustfs/src/protocols/ftps/driver.rs b/rustfs/src/protocols/ftps/driver.rs new file mode 100644 index 00000000..6d727d61 --- /dev/null +++ b/rustfs/src/protocols/ftps/driver.rs @@ -0,0 +1,910 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! FTPS driver implementation +//! +//! This module provides the FTPS driver that integrates with libunftp +//! and translates FTP operations to S3 actions through the gateway. + +use crate::protocols::client::s3::ProtocolS3Client; +use crate::protocols::gateway::action::S3Action; +use crate::protocols::gateway::adapter::is_operation_supported; +use crate::protocols::gateway::authorize::authorize_operation; +use crate::protocols::gateway::error::map_s3_error_to_ftps; +use crate::protocols::gateway::restrictions::{get_s3_equivalent_operation, is_ftp_feature_supported}; +use crate::protocols::session::context::SessionContext; +use async_trait::async_trait; +use futures::stream; +use futures_util::TryStreamExt; +use libunftp::storage::{Error, ErrorKind, Fileinfo, Metadata, Result, StorageBackend}; +use rustfs_utils::path; +use s3s::dto::StreamingBlob; +use s3s::dto::{GetObjectInput, PutObjectInput}; +use std::fmt::Debug; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncRead; +use tracing::{debug, error, info, trace}; + +/// FTPS storage driver implementation +#[derive(Debug)] +pub struct FtpsDriver {} + +impl FtpsDriver { + /// Create a new FTPS driver + pub fn new() -> Self { + Self {} + } + + /// Validate FTP feature support + fn validate_feature_support(&self, feature: &str) -> Result<()> { + if !is_ftp_feature_supported(feature) { + let error_msg = if let Some(s3_equivalent) = get_s3_equivalent_operation(feature) { + format!("Unsupported FTP feature: {}. S3 equivalent: {}", feature, s3_equivalent) + } else { + format!("Unsupported FTP feature: {}", feature) + }; + error!("{}", error_msg); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, error_msg)); + } + Ok(()) + } + + /// Get SessionContext from User + fn get_session_context_from_user(&self, user: &super::server::FtpsUser) -> Result { + Ok(user.session_context.clone()) + } + + /// Create ProtocolS3Client for the given user + fn create_s3_client_for_user(&self, user: &super::server::FtpsUser) -> Result { + let session_context = &user.session_context; + let fs = crate::storage::ecfs::FS {}; + + let s3_client = ProtocolS3Client::new(fs, session_context.access_key().to_string()); + Ok(s3_client) + } + + /// List all buckets (for root path) + async fn list_buckets( + &self, + user: &super::server::FtpsUser, + session_context: &SessionContext, + ) -> Result>> { + let s3_client = self.create_s3_client_for_user(user)?; + + let action = S3Action::ListBuckets; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + error!("FTPS LIST - ListBuckets operation not supported for FTPS protocol"); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + match authorize_operation(session_context, &action, "", None).await { + Ok(_) => debug!("FTPS LIST - ListBuckets authorization successful"), + Err(e) => { + error!("FTPS LIST - ListBuckets authorization failed: {}", e); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied")); + } + } + + let mut list_result = Vec::new(); + + // List all buckets + let input = s3s::dto::ListBucketsInput::builder() + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build ListBucketsInput"))?; + + // Get the real secret key from the authenticated user + let secret_key = &session_context.principal.user_identity.credentials.secret_key; + debug!( + "FTPS LIST - calling S3 list_buckets with access_key: {}", + session_context.principal.access_key() + ); + + match s3_client.list_buckets(input, secret_key).await { + Ok(output) => { + debug!( + "FTPS LIST - S3 list_buckets succeeded, buckets count: {:?}", + output.buckets.as_ref().map(|b| b.len()).unwrap_or(0) + ); + if let Some(buckets) = output.buckets { + for bucket in buckets { + if let Some(ref bucket_name) = bucket.name { + debug!("FTPS LIST - found bucket: '{}'", bucket_name); + + let metadata = FtpsMetadata { + size: 0, + is_directory: true, + modification_time: bucket + .creation_date + .map(|t| { + let offset_datetime: time::OffsetDateTime = t.into(); + offset_datetime.unix_timestamp() as u64 + }) + .unwrap_or(0), + }; + + list_result.push(Fileinfo { + path: PathBuf::from(bucket_name), + metadata, + }); + } + } + } + + Ok(list_result) + } + Err(e) => { + error!("FTPS LIST - Failed to list buckets: {}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + /// Create bucket + async fn create_bucket(&self, user: &super::server::FtpsUser, session_context: &SessionContext, bucket: &str) -> Result<()> { + let s3_client = self.create_s3_client_for_user(user)?; + + let action = S3Action::CreateBucket; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + error!("FTPS CREATE_BUCKET - operation not supported for FTPS protocol"); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + match authorize_operation(session_context, &action, bucket, None).await { + Ok(_) => debug!("FTPS CREATE_BUCKET - authorization successful"), + Err(e) => { + error!("FTPS CREATE_BUCKET - authorization failed: {}", e); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied")); + } + } + + // Create bucket + let mut input_builder = s3s::dto::CreateBucketInput::builder(); + input_builder.set_bucket(bucket.to_string()); + let input = input_builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build CreateBucketInput"))?; + + match s3_client.create_bucket(input).await { + Ok(_) => { + debug!("FTPS CREATE_BUCKET - successfully created bucket: '{}'", bucket); + Ok(()) + } + Err(e) => { + error!("FTPS CREATE_BUCKET - failed to create bucket: '{}', error: {}", bucket, e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + /// Get bucket and key from path + fn parse_path(&self, path_str: &str) -> Result<(String, Option)> { + debug!("FTPS parse_path - input: '{}'", path_str); + let (bucket, object) = path::path_to_bucket_object(path_str); + + let key = if object.is_empty() { None } else { Some(object) }; + + debug!("FTPS parse_path - bucket: '{}', key: {:?}", bucket, key); + Ok((bucket, key)) + } +} + +#[async_trait] +impl StorageBackend for FtpsDriver { + type Metadata = FtpsMetadata; + + /// Get file metadata + async fn metadata + Send + Debug>(&self, user: &super::server::FtpsUser, path: P) -> Result { + trace!("FTPS metadata request for path: {:?}", path); + + let s3_client = self.create_s3_client_for_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + let (bucket, key) = self.parse_path(&path_str)?; + + if let Some(object_key) = key { + // Object metadata request + let action = S3Action::HeadObject; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + let session_context = self.get_session_context_from_user(user)?; + // Log the operation for audit purposes + debug!( + "FTPS operation authorized: user={}, action={}, bucket={}, object={}, source_ip={}", + session_context.access_key(), + action.as_str(), + bucket, + object_key, + session_context.source_ip + ); + + authorize_operation(&session_context, &action, &bucket, Some(&object_key)) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + let mut builder = s3s::dto::HeadObjectInput::builder(); + builder.set_bucket(bucket.clone()); + builder.set_key(object_key.clone()); + let input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build HeadObjectInput"))?; + + match s3_client.head_object(input).await { + Ok(output) => { + let metadata = FtpsMetadata { + size: output.content_length.unwrap_or(0) as u64, + is_directory: false, + modification_time: output + .last_modified + .map(|t| { + let offset_datetime: time::OffsetDateTime = t.into(); + offset_datetime.unix_timestamp() as u64 + }) + .unwrap_or(0), + }; + Ok(metadata) + } + Err(e) => { + error!("Failed to get object metadata: {}", e); + Err(map_s3_error_to_ftps(&e)) + } + } + } else { + // Bucket metadata request + let action = S3Action::HeadBucket; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + let session_context = self.get_session_context_from_user(user)?; + authorize_operation(&session_context, &action, &bucket, None) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + let mut builder = s3s::dto::HeadBucketInput::builder(); + builder.set_bucket(bucket.clone()); + let input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build HeadBucketInput"))?; + + match s3_client.head_bucket(input).await { + Ok(_) => { + let metadata = FtpsMetadata { + size: 0, + is_directory: true, + modification_time: 0, + }; + Ok(metadata) + } + Err(e) => { + error!("Failed to get bucket metadata: {}", e); + Err(map_s3_error_to_ftps(&e)) + } + } + } + } + + /// Get directory listing + async fn list + Send + Debug>( + &self, + user: &super::server::FtpsUser, + path: P, + ) -> Result>> { + info!("FTPS LIST request - user: {}, raw path: {:?}", user.username, path); + + let s3_client = self.create_s3_client_for_user(user)?; + let session_context = self.get_session_context_from_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + info!("FTPS LIST - parsing path: '{}'", path_str); + + // Check if this is root path listing + if path_str == "/" || path_str == "/." { + debug!("FTPS LIST - root path listing (including /.), using ListBuckets"); + return self.list_buckets(user, &session_context).await; + } + + // Handle paths ending with /., e.g., /testbucket/. + // Remove trailing /. to get the actual path + let cleaned_path = if let Some(stripped) = path_str.strip_suffix("/.") { + info!("FTPS LIST - path ends with /., removing trailing /."); + stripped + } else { + &path_str + }; + + let (bucket, prefix) = self.parse_path(cleaned_path)?; + debug!("FTPS LIST - parsed bucket: '{}', prefix: {:?}", bucket, prefix); + + // Validate feature support + self.validate_feature_support("LIST command")?; + + let action = S3Action::ListBucket; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + debug!("FTPS LIST - authorizing operation for bucket: '{}', prefix: {:?}", bucket, prefix); + match authorize_operation(&session_context, &action, &bucket, prefix.as_deref()).await { + Ok(_) => debug!("FTPS LIST - authorization successful"), + Err(e) => { + error!("FTPS LIST - authorization failed: {}", e); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied")); + } + } + + let mut list_result = Vec::new(); + + // List objects with prefix + let mut builder = s3s::dto::ListObjectsV2Input::builder(); + builder.set_bucket(bucket.clone()); + builder.set_prefix(prefix.clone()); + builder.set_delimiter(Option::from("/".to_string())); + let input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build ListObjectsV2Input"))?; + + match s3_client.list_objects_v2(input).await { + Ok(output) => { + // Add directories (common prefixes) + if let Some(common_prefixes) = output.common_prefixes { + for prefix_info in common_prefixes { + if let Some(key) = prefix_info.prefix { + let dir_name = key.trim_end_matches('/').to_string(); + + let metadata = FtpsMetadata { + size: 0, + is_directory: true, + modification_time: 0, + }; + + list_result.push(Fileinfo { + path: PathBuf::from(dir_name), + metadata, + }); + } + } + } + + // Add files (objects) + if let Some(contents) = output.contents { + for object in contents { + if let Some(key) = object.key { + let file_name = key; + + let metadata = FtpsMetadata { + size: object.size.unwrap_or(0) as u64, + is_directory: false, + modification_time: object + .last_modified + .map(|t| { + let offset_datetime: time::OffsetDateTime = t.into(); + offset_datetime.unix_timestamp() as u64 + }) + .unwrap_or(0), + }; + + list_result.push(Fileinfo { + path: PathBuf::from(file_name), + metadata, + }); + } + } + } + + Ok(list_result) + } + Err(e) => { + error!("Failed to list objects: {}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + /// Get file + async fn get + Send + Debug>( + &self, + user: &super::server::FtpsUser, + path: P, + start_pos: u64, + ) -> Result> { + trace!("FTPS get request for path: {:?} at position: {}", path, start_pos); + + let s3_client = self.create_s3_client_for_user(user)?; + let session_context = self.get_session_context_from_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + let (bucket, key) = self.parse_path(&path_str)?; + + if key.is_none() { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Cannot read bucket as file")); + } + + let object_key = key.unwrap(); + + let action = S3Action::GetObject; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + authorize_operation(&session_context, &action, &bucket, Some(&object_key)) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + let mut builder = GetObjectInput::builder(); + builder.set_bucket(bucket); + builder.set_key(object_key); + let mut input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build GetObjectInput"))?; + + if start_pos > 0 { + input.range = Some( + s3s::dto::Range::parse(&format!("bytes={}-", start_pos)) + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Invalid range format"))?, + ); + } + + match s3_client.get_object(input).await { + Ok(output) => { + if let Some(body) = output.body { + // Map the s3s/Box error to std::io::Error + let stream = body.map_err(std::io::Error::other); + // Wrap the stream in StreamReader to make it a tokio::io::AsyncRead + let reader = tokio_util::io::StreamReader::new(stream); + Ok(Box::new(reader)) + } else { + Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Empty object body")) + } + } + Err(e) => { + error!("Failed to get object: {}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + /// Put file + async fn put + Send + Debug, R: AsyncRead + Send + Sync + Unpin + 'static>( + &self, + user: &super::server::FtpsUser, + input: R, + path: P, + start_pos: u64, + ) -> Result { + trace!("FTPS put request for path: {:?} at position: {}", path, start_pos); + + let s3_client = self.create_s3_client_for_user(user)?; + let session_context = self.get_session_context_from_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + let (bucket, key) = self.parse_path(&path_str)?; + + if key.is_none() { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Cannot write to bucket directly")); + } + + let object_key = key.unwrap(); + + // Check for append operation (not supported) + if start_pos > 0 { + self.validate_feature_support("APPE command (file append)")?; + } + + let action = S3Action::PutObject; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + authorize_operation(&session_context, &action, &bucket, Some(&object_key)) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + // Convert AsyncRead to bytes + let bytes_vec = { + let mut buffer = Vec::new(); + let mut reader = input; + tokio::io::copy(&mut reader, &mut buffer) + .await + .map_err(|e| Error::new(ErrorKind::TransientFileNotAvailable, e.to_string()))?; + buffer + }; + + let file_size = bytes_vec.len(); + + let mut put_builder = PutObjectInput::builder(); + put_builder.set_bucket(bucket.clone()); + put_builder.set_key(object_key.clone()); + put_builder.set_content_length(Some(file_size as i64)); + + // Create StreamingBlob with known size + let data_bytes = bytes::Bytes::from(bytes_vec); + let stream = stream::once(async move { Ok::(data_bytes) }); + let streaming_blob = StreamingBlob::wrap(stream); + + put_builder.set_body(Some(streaming_blob)); + let put_input = put_builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build PutObjectInput"))?; + + match s3_client.put_object(put_input).await { + Ok(output) => { + debug!("Successfully put object: {:?}", output); + // Return the size of the uploaded object + Ok(file_size as u64) + } + Err(e) => { + error!("FTPS put - S3 error details: {:?}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + /// Delete file + async fn del + Send + Debug>(&self, user: &super::server::FtpsUser, path: P) -> Result<()> { + trace!("FTPS delete request for path: {:?}", path); + + let s3_client = self.create_s3_client_for_user(user)?; + let session_context = self.get_session_context_from_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + let (bucket, key) = self.parse_path(&path_str)?; + + if key.is_none() { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Cannot delete bucket")); + } + + let object_key = key.unwrap(); + + let action = S3Action::DeleteObject; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + authorize_operation(&session_context, &action, &bucket, Some(&object_key)) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + let mut builder = s3s::dto::DeleteObjectInput::builder(); + builder.set_bucket(bucket); + builder.set_key(object_key); + let input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build DeleteObjectInput"))?; + + match s3_client.delete_object(input).await { + Ok(_) => { + debug!("Successfully deleted object"); + Ok(()) + } + Err(e) => { + error!("Failed to delete object: {}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + /// Create directory + async fn mkd + Send + Debug>(&self, user: &super::server::FtpsUser, path: P) -> Result<()> { + let s3_client = self.create_s3_client_for_user(user)?; + let session_context = self.get_session_context_from_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + let (bucket, key) = self.parse_path(&path_str)?; + + let dir_key = if let Some(k) = key { + // Creating directory inside bucket + path::retain_slash(&k) + } else { + // Creating bucket - use CreateBucket action instead of PutObject + debug!("FTPS MKDIR - Creating bucket: '{}'", bucket); + return self.create_bucket(user, &session_context, &bucket).await; + }; + + let action = S3Action::PutObject; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + authorize_operation(&session_context, &action, &bucket, Some(&dir_key)) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + // Create directory marker object + let mut input_builder = PutObjectInput::builder(); + input_builder.set_bucket(bucket); + input_builder.set_key(dir_key); + input_builder.set_body(Some(StreamingBlob::from(s3s::Body::from(Vec::new())))); + let input = input_builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build PutObjectInput"))?; + + match s3_client.put_object(input).await { + Ok(_) => { + debug!("Successfully created directory marker"); + Ok(()) + } + Err(e) => { + error!("Failed to create directory marker: {}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + + async fn rename + Send + Debug>(&self, _user: &super::server::FtpsUser, _from: P, _to: P) -> Result<()> { + // Rename/copy operations are not supported in FTPS + Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Rename operation not supported")) + } + + /// Remove directory + async fn rmd + Send + Debug>(&self, user: &super::server::FtpsUser, path: P) -> Result<()> { + debug!("FTPS RMD request for path: {:?}", path); + + let s3_client = self.create_s3_client_for_user(user)?; + let session_context = self.get_session_context_from_user(user)?; + + let path_str = path.as_ref().to_string_lossy(); + let (bucket, key) = self.parse_path(&path_str)?; + + if let Some(key) = key { + // Remove directory inside bucket + let dir_key = path::retain_slash(&key); + + let action = S3Action::DeleteObject; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation + authorize_operation(&session_context, &action, &bucket, Some(&dir_key)) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + // Save references for debug output after build + let bucket_for_log = bucket.clone(); + let dir_key_for_log = dir_key.clone(); + + let mut builder = s3s::dto::DeleteObjectInput::builder(); + builder = builder.bucket(bucket); + builder = builder.key(dir_key); + let input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build DeleteObjectInput"))?; + + match s3_client.delete_object(input).await { + Ok(_) => { + debug!( + "FTPS RMD - successfully removed directory marker: '{}' in bucket '{}'", + dir_key_for_log, bucket_for_log + ); + Ok(()) + } + Err(e) => { + error!("FTPS RMD - failed to remove directory marker: {}", e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } else { + // Delete bucket - check if bucket is empty first + debug!("FTPS RMD - attempting to delete bucket: '{}'", bucket); + + let action = S3Action::DeleteBucket; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + authorize_operation(&session_context, &action, &bucket, None) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + // Check if bucket is empty + let list_input = s3s::dto::ListObjectsV2Input { + bucket: bucket.clone(), + max_keys: Some(1), + ..Default::default() + }; + + match s3_client.list_objects_v2(list_input).await { + Ok(output) => { + if let Some(objects) = output.contents { + if !objects.is_empty() { + debug!("FTPS RMD - bucket '{}' is not empty, cannot delete", bucket); + return Err(Error::new( + ErrorKind::PermanentFileNotAvailable, + format!("Bucket '{}' is not empty", bucket), + )); + } + } + } + Err(e) => { + debug!("FTPS RMD - failed to list objects: {}", e); + } + } + + // Bucket is empty, delete it + let delete_bucket_input = s3s::dto::DeleteBucketInput { + bucket: bucket.clone(), + ..Default::default() + }; + + match s3_client.delete_bucket(delete_bucket_input).await { + Ok(_) => { + debug!("FTPS RMD - successfully deleted bucket: '{}'", bucket); + Ok(()) + } + Err(e) => { + error!("FTPS RMD - failed to delete bucket '{}': {}", bucket, e); + let protocol_error = map_s3_error_to_ftps(&e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, protocol_error)) + } + } + } + } + + /// Change working directory + async fn cwd + Send + Debug>(&self, user: &super::server::FtpsUser, path: P) -> Result<()> { + debug!("FTPS cwd request for path: {:?}", path); + + let session_context = self.get_session_context_from_user(user)?; + let path_str = path.as_ref().to_string_lossy(); + info!("FTPS cwd - received path: '{}'", path_str); + + // Handle special cases + if path_str == "/" || path_str == "/." { + // cd to root directory - always allowed + debug!("FTPS cwd - changing to root directory"); + return Ok(()); + } + + if path_str == "." { + // cd . - stay in current directory + debug!("FTPS cwd - staying in current directory"); + return Ok(()); + } + + if path_str == ".." { + // cd .. from root directory should fail + error!("FTPS cwd - cannot go above root directory"); + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Cannot go above root directory")); + } + + // Parse the path + let (bucket, key) = self.parse_path(&path_str)?; + debug!("FTPS cwd - parsed bucket: '{}', key: {:?}", bucket, key); + + // S3 does not support hierarchical directories - you can only cd to bucket root + // Exception: key being "." means stay in current place (handled earlier), but path::clean may have converted it + if key.is_some() && key.as_ref().map(|k| k != ".").unwrap_or(true) { + error!( + "FTPS cwd - S3 does not support multi-level directories, cannot cd to path with key: {:?}", + key + ); + return Err(Error::new( + ErrorKind::PermanentFileNotAvailable, + "S3 does not support multi-level directories. Use absolute path to switch buckets.", + )); + } + + // Validate feature support + self.validate_feature_support("CWD command")?; + + // Verify that the bucket exists by trying to list it + let s3_client = self.create_s3_client_for_user(user)?; + let action = S3Action::HeadBucket; + if !is_operation_supported(crate::protocols::session::context::Protocol::Ftps, &action) { + return Err(Error::new(ErrorKind::PermanentFileNotAvailable, "Operation not supported")); + } + + // Authorize the operation first + authorize_operation(&session_context, &action, &bucket, None) + .await + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Access denied"))?; + + // Check if bucket actually exists + let mut builder = s3s::dto::HeadBucketInput::builder(); + builder.set_bucket(bucket.clone()); + let input = builder + .build() + .map_err(|_| Error::new(ErrorKind::PermanentFileNotAvailable, "Failed to build HeadBucketInput"))?; + + match s3_client.head_bucket(input).await { + Ok(_) => { + debug!("FTPS cwd - bucket '{}' exists and is accessible", bucket); + Ok(()) + } + Err(e) => { + error!("FTPS cwd - bucket '{}' does not exist or access denied: {}", bucket, e); + Err(Error::new(ErrorKind::PermanentFileNotAvailable, format!("Bucket '{}' not found", bucket))) + } + } + } +} + +/// FTPS metadata implementation +#[derive(Debug, Clone)] +pub struct FtpsMetadata { + /// File size in bytes + size: u64, + /// Whether this is a directory + is_directory: bool, + /// Last modification time (Unix timestamp) + modification_time: u64, +} + +impl Metadata for FtpsMetadata { + /// Get file size + fn len(&self) -> u64 { + self.size + } + + /// Check if file is empty + fn is_empty(&self) -> bool { + self.size == 0 + } + + /// Check if this is a directory + fn is_dir(&self) -> bool { + self.is_directory + } + + /// Check if this is a file + fn is_file(&self) -> bool { + !self.is_directory + } + + /// Check if file is a symbolic link (stub implementation) + /// + /// S3 doesn't support symbolic links + fn is_symlink(&self) -> bool { + false + } + + /// Get last modification time + fn modified(&self) -> Result { + Ok(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.modification_time)) + } + + /// Get file permissions (stub implementation) + fn gid(&self) -> u32 { + 0 + } + + /// Get file permissions (stub implementation) + fn uid(&self) -> u32 { + 0 + } + + /// Get file permissions (stub implementation) + fn links(&self) -> u64 { + 1 + } +} diff --git a/rustfs/src/protocols/ftps/mod.rs b/rustfs/src/protocols/ftps/mod.rs new file mode 100644 index 00000000..4467d12f --- /dev/null +++ b/rustfs/src/protocols/ftps/mod.rs @@ -0,0 +1,18 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! FTPS protocol implementation + +pub mod driver; +pub mod server; diff --git a/rustfs/src/protocols/ftps/server.rs b/rustfs/src/protocols/ftps/server.rs new file mode 100644 index 00000000..95b93c92 --- /dev/null +++ b/rustfs/src/protocols/ftps/server.rs @@ -0,0 +1,329 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use crate::protocols::ftps::driver::FtpsDriver; +use crate::protocols::session::context::{Protocol as SessionProtocol, SessionContext}; +use crate::protocols::session::principal::ProtocolPrincipal; +use libunftp::{ + ServerError, + auth::{AuthenticationError, UserDetail}, + options::FtpsRequired, +}; +use std::fmt::{Debug, Display, Formatter}; +use std::net::{IpAddr, SocketAddr}; +use std::path::Path; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::broadcast; +use tracing::{debug, error, info, warn}; + +const ROOT_PATH: &str = "/"; +const DEFAULT_SOURCE_IP: &str = "0.0.0.0"; +const PORT_RANGE_SEPARATOR: &str = "-"; +const PASSIVE_PORTS_PART_COUNT: usize = 2; + +/// FTPS user implementation +#[derive(Debug, Clone)] +pub struct FtpsUser { + /// Username for the FTP session + pub username: String, + /// User's display name + pub name: Option, + /// Session context for this user + pub session_context: SessionContext, +} + +impl UserDetail for FtpsUser { + fn home(&self) -> Option<&Path> { + Some(Path::new(ROOT_PATH)) + } +} + +impl Display for FtpsUser { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.name { + Some(display_name) => write!(f, "FtpsUser({} - {})", self.username, display_name), + None => write!(f, "FtpsUser({})", self.username), + } + } +} + +/// FTPS server initialization error +#[derive(Debug, Error)] +pub enum FtpsInitError { + #[error("failed to bind address {0}")] + Bind(#[from] std::io::Error), + + #[error("server error: {0}")] + Server(#[from] ServerError), + + #[error("invalid FTPS configuration: {0}")] + InvalidConfig(String), +} + +/// FTPS server configuration +#[derive(Debug, Clone)] +pub struct FtpsConfig { + /// Server bind address + pub bind_addr: SocketAddr, + /// Passive port range (e.g., "40000-50000") + pub passive_ports: Option, + /// External IP address for passive mode + pub external_ip: Option, + /// Whether FTPS is required + pub ftps_required: bool, + /// Certificate file path + pub cert_file: Option, + /// Private key file path + pub key_file: Option, +} + +impl FtpsConfig { + /// Validates the configuration + pub async fn validate(&self) -> Result<(), FtpsInitError> { + if self.ftps_required && (self.cert_file.is_none() || self.key_file.is_none()) { + return Err(FtpsInitError::InvalidConfig( + "FTPS is required but certificate or key file is missing".to_string(), + )); + } + + if let Some(path) = &self.cert_file { + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + return Err(FtpsInitError::InvalidConfig(format!("Certificate file not found: {}", path))); + } + } + + if let Some(path) = &self.key_file { + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + return Err(FtpsInitError::InvalidConfig(format!("Key file not found: {}", path))); + } + } + + // Validate passive ports format + if self.passive_ports.is_some() { + self.parse_passive_ports()?; + } + + Ok(()) + } + + /// Parse passive ports range from string format "start-end" + fn parse_passive_ports(&self) -> Result, FtpsInitError> { + match &self.passive_ports { + Some(ports) => { + let parts: Vec<&str> = ports.split(PORT_RANGE_SEPARATOR).collect(); + if parts.len() != PASSIVE_PORTS_PART_COUNT { + return Err(FtpsInitError::InvalidConfig(format!( + "Invalid passive ports format: {}, expected 'start-end'", + ports + ))); + } + + let start = parts[0] + .parse::() + .map_err(|e| FtpsInitError::InvalidConfig(format!("Invalid start port: {}", e)))?; + let end = parts[1] + .parse::() + .map_err(|e| FtpsInitError::InvalidConfig(format!("Invalid end port: {}", e)))?; + + if start > end { + return Err(FtpsInitError::InvalidConfig("Start port cannot be greater than end port".to_string())); + } + + Ok(start..=end) + } + None => Err(FtpsInitError::InvalidConfig("No passive ports configured".to_string())), + } + } +} + +/// FTPS server implementation +pub struct FtpsServer { + /// Server configuration + config: FtpsConfig, +} + +impl FtpsServer { + /// Create a new FTPS server + pub async fn new(config: FtpsConfig) -> Result { + config.validate().await?; + Ok(Self { config }) + } + + /// Start the FTPS server + /// + /// This method binds the listener first to ensure the port is available, + /// then spawns the server loop in a background task. + pub async fn start(&self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<(), FtpsInitError> { + info!("Initializing FTPS server on {}", self.config.bind_addr); + + let mut server_builder = + libunftp::ServerBuilder::with_authenticator(Box::new(FtpsDriver::new), Arc::new(FtpsAuthenticator::new())); + + // Configure passive ports for data connections + if let Some(passive_ports) = &self.config.passive_ports { + let range = self.config.parse_passive_ports()?; + info!("Configuring FTPS passive ports range: {:?} ({})", range, passive_ports); + server_builder = server_builder.passive_ports(range); + } else { + warn!("No passive ports configured, using system-assigned ports"); + } + + // Configure external IP address for passive mode + if let Some(ref external_ip) = self.config.external_ip { + info!("Configuring FTPS external IP for passive mode: {}", external_ip); + server_builder = server_builder.passive_host(external_ip.as_str()); + } + + // Configure FTPS / TLS + if let Some(cert) = &self.config.cert_file { + if let Some(key) = &self.config.key_file { + debug!("Enabling FTPS with cert: {} and key: {}", cert, key); + server_builder = server_builder.ftps(cert, key); + + if self.config.ftps_required { + info!("FTPS is explicitly required for all connections"); + server_builder = server_builder.ftps_required(FtpsRequired::All, FtpsRequired::All); + } + } + } else if self.config.ftps_required { + return Err(FtpsInitError::InvalidConfig("FTPS required but certificates not provided".into())); + } + + // Build the server instance + let server = server_builder.build().map_err(FtpsInitError::Server)?; + + // libunftp's listen() binds to the address and runs the loop + let bind_addr = self.config.bind_addr.to_string(); + let server_handle = tokio::spawn(async move { + if let Err(e) = server.listen(bind_addr).await { + error!("FTPS server runtime error: {}", e); + return Err(FtpsInitError::Server(e)); + } + Ok(()) + }); + + // Wait for shutdown signal or server failure + tokio::select! { + result = server_handle => { + match result { + Ok(Ok(())) => { + info!("FTPS server stopped normally"); + Ok(()) + } + Ok(Err(e)) => { + error!("FTPS server internal error: {}", e); + Err(e) + } + Err(e) => { + error!("FTPS server panic or task cancellation: {}", e); + Err(FtpsInitError::Bind(std::io::Error::other(e.to_string()))) + } + } + } + _ = shutdown_rx.recv() => { + info!("FTPS server received shutdown signal"); + // libunftp listen() is not easily cancellable gracefully without dropping the future. + // The select! dropping server_handle will close the listener. + Ok(()) + } + } + } + + /// Get server configuration + pub fn config(&self) -> &FtpsConfig { + &self.config + } +} + +/// FTPS authenticator implementation +#[derive(Debug, Default)] +pub struct FtpsAuthenticator; + +impl FtpsAuthenticator { + /// Create a new FTPS authenticator + pub fn new() -> Self { + Self + } +} + +#[async_trait::async_trait] +impl libunftp::auth::Authenticator for FtpsAuthenticator { + /// Authenticate FTP user against RustFS IAM system + async fn authenticate(&self, username: &str, creds: &libunftp::auth::Credentials) -> Result { + use rustfs_credentials::Credentials as S3Credentials; + use rustfs_iam::get; + + debug!("FTPS authentication attempt for user: {}", username); + + // Access IAM system + let iam_sys = get().map_err(|e| { + error!("IAM system unavailable during FTPS auth: {}", e); + AuthenticationError::ImplPropagated("Internal authentication service unavailable".to_string(), Some(Box::new(e))) + })?; + + // Map FTP credentials to S3 Credentials structure + // Note: FTP PASSWORD is treated as S3 SECRET KEY + let s3_creds = S3Credentials { + access_key: username.to_string(), + secret_key: creds.password.clone().unwrap_or_default(), + // Fields below are not used for authentication verification, but for struct compliance + session_token: String::new(), + expiration: None, + status: String::new(), + parent_user: String::new(), + groups: None, + claims: None, + name: None, + description: None, + }; + + let (user_identity, is_valid) = iam_sys.check_key(&s3_creds.access_key).await.map_err(|e| { + error!("IAM check_key failed for {}: {}", username, e); + AuthenticationError::ImplPropagated("Authentication verification failed".to_string(), Some(Box::new(e))) + })?; + + if !is_valid { + warn!("FTPS login failed: Invalid access key '{}'", username); + return Err(AuthenticationError::BadUser); + } + + let identity = user_identity.ok_or_else(|| { + error!("User identity missing despite valid key for {}", username); + AuthenticationError::BadUser + })?; + + // Constant time comparison is preferred if available, but for now simple eq + if !identity.credentials.secret_key.eq(&s3_creds.secret_key) { + warn!("FTPS login failed: Invalid secret key for '{}'", username); + return Err(AuthenticationError::BadPassword); + } + + // Policy conditions relying on `aws:SourceIp` will currently not work correctly for FTP. + // TODO: Investigate wrapping the authenticator or using Proxy Protocol metadata if available in future libunftp versions. + let source_ip: IpAddr = DEFAULT_SOURCE_IP.parse().unwrap(); + + let session_context = + SessionContext::new(ProtocolPrincipal::new(Arc::new(identity.clone())), SessionProtocol::Ftps, source_ip); + + let ftps_user = FtpsUser { + username: username.to_string(), + name: identity.credentials.name.clone(), + session_context, + }; + + info!("FTPS user '{}' authenticated successfully", username); + Ok(ftps_user) + } +} diff --git a/rustfs/src/protocols/gateway/action.rs b/rustfs/src/protocols/gateway/action.rs new file mode 100644 index 00000000..e0a84c44 --- /dev/null +++ b/rustfs/src/protocols/gateway/action.rs @@ -0,0 +1,110 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use rustfs_policy::policy::action::S3Action as PolicyS3Action; + +/// S3 actions that can be performed through the gateway +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum S3Action { + // Bucket operations + CreateBucket, + DeleteBucket, + ListBucket, + ListBuckets, + HeadBucket, + + // Object operations + GetObject, + PutObject, + DeleteObject, + HeadObject, + + // Multipart operations + CreateMultipartUpload, + UploadPart, + CompleteMultipartUpload, + AbortMultipartUpload, + ListMultipartUploads, + ListParts, + + // ACL operations + GetBucketAcl, + PutBucketAcl, + GetObjectAcl, + PutObjectAcl, + + // Other operations + CopyObject, +} + +impl From for PolicyS3Action { + fn from(action: S3Action) -> Self { + match action { + S3Action::CreateBucket => PolicyS3Action::CreateBucketAction, + S3Action::DeleteBucket => PolicyS3Action::DeleteBucketAction, + S3Action::ListBucket => PolicyS3Action::ListBucketAction, + S3Action::ListBuckets => PolicyS3Action::ListAllMyBucketsAction, + S3Action::HeadBucket => PolicyS3Action::HeadBucketAction, + S3Action::GetObject => PolicyS3Action::GetObjectAction, + S3Action::PutObject => PolicyS3Action::PutObjectAction, + S3Action::DeleteObject => PolicyS3Action::DeleteObjectAction, + S3Action::HeadObject => PolicyS3Action::GetObjectAction, + S3Action::CreateMultipartUpload => PolicyS3Action::PutObjectAction, + S3Action::UploadPart => PolicyS3Action::PutObjectAction, + S3Action::CompleteMultipartUpload => PolicyS3Action::PutObjectAction, + S3Action::AbortMultipartUpload => PolicyS3Action::AbortMultipartUploadAction, + S3Action::ListMultipartUploads => PolicyS3Action::ListBucketMultipartUploadsAction, + S3Action::ListParts => PolicyS3Action::ListMultipartUploadPartsAction, + S3Action::GetBucketAcl => PolicyS3Action::GetBucketPolicyAction, + S3Action::PutBucketAcl => PolicyS3Action::PutBucketPolicyAction, + S3Action::GetObjectAcl => PolicyS3Action::GetObjectAction, + S3Action::PutObjectAcl => PolicyS3Action::PutObjectAction, + S3Action::CopyObject => PolicyS3Action::PutObjectAction, + } + } +} + +impl From for rustfs_policy::policy::action::Action { + fn from(action: S3Action) -> Self { + rustfs_policy::policy::action::Action::S3Action(action.into()) + } +} + +impl S3Action { + /// Get the string representation of the action + pub fn as_str(&self) -> &'static str { + match self { + S3Action::CreateBucket => "s3:CreateBucket", + S3Action::DeleteBucket => "s3:DeleteBucket", + S3Action::ListBucket => "s3:ListBucket", + S3Action::ListBuckets => "s3:ListAllMyBuckets", + S3Action::HeadBucket => "s3:ListBucket", + S3Action::GetObject => "s3:GetObject", + S3Action::PutObject => "s3:PutObject", + S3Action::DeleteObject => "s3:DeleteObject", + S3Action::HeadObject => "s3:GetObject", + S3Action::CreateMultipartUpload => "s3:PutObject", + S3Action::UploadPart => "s3:PutObject", + S3Action::CompleteMultipartUpload => "s3:PutObject", + S3Action::AbortMultipartUpload => "s3:AbortMultipartUpload", + S3Action::ListMultipartUploads => "s3:ListBucketMultipartUploads", + S3Action::ListParts => "s3:ListMultipartUploadParts", + S3Action::GetBucketAcl => "s3:GetBucketAcl", + S3Action::PutBucketAcl => "s3:PutBucketAcl", + S3Action::GetObjectAcl => "s3:GetObjectAcl", + S3Action::PutObjectAcl => "s3:PutObjectAcl", + S3Action::CopyObject => "s3:PutObject", + } + } +} diff --git a/rustfs/src/protocols/gateway/adapter.rs b/rustfs/src/protocols/gateway/adapter.rs new file mode 100644 index 00000000..e8f775a9 --- /dev/null +++ b/rustfs/src/protocols/gateway/adapter.rs @@ -0,0 +1,85 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Protocol to S3 action adapter + +use super::action::S3Action; +use crate::protocols::session::context::Protocol; + +pub fn is_operation_supported(protocol: Protocol, action: &S3Action) -> bool { + match protocol { + Protocol::Ftps => match action { + // Bucket operations: FTPS cannot create buckets via protocol commands + S3Action::CreateBucket => false, + S3Action::DeleteBucket => false, + + // Object operations: All file operations supported + S3Action::GetObject => true, // RETR command + S3Action::PutObject => true, // STOR and APPE commands both map to PutObject + S3Action::DeleteObject => true, // DELE command + S3Action::HeadObject => true, // SIZE command + + // Multipart operations: FTPS has no native multipart upload support + S3Action::CreateMultipartUpload => false, + S3Action::UploadPart => false, + S3Action::CompleteMultipartUpload => false, + S3Action::AbortMultipartUpload => false, + S3Action::ListMultipartUploads => false, + S3Action::ListParts => false, + + // ACL operations: FTPS has no native ACL support + S3Action::GetBucketAcl => false, + S3Action::PutBucketAcl => false, + S3Action::GetObjectAcl => false, + S3Action::PutObjectAcl => false, + + // Other operations + S3Action::CopyObject => false, // No native copy support in FTPS + S3Action::ListBucket => true, // LIST command + S3Action::ListBuckets => true, // LIST at root level + S3Action::HeadBucket => true, // Can check if directory exists + }, + Protocol::Sftp => match action { + // Bucket operations: SFTP can create/delete buckets via mkdir/rmdir + S3Action::CreateBucket => true, + S3Action::DeleteBucket => true, + + // Object operations: All file operations supported + S3Action::GetObject => true, // RealPath + Open + Read + S3Action::PutObject => true, // Open + Write + S3Action::DeleteObject => true, // Remove + S3Action::HeadObject => true, // Stat/Fstat + + // Multipart operations: SFTP has no native multipart upload support + S3Action::CreateMultipartUpload => false, + S3Action::UploadPart => false, + S3Action::CompleteMultipartUpload => false, + S3Action::AbortMultipartUpload => false, + S3Action::ListMultipartUploads => false, + S3Action::ListParts => false, + + // ACL operations: SFTP has no native ACL support + S3Action::GetBucketAcl => false, + S3Action::PutBucketAcl => false, + S3Action::GetObjectAcl => false, + S3Action::PutObjectAcl => false, + + // Other operations + S3Action::CopyObject => false, // No remote copy, only local rename + S3Action::ListBucket => true, // Readdir + S3Action::ListBuckets => true, // Readdir at root + S3Action::HeadBucket => true, // Stat on directory + }, + } +} diff --git a/rustfs/src/protocols/gateway/authorize.rs b/rustfs/src/protocols/gateway/authorize.rs new file mode 100644 index 00000000..2e065a14 --- /dev/null +++ b/rustfs/src/protocols/gateway/authorize.rs @@ -0,0 +1,97 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use super::action::S3Action; +use super::adapter::is_operation_supported; +use crate::protocols::session::context::SessionContext; +use rustfs_credentials; +use rustfs_iam::get; +use rustfs_policy::policy::Args; +use std::collections::HashMap; +use tracing::{debug, error}; + +/// Check if a principal is allowed to perform an S3 action +pub async fn is_authorized(session_context: &SessionContext, action: &S3Action, bucket: &str, object: Option<&str>) -> bool { + let iam_sys = match get() { + Ok(sys) => sys, + Err(e) => { + error!("IAM system unavailable: {}", e); + return false; + } + }; + + // Create policy arguments + let mut claims = HashMap::new(); + claims.insert( + "principal".to_string(), + serde_json::Value::String(session_context.principal.access_key().to_string()), + ); + + let policy_action: rustfs_policy::policy::action::Action = action.clone().into(); + + // Check if user is the owner (admin) + let is_owner = if let Some(global_cred) = rustfs_credentials::get_global_action_cred() { + session_context.principal.access_key() == global_cred.access_key + } else { + false + }; + + let args = Args { + account: session_context.principal.access_key(), + groups: &session_context.principal.user_identity.credentials.groups, + action: policy_action, + bucket, + conditions: &HashMap::new(), + is_owner, + object: object.unwrap_or(""), + claims: &claims, + deny_only: false, + }; + + debug!( + "FTPS AUTH - Checking authorization: account={}, action={:?}, bucket='{}', object={:?}", + args.account, args.action, args.bucket, args.object + ); + + let allowed = iam_sys.is_allowed(&args).await; + debug!("FTPS AUTH - Authorization result: {}", allowed); + allowed +} + +/// Unified authorization entry point for all protocols +pub async fn authorize_operation( + session_context: &SessionContext, + action: &S3Action, + bucket: &str, + object: Option<&str>, +) -> Result<(), AuthorizationError> { + // First check if the operation is supported + if !is_operation_supported(session_context.protocol.clone(), action) { + return Err(AuthorizationError::AccessDenied); + } + + // Then check IAM authorization + if is_authorized(session_context, action, bucket, object).await { + Ok(()) + } else { + Err(AuthorizationError::AccessDenied) + } +} + +/// Authorization errors +#[derive(Debug, thiserror::Error)] +pub enum AuthorizationError { + #[error("Access denied")] + AccessDenied, +} diff --git a/rustfs/src/protocols/gateway/error.rs b/rustfs/src/protocols/gateway/error.rs new file mode 100644 index 00000000..ab4782ed --- /dev/null +++ b/rustfs/src/protocols/gateway/error.rs @@ -0,0 +1,76 @@ +// Copyright 2024 RustFS Team +// +// 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. + +// FTP error code constants +pub mod ftp_errors { + pub const FILE_NOT_FOUND: &str = "550 File not found"; + pub const DIRECTORY_NOT_FOUND: &str = "550 Directory not found"; + pub const PERMISSION_DENIED: &str = "550 Permission denied"; + pub const DIRECTORY_NOT_EMPTY: &str = "550 Directory not empty"; + pub const DIRECTORY_ALREADY_EXISTS: &str = "550 Directory already exists"; + pub const INVALID_DIRECTORY_NAME: &str = "553 Invalid directory name"; + pub const INVALID_FILE_NAME: &str = "553 Invalid file name"; + pub const INVALID_REQUEST: &str = "501 Invalid request"; + pub const INTERNAL_SERVER_ERROR: &str = "421 Internal server error"; +} + +// FTP error messages mapping +pub fn map_s3_error_to_ftp_string(s3_error: &s3s::S3Error) -> String { + match s3_error.code() { + s3s::S3ErrorCode::NoSuchKey => ftp_errors::FILE_NOT_FOUND.to_string(), + s3s::S3ErrorCode::NoSuchBucket => ftp_errors::DIRECTORY_NOT_FOUND.to_string(), + s3s::S3ErrorCode::AccessDenied => ftp_errors::PERMISSION_DENIED.to_string(), + s3s::S3ErrorCode::BucketNotEmpty => ftp_errors::DIRECTORY_NOT_EMPTY.to_string(), + s3s::S3ErrorCode::BucketAlreadyExists => ftp_errors::DIRECTORY_ALREADY_EXISTS.to_string(), + s3s::S3ErrorCode::InvalidBucketName => ftp_errors::INVALID_DIRECTORY_NAME.to_string(), + s3s::S3ErrorCode::InvalidObjectState => ftp_errors::INVALID_FILE_NAME.to_string(), + s3s::S3ErrorCode::InvalidRequest => ftp_errors::INVALID_REQUEST.to_string(), + s3s::S3ErrorCode::InternalError => ftp_errors::INTERNAL_SERVER_ERROR.to_string(), + _ => ftp_errors::INTERNAL_SERVER_ERROR.to_string(), + } +} + +/// Map S3Error to FTPS libunftp Error +pub fn map_s3_error_to_ftps(s3_error: &s3s::S3Error) -> libunftp::storage::Error { + use libunftp::storage::{Error, ErrorKind}; + + match s3_error.code() { + s3s::S3ErrorCode::NoSuchKey | s3s::S3ErrorCode::NoSuchBucket => { + Error::new(ErrorKind::PermanentFileNotAvailable, map_s3_error_to_ftp_string(s3_error)) + } + s3s::S3ErrorCode::AccessDenied => Error::new(ErrorKind::PermissionDenied, map_s3_error_to_ftp_string(s3_error)), + s3s::S3ErrorCode::InvalidRequest | s3s::S3ErrorCode::InvalidBucketName | s3s::S3ErrorCode::InvalidObjectState => { + Error::new(ErrorKind::PermanentFileNotAvailable, map_s3_error_to_ftp_string(s3_error)) + } + _ => Error::new(ErrorKind::PermanentFileNotAvailable, map_s3_error_to_ftp_string(s3_error)), + } +} + +/// Map S3Error directly to SFTP StatusCode +pub fn map_s3_error_to_sftp_status(s3_error: &s3s::S3Error) -> russh_sftp::protocol::StatusCode { + use russh_sftp::protocol::StatusCode; + + match s3_error.code() { + s3s::S3ErrorCode::NoSuchKey => StatusCode::NoSuchFile, // SSH_FX_NO_SUCH_FILE (2) + s3s::S3ErrorCode::NoSuchBucket => StatusCode::NoSuchFile, // SSH_FX_NO_SUCH_FILE (2) + s3s::S3ErrorCode::AccessDenied => StatusCode::PermissionDenied, // SSH_FX_PERMISSION_DENIED (3) + s3s::S3ErrorCode::BucketNotEmpty => StatusCode::Failure, // SSH_FX_DIR_NOT_EMPTY (21) + s3s::S3ErrorCode::BucketAlreadyExists => StatusCode::Failure, // SSH_FX_FILE_ALREADY_EXISTS (17) + s3s::S3ErrorCode::InvalidBucketName => StatusCode::Failure, // SSH_FX_INVALID_FILENAME (22) + s3s::S3ErrorCode::InvalidObjectState => StatusCode::Failure, // SSH_FX_INVALID_FILENAME (22) + s3s::S3ErrorCode::InvalidRequest => StatusCode::OpUnsupported, // SSH_FX_OP_UNSUPPORTED (5) + s3s::S3ErrorCode::InternalError => StatusCode::Failure, // SSH_FX_FAILURE (4) + _ => StatusCode::Failure, // SSH_FX_FAILURE as default + } +} diff --git a/rustfs/src/protocols/gateway/mod.rs b/rustfs/src/protocols/gateway/mod.rs new file mode 100644 index 00000000..4dbccd15 --- /dev/null +++ b/rustfs/src/protocols/gateway/mod.rs @@ -0,0 +1,21 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Gateway module for protocol implementations + +pub mod action; +pub mod adapter; +pub mod authorize; +pub mod error; +pub mod restrictions; diff --git a/rustfs/src/protocols/gateway/restrictions.rs b/rustfs/src/protocols/gateway/restrictions.rs new file mode 100644 index 00000000..c67eeb61 --- /dev/null +++ b/rustfs/src/protocols/gateway/restrictions.rs @@ -0,0 +1,56 @@ +// Copyright 2024 RustFS Team +// +// 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. + +/// Unsupported FTP features list +pub const UNSUPPORTED_FTP_FEATURES: &[&str] = &[ + // Atomic rename operations (must be implemented via CopyObject+DeleteObject) + "Atomic RNFR/RNTO rename", + // File append operations (S3 does not support native append) + "APPE command (file append)", + // POSIX permission operations (S3 uses ACLs and Policies) + "chmod command", + "chown command", + // Symbolic links (S3 object storage does not support) + "SYMLINK creation", + // Hard links (S3 object storage does not support) + "HARD LINK creation", + // File locking (S3 does not support filesystem-level locking) + "File locking mechanism", + // Direct directory rename (must be implemented via object copy) + "Directory atomic rename", +]; + +/// Check if an FTP feature is supported +pub fn is_ftp_feature_supported(feature: &str) -> bool { + !UNSUPPORTED_FTP_FEATURES.contains(&feature) +} + +/// Get S3 equivalent operation for unsupported features +pub fn get_s3_equivalent_operation(unsupported_feature: &str) -> Option<&'static str> { + match unsupported_feature { + "Atomic RNFR/RNTO rename" | "SSH_FXP_RENAME atomic rename" | "Directory atomic rename" => { + Some("Use CopyObject + DeleteObject to implement rename") + } + "APPE command (file append)" | "SSH_FXP_OPEN append mode" => Some("Use PutObject to overwrite the entire object"), + "chmod command" + | "chown command" + | "SSH_FXP_SETSTAT permission modification" + | "SSH_FXP_FSETSTAT permission modification" => Some("Use S3 ACLs or Bucket Policies to manage permissions"), + "SYMLINK creation" | "SSH_FXP_SYMLINK creation" => Some("S3 object storage does not support symbolic links"), + "File locking mechanism" | "SSH_FXP_BLOCK file locking" => { + Some("Use S3 object versioning or conditional writes for concurrency control") + } + _ => None, + } +} diff --git a/rustfs/src/protocols/mod.rs b/rustfs/src/protocols/mod.rs new file mode 100644 index 00000000..e546c0e8 --- /dev/null +++ b/rustfs/src/protocols/mod.rs @@ -0,0 +1,19 @@ +// Copyright 2024 RustFS Team +// +// 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. + +pub mod client; +pub mod ftps; +pub mod gateway; +pub mod session; +pub mod sftp; diff --git a/rustfs/src/protocols/session/context.rs b/rustfs/src/protocols/session/context.rs new file mode 100644 index 00000000..e0809a00 --- /dev/null +++ b/rustfs/src/protocols/session/context.rs @@ -0,0 +1,54 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Session context for protocol implementations + +use crate::protocols::session::principal::ProtocolPrincipal; +use std::net::IpAddr; + +/// Protocol types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Protocol { + Ftps, + Sftp, +} + +/// Session context for protocol operations +#[derive(Debug, Clone)] +pub struct SessionContext { + /// The protocol principal (authenticated user) + pub principal: ProtocolPrincipal, + + /// The protocol type + pub protocol: Protocol, + + /// The source IP address + pub source_ip: IpAddr, +} + +impl SessionContext { + /// Create a new session context + pub fn new(principal: ProtocolPrincipal, protocol: Protocol, source_ip: IpAddr) -> Self { + Self { + principal, + protocol, + source_ip, + } + } + + /// Get the access key for this session + pub fn access_key(&self) -> &str { + self.principal.access_key() + } +} diff --git a/rustfs/src/protocols/session/mod.rs b/rustfs/src/protocols/session/mod.rs new file mode 100644 index 00000000..b206c80c --- /dev/null +++ b/rustfs/src/protocols/session/mod.rs @@ -0,0 +1,18 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! Session management for protocol implementations + +pub mod context; +pub mod principal; diff --git a/rustfs/src/protocols/session/principal.rs b/rustfs/src/protocols/session/principal.rs new file mode 100644 index 00000000..5182df1c --- /dev/null +++ b/rustfs/src/protocols/session/principal.rs @@ -0,0 +1,35 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use rustfs_policy::auth::UserIdentity; +use std::sync::Arc; + +/// Protocol principal representing an authenticated user +#[derive(Debug, Clone)] +pub struct ProtocolPrincipal { + /// User identity from IAM system + pub user_identity: Arc, +} + +impl ProtocolPrincipal { + /// Create a new protocol principal + pub fn new(user_identity: Arc) -> Self { + Self { user_identity } + } + + /// Get the access key for this principal + pub fn access_key(&self) -> &str { + &self.user_identity.credentials.access_key + } +} diff --git a/rustfs/src/protocols/sftp/handler.rs b/rustfs/src/protocols/sftp/handler.rs new file mode 100644 index 00000000..ff193264 --- /dev/null +++ b/rustfs/src/protocols/sftp/handler.rs @@ -0,0 +1,929 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use crate::protocols::client::s3::ProtocolS3Client; +use crate::protocols::gateway::action::S3Action; +use crate::protocols::gateway::authorize::authorize_operation; +use crate::protocols::gateway::error::map_s3_error_to_sftp_status; +use crate::protocols::session::context::SessionContext; +use futures::TryStreamExt; +use russh_sftp::protocol::{Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version}; +use russh_sftp::server::Handler; +use rustfs_utils::path; +use s3s::dto::{DeleteBucketInput, DeleteObjectInput, GetObjectInput, ListObjectsV2Input, PutObjectInput, StreamingBlob}; +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use tokio::fs::{File as TokioFile, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::RwLock; +use tracing::{debug, error, trace}; +use uuid::Uuid; + +const INITIAL_HANDLE_ID: u32 = 1; +const ROOT_PATH: &str = "/"; +const CURRENT_DIR: &str = "."; +const PARENT_DIR: &str = ".."; +const HANDLE_ID_PREFIX: &str = "handle_"; +const PATH_SEPARATOR: &str = "/"; +const PERMISSION_DENIED_PATH: &str = ".."; +const DIR_MODE: u32 = 0o040000; +const FILE_MODE: u32 = 0o100000; +const DIR_PERMISSIONS: u32 = 0o755; +const FILE_PERMISSIONS: u32 = 0o644; + +/// State associated with an open file handle +#[derive(Debug)] +enum HandleState { + Read { + path: String, + bucket: String, + key: String, + }, + Write { + path: String, + bucket: String, + key: String, + temp_file_path: PathBuf, + file_handle: Option, + }, + Dir { + path: String, + files: Vec, + offset: usize, + }, +} + +#[derive(Clone)] +pub struct SftpHandler { + session_context: SessionContext, + handles: Arc>>, + next_handle_id: Arc, + temp_dir: PathBuf, + current_dir: Arc>, +} + +impl SftpHandler { + pub fn new(session_context: SessionContext) -> Self { + Self { + session_context, + handles: Arc::new(RwLock::new(HashMap::new())), + next_handle_id: Arc::new(AtomicU32::new(INITIAL_HANDLE_ID)), + temp_dir: std::env::temp_dir(), + current_dir: Arc::new(RwLock::new(ROOT_PATH.to_string())), + } + } + + fn create_s3_client(&self) -> Result { + // Create FS instance (empty struct that accesses global ECStore) + let fs = crate::storage::ecfs::FS {}; + let client = ProtocolS3Client::new(fs, self.session_context.access_key().to_string()); + Ok(client) + } + + fn parse_path(&self, path_str: &str) -> Result<(String, Option), StatusCode> { + if path_str.contains(PERMISSION_DENIED_PATH) { + return Err(StatusCode::PermissionDenied); + } + + // Clean the path to normalize + let cleaned_path = path::clean(path_str); + + let (bucket, object) = path::path_to_bucket_object(&cleaned_path); + + let key = if object.is_empty() { None } else { Some(object) }; + + debug!( + "SFTP parse_path - input: '{}', cleaned: '{}', bucket: '{}', key: {:?}", + path_str, cleaned_path, bucket, key + ); + Ok((bucket, key)) + } + + fn generate_handle_id(&self) -> String { + let id = self.next_handle_id.fetch_add(1, Ordering::Relaxed); + format!("{}{}", HANDLE_ID_PREFIX, id) + } + + /// Convert relative path to absolute path based on current directory + async fn resolve_path(&self, path_str: &str) -> String { + let current = self.current_dir.read().await; + + if path_str.starts_with(PATH_SEPARATOR) { + // Absolute path + return path::clean(path_str).to_string(); + } + + // Relative path + if path_str == CURRENT_DIR { + current.clone() + } else if path_str == PARENT_DIR { + if *current == ROOT_PATH { + ROOT_PATH.to_string() + } else { + let parent = std::path::Path::new(&*current) + .parent() + .map(|p| p.to_str().unwrap()) + .unwrap_or(ROOT_PATH); + path::clean(parent).to_string() + } + } else { + // Join current directory with path + let joined = if *current == ROOT_PATH { + format!("{}{}", PATH_SEPARATOR, path_str.trim_start_matches(PATH_SEPARATOR)) + } else { + format!( + "{}{}{}", + current.trim_end_matches(PATH_SEPARATOR), + PATH_SEPARATOR, + path_str.trim_start_matches(PATH_SEPARATOR) + ) + }; + path::clean(&joined).to_string() + } + } + + async fn cleanup_state(&self, state: HandleState) { + if let HandleState::Write { temp_file_path, .. } = state { + let _ = tokio::fs::remove_file(temp_file_path).await; + } + } + + async fn do_stat(&self, path: String) -> Result { + debug!("SFTP do_stat - input path: '{}'", path); + + let (bucket, key_opt) = self.parse_path(&path)?; + + if bucket.is_empty() { + let mut attrs = FileAttributes::default(); + attrs.set_dir(true); + attrs.size = Some(0); + let current_mode = attrs.permissions.unwrap_or(0); + attrs.permissions = Some(current_mode | DIR_MODE | DIR_PERMISSIONS); + return Ok(attrs); + } + + let action = if key_opt.is_none() { + S3Action::HeadBucket + } else { + S3Action::HeadObject + }; + + debug!("SFTP do_stat - parsed bucket: '{}', key: {:?}, action: {:?}", bucket, key_opt, action); + + authorize_operation(&self.session_context, &action, &bucket, key_opt.as_deref()) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + let s3_client = self.create_s3_client()?; + + match action { + S3Action::HeadBucket => { + let input = s3s::dto::HeadBucketInput { + bucket, + ..Default::default() + }; + + match s3_client.head_bucket(input).await { + Ok(_) => { + let mut attrs = FileAttributes::default(); + attrs.set_dir(true); + attrs.size = Some(0); + attrs.permissions = Some(DIR_PERMISSIONS | DIR_MODE); + attrs.mtime = Some(0); + Ok(attrs) + } + Err(_) => Err(StatusCode::NoSuchFile), + } + } + + S3Action::HeadObject => { + let key = key_opt.expect("key_opt should be Some for HeadObject action"); + let input = s3s::dto::HeadObjectInput { + bucket, + key, + ..Default::default() + }; + + match s3_client.head_object(input).await { + Ok(out) => { + let mut attrs = FileAttributes::default(); + attrs.set_dir(false); + attrs.size = Some(out.content_length.unwrap_or(0) as u64); + + if let Some(lm) = out.last_modified { + let dt = time::OffsetDateTime::from(lm); + attrs.mtime = Some(dt.unix_timestamp() as u32); + } + + attrs.permissions = Some(FILE_PERMISSIONS | FILE_MODE); + Ok(attrs) + } + Err(_) => Err(StatusCode::NoSuchFile), + } + } + + _ => { + error!("SFTP do_stat - Unexpected action type"); + Err(StatusCode::Failure) + } + } + } +} + +impl Handler for SftpHandler { + type Error = StatusCode; + + fn unimplemented(&self) -> Self::Error { + StatusCode::OpUnsupported + } + + async fn init(&mut self, version: u32, _extensions: HashMap) -> Result { + trace!("SFTP Init version: {}", version); + Ok(Version::new()) + } + + fn open( + &mut self, + id: u32, + filename: String, + pflags: OpenFlags, + _attrs: FileAttributes, + ) -> impl Future> + Send { + let this = self.clone(); + + async move { + debug!("SFTP Open: {} (flags: {:?})", filename, pflags); + + // Resolve relative path to absolute path + let resolved_filename = this.resolve_path(&filename).await; + + let (bucket, key_opt) = this.parse_path(&resolved_filename)?; + + if bucket.is_empty() { + return Err(StatusCode::PermissionDenied); // Cannot open root directory as file + } + + let key = key_opt.ok_or(StatusCode::PermissionDenied)?; // Cannot open bucket as file + + let handle_id = this.generate_handle_id(); + let state; + + if pflags.contains(OpenFlags::WRITE) || pflags.contains(OpenFlags::CREATE) || pflags.contains(OpenFlags::TRUNCATE) { + let action = S3Action::PutObject; + authorize_operation(&this.session_context, &action, &bucket, Some(&key)) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + if pflags.contains(OpenFlags::APPEND) { + return Err(StatusCode::OpUnsupported); + } + + let temp_filename = format!("rustfs-sftp-{}.tmp", Uuid::new_v4()); + let temp_path = this.temp_dir.join(temp_filename); + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&temp_path) + .await + .map_err(|e| { + error!("Failed to create temp file: {}", e); + StatusCode::Failure + })?; + + state = HandleState::Write { + path: filename.clone(), + bucket, + key, + temp_file_path: temp_path, + file_handle: Some(file), + }; + } else { + let action = S3Action::GetObject; + authorize_operation(&this.session_context, &action, &bucket, Some(&key)) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + state = HandleState::Read { + path: filename.clone(), + bucket, + key, + }; + } + + this.handles.write().await.insert(handle_id.clone(), state); + Ok(Handle { id, handle: handle_id }) + } + } + + fn close(&mut self, id: u32, handle: String) -> impl Future> + Send { + let this = self.clone(); + async move { + let state = this.handles.write().await.remove(&handle); + + match state { + Some(HandleState::Write { + bucket, + key, + temp_file_path, + mut file_handle, + .. + }) => { + let mut file = file_handle.take().ok_or(StatusCode::Failure)?; + + if let Err(e) = file.flush().await { + error!("Flush to disk failed: {}", e); + let _ = tokio::fs::remove_file(&temp_file_path).await; + return Err(StatusCode::Failure); + } + + let metadata = file.metadata().await.map_err(|e| { + error!("Failed to get metadata: {}", e); + StatusCode::Failure + })?; + let file_size = metadata.len(); + + if let Err(e) = file.seek(std::io::SeekFrom::Start(0)).await { + error!("Seek temp file failed: {}", e); + let _ = tokio::fs::remove_file(&temp_file_path).await; + return Err(StatusCode::Failure); + } + + let s3_client = match this.create_s3_client() { + Ok(c) => c, + Err(e) => { + let _ = tokio::fs::remove_file(&temp_file_path).await; + return Err(e); + } + }; + + let stream = tokio_util::io::ReaderStream::new(file); + let body = StreamingBlob::wrap(stream); + + let input = PutObjectInput::builder() + .bucket(bucket.clone()) + .key(key.clone()) + .body(Option::from(body)) + .content_length(Option::from(file_size as i64)) // 告诉 S3 文件多大 + .build() + .unwrap(); + + let result = match s3_client.put_object(input).await { + Ok(_) => Status { + id, + status_code: StatusCode::Ok, + error_message: "Success".into(), + language_tag: "en".into(), + }, + Err(e) => { + error!("S3 PutObject failed: {}", e); + let status_code = map_s3_error_to_sftp_status(&e); + return Err(status_code); + } + }; + + let _ = tokio::fs::remove_file(&temp_file_path).await; + Ok(result) + } + Some(state) => { + this.cleanup_state(state).await; + Ok(Status { + id, + status_code: StatusCode::Ok, + error_message: "Success".into(), + language_tag: "en".into(), + }) + } + None => Err(StatusCode::NoSuchFile), + } + } + } + + fn read(&mut self, id: u32, handle: String, offset: u64, len: u32) -> impl Future> + Send { + let this = self.clone(); + async move { + let (bucket, key) = { + let guard = this.handles.read().await; + match guard.get(&handle) { + Some(HandleState::Read { bucket, key, .. }) => (bucket.clone(), key.clone()), + Some(_) => return Err(StatusCode::OpUnsupported), + None => return Err(StatusCode::NoSuchFile), + } + }; + + let range_end = offset + (len as u64) - 1; + let mut builder = GetObjectInput::builder(); + builder.set_bucket(bucket); + builder.set_key(key); + if let Ok(range) = s3s::dto::Range::parse(&format!("bytes={}-{}", offset, range_end)) { + builder.set_range(Some(range)); + } + + let s3_client = this.create_s3_client()?; + let input = builder.build().map_err(|_| StatusCode::Failure)?; + + match s3_client.get_object(input).await { + Ok(output) => { + let mut data = Vec::with_capacity(len as usize); + if let Some(body) = output.body { + let stream = body.map_err(std::io::Error::other); + let mut reader = tokio_util::io::StreamReader::new(stream); + let _ = reader.read_to_end(&mut data).await; + } + Ok(Data { id, data }) + } + Err(e) => { + debug!("S3 Read failed: {}", e); + Ok(Data { id, data: Vec::new() }) + } + } + } + } + + fn write( + &mut self, + id: u32, + handle: String, + offset: u64, + data: Vec, + ) -> impl Future> + Send { + let this = self.clone(); + async move { + let mut guard = this.handles.write().await; + + if let Some(HandleState::Write { file_handle, .. }) = guard.get_mut(&handle) { + if let Some(file) = file_handle { + if let Err(e) = file.seek(std::io::SeekFrom::Start(offset)).await { + error!("File seek failed: {}", e); + return Err(StatusCode::Failure); + } + + if let Err(e) = file.write_all(&data).await { + error!("File write failed: {}", e); + return Err(StatusCode::Failure); + } + + Ok(Status { + id, + status_code: StatusCode::Ok, + error_message: "Success".into(), + language_tag: "en".into(), + }) + } else { + Err(StatusCode::Failure) + } + } else { + Err(StatusCode::NoSuchFile) + } + } + } + + fn lstat(&mut self, id: u32, path: String) -> impl Future> + Send { + let this = self.clone(); + async move { + let resolved = this.resolve_path(&path).await; + let attrs = this.do_stat(resolved).await?; + Ok(Attrs { id, attrs }) + } + } + + fn fstat(&mut self, id: u32, handle: String) -> impl Future> + Send { + let this = self.clone(); + async move { + let path = { + let guard = this.handles.read().await; + match guard.get(&handle) { + Some(HandleState::Read { path, .. }) => path.clone(), + Some(HandleState::Write { path, .. }) => path.clone(), + Some(HandleState::Dir { path, .. }) => path.clone(), + None => return Err(StatusCode::NoSuchFile), + } + }; + let attrs = this.do_stat(path).await?; + Ok(Attrs { id, attrs }) + } + } + + fn opendir(&mut self, id: u32, path: String) -> impl Future> + Send { + let this = self.clone(); + async move { + debug!("SFTP Opendir START: path='{}'", path); + + // Resolve relative path to absolute path + let resolved_path = this.resolve_path(&path).await; + debug!("SFTP Opendir - resolved path: '{}'", resolved_path); + + // Handle root directory case - list all buckets + if resolved_path == "/" || resolved_path == "/." { + debug!("SFTP Opendir - listing root directory (all buckets)"); + let action = S3Action::ListBuckets; + authorize_operation(&this.session_context, &action, "", None) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + // List all buckets + let s3_client = this.create_s3_client().inspect_err(|&e| { + error!("SFTP Opendir - failed to create S3 client: {}", e); + })?; + + let input = s3s::dto::ListBucketsInput::builder() + .build() + .map_err(|_| StatusCode::Failure)?; + + let secret_key = &this.session_context.principal.user_identity.credentials.secret_key; + let output = s3_client.list_buckets(input, secret_key).await.map_err(|e| { + error!("SFTP Opendir - failed to list buckets: {}", e); + StatusCode::Failure + })?; + + let mut files = Vec::new(); + if let Some(buckets) = output.buckets { + for bucket in buckets { + if let Some(bucket_name) = bucket.name { + let mut attrs = FileAttributes::default(); + attrs.set_dir(true); + attrs.permissions = Some(0o755); + files.push(File { + filename: bucket_name.clone(), + longname: format!("drwxr-xr-x 2 0 0 0 Dec 28 18:54 {}", bucket_name), + attrs, + }); + } + } + } + + let handle_id = this.generate_handle_id(); + let mut guard = this.handles.write().await; + guard.insert( + handle_id.clone(), + HandleState::Dir { + path: "/".to_string(), + files, + offset: 0, + }, + ); + return Ok(Handle { id, handle: handle_id }); + } + + // Handle bucket directory listing + let (bucket, key_prefix) = this.parse_path(&resolved_path)?; + debug!("SFTP Opendir - bucket: '{}', key_prefix: {:?}", bucket, key_prefix); + + let action = S3Action::ListBucket; + authorize_operation(&this.session_context, &action, &bucket, key_prefix.as_deref()) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + let mut builder = s3s::dto::ListObjectsV2Input::builder(); + builder.set_bucket(bucket.clone()); + + let prefix = if let Some(ref p) = key_prefix { + path::retain_slash(p) + } else { + String::new() + }; + + if !prefix.is_empty() { + builder.set_prefix(Some(prefix)); + } + builder.set_delimiter(Some("/".to_string())); + + let s3_client = this.create_s3_client()?; + let input = builder.build().map_err(|_| StatusCode::Failure)?; + + let mut files = Vec::new(); + match s3_client.list_objects_v2(input).await { + Ok(output) => { + if let Some(prefixes) = output.common_prefixes { + for p in prefixes { + if let Some(prefix_str) = p.prefix { + let name = prefix_str + .trim_end_matches('/') + .split('/') + .next_back() + .unwrap_or("") + .to_string(); + if !name.is_empty() { + let mut attrs = FileAttributes::default(); + attrs.set_dir(true); + attrs.permissions = Some(0o755); + files.push(File { + filename: name.clone(), + longname: format!("drwxr-xr-x 1 rustfs rustfs 0 Jan 1 1970 {}", name), + attrs, + }); + } + } + } + } + if let Some(contents) = output.contents { + for obj in contents { + if let Some(key) = obj.key { + if key.ends_with('/') { + continue; + } + let name = key.split('/').next_back().unwrap_or("").to_string(); + let size = obj.size.unwrap_or(0) as u64; + let mut attrs = FileAttributes { + size: Some(size), + permissions: Some(0o644), + ..Default::default() + }; + if let Some(lm) = obj.last_modified { + let dt = time::OffsetDateTime::from(lm); + attrs.mtime = Some(dt.unix_timestamp() as u32); + } + files.push(File { + filename: name.clone(), + longname: format!("-rw-r--r-- 1 rustfs rustfs {} Jan 1 1970 {}", size, name), + attrs, + }); + } + } + } + } + Err(e) => { + error!("S3 List failed: {}", e); + return Err(StatusCode::Failure); + } + } + + let handle_id = this.generate_handle_id(); + this.handles + .write() + .await + .insert(handle_id.clone(), HandleState::Dir { path, files, offset: 0 }); + + Ok(Handle { id, handle: handle_id }) + } + } + + fn readdir(&mut self, id: u32, handle: String) -> impl Future> + Send { + let this = self.clone(); + async move { + let mut guard = this.handles.write().await; + + if let Some(HandleState::Dir { files, offset, .. }) = guard.get_mut(&handle) { + debug!("SFTP Readdir - handle: {}, offset: {}, total files: {}", handle, offset, files.len()); + for (i, f) in files.iter().enumerate() { + debug!("SFTP Readdir - file[{}]: filename='{}', longname='{}'", i, f.filename, f.longname); + } + + if *offset >= files.len() { + debug!("SFTP Readdir - offset {} >= files length {}, returning empty", offset, files.len()); + return Ok(Name { id, files: Vec::new() }); + } + let chunk = files[*offset..].to_vec(); + debug!("SFTP Readdir - returning {} files (offset {})", chunk.len(), offset); + *offset = files.len(); + Ok(Name { id, files: chunk }) + } else { + debug!("SFTP Readdir - handle '{}' not found or not a directory handle", handle); + Err(StatusCode::NoSuchFile) + } + } + } + + fn remove(&mut self, id: u32, filename: String) -> impl Future> + Send { + let this = self.clone(); + async move { + // Resolve relative path to absolute path + let resolved_filename = this.resolve_path(&filename).await; + + let (bucket, key_opt) = this.parse_path(&resolved_filename)?; + + if let Some(key) = key_opt { + // Delete object + let action = S3Action::DeleteObject; + authorize_operation(&this.session_context, &action, &bucket, Some(&key)) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + let input = DeleteObjectInput { + bucket, + key, + ..Default::default() + }; + + let s3_client = this.create_s3_client()?; + s3_client.delete_object(input).await.map_err(|e| { + error!("SFTP REMOVE - failed to delete object: {}", e); + StatusCode::Failure + })?; + + Ok(Status { + id, + status_code: StatusCode::Ok, + error_message: "Success".into(), + language_tag: "en".into(), + }) + } else { + // Delete bucket - check if bucket is empty first + debug!("SFTP REMOVE - attempting to delete bucket: '{}'", bucket); + + let action = S3Action::DeleteBucket; + authorize_operation(&this.session_context, &action, &bucket, None) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + let s3_client = this.create_s3_client()?; + + // Check if bucket is empty + let list_input = ListObjectsV2Input { + bucket: bucket.clone(), + max_keys: Some(1), + ..Default::default() + }; + + match s3_client.list_objects_v2(list_input).await { + Ok(output) => { + if let Some(objects) = output.contents { + if !objects.is_empty() { + debug!("SFTP REMOVE - bucket '{}' is not empty, cannot delete", bucket); + return Ok(Status { + id, + status_code: StatusCode::Failure, + error_message: format!("Bucket '{}' is not empty", bucket), + language_tag: "en".into(), + }); + } + } + } + Err(e) => { + debug!("SFTP REMOVE - failed to list objects: {}", e); + } + } + + // Bucket is empty, delete it + let delete_bucket_input = DeleteBucketInput { + bucket: bucket.clone(), + ..Default::default() + }; + + match s3_client.delete_bucket(delete_bucket_input).await { + Ok(_) => { + debug!("SFTP REMOVE - successfully deleted bucket: '{}'", bucket); + Ok(Status { + id, + status_code: StatusCode::Ok, + error_message: "Success".into(), + language_tag: "en".into(), + }) + } + Err(e) => { + error!("SFTP REMOVE - failed to delete bucket '{}': {}", bucket, e); + Ok(Status { + id, + status_code: StatusCode::Failure, + error_message: format!("Failed to delete bucket: {}", e), + language_tag: "en".into(), + }) + } + } + } + } + } + fn mkdir( + &mut self, + id: u32, + path: String, + _attrs: FileAttributes, + ) -> impl Future> + Send { + let this = self.clone(); + async move { + let (bucket, key_opt) = this.parse_path(&path)?; + + if let Some(key) = key_opt { + // Create directory inside bucket + let dir_key = path::retain_slash(&key); + + let action = S3Action::PutObject; + authorize_operation(&this.session_context, &action, &bucket, Some(&dir_key)) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + let s3_client = this.create_s3_client()?; + let empty_stream = futures::stream::empty::>(); + let body = StreamingBlob::wrap(empty_stream); + let input = PutObjectInput { + bucket, + key: dir_key, + body: Some(body), + ..Default::default() + }; + + match s3_client.put_object(input).await { + Ok(_) => Ok(Status { + id, + status_code: StatusCode::Ok, + error_message: "Directory created".into(), + language_tag: "en".into(), + }), + Err(e) => { + error!("SFTP Failed to create directory: {}", e); + Ok(Status { + id, + status_code: StatusCode::Failure, + error_message: format!("Failed to create directory: {}", e), + language_tag: "en".into(), + }) + } + } + } else { + // Create bucket + debug!("SFTP mkdir - Creating bucket: '{}'", bucket); + + let action = S3Action::CreateBucket; + authorize_operation(&this.session_context, &action, &bucket, None) + .await + .map_err(|_| StatusCode::PermissionDenied)?; + + let s3_client = this.create_s3_client()?; + let input = s3s::dto::CreateBucketInput { + bucket, + ..Default::default() + }; + + match s3_client.create_bucket(input).await { + Ok(_) => Ok(Status { + id, + status_code: StatusCode::Ok, + error_message: "Bucket created".into(), + language_tag: "en".into(), + }), + Err(e) => { + error!("SFTP Failed to create bucket: {}", e); + Ok(Status { + id, + status_code: StatusCode::Failure, + error_message: format!("Failed to create bucket: {}", e), + language_tag: "en".into(), + }) + } + } + } + } + } + + fn rmdir(&mut self, id: u32, path: String) -> impl Future> + Send { + self.remove(id, path) + } + + fn realpath(&mut self, id: u32, path: String) -> impl Future> + Send { + let this = self.clone(); + async move { + let resolved = this.resolve_path(&path).await; + debug!("SFTP Realpath - input: '{}', resolved: '{}'", path, resolved); + + // Check if this path is a directory and get proper attributes + let attrs = this.do_stat(resolved.clone()).await.unwrap_or_else(|_| { + let mut default_attrs = FileAttributes::default(); + // Assume it's a directory if stat fails (for root path) + default_attrs.set_dir(true); + default_attrs + }); + + Ok(Name { + id, + files: vec![File { + filename: resolved.clone(), + longname: format!( + "{:?} {:>4} {:>6} {:>6} {:>8} {} {}", + if attrs.is_dir() { "drwxr-xr-x" } else { "-rw-r--r--" }, + 1, + "rustfs", + "rustfs", + attrs.size.unwrap_or(0), + "Jan 1 1970", + resolved.split('/').next_back().unwrap_or(&resolved) + ), + attrs, + }], + }) + } + } + + fn stat(&mut self, id: u32, path: String) -> impl Future> + Send { + let this = self.clone(); + async move { + let attrs = this.do_stat(path).await?; + Ok(Attrs { id, attrs }) + } + } +} diff --git a/rustfs/src/protocols/sftp/mod.rs b/rustfs/src/protocols/sftp/mod.rs new file mode 100644 index 00000000..fe790dd6 --- /dev/null +++ b/rustfs/src/protocols/sftp/mod.rs @@ -0,0 +1,17 @@ +// Copyright 2024 RustFS Team +// +// 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. + +//! SFTP protocol implementation +pub mod handler; +pub mod server; diff --git a/rustfs/src/protocols/sftp/server.rs b/rustfs/src/protocols/sftp/server.rs new file mode 100644 index 00000000..e549d73b --- /dev/null +++ b/rustfs/src/protocols/sftp/server.rs @@ -0,0 +1,706 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use crate::protocols::session::context::{Protocol as SessionProtocol, SessionContext}; +use crate::protocols::session::principal::ProtocolPrincipal; +use crate::protocols::sftp::handler::SftpHandler; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use russh::ChannelId; +use russh::keys::{Algorithm, HashAlg, PrivateKey, PublicKey, PublicKeyBase64}; +use russh::server::{Auth, Handler, Server as RusshServer, Session}; +use ssh_key::Certificate; +use ssh_key::certificate::CertType; +use std::borrow::Cow; +use std::collections::HashMap; +use std::future::Future; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +const DEFAULT_ADDR: &str = "0.0.0.0:0"; +const AUTH_SUFFIX_SVC: &str = "=svc"; +const AUTH_SUFFIX_LDAP: &str = "=ldap"; +const SSH_KEY_TYPE_RSA: &str = "ssh-rsa"; +const SSH_KEY_TYPE_ED25519: &str = "ssh-ed25519"; +const SSH_KEY_TYPE_ECDSA: &str = "ecdsa-"; +const SFTP_SUBSYSTEM: &str = "sftp"; +const CRITICAL_OPTION_SOURCE_ADDRESS: &str = "source-address"; +const AUTH_FAILURE_DELAY_MS: u64 = 300; +const SFTP_BUFFER_SIZE: usize = 65536; +const SFTP_READ_BUF_SIZE: usize = 32 * 1024; + +type ServerError = Box; + +#[derive(Debug, Clone)] +pub struct SftpConfig { + pub bind_addr: SocketAddr, + pub require_key_auth: bool, + pub cert_file: Option, + pub key_file: Option, + pub authorized_keys_file: Option, +} + +#[derive(Clone)] +pub struct SftpServer { + config: SftpConfig, + key_pair: Arc, + trusted_certificates: Arc>, + authorized_keys: Arc>, +} + +impl SftpServer { + pub fn new(config: SftpConfig) -> Result { + let key_pair = if let Some(key_file) = &config.key_file { + let path = Path::new(key_file); + russh::keys::load_secret_key(path, None)? + } else { + warn!("No host key provided, generating random key (not recommended for production)."); + let mut rng = rand::rngs::OsRng; + PrivateKey::random(&mut rng, Algorithm::Ed25519)? + }; + + let trusted_certificates = if let Some(cert_file) = &config.cert_file { + info!("Loading trusted CA certificates from: {}", cert_file); + load_trusted_certificates(cert_file)? + } else { + if config.require_key_auth { + warn!("Key auth required but no CA certs provided."); + } + Vec::new() + }; + + let authorized_keys = if let Some(auth_keys_file) = &config.authorized_keys_file { + info!("Loading authorized SSH public keys from: {}", auth_keys_file); + load_authorized_keys(auth_keys_file).unwrap_or_else(|e| { + error!("Failed to load authorized keys from {}: {}", auth_keys_file, e); + Vec::new() + }) + } else { + info!("No authorized keys file provided, will use IAM for key validation."); + Vec::new() + }; + + info!("Loaded {} authorized SSH public key(s)", authorized_keys.len()); + + Ok(Self { + config, + key_pair: Arc::new(key_pair), + trusted_certificates: Arc::new(trusted_certificates), + authorized_keys: Arc::new(authorized_keys), + }) + } + + pub async fn start(&self, mut shutdown_rx: tokio::sync::broadcast::Receiver<()>) -> Result<(), ServerError> { + info!("Starting SFTP server on {}", self.config.bind_addr); + + let config = Arc::new(self.make_ssh_config()); + let socket = tokio::net::TcpListener::bind(&self.config.bind_addr).await?; + let server_stub = self.clone(); + + loop { + tokio::select! { + accept_res = socket.accept() => { + match accept_res { + Ok((stream, addr)) => { + let config = config.clone(); + let server_instance = server_stub.clone(); + tokio::spawn(async move { + let handler = SftpConnectionHandler::new(addr, server_instance.trusted_certificates.clone(), server_instance.authorized_keys.clone()); + if let Err(e) = russh::server::run_stream(config, stream, handler).await { + debug!("SFTP session closed from {}: {}", addr, e); + } + }); + } + Err(e) => error!("Failed to accept SFTP connection: {}", e), + } + } + _ = shutdown_rx.recv() => { + info!("SFTP server shutting down"); + break; + } + } + } + Ok(()) + } + + fn make_ssh_config(&self) -> russh::server::Config { + let mut config = russh::server::Config::default(); + config.keys.push(self.key_pair.as_ref().clone()); + + config.preferred.key = Cow::Borrowed(&[ + Algorithm::Ed25519, + Algorithm::Rsa { hash: None }, + Algorithm::Rsa { + hash: Some(HashAlg::Sha256), + }, + Algorithm::Rsa { + hash: Some(HashAlg::Sha512), + }, + ]); + + config + } + + pub fn config(&self) -> &SftpConfig { + &self.config + } +} + +impl RusshServer for SftpServer { + type Handler = SftpConnectionHandler; + + fn new_client(&mut self, peer_addr: Option) -> Self::Handler { + let addr = peer_addr.unwrap_or_else(|| DEFAULT_ADDR.parse().unwrap()); + SftpConnectionHandler::new(addr, self.trusted_certificates.clone(), self.authorized_keys.clone()) + } +} + +struct ConnectionState { + client_ip: SocketAddr, + identity: Option, + trusted_certificates: Arc>, + authorized_keys: Arc>, + sftp_channels: HashMap>>, +} + +#[derive(Clone)] +pub struct SftpConnectionHandler { + state: Arc>, +} + +impl SftpConnectionHandler { + fn new(client_ip: SocketAddr, trusted_certificates: Arc>, authorized_keys: Arc>) -> Self { + Self { + state: Arc::new(Mutex::new(ConnectionState { + client_ip, + identity: None, + trusted_certificates, + authorized_keys, + sftp_channels: HashMap::new(), + })), + } + } +} + +impl Handler for SftpConnectionHandler { + type Error = ServerError; + + fn auth_password(&mut self, user: &str, password: &str) -> impl Future> + Send { + let raw_user = user.to_string(); + let password = password.to_string(); + let state = self.state.clone(); + + async move { + use rustfs_credentials::Credentials as S3Credentials; + use rustfs_iam::get; + + let (username, suffix) = parse_auth_username(&raw_user); + if let Some(s) = suffix { + debug!("Detected auth suffix '{}' for user '{}'", s, username); + } + + let iam_sys = get().map_err(|e| format!("IAM system unavailable: {}", e))?; + + let s3_creds = S3Credentials { + access_key: username.to_string(), + secret_key: password.clone(), + session_token: String::new(), + expiration: None, + status: String::new(), + parent_user: String::new(), + groups: None, + claims: None, + name: None, + description: None, + }; + + let (user_identity, is_valid) = iam_sys + .check_key(&s3_creds.access_key) + .await + .map_err(|e| format!("IAM check failed: {}", e))?; + + if !is_valid { + warn!("Invalid AccessKey: {}", username); + tokio::time::sleep(std::time::Duration::from_millis(AUTH_FAILURE_DELAY_MS)).await; + return Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }); + } + + if let Some(identity) = user_identity { + if identity.credentials.secret_key != s3_creds.secret_key { + warn!("Invalid SecretKey for user: {}", username); + tokio::time::sleep(std::time::Duration::from_millis(AUTH_FAILURE_DELAY_MS)).await; + return Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }); + } + + { + let mut guard = state.lock().unwrap(); + guard.identity = Some(identity); + } + debug!("User {} authenticated successfully via password", username); + Ok(Auth::Accept) + } else { + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } + } + + fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> impl Future> + Send { + let raw_user = user.to_string(); + let key = key.clone(); + let state = self.state.clone(); + + async move { + debug!("SFTP public key auth request for user: {}", raw_user); + + let trusted_cas = { + let guard = state.lock().unwrap(); + guard.trusted_certificates.clone() + }; + + if !trusted_cas.is_empty() { + match validate_ssh_certificate(&key, &trusted_cas, &raw_user) { + Ok(true) => { + let (username, _) = parse_auth_username(&raw_user); + + use rustfs_iam::get; + let iam_sys = get().map_err(|e| format!("IAM system unavailable: {}", e))?; + + let (user_identity, is_valid) = iam_sys + .check_key(username) + .await + .map_err(|e| format!("IAM lookup error: {}", e))?; + + if is_valid && user_identity.is_some() { + { + let mut guard = state.lock().unwrap(); + guard.identity = user_identity; + } + info!("User {} authenticated via SSH certificate", username); + Ok(Auth::Accept) + } else { + warn!("Valid certificate presented, but user '{}' does not exist in IAM", username); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } + Ok(false) => Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }), + Err(e) => { + error!("SSH certificate validation error: {}", e); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } + } else { + let (username, _) = parse_auth_username(&raw_user); + + use russh::keys::PublicKeyBase64; + + let client_key_bytes = key.public_key_bytes(); + let client_key_openssh = BASE64.encode(&client_key_bytes); + + let authorized_keys_clone = { + let guard = state.lock().unwrap(); + guard.authorized_keys.clone() + }; + + if !authorized_keys_clone.is_empty() { + debug!("Checking against {} pre-loaded authorized key(s)", authorized_keys_clone.len()); + + for authorized_key in authorized_keys_clone.iter() { + if authorized_key.contains(&client_key_openssh) + || authorized_key == &client_key_openssh + || compare_keys(authorized_key, &client_key_openssh) + { + use rustfs_iam::get; + if let Ok(iam_sys) = get() { + match iam_sys.check_key(username).await { + Ok((user_identity, is_valid)) => { + if is_valid && user_identity.is_some() { + let mut guard = state.lock().unwrap(); + guard.identity = user_identity; + info!("User {} authenticated via pre-loaded authorized key (IAM verified)", username); + return Ok(Auth::Accept); + } + } + Err(e) => { + error!("IAM lookup error: {}", e); + } + } + } + warn!( + "Key matched pre-loaded authorized keys, but IAM verification failed for user '{}'", + username + ); + } + } + } + + use rustfs_iam::get; + match get() { + Ok(iam_sys) => match iam_sys.check_key(username).await { + Ok((user_identity, is_valid)) => { + if is_valid { + if let Some(identity) = user_identity { + let authorized_keys = identity.get_ssh_public_keys(); + + if authorized_keys.is_empty() { + warn!("User '{}' found in IAM but has no SSH public keys registered", username); + return Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }); + } + + let key_valid = authorized_keys.iter().any(|authorized_key| { + authorized_key.contains(&client_key_openssh) + || authorized_key == &client_key_openssh + || compare_keys(authorized_key, &client_key_openssh) + }); + + if key_valid { + { + let mut guard = state.lock().unwrap(); + guard.identity = Some(identity); + } + info!("User {} authenticated via public key from IAM", username); + Ok(Auth::Accept) + } else { + warn!("Public key auth failed: client key not in IAM for user '{}'", username); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } else { + warn!("Public key auth failed: user '{}' not found in IAM", username); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } else { + warn!("Public key auth failed: user '{}' not valid in IAM", username); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } + Err(e) => { + error!("IAM lookup error: {}", e); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + }, + Err(e) => { + error!("IAM system unavailable: {}", e); + Ok(Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + } + } + } + } + + async fn channel_open_session( + &mut self, + _channel: russh::Channel, + _session: &mut Session, + ) -> Result { + Ok(true) + } + + fn data( + &mut self, + channel_id: ChannelId, + data: &[u8], + _session: &mut Session, + ) -> impl Future> + Send { + let state = self.state.clone(); + let data = data.to_vec(); + + async move { + let sender = { + let guard = state.lock().unwrap(); + guard.sftp_channels.get(&channel_id).cloned() + }; + + if let Some(tx) = sender { + let _ = tx.send(data); + } + Ok(()) + } + } + + fn subsystem_request( + &mut self, + channel_id: ChannelId, + name: &str, + session: &mut Session, + ) -> impl Future> + Send { + let name = name.to_string(); + let state = self.state.clone(); + let session_handle = session.handle(); + + async move { + if name == SFTP_SUBSYSTEM { + let (identity, client_ip) = { + let guard = state.lock().unwrap(); + if let Some(id) = &guard.identity { + (id.clone(), guard.client_ip) + } else { + error!("SFTP subsystem requested but user not authenticated"); + return Ok(()); + } + }; + + debug!("Initializing SFTP subsystem for user: {}", identity.credentials.access_key); + + let context = + SessionContext::new(ProtocolPrincipal::new(Arc::new(identity)), SessionProtocol::Sftp, client_ip.ip()); + + let (client_pipe, server_pipe) = tokio::io::duplex(SFTP_BUFFER_SIZE); + let (mut client_read, mut client_write) = tokio::io::split(client_pipe); + + let (tx, mut rx) = mpsc::unbounded_channel::>(); + { + let mut guard = state.lock().unwrap(); + guard.sftp_channels.insert(channel_id, tx); + } + + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + while let Some(data) = rx.recv().await { + if let Err(e) = client_write.write_all(&data).await { + debug!("SFTP input pipe closed: {}", e); + break; + } + } + }); + + let sftp_handler = SftpHandler::new(context); + tokio::spawn(async move { + russh_sftp::server::run(server_pipe, sftp_handler).await; + debug!("SFTP handler finished"); + }); + + let session_handle = session_handle.clone(); + tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; SFTP_READ_BUF_SIZE]; + loop { + match client_read.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + let data: Vec = buf[..n].to_vec(); + if session_handle.data(channel_id, data.into()).await.is_err() { + break; + } + } + Err(e) => { + error!("Error reading from SFTP output: {}", e); + break; + } + } + } + let _ = session_handle.close(channel_id).await; + }); + } + Ok(()) + } + } +} + +fn load_trusted_certificates(ca_cert_path: &str) -> Result, ServerError> { + let path = Path::new(ca_cert_path); + if !path.exists() { + return Err(format!("CA certificate file not found: {}", ca_cert_path).into()); + } + + let contents = std::fs::read_to_string(path)?; + let mut keys = Vec::new(); + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + match ssh_key::PublicKey::from_openssh(line) { + Ok(key) => keys.push(key), + Err(e) => warn!("Skipping invalid CA key line in {}: {}", ca_cert_path, e), + } + } + + info!("Loaded {} trusted CA certificates from {}", keys.len(), ca_cert_path); + Ok(keys) +} + +fn load_authorized_keys(auth_keys_path: &str) -> Result, ServerError> { + let path = Path::new(auth_keys_path); + if !path.exists() { + return Err(format!("Authorized keys file not found: {}", auth_keys_path).into()); + } + + let contents = std::fs::read_to_string(path)?; + let mut keys = Vec::new(); + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with(SSH_KEY_TYPE_RSA) || line.starts_with(SSH_KEY_TYPE_ED25519) || line.starts_with(SSH_KEY_TYPE_ECDSA) { + keys.push(line.to_string()); + } else { + warn!( + "Skipping invalid authorized key line in {}: doesn't start with valid key type", + auth_keys_path + ); + } + } + + info!("Loaded {} authorized SSH public keys from {}", keys.len(), auth_keys_path); + Ok(keys) +} + +fn parse_auth_username(username: &str) -> (&str, Option<&str>) { + if let Some(idx) = username.rfind('=') { + let suffix = &username[idx..]; + if suffix == AUTH_SUFFIX_SVC || suffix == AUTH_SUFFIX_LDAP { + return (&username[..idx], Some(suffix)); + } + } + (username, None) +} + +fn validate_ssh_certificate( + russh_key: &PublicKey, + trusted_cas: &[ssh_key::PublicKey], + raw_username: &str, +) -> Result { + let (username, _suffix) = parse_auth_username(raw_username); + + let key_bytes = russh_key.public_key_bytes(); + + let cert = match Certificate::from_bytes(&key_bytes) { + Ok(c) => c, + Err(_) => { + debug!("Provided key is not a certificate. Skipping cert validation."); + return Ok(false); + } + }; + + debug!("Verifying SSH Certificate: KeyID='{}', Serial={}", cert.comment(), cert.serial()); + + let mut signature_valid = false; + let signature_key = cert.signature_key(); + + for ca in trusted_cas { + if ca.key_data() == signature_key { + signature_valid = true; + debug!("Certificate signed by trusted CA: {}", ca.fingerprint(Default::default())); + break; + } + } + + if !signature_valid { + warn!("Certificate signer not found in trusted CAs"); + return Ok(false); + } + + let now = SystemTime::now(); + let valid_after = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(cert.valid_after()); + let valid_before = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(cert.valid_before()); + + if now < valid_after { + warn!("Certificate is not yet valid (valid after {:?})", valid_after); + return Ok(false); + } + if now > valid_before { + warn!("Certificate has expired (valid until {:?})", valid_before); + return Ok(false); + } + + if !cert.valid_principals().contains(&username.to_string()) { + warn!( + "Certificate does not authorize user '{}'. Principals: {:?}", + username, + cert.valid_principals() + ); + return Ok(false); + } + + match cert.cert_type() { + CertType::User => {} + _ => { + warn!("Certificate is not a User certificate"); + return Ok(false); + } + } + + for (name, _value) in cert.critical_options().iter() { + if name.as_str() == CRITICAL_OPTION_SOURCE_ADDRESS { + } else { + warn!("Rejecting certificate due to unsupported critical option: {}", name); + return Ok(false); + } + } + + info!("SSH Certificate validation successful for user '{}'", username); + Ok(true) +} + +fn compare_keys(stored_key: &str, client_key_base64: &str) -> bool { + let stored_key_parts: Vec<&str> = stored_key.split_whitespace().collect(); + if stored_key_parts.is_empty() { + return false; + } + + let stored_key_data = stored_key_parts.get(1).unwrap_or(&stored_key); + + if *stored_key_data == client_key_base64 { + return true; + } + + if let Ok(stored_bytes) = BASE64.decode(stored_key_data) { + if let Ok(client_bytes) = BASE64.decode(client_key_base64) { + return stored_bytes == client_bytes; + } + } + + false +}