From cb468fb32f6010eb524b462921c77c0ac746a505 Mon Sep 17 00:00:00 2001 From: houseme Date: Tue, 3 Feb 2026 01:06:22 +0800 Subject: [PATCH] Refactor trusted-proxies: modernize utils, improve safety, and fix clippy lints (#1693) Co-authored-by: majinghe <42570491+majinghe@users.noreply.github.com> Co-authored-by: GatewayJ <835269233@qq.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: houseme <4829346+houseme@users.noreply.github.com> Co-authored-by: heihutu <30542132+heihutu@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + .gitignore | 1 + Cargo.lock | 182 ++++++---- Cargo.toml | 28 +- _typos.toml | 3 + crates/config/src/constants/app.rs | 6 + crates/config/src/constants/mod.rs | 1 + crates/config/src/constants/proxy.rs | 125 +++++++ crates/config/src/lib.rs | 2 + crates/crypto/src/encdec/id.rs | 17 +- crates/crypto/src/encdec/tests.rs | 2 +- crates/crypto/src/error.rs | 15 + crates/trusted-proxies/.env.example | 52 +++ crates/trusted-proxies/Cargo.toml | 58 +++ crates/trusted-proxies/README.md | 96 +++++ crates/trusted-proxies/src/cloud/detector.rs | 257 +++++++++++++ .../trusted-proxies/src/cloud/metadata/aws.rs | 153 ++++++++ .../src/cloud/metadata/azure.rs | 308 ++++++++++++++++ .../trusted-proxies/src/cloud/metadata/gcp.rs | 309 ++++++++++++++++ .../trusted-proxies/src/cloud/metadata/mod.rs | 26 ++ crates/trusted-proxies/src/cloud/mod.rs | 26 ++ crates/trusted-proxies/src/cloud/ranges.rs | 216 +++++++++++ crates/trusted-proxies/src/config/env.rs | 90 +++++ crates/trusted-proxies/src/config/loader.rs | 233 ++++++++++++ crates/trusted-proxies/src/config/mod.rs | 21 ++ crates/trusted-proxies/src/config/types.rs | 302 ++++++++++++++++ crates/trusted-proxies/src/error/config.rs | 82 +++++ crates/trusted-proxies/src/error/mod.rs | 94 +++++ crates/trusted-proxies/src/error/proxy.rs | 114 ++++++ crates/trusted-proxies/src/global.rs | 106 ++++++ crates/trusted-proxies/src/lib.rs | 29 ++ .../trusted-proxies/src/middleware/layer.rs | 75 ++++ crates/trusted-proxies/src/middleware/mod.rs | 21 ++ .../trusted-proxies/src/middleware/service.rs | 130 +++++++ crates/trusted-proxies/src/proxy/cache.rs | 84 +++++ crates/trusted-proxies/src/proxy/chain.rs | 257 +++++++++++++ crates/trusted-proxies/src/proxy/metrics.rs | 219 ++++++++++++ crates/trusted-proxies/src/proxy/mod.rs | 28 ++ crates/trusted-proxies/src/proxy/validator.rs | 337 ++++++++++++++++++ crates/trusted-proxies/src/utils/ip.rs | 230 ++++++++++++ crates/trusted-proxies/src/utils/mod.rs | 21 ++ .../trusted-proxies/src/utils/validation.rs | 223 ++++++++++++ .../tests/integration/cloud_tests.rs | 31 ++ .../trusted-proxies/tests/integration/mod.rs | 19 + .../tests/integration/proxy_tests.rs | 36 ++ .../tests/unit/config_tests.rs | 137 +++++++ crates/trusted-proxies/tests/unit/ip_tests.rs | 200 +++++++++++ crates/trusted-proxies/tests/unit/mod.rs | 24 ++ .../tests/unit/validation_tests.rs | 65 ++++ .../tests/unit/validator_tests.rs | 79 ++++ rustfmt.toml | 12 + rustfs/Cargo.toml | 4 +- rustfs/src/main.rs | 3 + rustfs/src/server/http.rs | 32 +- scripts/run.sh | 16 +- 55 files changed, 5145 insertions(+), 93 deletions(-) create mode 120000 .github/copilot-instructions.md create mode 100644 crates/config/src/constants/proxy.rs create mode 100644 crates/trusted-proxies/.env.example create mode 100644 crates/trusted-proxies/Cargo.toml create mode 100644 crates/trusted-proxies/README.md create mode 100644 crates/trusted-proxies/src/cloud/detector.rs create mode 100644 crates/trusted-proxies/src/cloud/metadata/aws.rs create mode 100644 crates/trusted-proxies/src/cloud/metadata/azure.rs create mode 100644 crates/trusted-proxies/src/cloud/metadata/gcp.rs create mode 100644 crates/trusted-proxies/src/cloud/metadata/mod.rs create mode 100644 crates/trusted-proxies/src/cloud/mod.rs create mode 100644 crates/trusted-proxies/src/cloud/ranges.rs create mode 100644 crates/trusted-proxies/src/config/env.rs create mode 100644 crates/trusted-proxies/src/config/loader.rs create mode 100644 crates/trusted-proxies/src/config/mod.rs create mode 100644 crates/trusted-proxies/src/config/types.rs create mode 100644 crates/trusted-proxies/src/error/config.rs create mode 100644 crates/trusted-proxies/src/error/mod.rs create mode 100644 crates/trusted-proxies/src/error/proxy.rs create mode 100644 crates/trusted-proxies/src/global.rs create mode 100644 crates/trusted-proxies/src/lib.rs create mode 100644 crates/trusted-proxies/src/middleware/layer.rs create mode 100644 crates/trusted-proxies/src/middleware/mod.rs create mode 100644 crates/trusted-proxies/src/middleware/service.rs create mode 100644 crates/trusted-proxies/src/proxy/cache.rs create mode 100644 crates/trusted-proxies/src/proxy/chain.rs create mode 100644 crates/trusted-proxies/src/proxy/metrics.rs create mode 100644 crates/trusted-proxies/src/proxy/mod.rs create mode 100644 crates/trusted-proxies/src/proxy/validator.rs create mode 100644 crates/trusted-proxies/src/utils/ip.rs create mode 100644 crates/trusted-proxies/src/utils/mod.rs create mode 100644 crates/trusted-proxies/src/utils/validation.rs create mode 100644 crates/trusted-proxies/tests/integration/cloud_tests.rs create mode 100644 crates/trusted-proxies/tests/integration/mod.rs create mode 100644 crates/trusted-proxies/tests/integration/proxy_tests.rs create mode 100644 crates/trusted-proxies/tests/unit/config_tests.rs create mode 100644 crates/trusted-proxies/tests/unit/ip_tests.rs create mode 100644 crates/trusted-proxies/tests/unit/mod.rs create mode 100644 crates/trusted-proxies/tests/unit/validation_tests.rs create mode 100644 crates/trusted-proxies/tests/unit/validator_tests.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 00000000..be77ac83 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 37b2a464..1977bd93 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ artifacts/ PR_DESCRIPTION.md IMPLEMENTATION_PLAN.md scripts/s3-tests/selected_tests.txt +docs \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 668f4b35..335f741e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -655,7 +655,7 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -718,7 +718,7 @@ dependencies = [ "aws-sigv4", "aws-smithy-async", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -745,7 +745,7 @@ dependencies = [ "aws-smithy-async", "aws-smithy-checksums", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-json", "aws-smithy-observability", "aws-smithy-runtime", @@ -777,7 +777,7 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-json", "aws-smithy-observability", "aws-smithy-runtime", @@ -800,7 +800,7 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-json", "aws-smithy-observability", "aws-smithy-runtime", @@ -823,7 +823,7 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-json", "aws-smithy-observability", "aws-smithy-query", @@ -846,7 +846,7 @@ checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", @@ -868,9 +868,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.9" +version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0e99800414b0c4cae85ed775a1559f8992f4e69f5ebafe9c936e29609eae78" +checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" dependencies = [ "futures-util", "pin-project-lite", @@ -883,7 +883,7 @@ version = "0.63.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23374b9170cbbcc6f5df8dc5ebb9b6c5c28a3c8f599f0e8b8b10eb6f4a5c6e74" dependencies = [ - "aws-smithy-http", + "aws-smithy-http 0.62.6", "aws-smithy-types", "bytes", "crc-fast", @@ -899,9 +899,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.16" +version = "0.60.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1adc2eb689a24741c9dcc21ec19be78839a7899594883d3305cf4c7abae9b3d" +checksum = "35b9c7354a3b13c66f60fe4616d6d1969c9fd36b1b5333a5dfb3ee716b33c588" dependencies = [ "aws-smithy-types", "bytes", @@ -931,10 +931,31 @@ dependencies = [ ] [[package]] -name = "aws-smithy-http-client" -version = "1.1.7" +name = "aws-smithy-http" +version = "0.63.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23141f8daeab46574a1969ddb7316bc2928732e5721b3abfa8d1e16927ea9a52" +checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -965,18 +986,18 @@ dependencies = [ [[package]] name = "aws-smithy-observability" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112e30b3c5379273de88c8cedfc96ce0211e9af22115ceb6975b5c072eccdfb9" +checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.11" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a57c5122eafc566cba4d3cbaacb53dd8a0cacd71155c728d1f4a9179cdd75ae" +checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" dependencies = [ "aws-smithy-types", "urlencoding", @@ -984,12 +1005,12 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.8" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5b6167fcdf47399024e81ac08e795180c576a20e4d4ce67949f9a88ae37dc1" +checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" dependencies = [ "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.63.3", "aws-smithy-http-client", "aws-smithy-observability", "aws-smithy-runtime-api", @@ -1000,6 +1021,7 @@ dependencies = [ "http 1.4.0", "http-body 0.4.6", "http-body 1.0.1", + "http-body-util", "pin-project-lite", "pin-utils", "tokio", @@ -1008,9 +1030,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.1" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d09ba34c17c65a53b65b0ec0d80cf7a934947d407cb7c4fb7753f96de147663" +checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -1025,9 +1047,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.1" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8169129deda9dc18731b7b160f75121507e02f45f2101e48f0252dcd997e9da1" +checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" dependencies = [ "base64-simd", "bytes", @@ -1341,9 +1363,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -1727,18 +1749,18 @@ dependencies = [ [[package]] name = "const-str" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e19f68b180ebff43d6d42005c4b5f046c65fcac28369ba8b3beaad633f9ec0" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" dependencies = [ "const-str-proc-macro", ] [[package]] name = "const-str-proc-macro" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3e0f24ee268386bd3ab4e04fc60df9a818ad801b5ffe592f388a6acc5053fb" +checksum = "1c7e7913ec01ed98b697e62f8d3fd63c86dc6cccaf983c7eebc64d0e563b0ad9" dependencies = [ "proc-macro2", "quote", @@ -1786,6 +1808,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -3111,7 +3142,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -4493,14 +4524,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -5294,9 +5324,9 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-ip-address" -version = "0.6.9" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92488bc8a0f99ee9f23577bdd06526d49657df8bd70504c61f812337cdad01ab" +checksum = "79ef8c257c92ade496781a32a581d43e3d512cf8ce714ecf04ea80f93ed0ff4a" dependencies = [ "libc", "neli", @@ -6544,15 +6574,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -7354,7 +7384,7 @@ dependencies = [ "pastey 0.2.1", "pin-project-lite", "rmcp-macros", - "schemars 1.2.0", + "schemars 1.2.1", "serde", "serde_json", "thiserror 2.0.18", @@ -7660,6 +7690,7 @@ dependencies = [ "rustfs-s3select-query", "rustfs-scanner", "rustfs-targets", + "rustfs-trusted-proxies", "rustfs-utils", "rustfs-zip", "rustls", @@ -8021,7 +8052,7 @@ dependencies = [ "clap", "mime_guess", "rmcp", - "schemars 1.2.0", + "schemars 1.2.1", "serde", "serde_json", "tokio", @@ -8265,6 +8296,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "rustfs-trusted-proxies" +version = "0.0.5" +dependencies = [ + "async-trait", + "axum", + "http 1.4.0", + "ipnetwork", + "metrics", + "moka", + "regex", + "reqwest 0.13.1", + "rustfs-config", + "rustfs-utils", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", +] + [[package]] name = "rustfs-utils" version = "0.0.5" @@ -8273,7 +8326,7 @@ dependencies = [ "blake3", "brotli", "bytes", - "convert_case", + "convert_case 0.11.0", "crc-fast", "flate2", "futures", @@ -8511,7 +8564,7 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "s3s" version = "0.13.0-alpha.2" -source = "git+https://github.com/s3s-project/s3s.git?rev=3cdb3fe22fe8a1b7fc3f71ead4beacac2683ba7f#3cdb3fe22fe8a1b7fc3f71ead4beacac2683ba7f" +source = "git+https://github.com/rustfs/s3s.git?branch=dependabot%2Fcargo%2Fdep-0202#13f968fc89e3bd286d308ca855456b23734d4f74" dependencies = [ "arc-swap", "arrayvec", @@ -8554,6 +8607,7 @@ dependencies = [ "tower", "tracing", "transform-stream", + "url", "urlencoding", "zeroize", ] @@ -8608,9 +8662,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", @@ -8622,9 +8676,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -8845,7 +8899,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.2.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -9054,9 +9108,9 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slog" @@ -9339,15 +9393,17 @@ dependencies = [ [[package]] name = "starshard" -version = "0.6.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b6e011736aa3523f5962c02ba2d6c4cff35d97a0a9a3999afa6111d704a76" +checksum = "6b3a2034ea62d2981c3bdeb21002f07707952ff3bd4594aa39f86ae38ea27dc6" dependencies = [ + "async-trait", "hashbrown 0.16.1", "rayon", "rustc-hash", "serde", "tokio", + "tracing", ] [[package]] @@ -9602,9 +9658,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -11145,18 +11201,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.36" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dafd85c832c1b68bbb4ec0c72c7f6f4fc5179627d2bc7c26b30e4c0cc11e76cc" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.36" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cb7e4e8436d9db52fbd6625dbf2f45243ab84994a72882ec8227b99e72b439a" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" dependencies = [ "proc-macro2", "quote", @@ -11273,9 +11329,9 @@ checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" [[package]] name = "zmij" -version = "1.0.17" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index db392775..7d3c4e26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "rustfs", # Core file system implementation "crates/appauth", # Application authentication and authorization "crates/audit", # Audit target management system with multi-target fan-out + "crates/checksums", # client checksums "crates/common", # Shared utilities and data structures "crates/config", # Configuration management "crates/credentials", # Credential management system @@ -24,26 +25,26 @@ members = [ "crates/ecstore", # Erasure coding storage implementation "crates/e2e_test", # End-to-end test suite "crates/filemeta", # File metadata management + "crates/heal", # Erasure set and object healing "crates/iam", # Identity and Access Management + "crates/kms", # Key Management Service "crates/lock", # Distributed locking implementation "crates/madmin", # Management dashboard and admin API interface + "crates/mcp", # MCP server for S3 operations "crates/notify", # Notification system for events "crates/obs", # Observability utilities "crates/policy", # Policy management "crates/protos", # Protocol buffer definitions "crates/rio", # Rust I/O utilities and abstractions - "crates/targets", # Target-specific configurations and utilities "crates/s3select-api", # S3 Select API interface "crates/s3select-query", # S3 Select query engine "crates/scanner", # Scanner for data integrity checks and health monitoring "crates/signer", # client signer - "crates/checksums", # client checksums + "crates/targets", # Target-specific configurations and utilities + "crates/trusted-proxies", # Trusted proxies management "crates/utils", # Utility functions and helpers "crates/workers", # Worker thread pools and task scheduling "crates/zip", # ZIP file handling and compression - "crates/heal", # Erasure set and object healing - "crates/mcp", # MCP server for S3 operations - "crates/kms", # Key Management Service ] resolver = "2" @@ -91,6 +92,7 @@ rustfs-s3select-api = { path = "crates/s3select-api", version = "0.0.5" } rustfs-s3select-query = { path = "crates/s3select-query", version = "0.0.5" } rustfs-scanner = { path = "crates/scanner", version = "0.0.5" } rustfs-signer = { path = "crates/signer", version = "0.0.5" } +rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "0.0.5" } rustfs-targets = { path = "crates/targets", version = "0.0.5" } rustfs-utils = { path = "crates/utils", version = "0.0.5" } rustfs-workers = { path = "crates/workers", version = "0.0.5" } @@ -109,7 +111,7 @@ futures-util = "0.3.31" pollster = "0.4.0" hyper = { version = "1.8.1", features = ["http2", "http1", "server"] } hyper-rustls = { version = "0.27.7", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs", "webpki-roots"] } -hyper-util = { version = "0.1.19", features = ["tokio", "server-auto", "server-graceful", "tracing"] } +hyper-util = { version = "0.1.20", features = ["tokio", "server-auto", "server-graceful", "tracing"] } http = "1.4.0" http-body = "1.0.1" http-body-util = "0.1.3" @@ -140,7 +142,7 @@ rmp-serde = { version = "1.3.1" } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["raw_value"] } serde_urlencoded = "0.7.1" -schemars = "1.2.0" +schemars = "1.2.1" # Cryptography and Security aes-gcm = { version = "0.11.0-rc.2", features = ["rand_core"] } @@ -175,15 +177,15 @@ atomic_enum = "0.3.0" aws-config = { version = "1.8.12" } aws-credential-types = { version = "1.2.11" } aws-sdk-s3 = { version = "1.121.0", default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] } -aws-smithy-types = { version = "1.4.1" } +aws-smithy-types = { version = "1.4.3" } backtrace = "0.3.76" base64 = "0.22.1" base64-simd = "0.8.0" brotli = "8.0.2" cfg-if = "1.0.4" clap = { version = "4.5.56", features = ["derive", "env"] } -const-str = { version = "1.0.0", features = ["std", "proc"] } -convert_case = "0.10.0" +const-str = { version = "1.1.0", features = ["std", "proc"] } +convert_case = "0.11.0" criterion = { version = "0.8", features = ["html_reports"] } crossbeam-queue = "0.3.12" datafusion = "52.1.0" @@ -202,7 +204,7 @@ ipnetwork = { version = "0.21.1", features = ["serde"] } lazy_static = "1.5.0" libc = "0.2.180" libsystemd = "0.7.2" -local-ip-address = "0.6.9" +local-ip-address = "0.6.10" lz4 = "1.28.1" matchit = "0.9.1" md-5 = "0.11.0-rc.3" @@ -227,7 +229,7 @@ rumqttc = { version = "0.25.1" } rustix = { version = "1.1.3", features = ["fs"] } rust-embed = { version = "8.11.0" } rustc-hash = { version = "2.1.1" } -s3s = { version = "0.13.0-alpha.2", features = ["minio"], git = "https://github.com/s3s-project/s3s.git", rev = "3cdb3fe22fe8a1b7fc3f71ead4beacac2683ba7f" } +s3s = { version = "0.13.0-alpha.2", features = ["minio"], git = "https://github.com/rustfs/s3s.git", branch = "dependabot/cargo/dep-0202" } serial_test = "3.3.1" shadow-rs = { version = "1.7.0", default-features = false } siphasher = "1.0.2" @@ -235,7 +237,7 @@ smallvec = { version = "1.15.1", features = ["serde"] } smartstring = "1.0.1" snafu = "0.8.9" snap = "1.1.1" -starshard = { version = "0.6.0", features = ["rayon", "async", "serde"] } +starshard = { version = "1.1.0", features = ["rayon", "async", "serde"] } strum = { version = "0.27.2", features = ["derive"] } sysinfo = "0.38.0" temp-env = "0.3.6" diff --git a/_typos.toml b/_typos.toml index 231928f6..95ee27bd 100644 --- a/_typos.toml +++ b/_typos.toml @@ -37,8 +37,11 @@ datas = "datas" bre = "bre" abd = "abd" mak = "mak" +gae = "gae" +GAE = "GAE" # s3-tests original test names (cannot be changed) nonexisted = "nonexisted" +consts = "consts" [files] extend-exclude = [] diff --git a/crates/config/src/constants/app.rs b/crates/config/src/constants/app.rs index b0034138..6581303e 100644 --- a/crates/config/src/constants/app.rs +++ b/crates/config/src/constants/app.rs @@ -98,6 +98,12 @@ pub const RUSTFS_HTTP_PREFIX: &str = "http://"; /// Default value: https:// pub const RUSTFS_HTTPS_PREFIX: &str = "https://"; +/// Environment variable for rustfs address +/// This is the environment variable for rustfs address. +/// It is used to bind the server to a specific address. +/// Example: RUSTFS_ADDRESS=":9000" +pub const ENV_RUSTFS_ADDRESS: &str = "RUSTFS_ADDRESS"; + /// Default port for rustfs /// This is the default port for rustfs. /// This is used to bind the server to a specific port. diff --git a/crates/config/src/constants/mod.rs b/crates/config/src/constants/mod.rs index 5c5de2a0..ad245cc2 100644 --- a/crates/config/src/constants/mod.rs +++ b/crates/config/src/constants/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod heal; pub(crate) mod object; pub(crate) mod profiler; pub(crate) mod protocols; +pub(crate) mod proxy; pub(crate) mod quota; pub(crate) mod runtime; pub(crate) mod scanner; diff --git a/crates/config/src/constants/proxy.rs b/crates/config/src/constants/proxy.rs new file mode 100644 index 00000000..09a36720 --- /dev/null +++ b/crates/config/src/constants/proxy.rs @@ -0,0 +1,125 @@ +// 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::DEFAULT_LOG_LEVEL; + +// ==================== Base Proxy Configuration ==================== +/// Environment variable to enable the trusted proxy middleware. +pub const ENV_TRUSTED_PROXY_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_ENABLED"; +/// Trusted proxy middleware is enabled by default. +pub const DEFAULT_TRUSTED_PROXY_ENABLED: bool = true; + +/// Environment variable for the proxy validation mode. +pub const ENV_TRUSTED_PROXY_VALIDATION_MODE: &str = "RUSTFS_TRUSTED_PROXY_VALIDATION_MODE"; +/// Default validation mode is "hop_by_hop". +pub const DEFAULT_TRUSTED_PROXY_VALIDATION_MODE: &str = "hop_by_hop"; + +/// Environment variable to enable RFC 7239 "Forwarded" header support. +pub const ENV_TRUSTED_PROXY_ENABLE_RFC7239: &str = "RUSTFS_TRUSTED_PROXY_ENABLE_RFC7239"; +/// RFC 7239 support is enabled by default. +pub const DEFAULT_TRUSTED_PROXY_ENABLE_RFC7239: bool = true; + +/// Environment variable for the maximum allowed proxy hops. +pub const ENV_TRUSTED_PROXY_MAX_HOPS: &str = "RUSTFS_TRUSTED_PROXY_MAX_HOPS"; +/// Default maximum hops is 10. +pub const DEFAULT_TRUSTED_PROXY_MAX_HOPS: usize = 10; + +/// Environment variable to enable proxy chain continuity checks. +pub const ENV_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK: &str = "RUSTFS_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK"; +/// Continuity checks are enabled by default. +pub const DEFAULT_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK: bool = true; + +/// Environment variable to enable logging of failed proxy validations. +pub const ENV_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS: &str = "RUSTFS_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS"; +/// Logging of failed validations is enabled by default. +pub const DEFAULT_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS: bool = true; + +// ==================== Trusted Proxy Networks ==================== +/// Environment variable for the list of trusted proxy networks (comma-separated IP/CIDR). +pub const ENV_TRUSTED_PROXY_PROXIES: &str = "RUSTFS_TRUSTED_PROXY_NETWORKS"; +/// Default trusted networks include localhost and common private ranges. +pub const DEFAULT_TRUSTED_PROXY_PROXIES: &str = "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8"; + +/// Environment variable for additional trusted proxy networks (production specific). +pub const ENV_TRUSTED_PROXY_EXTRA_PROXIES: &str = "RUSTFS_TRUSTED_PROXY_EXTRA_NETWORKS"; +/// No extra trusted networks by default. +pub const DEFAULT_TRUSTED_PROXY_EXTRA_PROXIES: &str = ""; + +/// Environment variable for individual trusted proxy IPs. +pub const ENV_TRUSTED_PROXY_IPS: &str = "RUSTFS_TRUSTED_PROXY_IPS"; +/// No individual trusted IPs by default. +pub const DEFAULT_TRUSTED_PROXY_IPS: &str = ""; + +/// Environment variable for private network ranges used in internal validation. +pub const ENV_TRUSTED_PROXY_PRIVATE_NETWORKS: &str = "RUSTFS_TRUSTED_PROXY_PRIVATE_NETWORKS"; +/// Default private networks include common RFC 1918 and RFC 4193 ranges. +pub const DEFAULT_TRUSTED_PROXY_PRIVATE_NETWORKS: &str = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8"; + +// ==================== Cache Configuration ==================== +/// Environment variable for the proxy validation cache capacity. +pub const ENV_TRUSTED_PROXY_CACHE_CAPACITY: &str = "RUSTFS_TRUSTED_PROXY_CACHE_CAPACITY"; +/// Default cache capacity is 10,000 entries. +pub const DEFAULT_TRUSTED_PROXY_CACHE_CAPACITY: usize = 10_000; + +/// Environment variable for the cache entry time-to-live (TTL) in seconds. +pub const ENV_TRUSTED_PROXY_CACHE_TTL_SECONDS: &str = "RUSTFS_TRUSTED_PROXY_CACHE_TTL_SECONDS"; +/// Default cache TTL is 300 seconds (5 minutes). +pub const DEFAULT_TRUSTED_PROXY_CACHE_TTL_SECONDS: u64 = 300; + +/// Environment variable for the cache cleanup interval in seconds. +pub const ENV_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL: &str = "RUSTFS_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL"; +/// Default cleanup interval is 60 seconds. +pub const DEFAULT_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL: u64 = 60; + +// ==================== Monitoring Configuration ==================== +/// Environment variable to enable Prometheus metrics. +pub const ENV_TRUSTED_PROXY_METRICS_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_METRICS_ENABLED"; +/// Metrics are enabled by default. +pub const DEFAULT_TRUSTED_PROXY_METRICS_ENABLED: bool = true; + +/// Environment variable for the application log level. +pub const ENV_TRUSTED_PROXIES_LOG_LEVEL: &str = "RUSTFS_TRUSTED_PROXY_LOG_LEVEL"; +/// Default log level is "info". +pub const DEFAULT_TRUSTED_PROXIES_LOG_LEVEL: &str = DEFAULT_LOG_LEVEL; + +/// Environment variable to enable structured JSON logging. +pub const ENV_TRUSTED_PROXY_STRUCTURED_LOGGING: &str = "RUSTFS_TRUSTED_PROXY_STRUCTURED_LOGGING"; +/// Structured logging is disabled by default. +pub const DEFAULT_TRUSTED_PROXY_STRUCTURED_LOGGING: bool = false; + +/// Environment variable to enable distributed tracing. +pub const ENV_TRUSTED_PROXY_TRACING_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_TRACING_ENABLED"; +/// Tracing is enabled by default. +pub const DEFAULT_TRUSTED_PROXY_TRACING_ENABLED: bool = true; + +// ==================== Cloud Integration ==================== +/// Environment variable to enable automatic cloud metadata discovery. +pub const ENV_TRUSTED_PROXY_CLOUD_METADATA_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_ENABLED"; +/// Cloud metadata discovery is disabled by default. +pub const DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_ENABLED: bool = false; + +/// Environment variable for the cloud metadata request timeout in seconds. +pub const ENV_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT: &str = "RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT"; +/// Default cloud metadata timeout is 5 seconds. +pub const DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT: u64 = 5; + +/// Environment variable to enable Cloudflare IP range integration. +pub const ENV_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED: &str = "RUSTFS_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED"; +/// Cloudflare integration is disabled by default. +pub const DEFAULT_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED: bool = false; + +/// Environment variable to force a specific cloud provider (overrides auto-detection). +pub const ENV_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE: &str = "RUSTFS_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE"; +/// No forced provider by default. +pub const DEFAULT_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE: &str = ""; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 3f650232..e0d28aae 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -33,6 +33,8 @@ pub use constants::profiler::*; #[cfg(feature = "constants")] pub use constants::protocols::*; #[cfg(feature = "constants")] +pub use constants::proxy::*; +#[cfg(feature = "constants")] pub use constants::quota::*; #[cfg(feature = "constants")] pub use constants::runtime::*; diff --git a/crates/crypto/src/encdec/id.rs b/crates/crypto/src/encdec/id.rs index 956e8fbb..1232506e 100644 --- a/crates/crypto/src/encdec/id.rs +++ b/crates/crypto/src/encdec/id.rs @@ -40,11 +40,18 @@ impl ID { pub(crate) fn get_key(&self, password: &[u8], salt: &[u8]) -> Result<[u8; 32], crate::Error> { let mut key = [0u8; 32]; match self { - ID::Pbkdf2AESGCM => pbkdf2_hmac::(password, salt, 8192, &mut key), - _ => { - let params = Params::new(64 * 1024, 1, 4, Some(32))?; - let argon_2id = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - argon_2id.hash_password_into(password, salt, &mut key)?; + ID::Pbkdf2AESGCM => { + pbkdf2_hmac::(password, salt, 8192, &mut key); + } + ID::Argon2idAESGCM | ID::Argon2idChaCHa20Poly1305 => { + const ARGON2_MEMORY: u32 = 64 * 1024; + const ARGON2_ITERATIONS: u32 = 1; + const ARGON2_PARALLELISM: u32 = 4; + const ARGON2_OUTPUT_LEN: usize = 32; + + let params = Params::new(ARGON2_MEMORY, ARGON2_ITERATIONS, ARGON2_PARALLELISM, Some(ARGON2_OUTPUT_LEN))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + argon2.hash_password_into(password, salt, &mut key)?; } } diff --git a/crates/crypto/src/encdec/tests.rs b/crates/crypto/src/encdec/tests.rs index 79e2dcbb..675c1e4f 100644 --- a/crates/crypto/src/encdec/tests.rs +++ b/crates/crypto/src/encdec/tests.rs @@ -106,7 +106,7 @@ fn test_encrypt_decrypt_binary_data() -> Result<(), crate::Error> { #[test] fn test_encrypt_decrypt_unicode_data() -> Result<(), crate::Error> { let unicode_strings = [ - "Hello, 世界! 🌍", + "Hello, 世界!🌍", "Тест на русском языке", "العربية اختبار", "🚀🔐💻🌟⭐", diff --git a/crates/crypto/src/error.rs b/crates/crypto/src/error.rs index 189ca09e..d67d6320 100644 --- a/crates/crypto/src/error.rs +++ b/crates/crypto/src/error.rs @@ -20,6 +20,12 @@ pub enum Error { #[error("invalid encryption algorithm ID: {0}")] ErrInvalidAlgID(u8), + #[error("invalid input: {0}")] + ErrInvalidInput(String), + + #[error("invalid key length")] + ErrInvalidKeyLength, + #[cfg(any(test, feature = "crypto"))] #[error("{0}")] ErrInvalidLength(#[from] sha2::digest::InvalidLength), @@ -38,4 +44,13 @@ pub enum Error { #[error("jwt err: {0}")] ErrJwt(#[from] jsonwebtoken::errors::Error), + + #[error("io error: {0}")] + ErrIo(#[from] std::io::Error), + + #[error("invalid signature")] + ErrInvalidSignature, + + #[error("invalid token")] + ErrInvalidToken, } diff --git a/crates/trusted-proxies/.env.example b/crates/trusted-proxies/.env.example new file mode 100644 index 00000000..51b890bd --- /dev/null +++ b/crates/trusted-proxies/.env.example @@ -0,0 +1,52 @@ +# Trusted Proxy Configuration +# Enable the trusted proxy middleware (default: true) +RUSTFS_TRUSTED_PROXY_ENABLED=true + +# Comma-separated list of trusted CIDR ranges (default includes localhost and private networks) +RUSTFS_TRUSTED_PROXY_NETWORKS=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8 + +# Additional trusted networks to append to the default list +RUSTFS_TRUSTED_PROXY_EXTRA_NETWORKS= + +# Validation strategy: strict, lenient, or hop_by_hop (default: hop_by_hop) +RUSTFS_TRUSTED_PROXY_VALIDATION_MODE=hop_by_hop + +# Enable support for RFC 7239 "Forwarded" header (default: true) +RUSTFS_TRUSTED_PROXY_ENABLE_RFC7239=true + +# Maximum allowed proxy hops (default: 10) +RUSTFS_TRUSTED_PROXY_MAX_HOPS=10 + +# Check for continuity in the proxy chain (default: true) +RUSTFS_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK=true + +# Log details about failed validations (default: true) +RUSTFS_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS=true + +# Cache Configuration +# Max entries in the validation cache (default: 10000) +RUSTFS_TRUSTED_PROXY_CACHE_CAPACITY=10000 +# Cache TTL in seconds (default: 300) +RUSTFS_TRUSTED_PROXY_CACHE_TTL_SECONDS=300 +# Cache cleanup interval in seconds (default: 60) +RUSTFS_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL=60 + +# Monitoring Configuration +# Enable Prometheus metrics collection (default: true) +RUSTFS_TRUSTED_PROXY_METRICS_ENABLED=true +# Log level for the proxy module (default: info) +RUSTFS_TRUSTED_PROXY_LOG_LEVEL=info +# Enable structured JSON logging (default: false) +RUSTFS_TRUSTED_PROXY_STRUCTURED_LOGGING=false +# Enable distributed tracing (default: true) +RUSTFS_TRUSTED_PROXY_TRACING_ENABLED=true + +# Cloud Integration +# Enable auto-discovery of cloud IP ranges (default: false) +RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_ENABLED=false +# Timeout for cloud metadata requests in seconds (default: 5) +RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT=5 +# Enable Cloudflare IP range integration (default: false) +RUSTFS_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED=false +# Force a specific cloud provider (aws, azure, gcp) instead of auto-detection +RUSTFS_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE= diff --git a/crates/trusted-proxies/Cargo.toml b/crates/trusted-proxies/Cargo.toml new file mode 100644 index 00000000..8e169360 --- /dev/null +++ b/crates/trusted-proxies/Cargo.toml @@ -0,0 +1,58 @@ +# 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. + +[package] +name = "rustfs-trusted-proxies" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +homepage.workspace = true +description = " RustFS Trusted Proxies module provides secure and efficient management of trusted proxy servers within the RustFS ecosystem, enhancing network security and performance." +keywords = ["trusted-proxies", "network-security", "rustfs", "proxy-management"] +categories = ["network-programming", "security", "web-programming"] + +[dependencies] +async-trait = { workspace = true } +axum = { workspace = true } +http = { workspace = true } +ipnetwork = { workspace = true } +metrics = { workspace = true } +moka = { workspace = true, features = ["future"] } +reqwest = { workspace = true } +rustfs-config = { workspace = true } +rustfs-utils = { workspace = true, features = ["net"] } +serde.workspace = true +serde_json.workspace = true +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time", "test-util"] } +tower = { workspace = true } +tracing = { workspace = true } +regex = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } +tower = { workspace = true, features = ["util"] } + +[lints] +workspace = true + +[[test]] +name = "unit_tests" +path = "tests/unit/mod.rs" + +[[test]] +name = "integration_tests" +path = "tests/integration/mod.rs" diff --git a/crates/trusted-proxies/README.md b/crates/trusted-proxies/README.md new file mode 100644 index 00000000..942ed574 --- /dev/null +++ b/crates/trusted-proxies/README.md @@ -0,0 +1,96 @@ +# RustFS Trusted Proxies + +The `rustfs-trusted-proxies` module provides secure and efficient management of trusted proxy servers within the RustFS +ecosystem. It is designed to handle multi-layer proxy architectures, ensuring accurate client IP identification while +maintaining a zero-trust security model. + +## Features + +- **Multi-Layer Proxy Validation**: Supports `Strict`, `Lenient`, and `HopByHop` validation modes to accurately identify + the real client IP address. +- **Zero-Trust Security**: Verifies every hop in the proxy chain against a configurable list of trusted networks. +- **Cloud Integration**: Automatic discovery of trusted IP ranges for major cloud providers including AWS, Azure, and + GCP. +- **High Performance**: Utilizes the `moka` cache for fast lookup of validation results and `axum` for a + high-performance web interface. +- **Observability**: Built-in support for Prometheus metrics and structured JSON logging via `tracing`. +- **RFC 7239 Support**: Full support for the modern `Forwarded` header alongside legacy `X-Forwarded-For` headers. + +## Configuration + +The module is configured primarily through environment variables: + +| Variable | Default | Description | +|-----------------------------------------------|---------------------|---------------------------------------------------------| +| `RUSTFS_TRUSTED_PROXY_ENABLED` | `true` | Enable the trusted proxy middleware | +| `RUSTFS_TRUSTED_PROXY_VALIDATION_MODE` | `hop_by_hop` | Validation strategy (`strict`, `lenient`, `hop_by_hop`) | +| `RUSTFS_TRUSTED_PROXY_NETWORKS` | `127.0.0.1,::1,...` | Comma-separated list of trusted CIDR ranges | +| `RUSTFS_TRUSTED_PROXY_MAX_HOPS` | `10` | Maximum allowed proxy hops | +| `RUSTFS_TRUSTED_PROXY_CACHE_CAPACITY` | `10000` | Max entries in the validation cache | +| `RUSTFS_TRUSTED_PROXY_METRICS_ENABLED` | `true` | Enable Prometheus metrics collection | +| `RUSTFS_TRUSTED_PROXY_CLOUD_METADATA_ENABLED` | `false` | Enable auto-discovery of cloud IP ranges | + +## Usage + +### Initialization + +Initialize the global trusted proxy system at the start of your application (e.g., in `main.rs`): + +```rust +// Initialize trusted proxies system +rustfs_trusted_proxies::init(); +``` + +### As a Middleware + +Integrate the trusted proxy validation into your Axum application or HTTP service stack: + +```rust +use rustfs_trusted_proxies; + +let app = Router::new() + .route("/", get(handler)) + // Add the trusted proxy layer if enabled + .option_layer(if rustfs_trusted_proxies::is_enabled() { + Some(rustfs_trusted_proxies::layer().clone()) + } else { + None + }); +``` + +### Accessing Client Info + +Retrieve the verified client information in your handlers or other middleware: + +```rust +use rustfs_trusted_proxies::ClientInfo; + +async fn handler(req: Request) -> impl IntoResponse { + if let Some(client_info) = req.extensions().get::() { + println!("Real Client IP: {}", client_info.real_ip); + println!("Is Trusted: {}", client_info.is_from_trusted_proxy); + } +} +``` + +## Development + +### Pre-Commit Checklist + +Before committing, ensure all checks pass: + +```bash +make pre-commit +``` + +### Testing + +Run the test suite: + +```bash +cargo test --workspace --exclude e2e_test +``` + +## License + +Licensed under the Apache License, Version 2.0. diff --git a/crates/trusted-proxies/src/cloud/detector.rs b/crates/trusted-proxies/src/cloud/detector.rs new file mode 100644 index 00000000..30424a69 --- /dev/null +++ b/crates/trusted-proxies/src/cloud/detector.rs @@ -0,0 +1,257 @@ +// 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. + +//! Cloud provider detection and metadata fetching. + +use async_trait::async_trait; +use std::str::FromStr; +use std::time::Duration; +use tracing::{debug, info, warn}; + +use crate::AppError; + +/// Supported cloud providers. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CloudProvider { + /// Amazon Web Services + Aws, + /// Microsoft Azure + Azure, + /// Google Cloud Platform + Gcp, + /// DigitalOcean + DigitalOcean, + /// Cloudflare + Cloudflare, + /// Unknown or custom provider. + Unknown(String), +} + +impl FromStr for CloudProvider { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "aws" | "amazon" => Self::Aws, + "azure" | "microsoft" => Self::Azure, + "gcp" | "google" => Self::Gcp, + "digitalocean" | "do" => Self::DigitalOcean, + "cloudflare" | "cf" => Self::Cloudflare, + _ => Self::Unknown(s.to_string()), + }) + } +} + +impl CloudProvider { + /// Detects the cloud provider based on environment variables. + pub fn detect_from_env() -> Option { + // Check for AWS environment variables. + if std::env::var("RUSTFS_AWS_EXECUTION_ENV").is_ok() + || std::env::var("RUSTFS_AWS_REGION").is_ok() + || std::env::var("RUSTFS_EC2_INSTANCE_ID").is_ok() + { + return Some(Self::Aws); + } + + // Check for Azure environment variables. + if std::env::var("RUSTFS_WEBSITE_SITE_NAME").is_ok() + || std::env::var("RUSTFS_WEBSITE_INSTANCE_ID").is_ok() + || std::env::var("RUSTFS_APPSETTING_WEBSITE_SITE_NAME").is_ok() + { + return Some(Self::Azure); + } + + // Check for GCP environment variables. + if std::env::var("RUSTFS_GCP_PROJECT").is_ok() + || std::env::var("RUSTFS_GOOGLE_CLOUD_PROJECT").is_ok() + || std::env::var("RUSTFS_GAE_INSTANCE").is_ok() + { + return Some(Self::Gcp); + } + + // Check for DigitalOcean environment variables. + if std::env::var("RUSTFS_DIGITALOCEAN_REGION").is_ok() { + return Some(Self::DigitalOcean); + } + + // Check for Cloudflare environment variables. + if std::env::var("RUSTFS_CF_PAGES").is_ok() || std::env::var("RUSTFS_CF_WORKERS").is_ok() { + return Some(Self::Cloudflare); + } + + None + } + + /// Returns the canonical name of the cloud provider. + pub fn name(&self) -> &str { + match self { + Self::Aws => "aws", + Self::Azure => "azure", + Self::Gcp => "gcp", + Self::DigitalOcean => "digitalocean", + Self::Cloudflare => "cloudflare", + Self::Unknown(name) => name, + } + } +} + +/// Trait for fetching metadata from a specific cloud provider. +#[async_trait] +pub trait CloudMetadataFetcher: Send + Sync { + /// Returns the name of the provider. + fn provider_name(&self) -> &str; + + /// Fetches the network CIDR ranges for the current instance. + async fn fetch_network_cidrs(&self) -> Result, AppError>; + + /// Fetches the public IP ranges for the cloud provider. + async fn fetch_public_ip_ranges(&self) -> Result, AppError>; + + /// Fetches all IP ranges that should be considered trusted proxies. + async fn fetch_trusted_proxy_ranges(&self) -> Result, AppError> { + let mut ranges = Vec::new(); + + match self.fetch_network_cidrs().await { + Ok(cidrs) => ranges.extend(cidrs), + Err(e) => warn!("Failed to fetch network CIDRs from {}: {}", self.provider_name(), e), + } + + match self.fetch_public_ip_ranges().await { + Ok(public_ranges) => ranges.extend(public_ranges), + Err(e) => warn!("Failed to fetch public IP ranges from {}: {}", self.provider_name(), e), + } + + Ok(ranges) + } +} + +/// Detector for identifying the current cloud environment and fetching relevant metadata. +#[derive(Debug, Clone)] +pub struct CloudDetector { + /// Whether cloud detection is enabled. + enabled: bool, + /// Timeout for metadata requests. + timeout: Duration, + /// Optionally force a specific provider. + forced_provider: Option, +} + +impl CloudDetector { + /// Creates a new `CloudDetector`. + pub fn new(enabled: bool, timeout: Duration, forced_provider: Option) -> Self { + let forced_provider = forced_provider.and_then(|s| CloudProvider::from_str(&s).ok()); + + Self { + enabled, + timeout, + forced_provider, + } + } + + /// Identifies the current cloud provider. + pub fn detect_provider(&self) -> Option { + if !self.enabled { + return None; + } + + if let Some(provider) = self.forced_provider.as_ref() { + return Some(provider.clone()); + } + + CloudProvider::detect_from_env() + } + + /// Fetches trusted IP ranges for the detected cloud provider. + pub async fn fetch_trusted_ranges(&self) -> Result, AppError> { + if !self.enabled { + debug!("Cloud metadata fetching is disabled"); + return Ok(Vec::new()); + } + + let provider = self.detect_provider(); + + match provider { + Some(CloudProvider::Aws) => { + info!("Detected AWS environment, fetching metadata"); + let fetcher = crate::AwsMetadataFetcher::new(self.timeout); + fetcher.fetch_trusted_proxy_ranges().await + } + Some(CloudProvider::Azure) => { + info!("Detected Azure environment, fetching metadata"); + let fetcher = crate::AzureMetadataFetcher::new(self.timeout); + fetcher.fetch_trusted_proxy_ranges().await + } + Some(CloudProvider::Gcp) => { + info!("Detected GCP environment, fetching metadata"); + let fetcher = crate::GcpMetadataFetcher::new(self.timeout); + fetcher.fetch_trusted_proxy_ranges().await + } + Some(CloudProvider::Cloudflare) => { + info!("Detected Cloudflare environment"); + let ranges = crate::CloudflareIpRanges::fetch().await?; + Ok(ranges) + } + Some(CloudProvider::DigitalOcean) => { + info!("Detected DigitalOcean environment"); + let ranges = crate::DigitalOceanIpRanges::fetch().await?; + Ok(ranges) + } + Some(CloudProvider::Unknown(name)) => { + warn!("Unknown cloud provider detected: {}", name); + Ok(Vec::new()) + } + None => { + debug!("No cloud provider detected"); + Ok(Vec::new()) + } + } + } + + /// Attempts to fetch metadata from all supported providers sequentially. + pub async fn try_all_providers(&self) -> Result, AppError> { + if !self.enabled { + return Ok(Vec::new()); + } + + let providers: Vec> = vec![ + Box::new(crate::AwsMetadataFetcher::new(self.timeout)), + Box::new(crate::AzureMetadataFetcher::new(self.timeout)), + Box::new(crate::GcpMetadataFetcher::new(self.timeout)), + ]; + + for provider in providers { + let provider_name = provider.provider_name(); + debug!("Trying to fetch metadata from {}", provider_name); + + match provider.fetch_trusted_proxy_ranges().await { + Ok(ranges) => { + if !ranges.is_empty() { + info!("Fetched {} IP ranges from {}", ranges.len(), provider_name); + return Ok(ranges); + } + } + Err(e) => { + debug!("Failed to fetch metadata from {}: {}", provider_name, e); + } + } + } + + Ok(Vec::new()) + } +} + +/// Returns a default `CloudDetector` with detection disabled. +pub fn default_cloud_detector() -> CloudDetector { + CloudDetector::new(false, Duration::from_secs(5), None) +} diff --git a/crates/trusted-proxies/src/cloud/metadata/aws.rs b/crates/trusted-proxies/src/cloud/metadata/aws.rs new file mode 100644 index 00000000..c5b88920 --- /dev/null +++ b/crates/trusted-proxies/src/cloud/metadata/aws.rs @@ -0,0 +1,153 @@ +// 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. + +//! AWS metadata fetching implementation for identifying trusted proxy ranges. + +use async_trait::async_trait; +use reqwest::Client; +use std::str::FromStr; +use std::time::Duration; +use tracing::{debug, info}; + +use crate::AppError; +use crate::CloudMetadataFetcher; + +/// Fetcher for AWS-specific metadata. +#[derive(Debug, Clone)] +pub struct AwsMetadataFetcher { + client: Client, + metadata_endpoint: String, +} + +impl AwsMetadataFetcher { + /// Creates a new `AwsMetadataFetcher`. + /// + /// # Arguments + /// + /// * `timeout` - Duration to use for HTTP request timeouts. + /// + /// Returns a new instance of `AwsMetadataFetcher`. + pub fn new(timeout: Duration) -> Self { + let client = Client::builder().timeout(timeout).build().unwrap_or_else(|_| Client::new()); + + Self { + client, + metadata_endpoint: "http://169.254.169.254".to_string(), + } + } + + /// Retrieves an IMDSv2 token for secure metadata access. + #[allow(dead_code)] + async fn get_metadata_token(&self) -> Result { + let url = format!("{}/latest/api/token", self.metadata_endpoint); + + match self + .client + .put(&url) + .header("X-aws-ec2-metadata-token-ttl-seconds", "21600") + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + let token = response + .text() + .await + .map_err(|e| AppError::cloud(format!("Failed to read IMDSv2 token: {}", e)))?; + Ok(token) + } else { + debug!("IMDSv2 token request failed with status: {}", response.status()); + Err(AppError::cloud("Failed to obtain IMDSv2 token")) + } + } + Err(e) => { + debug!("IMDSv2 token request failed: {}", e); + Err(AppError::cloud(format!("IMDSv2 request failed: {}", e))) + } + } + } +} + +#[async_trait] +impl CloudMetadataFetcher for AwsMetadataFetcher { + fn provider_name(&self) -> &str { + "aws" + } + + async fn fetch_network_cidrs(&self) -> Result, AppError> { + // Simplified implementation: returns standard AWS VPC private ranges. + let default_ranges = vec![ + "10.0.0.0/8", // Large VPCs + "172.16.0.0/12", // Medium VPCs + "192.168.0.0/16", // Small VPCs + ]; + + let networks: Result, _> = default_ranges.into_iter().map(ipnetwork::IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + debug!("Using default AWS VPC network ranges"); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse default AWS ranges: {}", e))), + } + } + + async fn fetch_public_ip_ranges(&self) -> Result, AppError> { + let url = "https://ip-ranges.amazonaws.com/ip-ranges.json"; + + #[derive(Debug, serde::Deserialize)] + struct AwsIpRanges { + prefixes: Vec, + } + + #[derive(Debug, serde::Deserialize)] + struct AwsPrefix { + ip_prefix: String, + service: String, + } + + match self.client.get(url).timeout(Duration::from_secs(5)).send().await { + Ok(response) => { + if response.status().is_success() { + let ip_ranges: AwsIpRanges = response + .json() + .await + .map_err(|e| AppError::cloud(format!("Failed to parse AWS IP ranges JSON: {}", e)))?; + + let mut networks = Vec::new(); + + for prefix in ip_ranges.prefixes { + // Include EC2 and CloudFront ranges as potential trusted proxies. + if (prefix.service == "EC2" || prefix.service == "CLOUDFRONT") + && let Ok(network) = ipnetwork::IpNetwork::from_str(&prefix.ip_prefix) + { + networks.push(network); + } + } + + info!("Successfully fetched {} AWS public IP ranges", networks.len()); + Ok(networks) + } else { + debug!("Failed to fetch AWS IP ranges: HTTP {}", response.status()); + Ok(Vec::new()) + } + } + Err(e) => { + debug!("Failed to fetch AWS IP ranges: {}", e); + Ok(Vec::new()) + } + } + } +} diff --git a/crates/trusted-proxies/src/cloud/metadata/azure.rs b/crates/trusted-proxies/src/cloud/metadata/azure.rs new file mode 100644 index 00000000..e8ce7e91 --- /dev/null +++ b/crates/trusted-proxies/src/cloud/metadata/azure.rs @@ -0,0 +1,308 @@ +// 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. + +//! Azure Cloud metadata fetching implementation for identifying trusted proxy ranges. + +use async_trait::async_trait; +use reqwest::Client; +use serde::Deserialize; +use std::str::FromStr; +use std::time::Duration; +use tracing::{debug, info, warn}; + +use crate::AppError; +use crate::CloudMetadataFetcher; + +/// Fetcher for Azure-specific metadata. +#[derive(Debug, Clone)] +pub struct AzureMetadataFetcher { + client: Client, + metadata_endpoint: String, +} + +impl AzureMetadataFetcher { + /// Creates a new `AzureMetadataFetcher`. + pub fn new(timeout: Duration) -> Self { + let client = Client::builder().timeout(timeout).build().unwrap_or_else(|_| Client::new()); + + Self { + client, + metadata_endpoint: "http://169.254.169.254".to_string(), + } + } + + /// Retrieves metadata from the Azure Instance Metadata Service (IMDS). + async fn get_metadata(&self, path: &str) -> Result { + let url = format!("{}/metadata/{}?api-version=2021-05-01", self.metadata_endpoint, path); + + debug!("Fetching Azure metadata from: {}", url); + + match self.client.get(&url).header("Metadata", "true").send().await { + Ok(response) => { + if response.status().is_success() { + let text = response + .text() + .await + .map_err(|e| AppError::cloud(format!("Failed to read Azure metadata response: {}", e)))?; + Ok(text) + } else { + debug!("Azure metadata request failed with status: {}", response.status()); + Err(AppError::cloud(format!("Azure metadata API returned status: {}", response.status()))) + } + } + Err(e) => { + debug!("Azure metadata request failed: {}", e); + Err(AppError::cloud(format!("Azure metadata request failed: {}", e))) + } + } + } + + /// Fetches Azure public IP ranges from the official Microsoft download source. + async fn fetch_azure_ip_ranges(&self) -> Result, AppError> { + // Official Azure IP ranges download URL (periodically updated). + // See: https://www.microsoft.com/en-us/download/details.aspx?id=56519 + let url = + "https://download.microsoft.com/download/7/1/D/71D86715-5596-4529-9B13-DA13A5DE5B63/ServiceTags_Public_20260126.json"; + + #[derive(Debug, Deserialize)] + struct AzureServiceTags { + values: Vec, + } + + #[derive(Debug, Deserialize)] + struct AzureServiceTag { + name: String, + properties: AzureServiceTagProperties, + } + + #[derive(Debug, Deserialize)] + struct AzureServiceTagProperties { + address_prefixes: Vec, + } + + debug!("Fetching Azure IP ranges from: {}", url); + + match self.client.get(url).timeout(Duration::from_secs(10)).send().await { + Ok(response) => { + if response.status().is_success() { + let service_tags: AzureServiceTags = response + .json() + .await + .map_err(|e| AppError::cloud(format!("Failed to parse Azure IP ranges JSON: {}", e)))?; + + let mut networks = Vec::new(); + + for tag in service_tags.values { + // Include general Azure datacenter ranges, excluding specific internal services. + if tag.name.contains("Azure") && !tag.name.contains("ActiveDirectory") { + for prefix in tag.properties.address_prefixes { + if let Ok(network) = ipnetwork::IpNetwork::from_str(&prefix) { + networks.push(network); + } + } + } + } + + info!("Successfully fetched {} Azure public IP ranges", networks.len()); + Ok(networks) + } else { + debug!("Failed to fetch Azure IP ranges: HTTP {}", response.status()); + Ok(Vec::new()) + } + } + Err(e) => { + debug!("Failed to fetch Azure IP ranges: {}", e); + // Fallback to hardcoded ranges if the download fails. + Self::default_azure_ranges() + } + } + } + + /// Returns a set of default Azure IP ranges as a fallback. + fn default_azure_ranges() -> Result, AppError> { + let ranges = vec![ + "13.64.0.0/11", + "13.96.0.0/13", + "13.104.0.0/14", + "20.33.0.0/16", + "20.34.0.0/15", + "20.36.0.0/14", + "20.40.0.0/13", + "20.48.0.0/12", + "20.64.0.0/10", + "20.128.0.0/16", + "20.135.0.0/16", + "20.136.0.0/13", + "20.150.0.0/15", + "20.157.0.0/16", + "20.184.0.0/13", + "20.190.0.0/16", + "20.192.0.0/10", + "40.64.0.0/10", + "40.80.0.0/12", + "40.96.0.0/13", + "40.112.0.0/13", + "40.120.0.0/14", + "40.124.0.0/16", + "40.125.0.0/17", + "51.12.0.0/15", + "51.104.0.0/15", + "51.120.0.0/16", + "51.124.0.0/16", + "51.132.0.0/16", + "51.136.0.0/15", + "51.138.0.0/16", + "51.140.0.0/14", + "51.144.0.0/15", + "52.96.0.0/12", + "52.112.0.0/14", + "52.120.0.0/14", + "52.124.0.0/16", + "52.125.0.0/16", + "52.126.0.0/15", + "52.130.0.0/15", + "52.136.0.0/13", + "52.144.0.0/15", + "52.146.0.0/15", + "52.148.0.0/14", + "52.152.0.0/13", + "52.160.0.0/12", + "52.176.0.0/13", + "52.184.0.0/14", + "52.188.0.0/14", + "52.224.0.0/11", + "65.52.0.0/14", + "104.40.0.0/13", + "104.208.0.0/13", + "104.215.0.0/16", + "137.116.0.0/15", + "137.135.0.0/16", + "138.91.0.0/16", + "157.56.0.0/16", + "168.61.0.0/16", + "168.62.0.0/15", + "191.233.0.0/18", + "193.149.0.0/19", + "2603:1000::/40", + "2603:1010::/40", + "2603:1020::/40", + "2603:1030::/40", + "2603:1040::/40", + "2603:1050::/40", + "2603:1060::/40", + "2603:1070::/40", + "2603:1080::/40", + "2603:1090::/40", + "2603:10a0::/40", + "2603:10b0::/40", + "2603:10c0::/40", + "2603:10d0::/40", + "2603:10e0::/40", + "2603:10f0::/40", + "2603:1100::/40", + ]; + + let networks: Result, _> = ranges.into_iter().map(ipnetwork::IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + debug!("Using default Azure public IP ranges"); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse default Azure ranges: {}", e))), + } + } +} + +#[async_trait] +impl CloudMetadataFetcher for AzureMetadataFetcher { + fn provider_name(&self) -> &str { + "azure" + } + + async fn fetch_network_cidrs(&self) -> Result, AppError> { + // Attempt to fetch network interface information from Azure IMDS. + match self.get_metadata("instance/network/interface").await { + Ok(metadata) => { + #[derive(Debug, Deserialize)] + struct AzureNetworkInterface { + ipv4: AzureIpv4Info, + } + + #[derive(Debug, Deserialize)] + struct AzureIpv4Info { + subnet: Vec, + } + + #[derive(Debug, Deserialize)] + struct AzureSubnet { + address: String, + prefix: String, + } + + let interfaces: Vec = serde_json::from_str(&metadata) + .map_err(|e| AppError::cloud(format!("Failed to parse Azure network metadata JSON: {}", e)))?; + + let mut cidrs = Vec::new(); + for interface in interfaces { + for subnet in interface.ipv4.subnet { + let cidr = format!("{}/{}", subnet.address, subnet.prefix); + if let Ok(network) = ipnetwork::IpNetwork::from_str(&cidr) { + cidrs.push(network); + } + } + } + + if !cidrs.is_empty() { + info!("Successfully fetched {} network CIDRs from Azure metadata", cidrs.len()); + Ok(cidrs) + } else { + debug!("No network CIDRs found in Azure metadata, falling back to defaults"); + Self::default_azure_network_ranges() + } + } + Err(e) => { + warn!("Failed to fetch Azure network metadata: {}", e); + Self::default_azure_network_ranges() + } + } + } + + async fn fetch_public_ip_ranges(&self) -> Result, AppError> { + self.fetch_azure_ip_ranges().await + } +} + +impl AzureMetadataFetcher { + /// Returns a set of default Azure VNet ranges as a fallback. + fn default_azure_network_ranges() -> Result, AppError> { + let ranges = vec![ + "10.0.0.0/8", // Large VNets + "172.16.0.0/12", // Medium VNets + "192.168.0.0/16", // Small VNets + "100.64.0.0/10", // Azure reserved range + "192.0.0.0/24", // Azure reserved + ]; + + let networks: Result, _> = ranges.into_iter().map(ipnetwork::IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + debug!("Using default Azure VNet network ranges"); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse default Azure network ranges: {}", e))), + } + } +} diff --git a/crates/trusted-proxies/src/cloud/metadata/gcp.rs b/crates/trusted-proxies/src/cloud/metadata/gcp.rs new file mode 100644 index 00000000..ad4f90dd --- /dev/null +++ b/crates/trusted-proxies/src/cloud/metadata/gcp.rs @@ -0,0 +1,309 @@ +// 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. + +//! Google Cloud Platform (GCP) metadata fetching implementation for identifying trusted proxy ranges. + +use async_trait::async_trait; +use reqwest::Client; +use serde::Deserialize; +use std::str::FromStr; +use std::time::Duration; +use tracing::{debug, info, warn}; + +use crate::AppError; +use crate::CloudMetadataFetcher; + +/// Fetcher for GCP-specific metadata. +#[derive(Debug, Clone)] +pub struct GcpMetadataFetcher { + client: Client, + metadata_endpoint: String, +} + +impl GcpMetadataFetcher { + /// Creates a new `GcpMetadataFetcher`. + pub fn new(timeout: Duration) -> Self { + let client = Client::builder().timeout(timeout).build().unwrap_or_else(|_| Client::new()); + + Self { + client, + metadata_endpoint: "http://metadata.google.internal".to_string(), + } + } + + /// Retrieves metadata from the GCP Compute Engine metadata server. + async fn get_metadata(&self, path: &str) -> Result { + let url = format!("{}/computeMetadata/v1/{}", self.metadata_endpoint, path); + + debug!("Fetching GCP metadata from: {}", url); + + match self.client.get(&url).header("Metadata-Flavor", "Google").send().await { + Ok(response) => { + if response.status().is_success() { + let text = response + .text() + .await + .map_err(|e| AppError::cloud(format!("Failed to read GCP metadata response: {}", e)))?; + Ok(text) + } else { + debug!("GCP metadata request failed with status: {}", response.status()); + Err(AppError::cloud(format!("GCP metadata API returned status: {}", response.status()))) + } + } + Err(e) => { + debug!("GCP metadata request failed: {}", e); + Err(AppError::cloud(format!("GCP metadata request failed: {}", e))) + } + } + } + + /// Converts a dotted-decimal subnet mask to a CIDR prefix length. + fn subnet_mask_to_prefix_length(mask: &str) -> Result { + let parts: Vec<&str> = mask.split('.').collect(); + if parts.len() != 4 { + return Err(AppError::cloud(format!("Invalid subnet mask format: {}", mask))); + } + + let mut prefix_length = 0; + for part in parts { + let octet: u8 = part + .parse() + .map_err(|_| AppError::cloud(format!("Invalid octet in subnet mask: {}", part)))?; + + let mut remaining = octet; + while remaining > 0 { + if remaining & 0x80 == 0x80 { + prefix_length += 1; + remaining <<= 1; + } else { + break; + } + } + + if remaining != 0 { + return Err(AppError::cloud("Non-contiguous subnet mask detected")); + } + } + + Ok(prefix_length) + } +} + +#[async_trait] +impl CloudMetadataFetcher for GcpMetadataFetcher { + fn provider_name(&self) -> &str { + "gcp" + } + + async fn fetch_network_cidrs(&self) -> Result, AppError> { + // Attempt to list network interfaces from GCP metadata. + match self.get_metadata("instance/network-interfaces/").await { + Ok(interfaces_metadata) => { + let interface_indices: Vec = interfaces_metadata + .lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches('/'); + if line.chars().all(|c| c.is_ascii_digit()) { + line.parse().ok() + } else { + None + } + }) + .collect(); + + if interface_indices.is_empty() { + warn!("No network interfaces found in GCP metadata"); + return Self::default_gcp_network_ranges(); + } + + let mut cidrs = Vec::new(); + + for index in interface_indices { + // Try to get IP and subnet mask for each interface. + let ip_path = format!("instance/network-interfaces/{}/ip", index); + let mask_path = format!("instance/network-interfaces/{}/subnetmask", index); + + match tokio::try_join!(self.get_metadata(&ip_path), self.get_metadata(&mask_path)) { + Ok((ip, mask)) => { + let ip = ip.trim(); + let mask = mask.trim(); + + if let (Ok(ip_addr), Ok(prefix_len)) = + (std::net::Ipv4Addr::from_str(ip), Self::subnet_mask_to_prefix_length(mask)) + { + let cidr_str = format!("{}/{}", ip_addr, prefix_len); + if let Ok(network) = ipnetwork::IpNetwork::from_str(&cidr_str) { + cidrs.push(network); + } + } + } + Err(e) => { + debug!("Failed to get IP/mask for GCP interface {}: {}", index, e); + } + } + } + + if cidrs.is_empty() { + warn!("Could not determine network CIDRs from GCP metadata, falling back to defaults"); + Self::default_gcp_network_ranges() + } else { + info!("Successfully fetched {} network CIDRs from GCP metadata", cidrs.len()); + Ok(cidrs) + } + } + Err(e) => { + warn!("Failed to fetch GCP network metadata: {}", e); + Self::default_gcp_network_ranges() + } + } + } + + async fn fetch_public_ip_ranges(&self) -> Result, AppError> { + self.fetch_gcp_ip_ranges().await + } +} + +impl GcpMetadataFetcher { + /// Fetches GCP public IP ranges from the official Google source. + async fn fetch_gcp_ip_ranges(&self) -> Result, AppError> { + let url = "https://www.gstatic.com/ipranges/cloud.json"; + + #[derive(Debug, Deserialize)] + struct GcpIpRanges { + prefixes: Vec, + } + + #[derive(Debug, Deserialize)] + struct GcpPrefix { + ipv4_prefix: Option, + } + + debug!("Fetching GCP IP ranges from: {}", url); + + match self.client.get(url).timeout(Duration::from_secs(10)).send().await { + Ok(response) => { + if response.status().is_success() { + let ip_ranges: GcpIpRanges = response + .json() + .await + .map_err(|e| AppError::cloud(format!("Failed to parse GCP IP ranges JSON: {}", e)))?; + + let mut networks = Vec::new(); + + for prefix in ip_ranges.prefixes { + if let Some(ipv4_prefix) = prefix.ipv4_prefix + && let Ok(network) = ipnetwork::IpNetwork::from_str(&ipv4_prefix) + { + networks.push(network); + } + } + + info!("Successfully fetched {} GCP public IP ranges", networks.len()); + Ok(networks) + } else { + debug!("Failed to fetch GCP IP ranges: HTTP {}", response.status()); + Self::default_gcp_ip_ranges() + } + } + Err(e) => { + debug!("Failed to fetch GCP IP ranges: {}", e); + Self::default_gcp_ip_ranges() + } + } + } + + /// Returns a set of default GCP public IP ranges as a fallback. + fn default_gcp_ip_ranges() -> Result, AppError> { + let ranges = vec![ + "8.34.208.0/20", + "8.35.192.0/20", + "8.35.208.0/20", + "23.236.48.0/20", + "23.251.128.0/19", + "34.0.0.0/15", + "34.2.0.0/16", + "34.3.0.0/23", + "34.4.0.0/14", + "34.8.0.0/13", + "34.16.0.0/12", + "34.32.0.0/11", + "34.64.0.0/10", + "34.128.0.0/10", + "35.184.0.0/13", + "35.192.0.0/14", + "35.196.0.0/15", + "35.198.0.0/16", + "35.200.0.0/13", + "35.208.0.0/12", + "35.224.0.0/12", + "35.240.0.0/13", + "104.154.0.0/15", + "104.196.0.0/14", + "107.167.160.0/19", + "107.178.192.0/18", + "108.59.80.0/20", + "108.170.192.0/18", + "108.177.0.0/17", + "130.211.0.0/16", + "136.112.0.0/12", + "142.250.0.0/15", + "146.148.0.0/17", + "172.217.0.0/16", + "172.253.0.0/16", + "173.194.0.0/16", + "192.178.0.0/15", + "209.85.128.0/17", + "216.58.192.0/19", + "216.239.32.0/19", + "2001:4860::/32", + "2404:6800::/32", + "2600:1900::/28", + "2607:f8b0::/32", + "2620:15c::/36", + "2800:3f0::/32", + "2a00:1450::/32", + "2c0f:fb50::/32", + ]; + + let networks: Result, _> = ranges.into_iter().map(ipnetwork::IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + debug!("Using default GCP public IP ranges"); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse default GCP ranges: {}", e))), + } + } + + /// Returns a set of default GCP VPC ranges as a fallback. + fn default_gcp_network_ranges() -> Result, AppError> { + let ranges = vec![ + "10.0.0.0/8", // Large VPCs + "172.16.0.0/12", // Medium VPCs + "192.168.0.0/16", // Small VPCs + "100.64.0.0/10", // GCP reserved range + ]; + + let networks: Result, _> = ranges.into_iter().map(ipnetwork::IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + debug!("Using default GCP VPC network ranges"); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse default GCP network ranges: {}", e))), + } + } +} diff --git a/crates/trusted-proxies/src/cloud/metadata/mod.rs b/crates/trusted-proxies/src/cloud/metadata/mod.rs new file mode 100644 index 00000000..31a4fafd --- /dev/null +++ b/crates/trusted-proxies/src/cloud/metadata/mod.rs @@ -0,0 +1,26 @@ +// 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. + +//! Cloud provider metadata fetching +//! +//! This module contains implementations for fetching metadata +//! from various cloud providers. + +mod aws; +mod azure; +mod gcp; + +pub use aws::*; +pub use azure::*; +pub use gcp::*; diff --git a/crates/trusted-proxies/src/cloud/mod.rs b/crates/trusted-proxies/src/cloud/mod.rs new file mode 100644 index 00000000..b30cd0cc --- /dev/null +++ b/crates/trusted-proxies/src/cloud/mod.rs @@ -0,0 +1,26 @@ +// 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. + +//! Cloud service integration module +//! +//! This module provides integration with various cloud providers +//! for automatic IP range detection and metadata fetching. + +mod detector; +pub mod metadata; +mod ranges; + +pub use detector::*; +pub use metadata::*; +pub use ranges::*; diff --git a/crates/trusted-proxies/src/cloud/ranges.rs b/crates/trusted-proxies/src/cloud/ranges.rs new file mode 100644 index 00000000..b7b81a7f --- /dev/null +++ b/crates/trusted-proxies/src/cloud/ranges.rs @@ -0,0 +1,216 @@ +// 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. + +//! Static and dynamic IP range definitions for various cloud providers. + +use std::str::FromStr; +use std::time::Duration; + +use ipnetwork::IpNetwork; +use reqwest::Client; +use tracing::{debug, info}; + +use crate::error::AppError; + +/// Utility for fetching Cloudflare IP ranges. +pub struct CloudflareIpRanges; + +impl CloudflareIpRanges { + /// Returns a static list of Cloudflare IP ranges. + pub async fn fetch() -> Result, AppError> { + let ranges = vec![ + // IPv4 ranges + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "104.16.0.0/13", + "104.24.0.0/14", + "108.162.192.0/18", + "131.0.72.0/22", + "141.101.64.0/18", + "162.158.0.0/15", + "172.64.0.0/13", + "173.245.48.0/20", + "188.114.96.0/20", + "190.93.240.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + // IPv6 ranges + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + ]; + + let networks: Result, _> = ranges.into_iter().map(IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + info!("Loaded {} static Cloudflare IP ranges", networks.len()); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse static Cloudflare IP ranges: {}", e))), + } + } + + /// Fetches the latest Cloudflare IP ranges from their official API. + pub async fn fetch_from_api() -> Result, AppError> { + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| AppError::cloud(format!("Failed to create HTTP client: {}", e)))?; + + let urls = ["https://www.cloudflare.com/ips-v4", "https://www.cloudflare.com/ips-v6"]; + + let mut all_ranges = Vec::new(); + + for url in urls { + match client.get(url).send().await { + Ok(response) => { + if response.status().is_success() { + let text = response + .text() + .await + .map_err(|e| AppError::cloud(format!("Failed to read response from {}: {}", url, e)))?; + + let ranges: Result, _> = text + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(IpNetwork::from_str) + .collect(); + + match ranges { + Ok(mut networks) => { + debug!("Fetched {} IP ranges from {}", networks.len(), url); + all_ranges.append(&mut networks); + } + Err(e) => { + debug!("Failed to parse IP ranges from {}: {}", url, e); + } + } + } else { + debug!("Failed to fetch IP ranges from {}: HTTP {}", url, response.status()); + } + } + Err(e) => { + debug!("Failed to fetch from {}: {}", url, e); + } + } + } + + if all_ranges.is_empty() { + // Fallback to static list if API requests fail. + Self::fetch().await + } else { + info!("Successfully fetched {} Cloudflare IP ranges from API", all_ranges.len()); + Ok(all_ranges) + } + } +} + +/// Utility for fetching DigitalOcean IP ranges. +pub struct DigitalOceanIpRanges; + +impl DigitalOceanIpRanges { + /// Returns a static list of DigitalOcean IP ranges. + pub async fn fetch() -> Result, AppError> { + let ranges = vec![ + // Datacenter IP ranges + "64.227.0.0/16", + "138.197.0.0/16", + "139.59.0.0/16", + "157.230.0.0/16", + "159.65.0.0/16", + "167.99.0.0/16", + "178.128.0.0/16", + "206.189.0.0/16", + "207.154.0.0/16", + "209.97.0.0/16", + // Load Balancer IP ranges + "144.126.0.0/16", + "143.198.0.0/16", + "161.35.0.0/16", + ]; + + let networks: Result, _> = ranges.into_iter().map(IpNetwork::from_str).collect(); + + match networks { + Ok(networks) => { + info!("Loaded {} static DigitalOcean IP ranges", networks.len()); + Ok(networks) + } + Err(e) => Err(AppError::cloud(format!("Failed to parse static DigitalOcean IP ranges: {}", e))), + } + } +} + +/// Utility for fetching Google Cloud IP ranges. +pub struct GoogleCloudIpRanges; + +impl GoogleCloudIpRanges { + /// Fetches the latest Google Cloud IP ranges from their official source. + pub async fn fetch() -> Result, AppError> { + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| AppError::cloud(format!("Failed to create HTTP client: {}", e)))?; + + let url = "https://www.gstatic.com/ipranges/cloud.json"; + + #[derive(Debug, serde::Deserialize)] + struct GoogleIpRanges { + prefixes: Vec, + } + + #[derive(Debug, serde::Deserialize)] + struct GooglePrefix { + ipv4_prefix: Option, + } + + match client.get(url).send().await { + Ok(response) => { + if response.status().is_success() { + let ip_ranges: GoogleIpRanges = response + .json() + .await + .map_err(|e| AppError::cloud(format!("Failed to parse Google IP ranges JSON: {}", e)))?; + + let mut networks = Vec::new(); + + for prefix in ip_ranges.prefixes { + if let Some(ipv4_prefix) = prefix.ipv4_prefix + && let Ok(network) = IpNetwork::from_str(&ipv4_prefix) + { + networks.push(network); + } + } + + info!("Successfully fetched {} Google Cloud IP ranges from API", networks.len()); + Ok(networks) + } else { + debug!("Failed to fetch Google IP ranges: HTTP {}", response.status()); + Ok(Vec::new()) + } + } + Err(e) => { + debug!("Failed to fetch Google IP ranges: {}", e); + Ok(Vec::new()) + } + } + } +} diff --git a/crates/trusted-proxies/src/config/env.rs b/crates/trusted-proxies/src/config/env.rs new file mode 100644 index 00000000..53b887ff --- /dev/null +++ b/crates/trusted-proxies/src/config/env.rs @@ -0,0 +1,90 @@ +// 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. + +//! Environment variable configuration constants and helpers for the trusted proxy system. + +use crate::ConfigError; +use ipnetwork::IpNetwork; +use rustfs_config::{ + ENV_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK, ENV_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, ENV_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + ENV_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, ENV_TRUSTED_PROXY_ENABLE_RFC7239, ENV_TRUSTED_PROXY_ENABLED, + ENV_TRUSTED_PROXY_EXTRA_PROXIES, ENV_TRUSTED_PROXY_IPS, ENV_TRUSTED_PROXY_MAX_HOPS, ENV_TRUSTED_PROXY_PROXIES, + ENV_TRUSTED_PROXY_VALIDATION_MODE, +}; +use std::str::FromStr; +// ==================== Helper Functions ==================== + +/// Parses a comma-separated list of IP/CIDR strings from an environment variable. +pub fn parse_ip_list_from_env(key: &str, default: &str) -> Result, ConfigError> { + let value = std::env::var(key).unwrap_or_else(|_| default.to_string()); + + if value.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut networks = Vec::new(); + for item in value.split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + + match IpNetwork::from_str(item) { + Ok(network) => networks.push(network), + Err(e) => { + tracing::warn!("Failed to parse network '{}' from environment variable {}: {}", item, key, e); + } + } + } + + Ok(networks) +} + +/// Parses a comma-separated list of strings from an environment variable. +pub fn parse_string_list_from_env(key: &str, default: &str) -> Vec { + let value = std::env::var(key).unwrap_or_else(|_| default.to_string()); + + value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Checks if an environment variable is set. +pub fn is_env_set(key: &str) -> bool { + std::env::var(key).is_ok() +} + +/// Returns a list of all proxy-related environment variables and their current values. +#[allow(dead_code)] +pub fn get_all_proxy_env_vars() -> Vec<(String, String)> { + let vars = [ + ENV_TRUSTED_PROXY_ENABLED, + ENV_TRUSTED_PROXY_VALIDATION_MODE, + ENV_TRUSTED_PROXY_ENABLE_RFC7239, + ENV_TRUSTED_PROXY_MAX_HOPS, + ENV_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK, + ENV_TRUSTED_PROXY_PROXIES, + ENV_TRUSTED_PROXY_EXTRA_PROXIES, + ENV_TRUSTED_PROXY_IPS, + ENV_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, + ENV_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + ENV_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, + ]; + + vars.iter() + .filter_map(|&key| std::env::var(key).ok().map(|value| (key.to_string(), value))) + .collect() +} diff --git a/crates/trusted-proxies/src/config/loader.rs b/crates/trusted-proxies/src/config/loader.rs new file mode 100644 index 00000000..9cb43911 --- /dev/null +++ b/crates/trusted-proxies/src/config/loader.rs @@ -0,0 +1,233 @@ +// 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. + +//! Configuration loader for environment variables and files. + +use crate::{ + AppConfig, CacheConfig, CloudConfig, ConfigError, MonitoringConfig, TrustedProxy, TrustedProxyConfig, ValidationMode, + parse_ip_list_from_env, parse_string_list_from_env, +}; +use ipnetwork::IpNetwork; +use rustfs_config::{ + DEFAULT_TRUSTED_PROXIES_LOG_LEVEL, DEFAULT_TRUSTED_PROXY_CACHE_CAPACITY, DEFAULT_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL, + DEFAULT_TRUSTED_PROXY_CACHE_TTL_SECONDS, DEFAULT_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK, + DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + DEFAULT_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE, DEFAULT_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, + DEFAULT_TRUSTED_PROXY_ENABLE_RFC7239, DEFAULT_TRUSTED_PROXY_EXTRA_PROXIES, DEFAULT_TRUSTED_PROXY_IPS, + DEFAULT_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS, DEFAULT_TRUSTED_PROXY_MAX_HOPS, DEFAULT_TRUSTED_PROXY_METRICS_ENABLED, + DEFAULT_TRUSTED_PROXY_PRIVATE_NETWORKS, DEFAULT_TRUSTED_PROXY_PROXIES, DEFAULT_TRUSTED_PROXY_STRUCTURED_LOGGING, + DEFAULT_TRUSTED_PROXY_TRACING_ENABLED, DEFAULT_TRUSTED_PROXY_VALIDATION_MODE, ENV_TRUSTED_PROXIES_LOG_LEVEL, + ENV_TRUSTED_PROXY_CACHE_CAPACITY, ENV_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL, ENV_TRUSTED_PROXY_CACHE_TTL_SECONDS, + ENV_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK, ENV_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, ENV_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + ENV_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE, ENV_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, ENV_TRUSTED_PROXY_ENABLE_RFC7239, + ENV_TRUSTED_PROXY_EXTRA_PROXIES, ENV_TRUSTED_PROXY_IPS, ENV_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS, ENV_TRUSTED_PROXY_MAX_HOPS, + ENV_TRUSTED_PROXY_METRICS_ENABLED, ENV_TRUSTED_PROXY_PRIVATE_NETWORKS, ENV_TRUSTED_PROXY_PROXIES, + ENV_TRUSTED_PROXY_STRUCTURED_LOGGING, ENV_TRUSTED_PROXY_TRACING_ENABLED, ENV_TRUSTED_PROXY_VALIDATION_MODE, +}; +use rustfs_utils::{get_env_bool, get_env_str, get_env_u64, get_env_usize, parse_and_resolve_address}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::str::FromStr; +use tracing::info; + +/// Loader for application configuration. +#[derive(Debug, Clone)] +pub struct ConfigLoader; + +impl ConfigLoader { + /// Loads the complete application configuration from environment variables. + pub fn from_env() -> Result { + // Load proxy-specific configuration. + let proxy_config = Self::load_proxy_config()?; + + // Load cache configuration. + let cache_config = Self::load_cache_config(); + + // Load monitoring and observability configuration. + let monitoring_config = Self::load_monitoring_config(); + + // Load cloud provider integration configuration. + let cloud_config = Self::load_cloud_config(); + + // Load server binding address. + let server_addr = Self::load_server_addr(); + + Ok(AppConfig::new(proxy_config, cache_config, monitoring_config, cloud_config, server_addr)) + } + + /// Loads trusted proxy configuration from environment variables. + fn load_proxy_config() -> Result { + let mut proxies = Vec::new(); + + // Parse base trusted proxies from environment. + let base_networks = parse_ip_list_from_env(ENV_TRUSTED_PROXY_PROXIES, DEFAULT_TRUSTED_PROXY_PROXIES)?; + for network in base_networks { + proxies.push(TrustedProxy::Cidr(network)); + } + + // Parse extra trusted proxies from environment. + let extra_networks = parse_ip_list_from_env(ENV_TRUSTED_PROXY_EXTRA_PROXIES, DEFAULT_TRUSTED_PROXY_EXTRA_PROXIES)?; + for network in extra_networks { + proxies.push(TrustedProxy::Cidr(network)); + } + + // Parse individual trusted proxy IPs. + let ip_strings = parse_string_list_from_env(ENV_TRUSTED_PROXY_IPS, DEFAULT_TRUSTED_PROXY_IPS); + for ip_str in ip_strings { + if let Ok(ip) = ip_str.parse::() { + proxies.push(TrustedProxy::Single(ip)); + } + } + + // Determine validation mode. + let validation_mode_str = get_env_str(ENV_TRUSTED_PROXY_VALIDATION_MODE, DEFAULT_TRUSTED_PROXY_VALIDATION_MODE); + let validation_mode = ValidationMode::from_str(&validation_mode_str)?; + + // Load other proxy settings. + let enable_rfc7239 = get_env_bool(ENV_TRUSTED_PROXY_ENABLE_RFC7239, DEFAULT_TRUSTED_PROXY_ENABLE_RFC7239); + let max_hops = get_env_usize(ENV_TRUSTED_PROXY_MAX_HOPS, DEFAULT_TRUSTED_PROXY_MAX_HOPS); + let enable_chain_check = + get_env_bool(ENV_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK, DEFAULT_TRUSTED_PROXY_CHAIN_CONTINUITY_CHECK); + + // Load private network ranges. + let private_networks = + parse_ip_list_from_env(ENV_TRUSTED_PROXY_PRIVATE_NETWORKS, DEFAULT_TRUSTED_PROXY_PRIVATE_NETWORKS)?; + + Ok(TrustedProxyConfig::new( + proxies, + validation_mode, + enable_rfc7239, + max_hops, + enable_chain_check, + private_networks, + )) + } + + /// Loads cache configuration from environment variables. + fn load_cache_config() -> CacheConfig { + CacheConfig { + capacity: get_env_usize(ENV_TRUSTED_PROXY_CACHE_CAPACITY, DEFAULT_TRUSTED_PROXY_CACHE_CAPACITY), + ttl_seconds: get_env_u64(ENV_TRUSTED_PROXY_CACHE_TTL_SECONDS, DEFAULT_TRUSTED_PROXY_CACHE_TTL_SECONDS), + cleanup_interval_seconds: get_env_u64( + ENV_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL, + DEFAULT_TRUSTED_PROXY_CACHE_CLEANUP_INTERVAL, + ), + } + } + + /// Loads monitoring configuration from environment variables. + fn load_monitoring_config() -> MonitoringConfig { + MonitoringConfig { + metrics_enabled: get_env_bool(ENV_TRUSTED_PROXY_METRICS_ENABLED, DEFAULT_TRUSTED_PROXY_METRICS_ENABLED), + log_level: get_env_str(ENV_TRUSTED_PROXIES_LOG_LEVEL, DEFAULT_TRUSTED_PROXIES_LOG_LEVEL), + structured_logging: get_env_bool(ENV_TRUSTED_PROXY_STRUCTURED_LOGGING, DEFAULT_TRUSTED_PROXY_STRUCTURED_LOGGING), + tracing_enabled: get_env_bool(ENV_TRUSTED_PROXY_TRACING_ENABLED, DEFAULT_TRUSTED_PROXY_TRACING_ENABLED), + log_failed_validations: get_env_bool( + ENV_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS, + DEFAULT_TRUSTED_PROXY_LOG_FAILED_VALIDATIONS, + ), + } + } + + /// Loads cloud configuration from environment variables. + fn load_cloud_config() -> CloudConfig { + let forced_provider_str = get_env_str(ENV_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE, DEFAULT_TRUSTED_PROXY_CLOUD_PROVIDER_FORCE); + let forced_provider = if forced_provider_str.is_empty() { + None + } else { + Some(forced_provider_str) + }; + + CloudConfig { + metadata_enabled: get_env_bool( + ENV_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, + DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, + ), + metadata_timeout_seconds: get_env_u64( + ENV_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + ), + cloudflare_ips_enabled: get_env_bool( + ENV_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, + DEFAULT_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, + ), + forced_provider, + } + } + + /// Loads the server binding address from environment variables. + fn load_server_addr() -> SocketAddr { + let address = get_env_str("RUSTFS_ADDRESS", rustfs_config::DEFAULT_ADDRESS); + parse_and_resolve_address(&address) + .unwrap_or_else(|_| SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), rustfs_config::DEFAULT_PORT)) + } + + /// Loads configuration from environment, falling back to defaults on failure. + pub fn from_env_or_default() -> AppConfig { + match Self::from_env() { + Ok(config) => { + info!("Configuration loaded successfully from environment variables"); + config + } + Err(e) => { + tracing::warn!("Failed to load configuration from environment: {}. Using defaults", e); + Self::default_config() + } + } + } + + /// Returns a default configuration. + pub fn default_config() -> AppConfig { + let proxy_config = TrustedProxyConfig::new( + vec![ + TrustedProxy::Single(IpAddr::V4(Ipv4Addr::LOCALHOST)), + TrustedProxy::Single(IpAddr::V6(Ipv6Addr::LOCALHOST)), + ], + ValidationMode::HopByHop, + true, + 10, + true, + DEFAULT_TRUSTED_PROXY_PRIVATE_NETWORKS + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(), + ); + + AppConfig::new( + proxy_config, + CacheConfig::default(), + MonitoringConfig::default(), + CloudConfig::default(), + SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), rustfs_config::DEFAULT_PORT), + ) + } + + /// Prints a summary of the configuration to the log. + pub fn print_summary(config: &AppConfig) { + info!("=== Application Configuration ==="); + info!("Server: {}", config.server_addr); + info!("Trusted Proxies: {}", config.proxy.proxies.len()); + info!("Validation Mode: {:?}", config.proxy.validation_mode); + info!("Cache Capacity: {}", config.cache.capacity); + info!("Metrics Enabled: {}", config.monitoring.metrics_enabled); + info!("Cloud Metadata: {}", config.cloud.metadata_enabled); + + if config.monitoring.log_failed_validations { + info!("Failed validations will be logged"); + } + + if !config.proxy.proxies.is_empty() { + tracing::debug!("Trusted networks: {:?}", config.proxy.get_network_strings()); + } + } +} diff --git a/crates/trusted-proxies/src/config/mod.rs b/crates/trusted-proxies/src/config/mod.rs new file mode 100644 index 00000000..10a6087f --- /dev/null +++ b/crates/trusted-proxies/src/config/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. + +mod env; +mod loader; +mod types; + +pub use env::*; +pub use loader::*; +pub use types::*; diff --git a/crates/trusted-proxies/src/config/types.rs b/crates/trusted-proxies/src/config/types.rs new file mode 100644 index 00000000..e6572585 --- /dev/null +++ b/crates/trusted-proxies/src/config/types.rs @@ -0,0 +1,302 @@ +// 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. + +//! Configuration type definitions for the trusted proxy system. + +use crate::ConfigError; +use ipnetwork::IpNetwork; +use rustfs_config::{ + DEFAULT_TRUSTED_PROXIES_LOG_LEVEL, DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, + DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, DEFAULT_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, +}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::time::Duration; + +/// Proxy validation mode defining how the proxy chain is verified. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum ValidationMode { + /// Lenient mode: Accepts the entire chain as long as the last proxy is trusted. + Lenient, + /// Strict mode: Requires all proxies in the chain to be trusted. + Strict, + /// Hop-by-hop mode: Finds the first untrusted proxy from right to left. + /// This is the recommended mode for most production environments. + #[default] + HopByHop, +} + +impl FromStr for ValidationMode { + type Err = ConfigError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "lenient" => Ok(Self::Lenient), + "strict" => Ok(Self::Strict), + "hop_by_hop" | "hopbyhop" => Ok(Self::HopByHop), + _ => Err(ConfigError::InvalidConfig(format!( + "Invalid validation mode: '{}'. Must be one of: lenient, strict, hop_by_hop", + s + ))), + } + } +} + +impl ValidationMode { + /// Returns the string representation of the validation mode. + pub fn as_str(&self) -> &'static str { + match self { + Self::Lenient => "lenient", + Self::Strict => "strict", + Self::HopByHop => "hop_by_hop", + } + } +} + +/// Represents a trusted proxy entry, which can be a single IP or a CIDR range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustedProxy { + /// A single IP address. + Single(IpAddr), + /// An IP network range (CIDR notation). + Cidr(IpNetwork), +} + +impl fmt::Display for TrustedProxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Single(ip) => write!(f, "{}", ip), + Self::Cidr(network) => write!(f, "{}", network), + } + } +} + +impl TrustedProxy { + /// Checks if the given IP address matches this proxy configuration. + pub fn contains(&self, ip: &IpAddr) -> bool { + match self { + Self::Single(proxy_ip) => ip == proxy_ip, + Self::Cidr(network) => network.contains(*ip), + } + } +} + +/// Configuration for trusted proxies and validation logic. +#[derive(Debug, Clone)] +pub struct TrustedProxyConfig { + /// List of trusted proxy entries. + pub proxies: Vec, + /// The validation mode to use for verifying proxy chains. + pub validation_mode: ValidationMode, + /// Whether to enable RFC 7239 "Forwarded" header support. + pub enable_rfc7239: bool, + /// Maximum allowed proxy hops in the chain. + pub max_hops: usize, + /// Whether to enable continuity checks for the proxy chain. + pub enable_chain_continuity_check: bool, + /// Private network ranges that should be treated with caution. + pub private_networks: Vec, +} + +impl TrustedProxyConfig { + /// Creates a new trusted proxy configuration. + pub fn new( + proxies: Vec, + validation_mode: ValidationMode, + enable_rfc7239: bool, + max_hops: usize, + enable_chain_continuity_check: bool, + private_networks: Vec, + ) -> Self { + Self { + proxies, + validation_mode, + enable_rfc7239, + max_hops, + enable_chain_continuity_check, + private_networks, + } + } + + /// Checks if a SocketAddr originates from a trusted proxy. + pub fn is_trusted(&self, addr: &SocketAddr) -> bool { + let ip = addr.ip(); + self.proxies.iter().any(|proxy| proxy.contains(&ip)) + } + + /// Checks if an IP address belongs to a private network range. + pub fn is_private_network(&self, ip: &IpAddr) -> bool { + self.private_networks.iter().any(|network| network.contains(*ip)) + } + + /// Returns a list of all network strings for debugging purposes. + pub fn get_network_strings(&self) -> Vec { + self.proxies.iter().map(|p| p.to_string()).collect() + } + + /// Returns a summary of the configuration. + pub fn summary(&self) -> String { + format!( + "TrustedProxyConfig {{ proxies: {}, mode: {}, max_hops: {} }}", + self.proxies.len(), + self.validation_mode.as_str(), + self.max_hops + ) + } +} + +/// Configuration for the internal caching mechanism. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheConfig { + /// Maximum number of entries in the cache. + pub capacity: usize, + /// Time-to-live for cache entries in seconds. + pub ttl_seconds: u64, + /// Interval for cache cleanup in seconds. + pub cleanup_interval_seconds: u64, +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + capacity: 10_000, + ttl_seconds: 300, + cleanup_interval_seconds: 60, + } + } +} + +impl CacheConfig { + /// Returns the TTL as a Duration. + pub fn ttl_duration(&self) -> Duration { + Duration::from_secs(self.ttl_seconds) + } + + /// Returns the cleanup interval as a Duration. + pub fn cleanup_interval(&self) -> Duration { + Duration::from_secs(self.cleanup_interval_seconds) + } +} + +/// Configuration for monitoring and observability. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitoringConfig { + /// Whether to enable Prometheus metrics. + pub metrics_enabled: bool, + /// The logging level (e.g., "info", "debug"). + pub log_level: String, + /// Whether to use structured JSON logging. + pub structured_logging: bool, + /// Whether to enable distributed tracing. + pub tracing_enabled: bool, + /// Whether to log detailed information about failed validations. + pub log_failed_validations: bool, +} + +impl Default for MonitoringConfig { + fn default() -> Self { + Self { + metrics_enabled: true, + log_level: DEFAULT_TRUSTED_PROXIES_LOG_LEVEL.to_string(), + structured_logging: false, + tracing_enabled: true, + log_failed_validations: true, + } + } +} + +/// Configuration for cloud provider integration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudConfig { + /// Whether to enable automatic cloud metadata discovery. + pub metadata_enabled: bool, + /// Timeout for cloud metadata requests in seconds. + pub metadata_timeout_seconds: u64, + /// Whether to automatically include Cloudflare IP ranges. + pub cloudflare_ips_enabled: bool, + /// Optionally force a specific cloud provider. + pub forced_provider: Option, +} + +impl Default for CloudConfig { + fn default() -> Self { + Self { + metadata_enabled: DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_ENABLED, + metadata_timeout_seconds: DEFAULT_TRUSTED_PROXY_CLOUD_METADATA_TIMEOUT, + cloudflare_ips_enabled: DEFAULT_TRUSTED_PROXY_CLOUDFLARE_IPS_ENABLED, + forced_provider: None, + } + } +} + +impl CloudConfig { + /// Returns the metadata timeout as a Duration. + pub fn metadata_timeout(&self) -> Duration { + Duration::from_secs(self.metadata_timeout_seconds) + } +} + +/// Complete application configuration. +#[derive(Debug, Clone)] +pub struct AppConfig { + /// Trusted proxy settings. + pub proxy: TrustedProxyConfig, + /// Cache settings. + pub cache: CacheConfig, + /// Monitoring and observability settings. + pub monitoring: MonitoringConfig, + /// Cloud integration settings. + pub cloud: CloudConfig, + /// The address the server should bind to. + pub server_addr: SocketAddr, +} + +impl AppConfig { + /// Creates a new application configuration. + pub fn new( + proxy: TrustedProxyConfig, + cache: CacheConfig, + monitoring: MonitoringConfig, + cloud: CloudConfig, + server_addr: SocketAddr, + ) -> Self { + Self { + proxy, + cache, + monitoring, + cloud, + server_addr, + } + } + + /// Returns a summary of the application configuration. + pub fn summary(&self) -> String { + format!( + "AppConfig {{\n\ + \x20\x20proxy: {},\n\ + \x20\x20cache_capacity: {},\n\ + \x20\x20metrics: {},\n\ + \x20\x20cloud_metadata: {}\n\ + }}", + self.proxy.summary(), + self.cache.capacity, + self.monitoring.metrics_enabled, + self.cloud.metadata_enabled + ) + } +} diff --git a/crates/trusted-proxies/src/error/config.rs b/crates/trusted-proxies/src/error/config.rs new file mode 100644 index 00000000..f265dfbd --- /dev/null +++ b/crates/trusted-proxies/src/error/config.rs @@ -0,0 +1,82 @@ +// 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. + +//! Configuration error types for the trusted proxy system. + +use std::net::AddrParseError; + +/// Errors related to application configuration. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// Required environment variable is missing. + #[error("Missing environment variable: {0}")] + MissingEnvVar(String), + + /// Environment variable exists but could not be parsed. + #[error("Failed to parse environment variable {0}: {1}")] + EnvParseError(String, String), + + /// A configuration value is logically invalid. + #[error("Invalid configuration value for {0}: {1}")] + InvalidValue(String, String), + + /// An IP address or CIDR range is malformed. + #[error("Invalid IP address or network: {0}")] + InvalidIp(String), + + /// Configuration failed overall validation. + #[error("Configuration validation failed: {0}")] + ValidationFailed(String), + + /// Two or more configuration settings are in conflict. + #[error("Configuration conflict: {0}")] + Conflict(String), + + /// Error reading or parsing a configuration file. + #[error("Config file error: {0}")] + FileError(String), + + /// General invalid configuration error. + #[error("Invalid config: {0}")] + InvalidConfig(String), +} + +impl From for ConfigError { + fn from(err: AddrParseError) -> Self { + Self::InvalidIp(err.to_string()) + } +} + +impl From for ConfigError { + fn from(err: ipnetwork::IpNetworkError) -> Self { + Self::InvalidIp(err.to_string()) + } +} + +impl ConfigError { + /// Creates a `MissingEnvVar` error. + pub fn missing_env_var(key: &str) -> Self { + Self::MissingEnvVar(key.to_string()) + } + + /// Creates an `EnvParseError`. + pub fn env_parse(key: &str, value: &str) -> Self { + Self::EnvParseError(key.to_string(), value.to_string()) + } + + /// Creates an `InvalidValue` error. + pub fn invalid_value(field: &str, value: &str) -> Self { + Self::InvalidValue(field.to_string(), value.to_string()) + } +} diff --git a/crates/trusted-proxies/src/error/mod.rs b/crates/trusted-proxies/src/error/mod.rs new file mode 100644 index 00000000..0ab23974 --- /dev/null +++ b/crates/trusted-proxies/src/error/mod.rs @@ -0,0 +1,94 @@ +// 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. + +//! Error types for the trusted proxy system. + +mod config; +mod proxy; + +pub use config::*; +pub use proxy::*; + +/// Unified error type for the application. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// Errors related to configuration. + #[error("Configuration error: {0}")] + Config(#[from] ConfigError), + + /// Errors related to proxy validation. + #[error("Proxy validation error: {0}")] + Proxy(#[from] ProxyError), + + /// Errors related to cloud service integration. + #[error("Cloud service error: {0}")] + Cloud(String), + + /// General internal errors. + #[error("Internal error: {0}")] + Internal(String), + + /// Standard I/O errors. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Errors related to HTTP requests or responses. + #[error("HTTP error: {0}")] + Http(String), +} + +impl AppError { + /// Creates a new `Cloud` error. + pub fn cloud(msg: impl Into) -> Self { + Self::Cloud(msg.into()) + } + + /// Creates a new `Internal` error. + pub fn internal(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } + + /// Creates a new `Http` error. + pub fn http(msg: impl Into) -> Self { + Self::Http(msg.into()) + } + + /// Returns true if the error is considered recoverable. + pub fn is_recoverable(&self) -> bool { + match self { + Self::Config(_) => true, + Self::Proxy(e) => e.is_recoverable(), + Self::Cloud(_) => true, + Self::Internal(_) => false, + Self::Io(_) => true, + Self::Http(_) => true, + } + } +} + +/// Type alias for API error responses (Status Code, Error Message). +pub type ApiError = (http::StatusCode, String); + +impl From for ApiError { + fn from(err: AppError) -> Self { + match err { + AppError::Config(_) => (http::StatusCode::BAD_REQUEST, err.to_string()), + AppError::Proxy(_) => (http::StatusCode::BAD_REQUEST, err.to_string()), + AppError::Cloud(_) => (http::StatusCode::SERVICE_UNAVAILABLE, err.to_string()), + AppError::Internal(_) => (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + AppError::Io(_) => (http::StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + AppError::Http(_) => (http::StatusCode::BAD_GATEWAY, err.to_string()), + } + } +} diff --git a/crates/trusted-proxies/src/error/proxy.rs b/crates/trusted-proxies/src/error/proxy.rs new file mode 100644 index 00000000..f90371c9 --- /dev/null +++ b/crates/trusted-proxies/src/error/proxy.rs @@ -0,0 +1,114 @@ +// 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. + +//! Proxy validation error types for the trusted proxy system. + +use std::net::AddrParseError; + +/// Errors that can occur during proxy chain validation. +#[derive(Debug, thiserror::Error)] +pub enum ProxyError { + /// The X-Forwarded-For header is malformed or contains invalid data. + #[error("Invalid X-Forwarded-For header: {0}")] + InvalidXForwardedFor(String), + + /// The RFC 7239 Forwarded header is malformed. + #[error("Invalid Forwarded header (RFC 7239): {0}")] + InvalidForwardedHeader(String), + + /// General failure during proxy chain validation. + #[error("Proxy chain validation failed: {0}")] + ChainValidationFailed(String), + + /// The number of proxy hops exceeds the configured limit. + #[error("Proxy chain too long: {0} hops (max: {1})")] + ChainTooLong(usize, usize), + + /// The request originated from a proxy that is not in the trusted list. + #[error("Request from untrusted proxy: {0}")] + UntrustedProxy(String), + + /// The proxy chain is not continuous (e.g., an untrusted IP is between trusted ones). + #[error("Proxy chain is not continuous")] + ChainNotContinuous, + + /// An IP address in the chain could not be parsed. + #[error("Failed to parse IP address: {0}")] + IpParseError(String), + + /// A header value could not be parsed as a string. + #[error("Failed to parse header: {0}")] + HeaderParseError(String), + + /// Validation took too long and timed out. + #[error("Validation timeout")] + Timeout, + + /// An unexpected internal error occurred during validation. + #[error("Internal validation error: {0}")] + Internal(String), +} + +impl From for ProxyError { + fn from(err: AddrParseError) -> Self { + Self::IpParseError(err.to_string()) + } +} + +impl ProxyError { + /// Creates an `InvalidXForwardedFor` error. + pub fn invalid_xff(msg: impl Into) -> Self { + Self::InvalidXForwardedFor(msg.into()) + } + + /// Creates an `InvalidForwardedHeader` error. + pub fn invalid_forwarded(msg: impl Into) -> Self { + Self::InvalidForwardedHeader(msg.into()) + } + + /// Creates a `ChainValidationFailed` error. + pub fn chain_failed(msg: impl Into) -> Self { + Self::ChainValidationFailed(msg.into()) + } + + /// Creates an `UntrustedProxy` error. + pub fn untrusted(proxy: impl Into) -> Self { + Self::UntrustedProxy(proxy.into()) + } + + /// Creates an `Internal` validation error. + pub fn internal(msg: impl Into) -> Self { + Self::Internal(msg.into()) + } + + /// Determines if the error is recoverable, meaning the request can still be processed + /// (perhaps by falling back to the direct peer IP). + pub fn is_recoverable(&self) -> bool { + match self { + // These errors typically mean we should use the direct peer IP as a fallback. + Self::UntrustedProxy(_) => true, + Self::ChainTooLong(_, _) => true, + Self::ChainNotContinuous => true, + + // These errors suggest malformed requests or severe configuration issues. + Self::InvalidXForwardedFor(_) => false, + Self::InvalidForwardedHeader(_) => false, + Self::ChainValidationFailed(_) => false, + Self::IpParseError(_) => false, + Self::HeaderParseError(_) => false, + Self::Timeout => true, + Self::Internal(_) => false, + } + } +} diff --git a/crates/trusted-proxies/src/global.rs b/crates/trusted-proxies/src/global.rs new file mode 100644 index 00000000..f9eeadf7 --- /dev/null +++ b/crates/trusted-proxies/src/global.rs @@ -0,0 +1,106 @@ +// 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. + +//! Global entry point for the Trusted Proxies module. +//! +//! This module provides a unified interface for initializing and using the +//! trusted proxy functionality within the RustFS server. + +use crate::{AppConfig, ConfigLoader, ProxyMetrics, TrustedProxyLayer, default_proxy_metrics}; +use rustfs_config::{DEFAULT_TRUSTED_PROXY_ENABLED, ENV_TRUSTED_PROXY_ENABLED}; +use std::sync::Arc; +use std::sync::OnceLock; + +/// Global instance of the application configuration. +static CONFIG: OnceLock> = OnceLock::new(); + +/// Global instance of the metrics collector. +static METRICS: OnceLock> = OnceLock::new(); + +/// Global instance of the trusted proxy layer. +static PROXY_LAYER: OnceLock = OnceLock::new(); + +/// Global flag indicating if the trusted proxy middleware is enabled. +static ENABLED: OnceLock = OnceLock::new(); + +/// Initializes the global trusted proxy system. +/// +/// This function should be called once at the start of the application. +/// It loads the configuration, initializes metrics, and sets up the proxy layer. +pub fn init() { + // Check if the trusted proxy system is enabled via environment variable. + let enabled = rustfs_utils::get_env_bool(ENV_TRUSTED_PROXY_ENABLED, DEFAULT_TRUSTED_PROXY_ENABLED); + ENABLED.set(enabled).expect("Trusted proxy enabled flag already initialized"); + + if !enabled { + tracing::info!("Trusted Proxies module is disabled via configuration"); + return; + } + + // Load configuration from environment variables. + let config = Arc::new(ConfigLoader::from_env_or_default()); + CONFIG.set(config.clone()).expect("Trusted proxy config already initialized"); + + // Initialize metrics if enabled. + let metrics = if config.monitoring.metrics_enabled { + let m = default_proxy_metrics(enabled); + Some(m) + } else { + None + }; + METRICS + .set(metrics.clone()) + .expect("Trusted proxy metrics already initialized"); + + // Initialize the trusted proxy layer. + let layer = TrustedProxyLayer::new(config.proxy.clone(), metrics, enabled); + PROXY_LAYER.set(layer).expect("Trusted proxy layer already initialized"); + + tracing::info!("Trusted Proxies module initialized"); + ConfigLoader::print_summary(&config); +} + +/// Returns a reference to the global trusted proxy layer. +/// +/// This layer can be used to wrap Axum services or other Tower-compatible services. +/// +/// # Panics +/// +/// Panics if `init()` has not been called. +pub fn layer() -> &'static TrustedProxyLayer { + PROXY_LAYER + .get() + .expect("Trusted proxy system not initialized. Call init() first.") +} + +/// Returns a reference to the global configuration. +/// +/// # Panics +/// +/// Panics if `init()` has not been called. +pub fn config() -> &'static AppConfig { + CONFIG + .get() + .expect("Trusted proxy system not initialized. Call init() first.") +} + +/// Returns a reference to the global metrics collector, if enabled. +pub fn metrics() -> Option<&'static ProxyMetrics> { + METRICS.get().and_then(|m| m.as_ref()) +} + +/// Returns true if the trusted proxy system is enabled. +pub fn is_enabled() -> bool { + *ENABLED.get().unwrap_or(&false) +} diff --git a/crates/trusted-proxies/src/lib.rs b/crates/trusted-proxies/src/lib.rs new file mode 100644 index 00000000..6252160a --- /dev/null +++ b/crates/trusted-proxies/src/lib.rs @@ -0,0 +1,29 @@ +// 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. + +mod cloud; +mod config; +mod error; +mod global; +mod middleware; +mod proxy; +mod utils; + +pub use cloud::*; +pub use config::*; +pub use error::*; +pub use global::{config as global_config, init, is_enabled, layer, metrics}; +pub use middleware::{TrustedProxyLayer, TrustedProxyMiddleware}; +pub use proxy::*; +pub use utils::*; diff --git a/crates/trusted-proxies/src/middleware/layer.rs b/crates/trusted-proxies/src/middleware/layer.rs new file mode 100644 index 00000000..eb651d18 --- /dev/null +++ b/crates/trusted-proxies/src/middleware/layer.rs @@ -0,0 +1,75 @@ +// 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. + +//! Tower layer implementation for the trusted proxy middleware. + +use std::sync::Arc; +use tower::Layer; + +use crate::ProxyMetrics; +use crate::ProxyValidator; +use crate::TrustedProxyConfig; +use crate::TrustedProxyMiddleware; + +/// Tower Layer for the trusted proxy middleware. +#[derive(Clone, Debug)] +pub struct TrustedProxyLayer { + /// The validator used to verify proxy chains. + pub(crate) validator: Arc, + /// Whether the middleware is enabled. + pub(crate) enabled: bool, +} + +impl TrustedProxyLayer { + /// Creates a new `TrustedProxyLayer`. + pub fn new(config: TrustedProxyConfig, metrics: Option, enabled: bool) -> Self { + let validator = ProxyValidator::new(config, metrics); + + Self { + validator: Arc::new(validator), + enabled, + } + } + + /// Creates a new `TrustedProxyLayer` that is enabled by default. + pub fn enabled(config: TrustedProxyConfig, metrics: Option) -> Self { + Self::new(config, metrics, true) + } + + /// Creates a new `TrustedProxyLayer` that is disabled. + pub fn disabled() -> Self { + Self::new( + TrustedProxyConfig::new(Vec::new(), crate::config::ValidationMode::Lenient, true, 10, true, Vec::new()), + None, + false, + ) + } + + /// Returns true if the middleware is enabled. + pub fn is_enabled(&self) -> bool { + self.enabled + } +} + +impl Layer for TrustedProxyLayer { + type Service = TrustedProxyMiddleware; + + fn layer(&self, inner: S) -> Self::Service { + TrustedProxyMiddleware { + inner, + validator: self.validator.clone(), + enabled: self.enabled, + } + } +} diff --git a/crates/trusted-proxies/src/middleware/mod.rs b/crates/trusted-proxies/src/middleware/mod.rs new file mode 100644 index 00000000..d74cb176 --- /dev/null +++ b/crates/trusted-proxies/src/middleware/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. + +//! Middleware module for Axum web framework + +mod layer; +mod service; + +pub use layer::*; +pub use service::*; diff --git a/crates/trusted-proxies/src/middleware/service.rs b/crates/trusted-proxies/src/middleware/service.rs new file mode 100644 index 00000000..025c7191 --- /dev/null +++ b/crates/trusted-proxies/src/middleware/service.rs @@ -0,0 +1,130 @@ +// 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. + +//! Tower service implementation for the trusted proxy middleware. + +use crate::{ClientInfo, ProxyValidator, TrustedProxyLayer}; +use http::Request; +use std::sync::Arc; +use std::task::{Context, Poll}; +use tower::Service; +use tracing::{Span, debug, instrument}; + +/// Tower Service for the trusted proxy middleware. +#[derive(Clone)] +pub struct TrustedProxyMiddleware { + /// The inner service being wrapped. + pub(crate) inner: S, + /// The validator used to verify proxy chains. + pub(crate) validator: Arc, + /// Whether the middleware is enabled. + pub(crate) enabled: bool, +} + +impl TrustedProxyMiddleware { + /// Creates a new `TrustedProxyMiddleware`. + pub fn new(inner: S, validator: Arc, enabled: bool) -> Self { + Self { + inner, + validator, + enabled, + } + } + + /// Creates a new `TrustedProxyMiddleware` from a `TrustedProxyLayer`. + pub fn from_layer(inner: S, layer: &TrustedProxyLayer) -> Self { + Self::new(inner, layer.validator.clone(), layer.enabled) + } +} + +impl Service> for TrustedProxyMiddleware +where + S: Service> + Clone + Send + 'static, + S::Future: Send, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + #[instrument( + name = "trusted_proxy_middleware", + skip_all, + fields( + http.method = %req.method(), + http.uri = %req.uri(), + http.version = ?req.version(), + enabled = self.enabled, + peer.addr = tracing::field::Empty, + client.ip = tracing::field::Empty, + client.trusted = tracing::field::Empty, + client.hops = tracing::field::Empty, + error = tracing::field::Empty, + error.message = tracing::field::Empty, + ) + )] + fn call(&mut self, mut req: Request) -> Self::Future { + let span = Span::current(); + + // If the middleware is disabled, pass the request through immediately. + if !self.enabled { + debug!("Trusted proxy middleware is disabled"); + return self.inner.call(req); + } + + let start_time = std::time::Instant::now(); + + // Extract the direct peer address from the request extensions. + let peer_addr = req.extensions().get::().copied(); + + if let Some(addr) = peer_addr { + span.record("peer.addr", addr.to_string()); + } + + // Validate the request and extract client information. + match self.validator.validate_request(peer_addr, req.headers()) { + Ok(client_info) => { + span.record("client.ip", client_info.real_ip.to_string()); + span.record("client.trusted", client_info.is_from_trusted_proxy); + span.record("client.hops", client_info.proxy_hops as i64); + + // Insert the verified client info into the request extensions. + req.extensions_mut().insert(client_info); + + let duration = start_time.elapsed(); + debug!("Proxy validation successful in {:?}", duration); + } + Err(err) => { + span.record("error", true); + span.record("error.message", err.to_string()); + + // If the error is recoverable, fallback to a direct connection info. + if err.is_recoverable() { + let client_info = ClientInfo::direct( + peer_addr.unwrap_or_else(|| std::net::SocketAddr::new(std::net::IpAddr::from([0, 0, 0, 0]), 0)), + ); + req.extensions_mut().insert(client_info); + } else { + debug!("Unrecoverable proxy validation error: {}", err); + } + } + } + + // Call the inner service. + self.inner.call(req) + } +} diff --git a/crates/trusted-proxies/src/proxy/cache.rs b/crates/trusted-proxies/src/proxy/cache.rs new file mode 100644 index 00000000..f80a534d --- /dev/null +++ b/crates/trusted-proxies/src/proxy/cache.rs @@ -0,0 +1,84 @@ +// 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. + +//! High-performance cache implementation for proxy validation results using Moka. + +use moka::future::Cache; +use std::net::IpAddr; +use std::time::Duration; + +/// Cache for storing IP validation results. +#[derive(Debug, Clone)] +pub struct IpValidationCache { + /// The underlying Moka cache. + cache: Cache, + /// Whether the cache is enabled. + enabled: bool, +} + +impl IpValidationCache { + /// Creates a new `IpValidationCache` using Moka. + pub fn new(capacity: usize, ttl: Duration, enabled: bool) -> Self { + let cache = Cache::builder().max_capacity(capacity as u64).time_to_live(ttl).build(); + + Self { cache, enabled } + } + + /// Checks if an IP is trusted, using the cache if available. + pub async fn is_trusted(&self, ip: &IpAddr, validator: impl FnOnce(&IpAddr) -> bool) -> bool { + if !self.enabled { + return validator(ip); + } + + // Attempt to get the result from cache. + if let Some(is_trusted) = self.cache.get(ip).await { + metrics::counter!("rustfs_trusted_proxy_cache_hits").increment(1); + return is_trusted; + } + + // Cache miss: perform validation and update cache. + metrics::counter!("rustfs_trusted_proxy_cache_misses").increment(1); + let is_trusted = validator(ip); + self.cache.insert(*ip, is_trusted).await; + + is_trusted + } + + /// Clears all entries from the cache. + pub async fn clear(&self) { + self.cache.invalidate_all(); + metrics::gauge!("rustfs_trusted_proxy_cache_size").set(0.0); + } + + /// Returns statistics about the current state of the cache. + pub fn stats(&self) -> CacheStats { + let entry_count = self.cache.entry_count(); + + CacheStats { + size: entry_count as usize, + // Moka doesn't expose max_capacity directly in a simple way after build, + // but we can track it if needed. + capacity: 0, + } + } +} + +/// Statistics about the IP validation cache. +#[derive(Debug, Clone)] +pub struct CacheStats { + /// Current number of entries in the cache. + pub size: usize, + /// Maximum capacity of the cache. + pub capacity: usize, +} diff --git a/crates/trusted-proxies/src/proxy/chain.rs b/crates/trusted-proxies/src/proxy/chain.rs new file mode 100644 index 00000000..57f45315 --- /dev/null +++ b/crates/trusted-proxies/src/proxy/chain.rs @@ -0,0 +1,257 @@ +// 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. + +//! Proxy chain analysis and validation logic. + +use crate::{ProxyError, TrustedProxyConfig, ValidationMode, is_valid_ip_address}; +use axum::http::HeaderMap; +use std::collections::HashSet; +use std::net::IpAddr; +use tracing::trace; + +/// Result of analyzing a proxy chain. +#[derive(Debug, Clone)] +pub struct ChainAnalysis { + /// The identified real client IP address. + pub client_ip: IpAddr, + /// The number of validated proxy hops. + pub hops: usize, + /// Whether the proxy chain is continuous and trusted. + pub is_continuous: bool, + /// List of warnings generated during analysis. + pub warnings: Vec, + /// The validation mode used for analysis. + pub validation_mode: ValidationMode, + /// The portion of the chain that consists of trusted proxies. + pub trusted_chain: Vec, +} + +/// Analyzer for verifying the integrity of proxy chains. +#[derive(Debug, Clone)] +pub struct ProxyChainAnalyzer { + /// Configuration for trusted proxies. + config: TrustedProxyConfig, + /// Cache of trusted IP addresses for fast lookup. + trusted_ip_cache: HashSet, +} + +impl ProxyChainAnalyzer { + /// Creates a new `ProxyChainAnalyzer`. + pub fn new(config: TrustedProxyConfig) -> Self { + let mut trusted_ip_cache = HashSet::new(); + + for proxy in &config.proxies { + match proxy { + crate::TrustedProxy::Single(ip) => { + trusted_ip_cache.insert(*ip); + } + crate::TrustedProxy::Cidr(network) => { + // For small networks, cache all IPs to speed up lookups. + // Only cache IPv4 networks to avoid iterating huge IPv6 ranges. + if network.is_ipv4() && network.prefix() >= 24 { + for ip in network.iter() { + trusted_ip_cache.insert(ip); + } + } + } + } + } + + Self { + config, + trusted_ip_cache, + } + } + + /// Analyzes a proxy chain to identify the real client IP and verify trust. + pub fn analyze_chain( + &self, + proxy_chain: &[IpAddr], + current_proxy_ip: IpAddr, + headers: &HeaderMap, + ) -> Result { + trace!("Analyzing proxy chain: {:?} with current proxy: {}", proxy_chain, current_proxy_ip); + + // Validate all IP addresses in the chain. + self.validate_ip_addresses(proxy_chain)?; + + // Construct the full chain including the direct peer. + let mut full_chain = proxy_chain.to_vec(); + full_chain.push(current_proxy_ip); + + // Enforce maximum hop limit. + if full_chain.len() > self.config.max_hops { + return Err(ProxyError::ChainTooLong(full_chain.len(), self.config.max_hops)); + } + + // Analyze the chain based on the configured validation mode. + let (client_ip, trusted_chain, hops) = match self.config.validation_mode { + ValidationMode::Lenient => self.analyze_lenient(&full_chain), + ValidationMode::Strict => self.analyze_strict(&full_chain)?, + ValidationMode::HopByHop => self.analyze_hop_by_hop(&full_chain), + }; + + // Check for chain continuity if enabled. + let is_continuous = if self.config.enable_chain_continuity_check { + self.check_chain_continuity(&full_chain, &trusted_chain) + } else { + true + }; + + // Collect any warnings. + let warnings = self.collect_warnings(&full_chain, &trusted_chain, headers); + + // Final validation of the identified client IP. + if !is_valid_ip_address(&client_ip) { + return Err(ProxyError::internal(format!("Invalid client IP identified: {}", client_ip))); + } + + Ok(ChainAnalysis { + client_ip, + hops, + is_continuous, + warnings, + validation_mode: self.config.validation_mode, + trusted_chain, + }) + } + + /// Lenient mode: Accepts the entire chain if the last proxy is trusted. + fn analyze_lenient(&self, chain: &[IpAddr]) -> (IpAddr, Vec, usize) { + if chain.is_empty() { + return (IpAddr::from([0, 0, 0, 0]), Vec::new(), 0); + } + + if let Some(last_proxy) = chain.last() + && self.is_ip_trusted(last_proxy) + { + let client_ip = chain.first().copied().unwrap_or(*last_proxy); + return (client_ip, chain.to_vec(), chain.len()); + } + + let client_ip = chain.first().copied().unwrap_or(IpAddr::from([0, 0, 0, 0])); + (client_ip, Vec::new(), 0) + } + + /// Strict mode: Requires every IP in the chain to be trusted. + fn analyze_strict(&self, chain: &[IpAddr]) -> Result<(IpAddr, Vec, usize), ProxyError> { + if chain.is_empty() { + return Ok((IpAddr::from([0, 0, 0, 0]), Vec::new(), 0)); + } + + for (i, ip) in chain.iter().enumerate() { + if !self.is_ip_trusted(ip) { + return Err(ProxyError::chain_failed(format!("Proxy at position {} ({}) is not trusted", i, ip))); + } + } + + let client_ip = chain.first().copied().unwrap_or(IpAddr::from([0, 0, 0, 0])); + Ok((client_ip, chain.to_vec(), chain.len())) + } + + /// Hop-by-hop mode: Traverses the chain from right to left to find the first untrusted IP. + fn analyze_hop_by_hop(&self, chain: &[IpAddr]) -> (IpAddr, Vec, usize) { + if chain.is_empty() { + return (IpAddr::from([0, 0, 0, 0]), Vec::new(), 0); + } + + let mut trusted_chain = Vec::new(); + let mut validated_hops = 0; + + // Traverse from the most recent proxy back towards the client. + for ip in chain.iter().rev() { + if self.is_ip_trusted(ip) { + trusted_chain.insert(0, *ip); + validated_hops += 1; + } else { + break; + } + } + + if trusted_chain.is_empty() { + let client_ip = *chain.last().unwrap(); + (client_ip, vec![client_ip], 0) + } else { + let client_ip_index = chain.len().saturating_sub(trusted_chain.len()); + let client_ip = if client_ip_index > 0 { + chain[client_ip_index - 1] + } else { + chain[0] + }; + + (client_ip, trusted_chain, validated_hops) + } + } + + /// Verifies that the trusted portion of the chain is a continuous suffix of the full chain. + fn check_chain_continuity(&self, full_chain: &[IpAddr], trusted_chain: &[IpAddr]) -> bool { + if full_chain.len() <= 1 || trusted_chain.is_empty() { + return true; + } + + if trusted_chain.len() > full_chain.len() { + return false; + } + + let expected_tail = &full_chain[full_chain.len() - trusted_chain.len()..]; + expected_tail == trusted_chain + } + + /// Validates that IP addresses are not unspecified, multicast, or otherwise invalid. + fn validate_ip_addresses(&self, chain: &[IpAddr]) -> Result<(), ProxyError> { + for ip in chain { + if ip.is_unspecified() { + return Err(ProxyError::invalid_xff("IP address cannot be unspecified (0.0.0.0 or ::)")); + } + + if ip.is_multicast() { + return Err(ProxyError::invalid_xff("IP address cannot be multicast")); + } + + if !is_valid_ip_address(ip) { + return Err(ProxyError::IpParseError(format!("Invalid IP address in chain: {}", ip))); + } + } + + Ok(()) + } + + /// Checks if an IP address is trusted based on the configuration. + fn is_ip_trusted(&self, ip: &IpAddr) -> bool { + if self.trusted_ip_cache.contains(ip) { + return true; + } + + self.config.proxies.iter().any(|proxy| proxy.contains(ip)) + } + + /// Collects warnings about potential issues in the proxy chain. + fn collect_warnings(&self, full_chain: &[IpAddr], trusted_chain: &[IpAddr], headers: &HeaderMap) -> Vec { + let mut warnings = Vec::new(); + + if !trusted_chain.is_empty() && !headers.contains_key("x-forwarded-for") && !headers.contains_key("forwarded") { + warnings.push("No proxy headers found for request from trusted proxy".to_string()); + } + + let mut seen_ips = HashSet::new(); + for ip in full_chain { + if !seen_ips.insert(ip) { + warnings.push(format!("Duplicate IP address detected in proxy chain: {}", ip)); + break; + } + } + + warnings + } +} diff --git a/crates/trusted-proxies/src/proxy/metrics.rs b/crates/trusted-proxies/src/proxy/metrics.rs new file mode 100644 index 00000000..1fd03b0f --- /dev/null +++ b/crates/trusted-proxies/src/proxy/metrics.rs @@ -0,0 +1,219 @@ +// 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. + +//! Metrics and monitoring for proxy validation performance and results. + +use crate::{ProxyError, ValidationMode}; +use metrics::{counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram}; +use std::time::Duration; +use tracing::info; + +/// Collector for proxy validation metrics. +#[derive(Debug, Clone)] +pub struct ProxyMetrics { + /// Whether metrics collection is enabled. + enabled: bool, + /// Application name used as a label for metrics. + app_name: String, +} + +impl ProxyMetrics { + /// Creates a new `ProxyMetrics` collector. + pub fn new(app_name: &str, enabled: bool) -> Self { + let metrics = Self { + enabled, + app_name: app_name.to_string(), + }; + + // Register metric descriptions for Prometheus. + metrics.register_descriptions(); + + metrics + } + + /// Registers descriptions for all metrics. + fn register_descriptions(&self) { + if !self.enabled { + return; + } + + describe_counter!( + "rustfs_trusted_proxy_validation_attempts_total", + "Total number of proxy validation attempts" + ); + describe_counter!( + "rustfs_trusted_proxy_validation_success_total", + "Total number of successful proxy validations" + ); + describe_counter!( + "rustfs_trusted_proxy_validation_failure_total", + "Total number of failed proxy validations" + ); + describe_counter!( + "rustfs_trusted_proxy_validation_failure_by_type_total", + "Total number of failed proxy validations categorized by error type" + ); + describe_gauge!("rustfs_trusted_proxy_chain_length", "Current length of proxy chains being validated"); + describe_histogram!( + "rustfs_trusted_proxy_validation_duration_seconds", + "Time taken to validate a proxy chain in seconds" + ); + describe_gauge!( + "rustfs_trusted_proxy_cache_size", + "Current number of entries in the proxy validation cache" + ); + describe_counter!("rustfs_trusted_proxy_cache_hits_total", "Total number of cache hits for proxy validation"); + describe_counter!( + "rustfs_trusted_proxy_cache_misses_total", + "Total number of cache misses for proxy validation" + ); + } + + /// Increments the total number of validation attempts. + pub fn increment_validation_attempts(&self) { + if !self.enabled { + return; + } + + counter!( + "rustfs_trusted_proxy_validation_attempts_total", + "app" => self.app_name.clone() + ) + .increment(1); + } + + /// Records a successful validation. + pub fn record_validation_success(&self, from_trusted_proxy: bool, proxy_hops: usize, duration: Duration) { + if !self.enabled { + return; + } + + counter!( + "rustfs_trusted_proxy_validation_success_total", + "app" => self.app_name.clone(), + "trusted" => from_trusted_proxy.to_string() + ) + .increment(1); + + gauge!( + "rustfs_trusted_proxy_chain_length", + "app" => self.app_name.clone() + ) + .set(proxy_hops as f64); + + histogram!( + "rustfs_trusted_proxy_validation_duration_seconds", + "app" => self.app_name.clone() + ) + .record(duration.as_secs_f64()); + } + + /// Records a failed validation with the specific error type. + pub fn record_validation_failure(&self, error: &ProxyError, duration: Duration) { + if !self.enabled { + return; + } + + let error_type = match error { + ProxyError::InvalidXForwardedFor(_) => "invalid_x_forwarded_for", + ProxyError::InvalidForwardedHeader(_) => "invalid_forwarded_header", + ProxyError::ChainValidationFailed(_) => "chain_validation_failed", + ProxyError::ChainTooLong(_, _) => "chain_too_long", + ProxyError::UntrustedProxy(_) => "untrusted_proxy", + ProxyError::ChainNotContinuous => "chain_not_continuous", + ProxyError::IpParseError(_) => "ip_parse_error", + ProxyError::HeaderParseError(_) => "header_parse_error", + ProxyError::Timeout => "timeout", + ProxyError::Internal(_) => "internal", + }; + + counter!( + "rustfs_trusted_proxy_validation_failure_total", + "app" => self.app_name.clone(), + "error_type" => error_type + ) + .increment(1); + + counter!( + "rustfs_trusted_proxy_validation_failure_by_type_total", + "app" => self.app_name.clone(), + "error_type" => error_type + ) + .increment(1); + + histogram!( + "rustfs_trusted_proxy_validation_duration_seconds", + "app" => self.app_name.clone(), + "error_type" => error_type + ) + .record(duration.as_secs_f64()); + } + + /// Records the validation mode currently in use. + pub fn record_validation_mode(&self, mode: ValidationMode) { + if !self.enabled { + return; + } + + gauge!( + "rustfs_trusted_proxy_validation_mode", + "app" => self.app_name.clone(), + "mode" => mode.as_str() + ) + .set(match mode { + ValidationMode::Lenient => 0.0, + ValidationMode::Strict => 1.0, + ValidationMode::HopByHop => 2.0, + }); + } + + /// Records cache performance metrics. + pub fn record_cache_metrics(&self, hits: u64, misses: u64, size: usize) { + if !self.enabled { + return; + } + + counter!("rustfs_trusted_proxy_cache_hits_total", "app" => self.app_name.clone()).increment(hits); + counter!("rustfs_trusted_proxy_cache_misses_total", "app" => self.app_name.clone()).increment(misses); + gauge!("rustfs_trusted_proxy_cache_size", "app" => self.app_name.clone()).set(size as f64); + } + + /// Prints a summary of enabled metrics to the log. + pub fn print_summary(&self) { + if !self.enabled { + info!("Metrics collection is disabled"); + return; + } + + info!("Proxy metrics enabled for application: {}", self.app_name); + info!("Available metrics:"); + info!(" - rustfs_trusted_proxy_validation_attempts_total"); + info!(" - rustfs_trusted_proxy_validation_success_total"); + info!(" - rustfs_trusted_proxy_validation_failure_total"); + info!(" - rustfs_trusted_proxy_validation_failure_by_type_total"); + info!(" - rustfs_trusted_proxy_chain_length"); + info!(" - rustfs_trusted_proxy_validation_duration_seconds"); + info!(" - rustfs_trusted_proxy_cache_size"); + info!(" - rustfs_trusted_proxy_cache_hits_total"); + info!(" - rustfs_trusted_proxy_cache_misses_total"); + } +} + +/// Default application name for metrics. +const DEFAULT_APP_NAME: &str = "trusted-proxy"; + +/// Creates a default `ProxyMetrics` collector. +pub fn default_proxy_metrics(enabled: bool) -> ProxyMetrics { + ProxyMetrics::new(DEFAULT_APP_NAME, enabled) +} diff --git a/crates/trusted-proxies/src/proxy/mod.rs b/crates/trusted-proxies/src/proxy/mod.rs new file mode 100644 index 00000000..784b5eb0 --- /dev/null +++ b/crates/trusted-proxies/src/proxy/mod.rs @@ -0,0 +1,28 @@ +// 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 proxy handling module +//! +//! This module contains the main logic for validating and processing +//! requests through trusted proxies. + +mod cache; +mod chain; +mod metrics; +mod validator; + +pub use cache::*; +pub use chain::*; +pub use metrics::*; +pub use validator::*; diff --git a/crates/trusted-proxies/src/proxy/validator.rs b/crates/trusted-proxies/src/proxy/validator.rs new file mode 100644 index 00000000..05ddcbc5 --- /dev/null +++ b/crates/trusted-proxies/src/proxy/validator.rs @@ -0,0 +1,337 @@ +// 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. + +//! Proxy validator for verifying proxy chains and extracting client information. + +use axum::http::HeaderMap; +use std::net::{IpAddr, SocketAddr}; +use std::time::Instant; +use tracing::{debug, warn}; + +use crate::{ProxyChainAnalyzer, ProxyError, ProxyMetrics, TrustedProxyConfig, ValidationMode}; + +/// Information about the client extracted from the request and proxy headers. +#[derive(Debug, Clone)] +pub struct ClientInfo { + /// The verified real IP address of the client. + pub real_ip: IpAddr, + /// The original host requested by the client (if provided by a trusted proxy). + pub forwarded_host: Option, + /// The original protocol (http/https) used by the client (if provided by a trusted proxy). + pub forwarded_proto: Option, + /// Whether the request was received from a trusted proxy. + pub is_from_trusted_proxy: bool, + /// The IP address of the proxy that directly connected to this server. + pub proxy_ip: Option, + /// The number of proxy hops identified in the chain. + pub proxy_hops: usize, + /// The validation mode used for this request. + pub validation_mode: ValidationMode, + /// Any warnings generated during the validation process. + pub warnings: Vec, +} + +impl ClientInfo { + /// Creates a `ClientInfo` for a direct connection without any proxies. + pub fn direct(addr: SocketAddr) -> Self { + Self { + real_ip: addr.ip(), + forwarded_host: None, + forwarded_proto: None, + is_from_trusted_proxy: false, + proxy_ip: None, + proxy_hops: 0, + validation_mode: ValidationMode::Lenient, + warnings: Vec::new(), + } + } + + /// Creates a `ClientInfo` for a request received through a trusted proxy. + pub fn from_trusted_proxy( + real_ip: IpAddr, + forwarded_host: Option, + forwarded_proto: Option, + proxy_ip: IpAddr, + proxy_hops: usize, + validation_mode: ValidationMode, + warnings: Vec, + ) -> Self { + Self { + real_ip, + forwarded_host, + forwarded_proto, + is_from_trusted_proxy: true, + proxy_ip: Some(proxy_ip), + proxy_hops, + validation_mode, + warnings, + } + } + + /// Returns a string representation of the client info for logging. + pub fn to_log_string(&self) -> String { + format!( + "client_ip={}, proxy={:?}, hops={}, trusted={}, mode={:?}", + self.real_ip, self.proxy_ip, self.proxy_hops, self.is_from_trusted_proxy, self.validation_mode + ) + } +} + +/// Core validator that processes incoming requests to verify proxy chains. +#[derive(Debug, Clone)] +pub struct ProxyValidator { + /// Configuration for trusted proxies. + config: TrustedProxyConfig, + /// Analyzer for verifying the integrity of the proxy chain. + chain_analyzer: ProxyChainAnalyzer, + /// Metrics collector for observability. + metrics: Option, +} + +impl ProxyValidator { + /// Creates a new `ProxyValidator` with the given configuration and metrics. + pub fn new(config: TrustedProxyConfig, metrics: Option) -> Self { + let chain_analyzer = ProxyChainAnalyzer::new(config.clone()); + + Self { + config, + chain_analyzer, + metrics, + } + } + + /// Validates an incoming request and extracts client information. + pub fn validate_request(&self, peer_addr: Option, headers: &HeaderMap) -> Result { + let start_time = Instant::now(); + + // Record the start of the validation attempt. + self.record_metric_start(); + + // Perform the internal validation logic. + let result = self.validate_request_internal(peer_addr, headers); + + // Record the result and duration. + let duration = start_time.elapsed(); + self.record_metric_result(&result, duration); + + result + } + + /// Internal logic for request validation. + fn validate_request_internal(&self, peer_addr: Option, headers: &HeaderMap) -> Result { + // Fallback to unspecified address if peer address is missing. + let peer_addr = peer_addr.unwrap_or_else(|| SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 0)); + + // Check if the direct peer is a trusted proxy. + if self.config.is_trusted(&peer_addr) { + debug!("Request received from trusted proxy: {}", peer_addr.ip()); + + // Parse and validate headers from the trusted proxy. + self.validate_trusted_proxy_request(&peer_addr, headers) + } else { + // Log a warning if the request is from a private network but not trusted. + if self.config.is_private_network(&peer_addr.ip()) { + warn!( + "Request from private network but not trusted: {}. This might indicate a configuration issue.", + peer_addr.ip() + ); + } + + // Treat as a direct connection if the peer is not trusted. + Ok(ClientInfo::direct(peer_addr)) + } + } + + /// Validates a request that originated from a trusted proxy. + fn validate_trusted_proxy_request(&self, proxy_addr: &SocketAddr, headers: &HeaderMap) -> Result { + let proxy_ip = proxy_addr.ip(); + + // Prefer RFC 7239 "Forwarded" header if enabled, otherwise fallback to legacy headers. + let client_info = if self.config.enable_rfc7239 { + self.try_parse_rfc7239_headers(headers, proxy_ip) + .unwrap_or_else(|| self.parse_legacy_headers(headers)) + } else { + self.parse_legacy_headers(headers) + }; + + // Analyze the integrity and continuity of the proxy chain. + let chain_analysis = self + .chain_analyzer + .analyze_chain(&client_info.proxy_chain, proxy_ip, headers)?; + + // Enforce maximum hop limit. + if chain_analysis.hops > self.config.max_hops { + return Err(ProxyError::ChainTooLong(chain_analysis.hops, self.config.max_hops)); + } + + // Enforce chain continuity if enabled. + if self.config.enable_chain_continuity_check && !chain_analysis.is_continuous { + return Err(ProxyError::ChainNotContinuous); + } + + Ok(ClientInfo::from_trusted_proxy( + chain_analysis.client_ip, + client_info.forwarded_host, + client_info.forwarded_proto, + proxy_ip, + chain_analysis.hops, + self.config.validation_mode, + chain_analysis.warnings, + )) + } + + /// Attempts to parse the RFC 7239 "Forwarded" header. + fn try_parse_rfc7239_headers(&self, headers: &HeaderMap, proxy_ip: IpAddr) -> Option { + headers + .get("forwarded") + .and_then(|h| h.to_str().ok()) + .and_then(|s| Self::parse_forwarded_header(s, proxy_ip)) + } + + /// Parses legacy proxy headers (X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto). + fn parse_legacy_headers(&self, headers: &HeaderMap) -> ParsedHeaders { + let forwarded_host = headers + .get("x-forwarded-host") + .and_then(|h| h.to_str().ok()) + .map(String::from); + + let forwarded_proto = headers + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .map(String::from); + + let proxy_chain = headers + .get("x-forwarded-for") + .and_then(|h| h.to_str().ok()) + .map(Self::parse_x_forwarded_for) + .unwrap_or_default(); + + ParsedHeaders { + proxy_chain, + forwarded_host, + forwarded_proto, + } + } + + /// Parses the RFC 7239 "Forwarded" header value. + fn parse_forwarded_header(header_value: &str, proxy_ip: IpAddr) -> Option { + // Simplified implementation: processes only the first entry in the header. + let first_part = header_value.split(',').next()?.trim(); + + let mut proxy_chain = Vec::new(); + let mut forwarded_host = None; + let mut forwarded_proto = None; + + for part in first_part.split(';') { + let part = part.trim(); + if let Some((key, value)) = part.split_once('=') { + let key = key.trim().to_lowercase(); + let value = value.trim().trim_matches('"'); + + match key.as_str() { + "for" => { + // Extract IP address, handling IPv6 addresses in brackets as per RFC 7239. + let ip_str = if value.starts_with('[') { + if let Some(end) = value.find(']') { + &value[1..end] + } else { + continue; // Invalid format, skip + } + } else { + // For IPv4 or IPv6 without brackets, take the part before the first colon. + value.split(':').next().unwrap_or(value) + }; + + if let Ok(ip) = ip_str.parse::() { + proxy_chain.push(ip); + } + } + "host" => { + forwarded_host = Some(value.to_string()); + } + "proto" => { + forwarded_proto = Some(value.to_string()); + } + _ => {} + } + } + } + + // Fallback to the proxy IP if no client IP was found in the header. + if proxy_chain.is_empty() { + proxy_chain.push(proxy_ip); + } + + Some(ParsedHeaders { + proxy_chain, + forwarded_host, + forwarded_proto, + }) + } + + /// Parses the X-Forwarded-For header into a list of IP addresses. + pub fn parse_x_forwarded_for(header_value: &str) -> Vec { + header_value + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .filter_map(|s| { + // Handle IPv6 addresses in brackets, e.g., [::1]:8080 + let ip_str = if s.starts_with('[') { + if let Some(end) = s.find(']') { + &s[1..end] + } else { + s // Invalid format, try parsing as is + } + } else { + // For IPv4 or IPv6 without brackets, take the part before the first colon. + s.split(':').next().unwrap_or(s) + }; + ip_str.parse::().ok() + }) + .collect() + } + + /// Records the start of a validation attempt in metrics. + fn record_metric_start(&self) { + if let Some(metrics) = &self.metrics { + metrics.increment_validation_attempts(); + } + } + + /// Records the result of a validation attempt in metrics. + fn record_metric_result(&self, result: &Result, duration: std::time::Duration) { + if let Some(metrics) = &self.metrics { + match result { + Ok(client_info) => { + metrics.record_validation_success(client_info.is_from_trusted_proxy, client_info.proxy_hops, duration); + } + Err(err) => { + metrics.record_validation_failure(err, duration); + } + } + } + } +} + +/// Internal structure for holding parsed header information. +#[derive(Debug, Clone)] +struct ParsedHeaders { + /// The chain of proxy IPs (client IP is typically the first). + proxy_chain: Vec, + /// The original host requested. + forwarded_host: Option, + /// The original protocol used. + forwarded_proto: Option, +} diff --git a/crates/trusted-proxies/src/utils/ip.rs b/crates/trusted-proxies/src/utils/ip.rs new file mode 100644 index 00000000..2bc42b31 --- /dev/null +++ b/crates/trusted-proxies/src/utils/ip.rs @@ -0,0 +1,230 @@ +// 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. + +//! IP address utility functions for validation and classification. + +use ipnetwork::IpNetwork; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; + +/// Collection of IP-related utility functions. +pub struct IpUtils; + +impl IpUtils { + /// Checks if an IP address is valid for general use. + /// + /// "Valid" here means the address is syntactically valid and not an unspecified or multicast + /// address. Classification (private/link-local/documentation/reserved) is handled separately. + pub fn is_valid_ip_address(ip: &IpAddr) -> bool { + !ip.is_unspecified() && !ip.is_multicast() + } + + /// Checks if an IP address belongs to a reserved range. + pub fn is_reserved_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => Self::is_reserved_ipv4(ipv4), + IpAddr::V6(ipv6) => Self::is_reserved_ipv6(ipv6), + } + } + + /// Checks if an IPv4 address belongs to a reserved range. + pub fn is_reserved_ipv4(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + + // Check common reserved IPv4 ranges + matches!( + octets, + [0, _, _, _] | // 0.0.0.0/8 + [10, _, _, _] | // 10.0.0.0/8 + [100, 64, _, _] | // 100.64.0.0/10 + [127, _, _, _] | // 127.0.0.0/8 + [169, 254, _, _] | // 169.254.0.0/16 + [172, 16..=31, _, _] | // 172.16.0.0/12 + [192, 0, 0, _] | // 192.0.0.0/24 + [192, 0, 2, _] | // 192.0.2.0/24 + [192, 88, 99, _] | // 192.88.99.0/24 + [192, 168, _, _] | // 192.168.0.0/16 + [198, 18..=19, _, _] | // 198.18.0.0/15 + [198, 51, 100, _] | // 198.51.100.0/24 + [203, 0, 113, _] | // 203.0.113.0/24 + [224..=239, _, _, _] | // 224.0.0.0/4 + [240..=255, _, _, _] // 240.0.0.0/4 + ) + } + + /// Checks if an IPv6 address belongs to a reserved range. + pub fn is_reserved_ipv6(ip: &Ipv6Addr) -> bool { + let segments = ip.segments(); + + // Check common reserved IPv6 ranges + matches!( + segments, + [0, 0, 0, 0, 0, 0, 0, 0] | // ::/128 + [0, 0, 0, 0, 0, 0, 0, 1] | // ::1/128 + [0x2001, 0xdb8, _, _, _, _, _, _] | // 2001:db8::/32 + [0xfc00..=0xfdff, _, _, _, _, _, _, _] | // fc00::/7 + [0xfe80..=0xfebf, _, _, _, _, _, _, _] | // fe80::/10 + [0xff00..=0xffff, _, _, _, _, _, _, _] // ff00::/8 + ) + } + + /// Checks if an IP address is a private address. + pub fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => Self::is_private_ipv4(ipv4), + IpAddr::V6(ipv6) => Self::is_private_ipv6(ipv6), + } + } + + /// Checks if an IPv4 address is a private address. + pub fn is_private_ipv4(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + + matches!( + octets, + [10, _, _, _] | // 10.0.0.0/8 + [172, 16..=31, _, _] | // 172.16.0.0/12 + [192, 168, _, _] // 192.168.0.0/16 + ) + } + + /// Checks if an IPv6 address is a private address. + pub fn is_private_ipv6(ip: &Ipv6Addr) -> bool { + let segments = ip.segments(); + + matches!( + segments, + [0xfc00..=0xfdff, _, _, _, _, _, _, _] // fc00::/7 + ) + } + + /// Checks if an IP address is a loopback address. + pub fn is_loopback_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => ipv4.is_loopback(), + IpAddr::V6(ipv6) => ipv6.is_loopback(), + } + } + + /// Checks if an IP address is a link-local address. + pub fn is_link_local_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => ipv4.is_link_local(), + IpAddr::V6(ipv6) => ipv6.is_unicast_link_local(), + } + } + + /// Checks if an IP address is a documentation address (TEST-NET). + pub fn is_documentation_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => { + let octets = ipv4.octets(); + matches!( + octets, + [192, 0, 2, _] | // 192.0.2.0/24 + [198, 51, 100, _] | // 198.51.100.0/24 + [203, 0, 113, _] // 203.0.113.0/24 + ) + } + IpAddr::V6(ipv6) => { + let segments = ipv6.segments(); + matches!(segments, [0x2001, 0xdb8, _, _, _, _, _, _]) // 2001:db8::/32 + } + } + } + + /// Parses an IP address or CIDR range from a string. + pub fn parse_ip_or_cidr(s: &str) -> Result { + IpNetwork::from_str(s).map_err(|e| format!("Failed to parse IP/CIDR '{}': {}", s, e)) + } + + /// Parses a comma-separated list of IP addresses. + pub fn parse_ip_list(s: &str) -> Result, String> { + let mut ips = Vec::new(); + + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + match IpAddr::from_str(part) { + Ok(ip) => ips.push(ip), + Err(e) => return Err(format!("Failed to parse IP '{}': {}", part, e)), + } + } + + Ok(ips) + } + + /// Parses a comma-separated list of IP networks (CIDR). + pub fn parse_network_list(s: &str) -> Result, String> { + let mut networks = Vec::new(); + + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + match Self::parse_ip_or_cidr(part) { + Ok(network) => networks.push(network), + Err(e) => return Err(e), + } + } + + Ok(networks) + } + + /// Checks if an IP address is contained within any of the given networks. + pub fn ip_in_networks(ip: &IpAddr, networks: &[IpNetwork]) -> bool { + networks.iter().any(|network| network.contains(*ip)) + } + + /// Returns a string description of the IP address type. + pub fn get_ip_type(ip: &IpAddr) -> &'static str { + if Self::is_private_ip(ip) { + "private" + } else if Self::is_loopback_ip(ip) { + "loopback" + } else if Self::is_link_local_ip(ip) { + "link_local" + } else if Self::is_documentation_ip(ip) { + "documentation" + } else if Self::is_reserved_ip(ip) { + "reserved" + } else { + "public" + } + } + + /// Returns the canonical string representation of an IP address. + pub fn canonical_ip(ip: &IpAddr) -> String { + match ip { + IpAddr::V4(ipv4) => ipv4.to_string(), + IpAddr::V6(ipv6) => { + // Use the standard library's Display implementation for canonical representation + ipv6.to_string() + } + } + } +} + +/// Checks if an IP address is valid for general use. +/// +/// "Valid" here means the address is syntactically valid and not an unspecified or multicast +/// address. Classification (private/link-local/documentation/reserved) is handled separately. +pub fn is_valid_ip_address(ip: &IpAddr) -> bool { + !ip.is_unspecified() && !ip.is_multicast() +} diff --git a/crates/trusted-proxies/src/utils/mod.rs b/crates/trusted-proxies/src/utils/mod.rs new file mode 100644 index 00000000..a9692902 --- /dev/null +++ b/crates/trusted-proxies/src/utils/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. + +//! Utility functions and helpers for the trusted proxy system. + +mod ip; +mod validation; + +pub use ip::*; +pub use validation::*; diff --git a/crates/trusted-proxies/src/utils/validation.rs b/crates/trusted-proxies/src/utils/validation.rs new file mode 100644 index 00000000..27e7acf7 --- /dev/null +++ b/crates/trusted-proxies/src/utils/validation.rs @@ -0,0 +1,223 @@ +// 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. + +//! Validation utility functions for various data types. + +use http::HeaderMap; +use regex::Regex; +use std::net::IpAddr; +use std::str::FromStr; +use std::sync::OnceLock; + +static EMAIL_REGEX: OnceLock = OnceLock::new(); +static URL_REGEX: OnceLock = OnceLock::new(); +static SAFE_REGEX: OnceLock = OnceLock::new(); + +/// Collection of validation utility functions. +pub struct ValidationUtils; + +impl ValidationUtils { + /// Validates an email address format. + pub fn is_valid_email(email: &str) -> bool { + EMAIL_REGEX + .get_or_init(|| Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").expect("Invalid email regex")) + .is_match(email) + } + + /// Validates a URL format. + pub fn is_valid_url(url: &str) -> bool { + URL_REGEX + .get_or_init(|| { + Regex::new(r"^(https?://)?([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}(/.*)?$") + .expect("Invalid URL regex") + }) + .is_match(url) + } + + /// Validates the format of an X-Forwarded-For header value. + pub fn validate_x_forwarded_for(header_value: &str) -> bool { + if header_value.is_empty() { + return false; + } + + let ips: Vec<&str> = header_value.split(',').map(|s| s.trim()).collect(); + + for ip_str in ips { + if ip_str.is_empty() { + return false; + } + + if let Some(ip_part) = Self::extract_ip_part(ip_str) { + if IpAddr::from_str(ip_part).is_err() { + return false; + } + } else { + continue; + } + } + + true + } + + /// Extracts the IP part from a string, handling brackets for IPv6. + pub fn extract_ip_part(ip_str: &str) -> Option<&str> { + if ip_str.starts_with('[') { + if let Some(end) = ip_str.find(']') { + Some(&ip_str[1..end]) + } else { + None + } + } else { + // For IPv4 or IPv6 without brackets, take the part before the first colon. + Some(ip_str.split(':').next().unwrap_or(ip_str)) + } + } + + /// Validates the format of an RFC 7239 Forwarded header value. + pub fn validate_forwarded_header(header_value: &str) -> bool { + if header_value.is_empty() { + return false; + } + + let parts: Vec<&str> = header_value.split(';').collect(); + + if parts.is_empty() { + return false; + } + + for part in parts { + let part = part.trim(); + if !part.contains('=') { + return false; + } + } + + true + } + + /// Checks if an IP address is within any of the specified CIDR ranges. + pub fn validate_ip_in_range(ip: &IpAddr, cidr_ranges: &[String]) -> bool { + for cidr in cidr_ranges { + if let Ok(network) = ipnetwork::IpNetwork::from_str(cidr) + && network.contains(*ip) + { + return true; + } + } + + false + } + + /// Validates a header value for security (length and control characters). + pub fn validate_header_value(value: &str) -> bool { + for c in value.chars() { + if c.is_control() && c != '\t' { + return false; + } + } + + if value.len() > 8192 { + return false; + } + + true + } + + /// Validates an entire HeaderMap for security. + pub fn validate_headers(headers: &HeaderMap) -> bool { + for (name, value) in headers { + let name_str = name.as_str(); + if name_str.len() > 256 { + return false; + } + + if let Ok(value_str) = value.to_str() { + if !Self::validate_header_value(value_str) { + return false; + } + } else if value.len() > 8192 { + return false; + } + } + + true + } + + /// Validates a port number. + pub fn validate_port(port: u16) -> bool { + port > 0 + } + + /// Validates a CIDR notation string. + pub fn validate_cidr(cidr: &str) -> bool { + ipnetwork::IpNetwork::from_str(cidr).is_ok() + } + + /// Validates the length of a proxy chain. + pub fn validate_proxy_chain_length(chain: &[IpAddr], max_length: usize) -> bool { + chain.len() <= max_length + } + + /// Validates that a proxy chain does not contain duplicate adjacent IPs. + pub fn validate_proxy_chain_continuity(chain: &[IpAddr]) -> bool { + if chain.len() < 2 { + return true; + } + + for i in 1..chain.len() { + if chain[i] == chain[i - 1] { + return false; + } + } + + true + } + + /// Checks if a string contains only safe characters for use in URLs or headers. + pub fn is_safe_string(s: &str) -> bool { + SAFE_REGEX + .get_or_init(|| Regex::new(r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=]+$").expect("Invalid safe string regex")) + .is_match(s) + } + + /// Validates rate limiting parameters. + pub fn validate_rate_limit_params(requests: u32, period_seconds: u64) -> bool { + requests > 0 && requests <= 10000 && period_seconds > 0 && period_seconds <= 86400 + } + + /// Validates cache configuration parameters. + pub fn validate_cache_params(capacity: usize, ttl_seconds: u64) -> bool { + capacity > 0 && capacity <= 1000000 && ttl_seconds > 0 && ttl_seconds <= 86400 + } + + /// Redacts sensitive information from a string based on provided patterns. + pub fn mask_sensitive_data(data: &str, sensitive_patterns: &[&str]) -> String { + let mut result = data.to_string(); + + for pattern in sensitive_patterns { + match Regex::new(&format!(r#"(?i)({})[:=]\s*([^&\s]+)"#, pattern)) { + Ok(regex) => { + result = regex + .replace_all(&result, |caps: ®ex::Captures| format!("{}:[REDACTED]", &caps[1])) + .to_string(); + } + Err(e) => { + tracing::warn!("Invalid sensitive pattern '{}': {}", pattern, e); + } + } + } + + result + } +} diff --git a/crates/trusted-proxies/tests/integration/cloud_tests.rs b/crates/trusted-proxies/tests/integration/cloud_tests.rs new file mode 100644 index 00000000..a1e776e7 --- /dev/null +++ b/crates/trusted-proxies/tests/integration/cloud_tests.rs @@ -0,0 +1,31 @@ +// 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_trusted_proxies::AwsMetadataFetcher; +use rustfs_trusted_proxies::CloudDetector; +use rustfs_trusted_proxies::CloudMetadataFetcher; +use std::time::Duration; + +#[tokio::test] +async fn test_cloud_detector_disabled() { + let detector = CloudDetector::new(false, Duration::from_secs(1), None); + let provider = detector.detect_provider(); + assert!(provider.is_none()); +} + +#[tokio::test] +async fn test_aws_metadata_fetcher() { + let fetcher = AwsMetadataFetcher::new(Duration::from_secs(5)); + assert_eq!(fetcher.provider_name(), "aws"); +} diff --git a/crates/trusted-proxies/tests/integration/mod.rs b/crates/trusted-proxies/tests/integration/mod.rs new file mode 100644 index 00000000..d946e6c1 --- /dev/null +++ b/crates/trusted-proxies/tests/integration/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. + +//! Integration tests for the trusted proxy system. +#[cfg(test)] +mod cloud_tests; +#[cfg(test)] +mod proxy_tests; diff --git a/crates/trusted-proxies/tests/integration/proxy_tests.rs b/crates/trusted-proxies/tests/integration/proxy_tests.rs new file mode 100644 index 00000000..48015249 --- /dev/null +++ b/crates/trusted-proxies/tests/integration/proxy_tests.rs @@ -0,0 +1,36 @@ +// 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 axum::body::Body; +use axum::{Router, routing::get}; +use rustfs_trusted_proxies::{TrustedProxy, TrustedProxyConfig, TrustedProxyLayer, ValidationMode}; +use tower::ServiceExt; + +#[tokio::test] +async fn test_proxy_validation_flow() { + let proxies = vec![TrustedProxy::Single("127.0.0.1".parse().unwrap())]; + let config = TrustedProxyConfig::new(proxies, ValidationMode::HopByHop, true, 10, true, vec![]); + let proxy_layer = TrustedProxyLayer::enabled(config, None); + + let app = Router::new().route("/test", get(|| async { "OK" })).layer(proxy_layer); + + let request = axum::http::Request::builder() + .uri("/test") + .header("X-Forwarded-For", "203.0.113.195") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); +} diff --git a/crates/trusted-proxies/tests/unit/config_tests.rs b/crates/trusted-proxies/tests/unit/config_tests.rs new file mode 100644 index 00000000..695e2a9b --- /dev/null +++ b/crates/trusted-proxies/tests/unit/config_tests.rs @@ -0,0 +1,137 @@ +// 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_config::{DEFAULT_TRUSTED_PROXY_PROXIES, ENV_TRUSTED_PROXY_PROXIES}; +use rustfs_trusted_proxies::{ConfigLoader, TrustedProxy, TrustedProxyConfig, ValidationMode}; +use std::net::IpAddr; + +#[test] +#[allow(unsafe_code)] +fn test_config_loader_default() { + unsafe { + std::env::remove_var(ENV_TRUSTED_PROXY_PROXIES); + } + let config = ConfigLoader::from_env_or_default(); + assert_eq!(config.server_addr.port(), 9000); + assert!(!config.proxy.proxies.is_empty()); + assert_eq!(config.proxy.validation_mode, ValidationMode::HopByHop); + assert!(config.proxy.enable_rfc7239); + assert_eq!(config.proxy.max_hops, 10); +} + +#[test] +#[allow(unsafe_code)] +fn test_config_loader_env_vars() { + unsafe { + std::env::set_var(ENV_TRUSTED_PROXY_PROXIES, "192.168.1.0/24,10.0.0.0/8"); + } + unsafe { + std::env::set_var("RUSTFS_TRUSTED_PROXY_VALIDATION_MODE", "strict"); + } + unsafe { + std::env::set_var("RUSTFS_TRUSTED_PROXY_MAX_HOPS", "5"); + } + + let config = ConfigLoader::from_env(); + + if let Ok(config) = config { + assert_eq!(config.server_addr.port(), 9000); + assert_eq!(config.proxy.validation_mode, ValidationMode::Strict); + assert_eq!(config.proxy.max_hops, 5); + + unsafe { + std::env::remove_var(ENV_TRUSTED_PROXY_PROXIES); + } + unsafe { + std::env::remove_var("RUSTFS_TRUSTED_PROXY_VALIDATION_MODE"); + } + unsafe { + std::env::remove_var("RUSTFS_TRUSTED_PROXY_MAX_HOPS"); + } + unsafe { + std::env::remove_var("SERVER_PORT"); + } + } else { + panic!("Failed to load configuration from environment variables"); + } +} + +#[test] +fn test_trusted_proxy_config() { + let proxies = vec![ + TrustedProxy::Single("192.168.1.1".parse().unwrap()), + TrustedProxy::Cidr("10.0.0.0/8".parse().unwrap()), + ]; + + let config = TrustedProxyConfig::new(proxies.clone(), ValidationMode::Strict, true, 10, true, vec![]); + + assert_eq!(config.proxies.len(), 2); + assert_eq!(config.validation_mode, ValidationMode::Strict); + assert!(config.enable_rfc7239); + assert_eq!(config.max_hops, 10); + assert!(config.enable_chain_continuity_check); + + let test_ip: IpAddr = "192.168.1.1".parse().unwrap(); + let test_socket_addr = std::net::SocketAddr::new(test_ip, 8080); + assert!(config.is_trusted(&test_socket_addr)); + + let test_ip2: IpAddr = "10.0.1.1".parse().unwrap(); + let test_socket_addr2 = std::net::SocketAddr::new(test_ip2, 8080); + assert!(config.is_trusted(&test_socket_addr2)); +} + +#[test] +fn test_trusted_proxy_contains() { + let single_proxy = TrustedProxy::Single("192.168.1.1".parse().unwrap()); + let test_ip: IpAddr = "192.168.1.1".parse().unwrap(); + let test_ip2: IpAddr = "192.168.1.2".parse().unwrap(); + + assert!(single_proxy.contains(&test_ip)); + assert!(!single_proxy.contains(&test_ip2)); + + let cidr_proxy = TrustedProxy::Cidr("192.168.1.0/24".parse().unwrap()); + assert!(cidr_proxy.contains(&test_ip)); + assert!(cidr_proxy.contains(&test_ip2)); + + let test_ip3: IpAddr = "192.168.2.1".parse().unwrap(); + assert!(!cidr_proxy.contains(&test_ip3)); +} + +#[test] +fn test_private_network_check() { + let config = TrustedProxyConfig::new( + Vec::new(), + ValidationMode::Lenient, + true, + 10, + true, + vec!["10.0.0.0/8".parse().unwrap(), "192.168.0.0/16".parse().unwrap()], + ); + + let private_ip: IpAddr = "10.0.1.1".parse().unwrap(); + let private_ip2: IpAddr = "192.168.1.1".parse().unwrap(); + let public_ip: IpAddr = "8.8.8.8".parse().unwrap(); + + assert!(config.is_private_network(&private_ip)); + assert!(config.is_private_network(&private_ip2)); + assert!(!config.is_private_network(&public_ip)); +} + +#[test] +fn test_default_values() { + assert_eq!( + DEFAULT_TRUSTED_PROXY_PROXIES, + "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8" + ); +} diff --git a/crates/trusted-proxies/tests/unit/ip_tests.rs b/crates/trusted-proxies/tests/unit/ip_tests.rs new file mode 100644 index 00000000..29846912 --- /dev/null +++ b/crates/trusted-proxies/tests/unit/ip_tests.rs @@ -0,0 +1,200 @@ +// 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_trusted_proxies::IpUtils; +use std::net::IpAddr; +use std::str::FromStr; + +#[test] +fn test_is_valid_ip_address() { + let valid_ip: IpAddr = "192.168.1.1".parse().unwrap(); + assert!(IpUtils::is_valid_ip_address(&valid_ip)); + + let unspecified_ip: IpAddr = "0.0.0.0".parse().unwrap(); + assert!(!IpUtils::is_valid_ip_address(&unspecified_ip)); + + let multicast_ip: IpAddr = "224.0.0.1".parse().unwrap(); + assert!(!IpUtils::is_valid_ip_address(&multicast_ip)); + + let valid_ipv6: IpAddr = "2001:db8::1".parse().unwrap(); + assert!(IpUtils::is_valid_ip_address(&valid_ipv6)); + + let unspecified_ipv6: IpAddr = "::".parse().unwrap(); + assert!(!IpUtils::is_valid_ip_address(&unspecified_ipv6)); +} + +#[test] +fn test_is_reserved_ip() { + let private_ip: IpAddr = "10.0.0.1".parse().unwrap(); + assert!(IpUtils::is_reserved_ip(&private_ip)); + + let loopback_ip: IpAddr = "127.0.0.1".parse().unwrap(); + assert!(IpUtils::is_reserved_ip(&loopback_ip)); + + let link_local_ip: IpAddr = "169.254.0.1".parse().unwrap(); + assert!(IpUtils::is_reserved_ip(&link_local_ip)); + + let documentation_ip: IpAddr = "192.0.2.1".parse().unwrap(); + assert!(IpUtils::is_reserved_ip(&documentation_ip)); + + let public_ip: IpAddr = "8.8.8.8".parse().unwrap(); + assert!(!IpUtils::is_reserved_ip(&public_ip)); +} + +#[test] +fn test_is_private_ip() { + assert!(IpUtils::is_private_ip(&"10.0.0.1".parse().unwrap())); + assert!(IpUtils::is_private_ip(&"10.255.255.254".parse().unwrap())); + + assert!(IpUtils::is_private_ip(&"172.16.0.1".parse().unwrap())); + assert!(IpUtils::is_private_ip(&"172.31.255.254".parse().unwrap())); + assert!(!IpUtils::is_private_ip(&"172.15.0.1".parse().unwrap())); + assert!(!IpUtils::is_private_ip(&"172.32.0.1".parse().unwrap())); + + assert!(IpUtils::is_private_ip(&"192.168.0.1".parse().unwrap())); + assert!(IpUtils::is_private_ip(&"192.168.255.254".parse().unwrap())); + + assert!(!IpUtils::is_private_ip(&"8.8.8.8".parse().unwrap())); + assert!(!IpUtils::is_private_ip(&"203.0.113.1".parse().unwrap())); +} + +#[test] +fn test_is_loopback_ip() { + assert!(IpUtils::is_loopback_ip(&"127.0.0.1".parse().unwrap())); + assert!(IpUtils::is_loopback_ip(&"127.255.255.254".parse().unwrap())); + assert!(IpUtils::is_loopback_ip(&"::1".parse().unwrap())); + assert!(!IpUtils::is_loopback_ip(&"192.168.1.1".parse().unwrap())); + assert!(!IpUtils::is_loopback_ip(&"2001:db8::1".parse().unwrap())); +} + +#[test] +fn test_is_link_local_ip() { + assert!(IpUtils::is_link_local_ip(&"169.254.0.1".parse().unwrap())); + assert!(IpUtils::is_link_local_ip(&"169.254.255.254".parse().unwrap())); + assert!(IpUtils::is_link_local_ip(&"fe80::1".parse().unwrap())); + assert!(IpUtils::is_link_local_ip(&"fe80::abcd:1234:5678:9abc".parse().unwrap())); + assert!(!IpUtils::is_link_local_ip(&"192.168.1.1".parse().unwrap())); + assert!(!IpUtils::is_link_local_ip(&"2001:db8::1".parse().unwrap())); +} + +#[test] +fn test_is_documentation_ip() { + assert!(IpUtils::is_documentation_ip(&"192.0.2.1".parse().unwrap())); + assert!(IpUtils::is_documentation_ip(&"198.51.100.1".parse().unwrap())); + assert!(IpUtils::is_documentation_ip(&"203.0.113.1".parse().unwrap())); + assert!(IpUtils::is_documentation_ip(&"2001:db8::1".parse().unwrap())); + assert!(!IpUtils::is_documentation_ip(&"8.8.8.8".parse().unwrap())); + assert!(!IpUtils::is_documentation_ip(&"2001:4860::1".parse().unwrap())); +} + +#[test] +fn test_parse_ip_or_cidr() { + let result = IpUtils::parse_ip_or_cidr("192.168.1.1"); + assert!(result.is_ok()); + + let result = IpUtils::parse_ip_or_cidr("192.168.1.0/24"); + assert!(result.is_ok()); + + let result = IpUtils::parse_ip_or_cidr("2001:db8::1"); + assert!(result.is_ok()); + + let result = IpUtils::parse_ip_or_cidr("2001:db8::/32"); + assert!(result.is_ok()); + + let result = IpUtils::parse_ip_or_cidr("invalid"); + assert!(result.is_err()); +} + +#[test] +fn test_parse_ip_list() { + let result = IpUtils::parse_ip_list("192.168.1.1, 10.0.0.1, 8.8.8.8"); + assert!(result.is_ok()); + + let ips = result.unwrap(); + assert_eq!(ips.len(), 3); + assert_eq!(ips[0], IpAddr::from_str("192.168.1.1").unwrap()); + assert_eq!(ips[1], IpAddr::from_str("10.0.0.1").unwrap()); + assert_eq!(ips[2], IpAddr::from_str("8.8.8.8").unwrap()); + + let result = IpUtils::parse_ip_list("192.168.1.1,10.0.0.1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 2); + + let result = IpUtils::parse_ip_list(""); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + + let result = IpUtils::parse_ip_list("192.168.1.1, invalid"); + assert!(result.is_err()); +} + +#[test] +fn test_parse_network_list() { + let result = IpUtils::parse_network_list("192.168.1.0/24, 10.0.0.0/8"); + assert!(result.is_ok()); + + let networks = result.unwrap(); + assert_eq!(networks.len(), 2); + + let result = IpUtils::parse_network_list("192.168.1.1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 1); + + let result = IpUtils::parse_network_list("192.168.1.0/24, invalid"); + assert!(result.is_err()); +} + +#[test] +fn test_ip_in_networks() { + let networks = vec!["10.0.0.0/8".parse().unwrap(), "192.168.1.0/24".parse().unwrap()]; + + let ip_in_network: IpAddr = "10.0.1.1".parse().unwrap(); + let ip_in_network2: IpAddr = "192.168.1.100".parse().unwrap(); + let ip_not_in_network: IpAddr = "8.8.8.8".parse().unwrap(); + + assert!(IpUtils::ip_in_networks(&ip_in_network, &networks)); + assert!(IpUtils::ip_in_networks(&ip_in_network2, &networks)); + assert!(!IpUtils::ip_in_networks(&ip_not_in_network, &networks)); +} + +#[test] +fn test_get_ip_type() { + assert_eq!(IpUtils::get_ip_type(&"10.0.0.1".parse().unwrap()), "private"); + assert_eq!(IpUtils::get_ip_type(&"127.0.0.1".parse().unwrap()), "loopback"); + assert_eq!(IpUtils::get_ip_type(&"169.254.0.1".parse().unwrap()), "link_local"); + assert_eq!(IpUtils::get_ip_type(&"192.0.2.1".parse().unwrap()), "documentation"); + assert_eq!(IpUtils::get_ip_type(&"224.0.0.1".parse().unwrap()), "reserved"); + assert_eq!(IpUtils::get_ip_type(&"8.8.8.8".parse().unwrap()), "public"); +} + +#[test] +fn test_canonical_ip() { + // NOTE: std parsing rejects IPv4 octets with leading zeros (e.g. "001") on some platforms/ + // Rust versions because of ambiguity with non-decimal notations. Use an unambiguous input. + let ipv4: IpAddr = "192.168.1.1".parse().unwrap(); + assert_eq!(IpUtils::canonical_ip(&ipv4), "192.168.1.1"); + + let ipv6_full: IpAddr = "2001:0db8:0000:0000:0000:0000:0000:0001".parse().unwrap(); + let ipv6_compressed: IpAddr = "2001:db8::1".parse().unwrap(); + + assert_eq!(IpUtils::canonical_ip(&ipv6_full), "2001:db8::1"); + assert_eq!(IpUtils::canonical_ip(&ipv6_compressed), "2001:db8::1"); + + // For IPv6 with multiple runs of zeros, the exact compression choice is an output formatting + // detail. Compare canonicalized address values instead of requiring a specific layout. + let ipv6_multi_zero: IpAddr = "2001:0db8:0000:0000:abcd:0000:0000:1234".parse().unwrap(); + let canonical = IpUtils::canonical_ip(&ipv6_multi_zero); + let reparsed: IpAddr = canonical.parse().unwrap(); + assert_eq!(reparsed, ipv6_multi_zero); +} diff --git a/crates/trusted-proxies/tests/unit/mod.rs b/crates/trusted-proxies/tests/unit/mod.rs new file mode 100644 index 00000000..fb112a67 --- /dev/null +++ b/crates/trusted-proxies/tests/unit/mod.rs @@ -0,0 +1,24 @@ +// 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. + +//! Unit tests for the trusted proxy system components. + +#[cfg(test)] +mod config_tests; +#[cfg(test)] +mod ip_tests; +#[cfg(test)] +mod validation_tests; +#[cfg(test)] +mod validator_tests; diff --git a/crates/trusted-proxies/tests/unit/validation_tests.rs b/crates/trusted-proxies/tests/unit/validation_tests.rs new file mode 100644 index 00000000..33a6b04b --- /dev/null +++ b/crates/trusted-proxies/tests/unit/validation_tests.rs @@ -0,0 +1,65 @@ +// 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_trusted_proxies::ValidationUtils; +use std::net::IpAddr; + +#[test] +fn test_email_validation() { + assert!(ValidationUtils::is_valid_email("user@example.com")); + assert!(!ValidationUtils::is_valid_email("invalid-email")); +} + +#[test] +fn test_url_validation() { + assert!(ValidationUtils::is_valid_url("https://example.com")); + assert!(!ValidationUtils::is_valid_url("invalid")); +} + +#[test] +fn test_x_forwarded_for_validation() { + assert!(ValidationUtils::validate_x_forwarded_for("203.0.113.195")); + assert!(!ValidationUtils::validate_x_forwarded_for("invalid")); +} + +#[test] +fn test_forwarded_header_validation() { + assert!(ValidationUtils::validate_forwarded_header("for=192.0.2.60")); + assert!(!ValidationUtils::validate_forwarded_header("invalid")); +} + +#[test] +fn test_ip_in_range_validation() { + let cidr_ranges = vec!["10.0.0.0/8".to_string(), "192.168.0.0/16".to_string()]; + let ip: IpAddr = "10.0.1.1".parse().unwrap(); + assert!(ValidationUtils::validate_ip_in_range(&ip, &cidr_ranges)); +} + +#[test] +fn test_header_value_validation() { + assert!(ValidationUtils::validate_header_value("text/plain")); + assert!(!ValidationUtils::validate_header_value(&"a".repeat(8193))); +} + +#[test] +fn test_port_validation() { + assert!(ValidationUtils::validate_port(80)); + assert!(!ValidationUtils::validate_port(0)); +} + +#[test] +fn test_cidr_validation() { + assert!(ValidationUtils::validate_cidr("192.168.1.0/24")); + assert!(!ValidationUtils::validate_cidr("invalid")); +} diff --git a/crates/trusted-proxies/tests/unit/validator_tests.rs b/crates/trusted-proxies/tests/unit/validator_tests.rs new file mode 100644 index 00000000..210bad5e --- /dev/null +++ b/crates/trusted-proxies/tests/unit/validator_tests.rs @@ -0,0 +1,79 @@ +// 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 axum::http::HeaderMap; +use rustfs_trusted_proxies::{ClientInfo, ProxyChainAnalyzer, ProxyValidator, TrustedProxy, TrustedProxyConfig, ValidationMode}; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; + +fn create_test_config() -> TrustedProxyConfig { + let proxies = vec![ + TrustedProxy::Single("192.168.1.100".parse().unwrap()), + TrustedProxy::Cidr("10.0.0.0/8".parse().unwrap()), + ]; + TrustedProxyConfig::new(proxies, ValidationMode::HopByHop, true, 5, true, vec![]) +} + +#[test] +fn test_client_info_direct() { + let addr = SocketAddr::new(IpAddr::from([192, 168, 1, 1]), 8080); + let client_info = ClientInfo::direct(addr); + assert_eq!(client_info.real_ip, IpAddr::from([192, 168, 1, 1])); +} + +#[test] +fn test_parse_x_forwarded_for() { + let header_value = "203.0.113.195, 198.51.100.1"; + let result = ProxyValidator::parse_x_forwarded_for(header_value); + assert_eq!(result.len(), 2); +} + +#[test] +fn test_proxy_chain_analyzer_hop_by_hop() { + let config = create_test_config(); + let analyzer = ProxyChainAnalyzer::new(config); + let chain = vec![ + IpAddr::from_str("203.0.113.195").unwrap(), + IpAddr::from_str("10.0.1.100").unwrap(), + ]; + let current_proxy = IpAddr::from_str("192.168.1.100").unwrap(); + let headers = HeaderMap::new(); + let result = analyzer.analyze_chain(&chain, current_proxy, &headers); + assert!(result.is_ok()); +} + +#[test] +fn test_proxy_chain_too_long() { + let config = create_test_config(); + let analyzer = ProxyChainAnalyzer::new(config); + let chain = vec![ + IpAddr::from_str("203.0.113.195").unwrap(), + IpAddr::from_str("10.0.1.100").unwrap(), + IpAddr::from_str("10.0.1.101").unwrap(), + IpAddr::from_str("10.0.1.102").unwrap(), + IpAddr::from_str("10.0.1.103").unwrap(), + ]; + let current_proxy = IpAddr::from_str("192.168.1.100").unwrap(); + let headers = HeaderMap::new(); + // Total chain length is 6 (5 in chain + 1 current proxy), max hops is 5 + let result = analyzer.analyze_chain(&chain, current_proxy, &headers); + assert!(result.is_err()); + match result { + Err(rustfs_trusted_proxies::ProxyError::ChainTooLong(len, max)) => { + assert_eq!(len, 6); + assert_eq!(max, 5); + } + _ => panic!("Expected ChainTooLong error"), + } +} diff --git a/rustfmt.toml b/rustfmt.toml index 479ba543..5f077599 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -15,3 +15,15 @@ max_width = 130 fn_call_width = 90 single_line_let_else_max_width = 100 + +## Enable group import Preserve, StdExternalCrate, One +#group_imports = "StdExternalCrate" +# +## Import granularity control Preserve, Crate, Module, Item, One +#imports_granularity = "Crate" +# +## Sort alphabetically +#reorder_imports = true +# +## The instability feature needs to be enabled +#unstable_features = true \ No newline at end of file diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index fa38b28b..b133e761 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -59,6 +59,7 @@ rustfs-rio.workspace = true rustfs-s3select-api = { workspace = true } rustfs-s3select-query = { workspace = true } rustfs-targets = { workspace = true } +rustfs-trusted-proxies = { workspace = true } rustfs-utils = { workspace = true, features = ["full"] } rustfs-zip = { workspace = true } rustfs-scanner = { workspace = true } @@ -141,8 +142,7 @@ libsystemd.workspace = true [target.'cfg(not(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64")))'.dependencies] mimalloc = { workspace = true } -# Note: If you want to *explicitly* exclude Windows from a target dependency set, -# you can use a cfg like the following instead: +# Only enable pprof-based profiling on non-Windows targets. [target.'cfg(all(not(target_os = "windows"), not(all(target_os = "linux", target_env = "gnu", target_arch = "x86_64"))))'.dependencies] starshard = { workspace = true } backtrace = { workspace = true } diff --git a/rustfs/src/main.rs b/rustfs/src/main.rs index 77a44b1b..267fd7a5 100644 --- a/rustfs/src/main.rs +++ b/rustfs/src/main.rs @@ -126,6 +126,9 @@ async fn async_main() -> Result<()> { // Initialize performance profiling if enabled profiling::init_from_env().await; + // Initialize trusted proxies system + rustfs_trusted_proxies::init(); + // Initialize TLS if a certificate path is provided if let Some(tls_path) = &opt.tls_path { match init_cert(tls_path).await { diff --git a/rustfs/src/server/http.rs b/rustfs/src/server/http.rs index 27712656..cf586e66 100644 --- a/rustfs/src/server/http.rs +++ b/rustfs/src/server/http.rs @@ -37,6 +37,7 @@ use rustfs_common::GlobalReadiness; use rustfs_config::{RUSTFS_TLS_CERT, RUSTFS_TLS_KEY}; use rustfs_ecstore::rpc::{TONIC_RPC_PREFIX, verify_rpc_signature}; use rustfs_protos::proto_gen::node_service::node_service_server::NodeServiceServer; +use rustfs_trusted_proxies::ClientInfo; use rustfs_utils::net::parse_and_resolve_address; use rustls::ServerConfig; use s3s::{host::MultiDomain, service::S3Service, service::S3ServiceBuilder}; @@ -216,7 +217,7 @@ pub async fn start_http_server( b.set_access(store.clone()); b.set_route(admin::make_admin_route(opt.console_enable)?); - // console server does not need to setup virtual-hosted-style requests + // Virtual-hosted-style requests are only set up for S3 API when server domains are configured and console is disabled if !opt.server_domains.is_empty() && !opt.console_enable { MultiDomain::new(&opt.server_domains).map_err(Error::other)?; // validate domains @@ -525,7 +526,7 @@ struct ConnectionContext { /// Process a single incoming TCP connection. /// -/// This function is executed in a new Tokio task and it will: +/// This function is executed in a new Tokio task, and it will: /// 1. If TLS is configured, perform TLS handshake. /// 2. Build a complete service stack for this connection, including S3, RPC services, and all middleware. /// 3. Use Hyper to handle HTTP requests on this connection. @@ -562,11 +563,24 @@ fn process_connection( None } }; - let hybrid_service = ServiceBuilder::new() + // NOTE: Both extension types are intentionally inserted to maintain compatibility: + // 1. `Option` - Used by existing admin/storage handlers throughout the codebase + // 2. `std::net::SocketAddr` - Required by TrustedProxyMiddleware for proxy validation + // This dual insertion is necessary because the middleware expects the raw SocketAddr type + // while our application code uses the RemoteAddr wrapper. Consolidating these would + // require either modifying the third-party middleware or refactoring all existing handlers. + .layer(AddExtensionLayer::new(remote_addr)) + .option_layer(remote_addr.map(|ra| AddExtensionLayer::new(ra.0))) + // Add TrustedProxyLayer to handle X-Forwarded-For and other proxy headers + // This should be placed before TraceLayer so that logs reflect the real client IP + .option_layer(if rustfs_trusted_proxies::is_enabled() { + Some(rustfs_trusted_proxies::layer().clone()) + } else { + None + }) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) .layer(CatchPanicLayer::new()) - .layer(AddExtensionLayer::new(remote_addr)) // CRITICAL: Insert ReadinessGateLayer before business logic // This stops requests from hitting IAMAuth or Storage if they are not ready. .layer(ReadinessGateLayer::new(readiness)) @@ -578,10 +592,18 @@ fn process_connection( .get(http::header::HeaderName::from_static("x-request-id")) .and_then(|v| v.to_str().ok()) .unwrap_or("unknown"); + + // Extract real client IP from trusted proxy middleware if available + let client_info = request.extensions().get::(); + let real_ip = client_info + .map(|info| info.real_ip.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let span = tracing::info_span!("http-request", trace_id = %trace_id, status_code = tracing::field::Empty, method = %request.method(), + real_ip = %real_ip, uri = %request.uri(), version = ?request.version(), ); @@ -773,7 +795,7 @@ fn get_listen_backlog() -> i32 { #[cfg(any(target_os = "macos", target_os = "freebsd"))] let mut name = [libc::CTL_KERN, libc::KERN_IPC, libc::KIPC_SOMAXCONN]; let mut buf = [0; 1]; - let mut buf_len = std::mem::size_of_val(&buf); + let mut buf_len = size_of_val(&buf); if unsafe { libc::sysctl( diff --git a/scripts/run.sh b/scripts/run.sh index 48a38102..0605656c 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -168,17 +168,27 @@ export RUSTFS_NS_SCANNER_INTERVAL=60 # Object scanning interval in seconds # - Minimum size threshold prevents compression of small files where overhead > benefit # - Wildcard patterns supported in MIME types (e.g., text/* matches text/plain, text/html, etc.) +# Trusted Proxy Configuration +# export RUSTFS_TRUSTED_PROXY_ENABLED=true +# export RUSTFS_TRUSTED_PROXY_NETWORKS=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fd00::/8 +# export RUSTFS_TRUSTED_PROXY_VALIDATION_MODE=hop_by_hop +# export RUSTFS_TRUSTED_PROXY_ENABLE_RFC7239=true +# export RUSTFS_TRUSTED_PROXY_MAX_HOPS=10 +# export RUSTFS_TRUSTED_PROXY_METRICS_ENABLED=true + #export RUSTFS_REGION="us-east-1" export RUSTFS_ENABLE_SCANNER=true -export RUSTFS_ENABLE_HEAL=false +export RUSTFS_ENABLE_HEAL=true # Object cache configuration export RUSTFS_OBJECT_CACHE_ENABLE=true # Profiling configuration -export RUSTFS_ENABLE_PROFILING=true +export RUSTFS_ENABLE_PROFILING=false +# Memory profiling periodic dump +export RUSTFS_PROF_MEM_PERIODIC=false # Heal configuration queue size export RUSTFS_HEAL_QUEUE_SIZE=10000 @@ -202,8 +212,6 @@ if [ -n "$1" ]; then export RUSTFS_VOLUMES="$1" fi -export RUSTFS_PROF_MEM_PERIODIC=true - # Enable jemalloc for memory profiling # MALLOC_CONF parameters: # prof:true - Enable heap profiling