diff --git a/.github/scripts/database-issue-catalog.mjs b/.github/scripts/database-issue-catalog.mjs index 402b4c1c4c..350d17d7ce 100644 --- a/.github/scripts/database-issue-catalog.mjs +++ b/.github/scripts/database-issue-catalog.mjs @@ -84,6 +84,7 @@ const supplementalDrivers = [ { dbType: "kafka", label: "Apache Kafka" }, { dbType: "rocketmq", label: "Apache RocketMQ" }, { dbType: "rabbitmq", label: "RabbitMQ" }, + { dbType: "mqtt", label: "MQTT", aliases: ["emqx", "hivemq", "mosquitto"] }, ]; const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url); diff --git a/.github/scripts/label-issue-database.test.mjs b/.github/scripts/label-issue-database.test.mjs index 74f48a2d16..782eccf7c1 100644 --- a/.github/scripts/label-issue-database.test.mjs +++ b/.github/scripts/label-issue-database.test.mjs @@ -57,6 +57,8 @@ test("labels native and compatibility database products with their families", () ["Dremio", ["db/dremio", "db/jdbc"]], ["Apache Kafka", ["db/kafka", "db/mq"]], ["RabbitMQ", ["db/mq", "db/rabbitmq"]], + ["MQTT 5.0", ["db/mqtt"]], + ["EMQX 5.8", ["db/mqtt"]], ]; for (const [database, expected] of cases) { diff --git a/Cargo.lock b/Cargo.lock index 36dc614c3f..14987fa8de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher 0.5.2", "cpubits", @@ -71,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" dependencies = [ "aead 0.6.0-rc.10", - "aes 0.9.1", + "aes 0.9.2", "cipher 0.5.2", "ctr 0.10.1", "ghash 0.6.0", @@ -256,9 +256,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -395,6 +395,35 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "async-tungstenite" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589" +dependencies = [ + "futures-io", + "futures-util", + "log", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tungstenite 0.21.0", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", + "tokio", +] + [[package]] name = "asynchronous-codec" version = "0.6.2" @@ -897,9 +926,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -1646,7 +1675,7 @@ dependencies = [ "reqwest 0.12.28", "russh", "rust_decimal", - "rustls 0.23.42", + "rustls 0.23.43", "semver", "serde", "serde_json", @@ -1726,10 +1755,11 @@ dependencies = [ "redis", "regex", "reqwest 0.12.28", + "rumqttc", "rusqlite", "russh", "rust_decimal", - "rustls 0.23.42", + "rustls 0.23.43", "rustls-pemfile 2.2.0", "serde", "serde_json", @@ -1767,7 +1797,7 @@ dependencies = [ "dirs", "reqwest 0.12.28", "rmcp", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "tempfile", @@ -1792,7 +1822,7 @@ dependencies = [ "pbkdf2 0.12.2", "redis", "reqwest 0.12.28", - "rustls 0.23.42", + "rustls 0.23.43", "serde", "serde_json", "sha2 0.10.9", @@ -2021,13 +2051,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2234,7 +2264,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg", ] @@ -2344,11 +2374,10 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2484,6 +2513,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3280,9 +3320,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3372,7 +3412,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.42", + "rustls 0.23.43", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -4442,7 +4482,7 @@ dependencies = [ "percent-encoding", "rand 0.9.5", "rustc_version_runtime", - "rustls 0.23.42", + "rustls 0.23.43", "serde", "serde_bytes", "serde_with", @@ -4555,7 +4595,7 @@ dependencies = [ "mysql_common", "percent-encoding", "rand 0.10.2", - "rustls 0.23.42", + "rustls 0.23.43", "serde", "socket2", "thiserror 2.0.19", @@ -5438,6 +5478,16 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.13.1" @@ -5524,7 +5574,7 @@ version = "0.8.0-rc.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6" dependencies = [ - "aes 0.9.1", + "aes 0.9.2", "aes-gcm 0.11.0-rc.3", "cbc 0.2.1", "der 0.8.1", @@ -5922,7 +5972,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.42", + "rustls 0.23.43", "socket2", "thiserror 2.0.19", "tokio", @@ -5943,7 +5993,7 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.42", + "rustls 0.23.43", "rustls-pki-types", "slab", "thiserror 2.0.19", @@ -6142,7 +6192,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand 0.9.5", - "rustls 0.23.42", + "rustls 0.23.43", "rustls-native-certs 0.8.4", "ryu", "sha1_smol", @@ -6257,7 +6307,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.42", + "rustls 0.23.43", "rustls-pki-types", "serde", "serde_json", @@ -6298,7 +6348,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "rustls 0.23.42", + "rustls 0.23.43", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -6413,7 +6463,7 @@ dependencies = [ "pastey", "pin-project-lite", "rmcp-macros", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "thiserror 2.0.19", @@ -6455,6 +6505,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rumqttc" +version = "0.24.0" +dependencies = [ + "async-tungstenite", + "bytes", + "flume", + "futures-util", + "http", + "log", + "rustls-native-certs 0.7.3", + "rustls-pemfile 2.2.0", + "rustls-webpki 0.102.8", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.25.0", + "ws_stream_tungstenite", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -6477,7 +6546,7 @@ checksum = "324b92f459d3e42da294e14e8eb150d2215fcfb7c966838bc1127cd68bc05a0d" dependencies = [ "aead 0.6.0-rc.10", "aes 0.8.4", - "aes 0.9.1", + "aes 0.9.2", "aes-gcm 0.11.0-rc.3", "aws-lc-rs", "bitflags 2.13.1", @@ -6676,9 +6745,23 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -6702,6 +6785,19 @@ dependencies = [ "security-framework 2.11.1", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -6753,7 +6849,7 @@ dependencies = [ "jni 0.22.4", "log", "once_cell", - "rustls 0.23.42", + "rustls 0.23.43", "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", "rustls-webpki 0.103.13", @@ -6779,6 +6875,17 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6866,14 +6973,14 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.2.1", + "schemars_derive 1.2.2", "serde", "serde_json", ] @@ -6886,20 +6993,20 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.29.1", "syn 2.0.119", ] [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.119", + "serde_derive_internals 0.30.0", + "syn 3.0.3", ] [[package]] @@ -7078,6 +7185,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -7157,7 +7275,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -7491,6 +7609,9 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -7814,9 +7935,9 @@ dependencies = [ [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", @@ -8049,7 +8170,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "url", ] @@ -8138,7 +8259,7 @@ dependencies = [ "osakit", "percent-encoding", "reqwest 0.13.4", - "rustls 0.23.42", + "rustls 0.23.43", "semver", "serde", "serde_json", @@ -8251,7 +8372,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.19", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "url", "urlpattern", "uuid", @@ -8266,7 +8387,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -8499,13 +8620,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -8551,7 +8672,7 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" dependencies = [ "const-oid 0.9.6", "ring", - "rustls 0.23.42", + "rustls 0.23.43", "tokio", "tokio-postgres", "tokio-rustls 0.26.4", @@ -8568,13 +8689,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.42", + "rustls 0.23.43", "tokio", ] @@ -8598,7 +8730,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.29.0", ] [[package]] @@ -8646,9 +8778,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -8724,9 +8856,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -8858,9 +8990,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" dependencies = [ "crossbeam-channel", "dirs", @@ -8895,6 +9027,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "rustls 0.22.4", + "rustls-pki-types", + "sha1 0.10.7", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.29.0" @@ -9121,6 +9274,12 @@ dependencies = [ "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -10252,6 +10411,26 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "ws_stream_tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f414f083fb19fcc1bffcb0fa0cf46d33ccfa229adf248cac12c180e91609" +dependencies = [ + "async-tungstenite", + "async_io_stream", + "bitflags 2.13.1", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pharos", + "rustc_version", + "tokio", + "tracing", + "tungstenite 0.21.0", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d6672554f0..0b37b46816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,14 @@ [workspace] resolver = "2" members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli"] -exclude = ["vendor/ctor"] +exclude = ["vendor/ctor", "vendor/rumqttc"] [patch.crates-io] # Tauri 2.11 uses ctor 0.8, which excludes Rust's win7 vendor. This vendors # upstream rust-ctor#443 until tauri-utils moves to ctor 1.x. ctor = { path = "vendor/ctor" } +# rumqttc 0.24 hardcodes MQTT 3.1.1; this patch adds MQTT 3.1 CONNECT encoding. +rumqttc = { path = "vendor/rumqttc" } tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" } postgres-types = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" } postgres-protocol = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", rev = "115f9fef10f0fc3669b5337955e4eb461fc349a6" } diff --git a/apps/desktop/public/icons/database/mqtt.png b/apps/desktop/public/icons/database/mqtt.png new file mode 100644 index 0000000000..28b7223e14 Binary files /dev/null and b/apps/desktop/public/icons/database/mqtt.png differ diff --git a/apps/desktop/public/icons/database/mqtt.svg b/apps/desktop/public/icons/database/mqtt.svg new file mode 100644 index 0000000000..237b47c7d4 --- /dev/null +++ b/apps/desktop/public/icons/database/mqtt.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 684d18acf4..526461d358 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -17,6 +17,7 @@ import { Switch } from "@/components/ui/switch"; import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database"; import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb"; import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq"; +import type { MqttConnectionConfig } from "@/types/mqtt"; import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos"; import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; @@ -657,6 +658,23 @@ const nacosTlsSkipVerify = ref(false); const nacosMetricsMode = ref("auto"); const nacosMetricsUrl = ref(""); const nacosPageSize = ref(20); + +// --- MQTT-specific form fields --- +const mqttHost = ref("127.0.0.1"); +const mqttPort = ref(1883); +const mqttClientId = ref(""); +const mqttProtocolVersion = ref<"v3" | "v4" | "v5">("v5"); +const mqttTransportMode = ref<"tcp" | "websocket">("tcp"); +const mqttWsPath = ref("/mqtt"); +const mqttAuthKind = ref<"none" | "password">("none"); +const mqttUsername = ref(""); +const mqttPassword = ref(""); +const mqttTls = ref(false); +const mqttTlsSkipVerify = ref(false); +const mqttKeepAliveSecs = ref(60); +const mqttConnectTimeoutSecs = ref(30); +const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024); + const nacosPrimaryAddressLabel = computed(() => { if (nacosImplementation.value === "rnacos") return t("connection.nacosPrimaryAddressRNacos"); if (nacosVersionMode.value === "v2") return t("connection.nacosPrimaryAddressV2"); @@ -973,6 +991,7 @@ const driverProfiles: Record< rocketmq: { type: "mq", port: 9876, user: "", label: "Apache RocketMQ", icon: "rocketmq", host: "127.0.0.1" }, rabbitmq: { type: "mq", port: 5672, user: "", label: "RabbitMQ", icon: "rabbitmq", host: "127.0.0.1" }, nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" }, + mqtt: { type: "mqtt", port: 1883, user: "", label: "MQTT", icon: "mqtt", host: "127.0.0.1" }, iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" }, influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" }, custom_mysql: { @@ -1188,6 +1207,57 @@ function hydrateNacosFields(value: unknown) { resetNacosFields(value as Partial); } +function resetMqttFields(config?: Partial) { + mqttHost.value = config?.host?.trim() || "127.0.0.1"; + mqttPort.value = config?.port || 1883; + mqttClientId.value = config?.clientId || ""; + mqttProtocolVersion.value = config?.protocolVersion || "v5"; + mqttTransportMode.value = config?.transport || "tcp"; + mqttWsPath.value = config?.wsPath || "/mqtt"; + mqttTls.value = config?.tls || false; + mqttTlsSkipVerify.value = config?.tlsSkipVerify || false; + mqttKeepAliveSecs.value = Math.max(1, config?.keepAliveSecs || 60); + mqttConnectTimeoutSecs.value = Math.max(1, config?.connectTimeoutSecs || 30); + mqttMaxPacketSizeBytes.value = Math.min(268435455, Math.max(1024, config?.maxPacketSizeBytes || 16 * 1024 * 1024)); + const auth = config?.auth; + if (auth && auth.kind === "password") { + mqttAuthKind.value = "password"; + mqttUsername.value = auth.username || ""; + mqttPassword.value = auth.password || ""; + } else { + mqttAuthKind.value = "none"; + mqttUsername.value = ""; + mqttPassword.value = ""; + } +} + +function hydrateMqttFields(value: unknown) { + if (!value || typeof value !== "object") { + resetMqttFields(); + return; + } + resetMqttFields(value as Partial); +} + +function buildMqttExternalConfig(): MqttConnectionConfig { + const auth: MqttConnectionConfig["auth"] = mqttAuthKind.value === "password" ? { kind: "password", username: mqttUsername.value, password: mqttPassword.value } : { kind: "none" }; + + return { + host: mqttHost.value.trim(), + port: mqttPort.value, + clientId: mqttClientId.value.trim() || `dbx-${Math.random().toString(36).slice(2, 10)}`, + protocolVersion: mqttProtocolVersion.value, + transport: mqttTransportMode.value, + tls: mqttTls.value, + tlsSkipVerify: mqttTlsSkipVerify.value, + auth, + keepAliveSecs: Math.max(1, mqttKeepAliveSecs.value), + connectTimeoutSecs: Math.max(1, mqttConnectTimeoutSecs.value), + maxPacketSizeBytes: Math.min(268435455, Math.max(1024, mqttMaxPacketSizeBytes.value)), + wsPath: mqttTransportMode.value === "websocket" ? mqttWsPath.value || "/mqtt" : undefined, + }; +} + const influxDbVersion = ref("1"); const influxDbOrg = ref(""); @@ -1957,6 +2027,12 @@ function applyProfile(val: string, preserveConnectionFields = false) { form.value.connection_string = undefined; form.value.url_params = ""; } + if (profile.type === "mqtt") { + resetMqttFields(); + form.value.database = undefined; + form.value.connection_string = undefined; + form.value.url_params = ""; + } if (profile.type === "influxdb") { resetInfluxDbFields(); form.value.database = undefined; @@ -2060,6 +2136,11 @@ watch( } else { resetNacosFields(); } + if (config.db_type === "mqtt") { + hydrateMqttFields(config.external_config); + } else { + resetMqttFields(); + } if (config.db_type === "influxdb") { hydrateInfluxDbFields(config.external_config); } else { @@ -2304,6 +2385,7 @@ const iconTypeMap: Record = { rocketmq: "rocketmq", rabbitmq: "rabbitmq", nacos: "nacos", + mqtt: "mqtt", dm: "dm", h2: "h2", snowflake: "snowflake", @@ -2403,6 +2485,7 @@ const dbOptions: DbOption[] = [ { value: "kafka", label: "Apache Kafka" }, { value: "rocketmq", label: "Apache RocketMQ" }, { value: "rabbitmq", label: "RabbitMQ" }, + { value: "mqtt", label: "MQTT" }, { value: "nacos", label: "Nacos" }, { value: "influxdb", label: "InfluxDB" }, { value: "iris", label: "IRIS" }, @@ -2456,7 +2539,7 @@ const dbCategoryDefinitions: Array<{ { key: "mq", titleKey: "connection.databaseCategoryMq", - optionValues: ["mq", "kafka", "rocketmq", "rabbitmq"], + optionValues: ["mq", "kafka", "rocketmq", "rabbitmq", "mqtt"], }, { key: "registry_config", @@ -2864,6 +2947,7 @@ const hasRequiredConnectionTarget = computed(() => { return !!mqAdminUrl.value.trim(); } if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim()); + if (form.value.db_type === "mqtt") return !!mqttHost.value.trim() && mqttPort.value > 0; if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim(); if (isCloudflareD1Connection(form.value)) return hasCloudflareD1Credentials(form.value); if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string)); @@ -3264,6 +3348,19 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo config.database = nacosConfig.namespace || undefined; config.connection_string = undefined; config.url_params = ""; + } else if (config.db_type === "mqtt") { + const mqttConfig = buildMqttExternalConfig(); + config.external_config = mqttConfig; + config.driver_profile = "mqtt"; + config.driver_label = "MQTT"; + config.host = mqttConfig.host; + config.port = mqttConfig.port; + config.ssl = mqttConfig.tls; + config.username = ""; + config.password = ""; + config.database = undefined; + config.connection_string = undefined; + config.url_params = ""; } else if (config.db_type === "influxdb") { config.external_config = buildInfluxDbExternalConfig(); config.connection_string = undefined; @@ -5851,6 +5948,81 @@ function openExternalUrl(url: string) { + + + + {{ t("connection.mqttBrokerAddress") }} + + + + {{ t("connection.mqttBrokerPort") }} + + + + {{ t("connection.mqttClientId") }} + + + + {{ t("connection.mqttProtocolVersion") }} + + MQTT 5.0 + MQTT 3.1.1 + MQTT 3.1 + + + + {{ t("connection.mqttTransport") }} + + {{ t("connection.mqttTransportTcp") }} + {{ t("connection.mqttTransportWebSocket") }} + + + + {{ t("connection.mqttWsPath") }} + + + + {{ t("connection.mqAuth") }} + + {{ t("connection.mqAuthNone") }} + {{ t("connection.mqAuthBasic") }} + + + + + {{ t("connection.mqttUsername") }} + + + + {{ t("connection.mqttPassword") }} + + + + + {{ t("connection.mqttTls") }} + + + TLS + + + {{ t("connection.mqttTlsSkipVerify") }} + + + + + {{ t("connection.mqttKeepAlive") }} + + + + {{ t("connection.mqttConnectTimeout") }} + + + + 最大报文(字节) + + + + diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue index 1a481017b2..c53af496f8 100644 --- a/apps/desktop/src/components/icons/DatabaseIcon.vue +++ b/apps/desktop/src/components/icons/DatabaseIcon.vue @@ -103,6 +103,7 @@ const assetIcons: Record = { zookeeper: "zookeeper", oscar: "oscar.png", jdbcx: "jdbcx", + mqtt: "mqtt", }; const normalizedType = computed(() => props.dbType.toLowerCase().replace(/[\s-]+/g, "_")); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index c4434f8aca..249591d516 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -58,6 +58,7 @@ const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/Vec const HBaseBrowser = defineAsyncComponent(() => import("@/components/hbase/HBaseBrowser.vue")); const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue")); const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue")); +const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/MqttAdminConsole.vue")); const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue")); const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue")); const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue")); @@ -1878,6 +1879,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand + + + + + + +import { ref, computed, onMounted, onUnmounted } from "vue"; +import { useI18n } from "vue-i18n"; +import { mqttGetBrokerInfo, mqttListTopics, mqttGetTopicTree, mqttGetMessages, mqttSubscribe, mqttUnsubscribe, mqttClearMessages } from "@/lib/backend/api"; +import type { MqttBrokerInfo, MqttTopicNode, MqttMessage } from "@/types/mqtt"; +import { Button } from "@/components/ui/button"; +import TopicTreeNode from "./TopicTreeNode.vue"; +import MqttPublishPanel from "./MqttPublishDialog.vue"; +import { decodePayload, PAYLOAD_ENCODINGS, PAYLOAD_ENCODING_LABELS, type PayloadEncoding } from "@/lib/mqtt/mqttPayloadCodec"; + +interface Props { + connectionId: string; + initialTopic?: string; +} +const props = defineProps(); +const { t } = useI18n(); + +/* ========== State ========== */ +const brokerInfo = ref(null); +const topicTree = ref(null); +const messages = ref([]); +const subscribedTopics = ref<[string, string][]>([]); +const selectedTopic = ref(props.initialTopic ?? ""); +const loading = ref(true); +const error = ref(null); +const pollingTimer = ref | null>(null); + +/* Payload 显示编码格式 */ +const displayEncoding = ref("plaintext"); + +/* ========== Computed ========== */ +const connected = computed(() => brokerInfo.value?.connected ?? false); + +/* ========== 数据刷新 ========== */ +async function refreshData() { + try { + const [info, topics, tree, msgs] = await Promise.all([ + mqttGetBrokerInfo(props.connectionId) as Promise, + mqttListTopics(props.connectionId) as Promise<[string, string][]>, + mqttGetTopicTree(props.connectionId) as Promise, + mqttGetMessages(props.connectionId, selectedTopic.value || undefined, 50) as Promise, + ]); + brokerInfo.value = info; + subscribedTopics.value = topics; + topicTree.value = tree; + messages.value = msgs; + error.value = null; + } catch (e) { + error.value = String(e); + } finally { + loading.value = false; + } +} + +async function handleSubscribe(topic: string) { + try { + await mqttSubscribe(props.connectionId, topic, "atmostonce"); + await refreshData(); + } catch (e) { + error.value = String(e); + } +} + +async function handleUnsubscribe(topics: string[]) { + const subscribed = new Set(subscribedTopics.value.map(([topic]) => topic)); + const targets = [...new Set(topics)].filter((topic) => subscribed.has(topic)); + if (targets.length === 0) { + error.value = t("connection.mqttUnsubscribeMissing"); + return; + } + + const prompt = targets.length === 1 ? t("connection.mqttUnsubscribeConfirm", { topic: targets[0] }) : t("connection.mqttUnsubscribeGroupConfirm", { count: targets.length }); + if (!window.confirm(prompt)) return; + + const failures: string[] = []; + for (const topic of targets) { + try { + await mqttUnsubscribe(props.connectionId, topic); + } catch (e) { + failures.push(`${topic}:${String(e)}`); + } + } + + if (targets.includes(selectedTopic.value)) selectedTopic.value = ""; + await refreshData(); + if (failures.length > 0) { + error.value = t("connection.mqttUnsubscribeFailed", { count: failures.length, errors: failures.join("; ") }); + } +} + +function handleTopicClick(topic: string) { + selectedTopic.value = topic; + refreshData(); +} + +function handleMessagePublished() { + refreshData(); +} + +async function handleClearMessages() { + try { + await mqttClearMessages(props.connectionId); + messages.value = []; + } catch (e) { + error.value = String(e); + } +} + +/* ========== 消息轮询 ========== */ +function startPolling() { + stopPolling(); + pollingTimer.value = setInterval(async () => { + try { + const msgs = (await mqttGetMessages(props.connectionId, selectedTopic.value || undefined, 50)) as MqttMessage[]; + messages.value = msgs; + } catch { + /* 忽略轮询错误 */ + } + }, 3000); +} + +function stopPolling() { + if (pollingTimer.value) { + clearInterval(pollingTimer.value); + pollingTimer.value = null; + } +} + +/* ========== 格式化消息 Payload ========== */ +function formatMessagePayload(msg: MqttMessage): string { + if (displayEncoding.value === "plaintext" && msg.payloadText != null) { + return msg.payloadText; + } + return decodePayload(msg.payloadBase64, displayEncoding.value); +} + +/* ========== 生命周期 ========== */ +onMounted(async () => { + await refreshData(); + startPolling(); +}); + +onUnmounted(() => { + stopPolling(); +}); + + + + + + + + {{ t("connection.mqttConsoleTitle") }} + {{ brokerInfo.brokerUrl }} + + {{ connected ? t("connection.mqttConnected") : t("connection.mqttDisconnected") }} + + + + {{ t("connection.mqttRefresh") }} + {{ t("connection.mqttClearMessages") }} + + + + + + {{ error }} + {{ t("common.close") }} + + + + + + + {{ t("connection.mqttTopics") }} + + {{ t("common.loading") }} + {{ t("connection.mqttNoTopicsHint") }} + + + + + { + const input = (e.target as HTMLFormElement).querySelector('input'); + const topic = input?.value.trim(); + if (topic && input) { + handleSubscribe(topic); + input.value = ''; + } + } + " + > + + {{ t("connection.mqttSubscribe") }} + + + + + + + + + + {{ t("connection.mqttMessagesForTopic", { topic: selectedTopic }) }} + {{ t("connection.mqttAllMessages") }} + + {{ messages.length }} + + + + {{ PAYLOAD_ENCODING_LABELS[enc] }} + + + + + + {{ t("connection.mqttNoMessages") }} + + + {{ t("connection.mqttSent") }} + {{ t("connection.mqttReceived") }} + {{ msg.topic }} + QoS{{ msg.qos }} + {{ t("connection.mqttRetained") }} + + {{ formatMessagePayload(msg) }} + {{ new Date(msg.receivedAtMs).toLocaleTimeString() }} + + + + + + + + + + diff --git a/apps/desktop/src/components/mqtt/MqttPublishDialog.vue b/apps/desktop/src/components/mqtt/MqttPublishDialog.vue new file mode 100644 index 0000000000..ddfcece43c --- /dev/null +++ b/apps/desktop/src/components/mqtt/MqttPublishDialog.vue @@ -0,0 +1,352 @@ + + + + + + {{ t("connection.mqttPublish") }} + {{ t("common.clear") }} + + + + + + {{ t("connection.mqttTopic") }} + + + + + + + + QoS + + + {{ label }} + + + + + + + + + + {{ t("connection.mqttRetainMessage") }} + + + + + + {{ t("connection.mqttPayloadEncoding") }} + + + {{ PAYLOAD_ENCODING_LABELS[enc] }} + + + + + + + + {{ t("connection.mqttPayload") }} + + {{ t("connection.mqttPublishShortcut") }} + + + + {{ error }} + ✓ {{ t("connection.mqttPublishSuccess") }} + + + + + {{ loading ? t("connection.mqttPublishing") : t("connection.mqttPublish") }} + + + + + + diff --git a/apps/desktop/src/components/mqtt/TopicTreeNode.vue b/apps/desktop/src/components/mqtt/TopicTreeNode.vue new file mode 100644 index 0000000000..1ca6be6c54 --- /dev/null +++ b/apps/desktop/src/components/mqtt/TopicTreeNode.vue @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + {{ node.name }} + + + + + + + + + + + + + + diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index bba6d265b0..04303248f8 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -603,6 +603,8 @@ async function toggle() { await connectionStore.loadMqTenants(node.connectionId); } else if (config?.db_type === "nacos") { await connectionStore.loadNacosNamespaces(node.connectionId); + } else if (config?.db_type === "mqtt") { + await connectionStore.loadMqttTopics(node.connectionId); } else { await connectionStore.loadDatabases(node.connectionId); } @@ -615,6 +617,10 @@ async function toggle() { } else if (node.type === "mq-tenant" && node.connectionId) { await connectionStore.ensureConnected(node.connectionId); queryStore.openMqAdmin(node.connectionId, { tenant: node.mqTenant || node.label, initialTab: node.mqInitialTab }); + } else if (node.type === "mqtt-topic" && node.connectionId) { + await connectionStore.ensureConnected(node.connectionId); + const topicFromId = node.id.endsWith(":mqtt-topic:__console__") ? undefined : node.id.split(":mqtt-topic:")[1] || node.label; + queryStore.openMqttAdmin(node.connectionId, topicFromId ? { initialTopic: topicFromId } : undefined); } else if (node.type === "nacos-namespace" && node.connectionId) { await connectionStore.ensureConnected(node.connectionId); queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1d0bece0d4..22352f724b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -488,6 +488,71 @@ export default { mqTokenSigningKeyPlaceholderHs256: "Broker SECRET", mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----", mqTokenSigningHint: "Choose based on the broker jwt.broker.token.mode: SECRET uses HS256, PRIVATE uses RS256. The key is stored with connection secrets.", + mqttBrokerAddress: "Broker Address", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "Port", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "Auto-generated", + mqttUsername: "Username", + mqttUsernamePlaceholder: "Optional", + mqttPassword: "Password", + mqttPasswordPlaceholder: "Optional", + mqttTls: "TLS", + mqttTlsSkipVerify: "Skip certificate verification", + mqttKeepAlive: "Keep Alive (seconds)", + mqttProtocolVersion: "Protocol Version", + mqttTransport: "Transport", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket Path", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "Connect Timeout (seconds)", + mqttConsoleTitle: "MQTT Console", + mqttTopics: "Topics", + mqttMessages: "Messages", + mqttPublish: "Publish Message", + mqttSubscribe: "Subscribe", + mqttUnsubscribe: "Unsubscribe", + mqttNoMessages: "No messages", + mqttNoTopics: "No subscribed topics", + mqttQuickSubscribe: "Quick Subscribe", + mqttTopicPlaceholder: "Topic name", + mqttConnected: "Connected", + mqttDisconnected: "Disconnected", + mqttRefresh: "Refresh", + mqttPublishDialogTitle: "Publish MQTT Message", + mqttClearMessages: "Clear messages", + mqttNoTopicsHint: "No subscriptions. Use the field below to subscribe to a topic.", + mqttSubscribePlaceholder: "Enter a topic filter...", + mqttMessagesForTopic: "Messages: {topic}", + mqttAllMessages: "Messages (all topics)", + mqttSent: "Sent", + mqttReceived: "Received", + mqttRetained: "Retained", + mqttUnsubscribeMissing: "No active subscriptions found. Refresh topics and try again.", + mqttUnsubscribeConfirm: 'Unsubscribe from "{topic}"?', + mqttUnsubscribeGroupConfirm: "Unsubscribe from {count} topics in this group?", + mqttUnsubscribeFailed: "Failed to unsubscribe from {count} topic(s): {errors}", + mqttUnsubscribeTopic: "Unsubscribe from {topic}", + mqttUnsubscribeGroup: "Unsubscribe from {count} topics in this group", + mqttTopic: "Topic", + mqttTopicExample: "e.g. sensors/temperature", + mqttRetainMessage: "Retain message", + mqttPayloadEncoding: "Payload encoding", + mqttPayload: "Payload", + mqttPayloadPlaceholderPlaintext: "Enter message content...", + mqttPayloadPlaceholderJson: 'Enter JSON, for example {"key": "value"}', + mqttPayloadPlaceholderBase64: "Enter Base64-encoded data", + mqttPayloadPlaceholderHex: "Enter hexadecimal data, for example 48 65 6C 6C 6F", + mqttPayloadPlaceholderStructured: "Enter a JSON value; it will be encoded as {encoding}", + mqttPublishShortcut: "Ctrl+Enter to publish", + mqttPublishTopicRequired: "Topic is required", + mqttPublishTopicWildcard: "Publish topics cannot contain # or + wildcards. Replace the subscription filter with a concrete topic.", + mqttPublishSuccess: "Message published", + mqttPublishing: "Publishing...", + mqttQosAtMostOnce: "At most once", + mqttQosAtLeastOnce: "At least once", + mqttQosExactlyOnce: "Exactly once", searchDatabasePlaceholder: "Search database types", searchResults: "Search results", databaseCategories: "Categories", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8370ff2595..5241057483 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -378,6 +378,25 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "Omitir verificación de certificado", nacosPageSize: "Tamaño de página", + mqttBrokerAddress: "Broker Address", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "Port", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "Auto-generated", + mqttUsername: "Username", + mqttUsernamePlaceholder: "Optional", + mqttPassword: "Password", + mqttPasswordPlaceholder: "Optional", + mqttTls: "TLS", + mqttTlsSkipVerify: "Skip certificate verification", + mqttKeepAlive: "Keep Alive (seconds)", + mqttProtocolVersion: "Protocol Version", + mqttTransport: "Transport", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket Path", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "Connect Timeout (seconds)", nacosMetrics: "Métricas Prometheus", nacosMetricsAuto: "Detección automática", nacosMetricsDisabled: "Desactivado", @@ -709,6 +728,52 @@ export default withEnglishFallback({ sshHostKeyNoticeRejected: "Ha rechazado la verificación de la clave del host {host}:{port}. La conexión se ha abortado, no se enviaron credenciales.", sshHostKeyNoticeGeneric: "Falló la verificación de la clave del host SSH {host}:{port}.", sshHostKeyNoticeLearnFailed: "No se pudo guardar la clave de host de {host}:{port} — este host solo es de confianza en esta sesión, la próxima conexión requerirá confirmación nuevamente.", + mqttConsoleTitle: "Consola MQTT", + mqttTopics: "Topics", + mqttMessages: "Mensajes", + mqttPublish: "Publicar mensaje", + mqttSubscribe: "Suscribir", + mqttUnsubscribe: "Cancelar suscripción", + mqttNoMessages: "No hay mensajes", + mqttNoTopics: "No hay Topics suscritos", + mqttQuickSubscribe: "Suscripción rápida", + mqttTopicPlaceholder: "Nombre del Topic", + mqttConnected: "Conectado", + mqttDisconnected: "Desconectado", + mqttRefresh: "Actualizar", + mqttPublishDialogTitle: "Publicar mensaje MQTT", + mqttClearMessages: "Borrar mensajes", + mqttNoTopicsHint: "No hay suscripciones. Usa el campo inferior para suscribirte a un Topic.", + mqttSubscribePlaceholder: "Introduce un filtro de Topic...", + mqttMessagesForTopic: "Mensajes: {topic}", + mqttAllMessages: "Mensajes (todos los Topics)", + mqttSent: "Enviado", + mqttReceived: "Recibido", + mqttRetained: "Retenido", + mqttUnsubscribeMissing: "No se encontraron suscripciones activas. Actualiza los Topics e inténtalo de nuevo.", + mqttUnsubscribeConfirm: '¿Cancelar la suscripción a "{topic}"?', + mqttUnsubscribeGroupConfirm: "¿Cancelar la suscripción a {count} Topics de este grupo?", + mqttUnsubscribeFailed: "No se pudo cancelar la suscripción a {count} Topic(s): {errors}", + mqttUnsubscribeTopic: "Cancelar la suscripción a {topic}", + mqttUnsubscribeGroup: "Cancelar la suscripción a {count} Topics de este grupo", + mqttTopic: "Topic", + mqttTopicExample: "p. ej. sensors/temperature", + mqttRetainMessage: "Retener mensaje", + mqttPayloadEncoding: "Codificación del Payload", + mqttPayload: "Payload", + mqttPayloadPlaceholderPlaintext: "Introduce el contenido del mensaje...", + mqttPayloadPlaceholderJson: 'Introduce JSON, por ejemplo {"key": "value"}', + mqttPayloadPlaceholderBase64: "Introduce datos codificados en Base64", + mqttPayloadPlaceholderHex: "Introduce datos hexadecimales, por ejemplo 48 65 6C 6C 6F", + mqttPayloadPlaceholderStructured: "Introduce un valor JSON; se codificará como {encoding}", + mqttPublishShortcut: "Ctrl+Enter para publicar", + mqttPublishTopicRequired: "El Topic es obligatorio", + mqttPublishTopicWildcard: "Los Topics de publicación no pueden contener los comodines # o +. Sustituye el filtro de suscripción por un Topic concreto.", + mqttPublishSuccess: "Mensaje publicado", + mqttPublishing: "Publicando...", + mqttQosAtMostOnce: "Como máximo una vez", + mqttQosAtLeastOnce: "Al menos una vez", + mqttQosExactlyOnce: "Exactamente una vez", damengJvmOptions: "Propiedades del sistema JVM", damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "Una propiedad -Dkey o -Dkey=value por línea. Los valores con espacios no necesitan comillas del shell.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9176c2af6f..ad86edae7e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -377,6 +377,25 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "Salta verifica certificato", nacosPageSize: "Dimensione pagina", + mqttBrokerAddress: "Broker Address", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "Port", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "Auto-generated", + mqttUsername: "Username", + mqttUsernamePlaceholder: "Optional", + mqttPassword: "Password", + mqttPasswordPlaceholder: "Optional", + mqttTls: "TLS", + mqttTlsSkipVerify: "Skip certificate verification", + mqttKeepAlive: "Keep Alive (seconds)", + mqttProtocolVersion: "Protocol Version", + mqttTransport: "Transport", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket Path", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "Connect Timeout (seconds)", nacosMetrics: "Metriche Prometheus", nacosMetricsAuto: "Rilevamento automatico", nacosMetricsDisabled: "Disabilitato", @@ -707,6 +726,52 @@ export default withEnglishFallback({ sshHostKeyNoticeRejected: "Hai rifiutato la verifica della chiave host di {host}:{port}, la connessione è stata interrotta e le credenziali non sono state inviate.", sshHostKeyNoticeGeneric: "Verifica della chiave host SSH {host}:{port} fallita.", sshHostKeyNoticeLearnFailed: "Impossibile salvare la chiave host di {host}:{port} — Questo host è considerato affidabile solo per questa sessione, alla prossima connessione sarà necessaria una nuova conferma.", + mqttConsoleTitle: "Console MQTT", + mqttTopics: "Topics", + mqttMessages: "Messaggi", + mqttPublish: "Pubblica messaggio", + mqttSubscribe: "Sottoscrivi", + mqttUnsubscribe: "Annulla sottoscrizione", + mqttNoMessages: "Nessun messaggio", + mqttNoTopics: "Nessun Topic sottoscritto", + mqttQuickSubscribe: "Sottoscrizione rapida", + mqttTopicPlaceholder: "Nome Topic", + mqttConnected: "Connesso", + mqttDisconnected: "Disconnesso", + mqttRefresh: "Aggiorna", + mqttPublishDialogTitle: "Pubblica messaggio MQTT", + mqttClearMessages: "Cancella messaggi", + mqttNoTopicsHint: "Nessuna sottoscrizione. Usa il campo sottostante per sottoscrivere un Topic.", + mqttSubscribePlaceholder: "Inserisci un filtro Topic...", + mqttMessagesForTopic: "Messaggi: {topic}", + mqttAllMessages: "Messaggi (tutti i Topic)", + mqttSent: "Inviato", + mqttReceived: "Ricevuto", + mqttRetained: "Mantenuto", + mqttUnsubscribeMissing: "Nessuna sottoscrizione attiva trovata. Aggiorna i Topic e riprova.", + mqttUnsubscribeConfirm: 'Annullare la sottoscrizione a "{topic}"?', + mqttUnsubscribeGroupConfirm: "Annullare la sottoscrizione a {count} Topic in questo gruppo?", + mqttUnsubscribeFailed: "Impossibile annullare la sottoscrizione a {count} Topic: {errors}", + mqttUnsubscribeTopic: "Annulla la sottoscrizione a {topic}", + mqttUnsubscribeGroup: "Annulla la sottoscrizione a {count} Topic in questo gruppo", + mqttTopic: "Topic", + mqttTopicExample: "ad es. sensors/temperature", + mqttRetainMessage: "Mantieni messaggio", + mqttPayloadEncoding: "Codifica del Payload", + mqttPayload: "Payload", + mqttPayloadPlaceholderPlaintext: "Inserisci il contenuto del messaggio...", + mqttPayloadPlaceholderJson: 'Inserisci JSON, ad esempio {"key": "value"}', + mqttPayloadPlaceholderBase64: "Inserisci dati codificati in Base64", + mqttPayloadPlaceholderHex: "Inserisci dati esadecimali, ad esempio 48 65 6C 6C 6F", + mqttPayloadPlaceholderStructured: "Inserisci un valore JSON; verrà codificato come {encoding}", + mqttPublishShortcut: "Ctrl+Invio per pubblicare", + mqttPublishTopicRequired: "Il Topic è obbligatorio", + mqttPublishTopicWildcard: "I Topic di pubblicazione non possono contenere i caratteri jolly # o +. Sostituisci il filtro di sottoscrizione con un Topic specifico.", + mqttPublishSuccess: "Messaggio pubblicato", + mqttPublishing: "Pubblicazione...", + mqttQosAtMostOnce: "Al massimo una volta", + mqttQosAtLeastOnce: "Almeno una volta", + mqttQosExactlyOnce: "Esattamente una volta", damengJvmOptions: "Proprietà di sistema JVM", damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "Una proprietà -Dkey o -Dkey=value per riga. I valori con spazi non richiedono virgolette della shell.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ac1430e2bd..74b20e95b4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -371,6 +371,25 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "証明書検証をスキップ", nacosPageSize: "ページサイズ", + mqttBrokerAddress: "Broker Address", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "Port", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "Auto-generated", + mqttUsername: "Username", + mqttUsernamePlaceholder: "Optional", + mqttPassword: "Password", + mqttPasswordPlaceholder: "Optional", + mqttTls: "TLS", + mqttTlsSkipVerify: "Skip certificate verification", + mqttKeepAlive: "Keep Alive (seconds)", + mqttProtocolVersion: "Protocol Version", + mqttTransport: "Transport", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket Path", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "Connect Timeout (seconds)", nacosMetrics: "Prometheus メトリクス", nacosMetricsAuto: "自動検出", nacosMetricsDisabled: "無効", @@ -707,6 +726,52 @@ export default withEnglishFallback({ sshHostKeyNoticeRejected: "ホスト {host}:{port} のホストキー検証を拒否しました。接続は中止され、資格情報は送信されませんでした。", sshHostKeyNoticeGeneric: "SSHホスト {host}:{port} のキー検証に失敗しました。", sshHostKeyNoticeLearnFailed: "{host}:{port} のホストキーを保存できません — このホストは現在のセッションでのみ信頼され、次回の接続時に再確認が必要です。", + mqttConsoleTitle: "MQTT コンソール", + mqttTopics: "トピック", + mqttMessages: "メッセージ", + mqttPublish: "メッセージを公開", + mqttSubscribe: "購読", + mqttUnsubscribe: "購読解除", + mqttNoMessages: "メッセージはありません", + mqttNoTopics: "購読中のトピックはありません", + mqttQuickSubscribe: "クイック購読", + mqttTopicPlaceholder: "トピック名", + mqttConnected: "接続済み", + mqttDisconnected: "切断済み", + mqttRefresh: "更新", + mqttPublishDialogTitle: "MQTT メッセージを公開", + mqttClearMessages: "メッセージを消去", + mqttNoTopicsHint: "購読はありません。下の入力欄からトピックを購読してください。", + mqttSubscribePlaceholder: "トピックフィルターを入力...", + mqttMessagesForTopic: "メッセージ: {topic}", + mqttAllMessages: "メッセージ(すべてのトピック)", + mqttSent: "送信", + mqttReceived: "受信", + mqttRetained: "保持", + mqttUnsubscribeMissing: "解除できる購読が見つかりません。トピックを更新して、もう一度お試しください。", + mqttUnsubscribeConfirm: "「{topic}」の購読を解除しますか?", + mqttUnsubscribeGroupConfirm: "このグループの {count} 件のトピック購読を解除しますか?", + mqttUnsubscribeFailed: "{count} 件のトピック購読を解除できませんでした: {errors}", + mqttUnsubscribeTopic: "{topic} の購読を解除", + mqttUnsubscribeGroup: "このグループの {count} 件のトピック購読を解除", + mqttTopic: "トピック", + mqttTopicExample: "例: sensors/temperature", + mqttRetainMessage: "メッセージを保持", + mqttPayloadEncoding: "ペイロードのエンコード", + mqttPayload: "ペイロード", + mqttPayloadPlaceholderPlaintext: "メッセージ内容を入力...", + mqttPayloadPlaceholderJson: 'JSON を入力(例: {"key": "value"})', + mqttPayloadPlaceholderBase64: "Base64 エンコード済みデータを入力", + mqttPayloadPlaceholderHex: "16 進数データを入力(例: 48 65 6C 6C 6F)", + mqttPayloadPlaceholderStructured: "JSON 値を入力してください。{encoding} としてエンコードされます", + mqttPublishShortcut: "Ctrl+Enter で公開", + mqttPublishTopicRequired: "トピックは必須です", + mqttPublishTopicWildcard: "公開トピックには # または + のワイルドカードを使用できません。購読フィルターを具体的なトピックに置き換えてください。", + mqttPublishSuccess: "メッセージを公開しました", + mqttPublishing: "公開中...", + mqttQosAtMostOnce: "最大 1 回", + mqttQosAtLeastOnce: "最低 1 回", + mqttQosExactlyOnce: "正確に 1 回", damengJvmOptions: "JVM システムプロパティ", damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "1 行に 1 つの -Dkey または -Dkey=value を入力します。空白を含む値にもシェル引用符は不要です。", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1fc7495298..08d1e378aa 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -378,6 +378,25 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "Ignorar verificação do certificado", nacosPageSize: "Tamanho da página", + mqttBrokerAddress: "Broker Address", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "Port", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "Auto-generated", + mqttUsername: "Username", + mqttUsernamePlaceholder: "Optional", + mqttPassword: "Password", + mqttPasswordPlaceholder: "Optional", + mqttTls: "TLS", + mqttTlsSkipVerify: "Skip certificate verification", + mqttKeepAlive: "Keep Alive (seconds)", + mqttProtocolVersion: "Protocol Version", + mqttTransport: "Transport", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket Path", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "Connect Timeout (seconds)", nacosMetrics: "Métricas Prometheus", nacosMetricsAuto: "Detecção automática", nacosMetricsDisabled: "Desativado", @@ -708,6 +727,52 @@ export default withEnglishFallback({ sshHostKeyNoticeRejected: "Você rejeitou a verificação da chave do host {host}:{port}. A conexão foi abortada e as credenciais não foram enviadas.", sshHostKeyNoticeGeneric: "Falha na verificação da chave do host SSH {host}:{port}.", sshHostKeyNoticeLearnFailed: "Não foi possível salvar a chave do host {host}:{port} — o host é confiável apenas nesta sessão, necessitando reconfirmação na próxima conexão.", + mqttConsoleTitle: "MQTT Console", + mqttTopics: "Tópicos", + mqttMessages: "Mensagens", + mqttPublish: "Publicar mensagem", + mqttSubscribe: "Inscrever", + mqttUnsubscribe: "Cancelar inscrição", + mqttNoMessages: "Nenhuma mensagem", + mqttNoTopics: "Nenhum Topic inscrito", + mqttQuickSubscribe: "Inscrição rápida", + mqttTopicPlaceholder: "Nome do Topic", + mqttConnected: "Conectado", + mqttDisconnected: "Desconectado", + mqttRefresh: "Atualizar", + mqttPublishDialogTitle: "Publicar mensagem MQTT", + mqttClearMessages: "Limpar mensagens", + mqttNoTopicsHint: "Nenhuma inscrição. Use o campo abaixo para assinar um Topic.", + mqttSubscribePlaceholder: "Digite um filtro de Topic...", + mqttMessagesForTopic: "Mensagens: {topic}", + mqttAllMessages: "Mensagens (todos os Topics)", + mqttSent: "Enviado", + mqttReceived: "Recebido", + mqttRetained: "Retido", + mqttUnsubscribeMissing: "Nenhuma inscrição ativa encontrada. Atualize os Topics e tente novamente.", + mqttUnsubscribeConfirm: 'Cancelar a inscrição em "{topic}"?', + mqttUnsubscribeGroupConfirm: "Cancelar a inscrição em {count} Topics deste grupo?", + mqttUnsubscribeFailed: "Falha ao cancelar a inscrição em {count} Topic(s): {errors}", + mqttUnsubscribeTopic: "Cancelar a inscrição em {topic}", + mqttUnsubscribeGroup: "Cancelar a inscrição em {count} Topics deste grupo", + mqttTopic: "Topic", + mqttTopicExample: "ex.: sensors/temperature", + mqttRetainMessage: "Reter mensagem", + mqttPayloadEncoding: "Codificação do Payload", + mqttPayload: "Payload", + mqttPayloadPlaceholderPlaintext: "Digite o conteúdo da mensagem...", + mqttPayloadPlaceholderJson: 'Digite JSON, por exemplo {"key": "value"}', + mqttPayloadPlaceholderBase64: "Digite dados codificados em Base64", + mqttPayloadPlaceholderHex: "Digite dados hexadecimais, por exemplo 48 65 6C 6C 6F", + mqttPayloadPlaceholderStructured: "Digite um valor JSON; ele será codificado como {encoding}", + mqttPublishShortcut: "Ctrl+Enter para publicar", + mqttPublishTopicRequired: "O Topic é obrigatório", + mqttPublishTopicWildcard: "Topics de publicação não podem conter os curingas # ou +. Substitua o filtro de inscrição por um Topic específico.", + mqttPublishSuccess: "Mensagem publicada", + mqttPublishing: "Publicando...", + mqttQosAtMostOnce: "No máximo uma vez", + mqttQosAtLeastOnce: "Pelo menos uma vez", + mqttQosExactlyOnce: "Exatamente uma vez", damengJvmOptions: "Propriedades do sistema da JVM", damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "Uma propriedade -Dkey ou -Dkey=value por linha. Valores com espaços não precisam de aspas do shell.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index f823137d74..21ac5c939b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -489,6 +489,71 @@ export default withEnglishFallback({ mqTokenSigningKeyPlaceholderHs256: "Broker SECRET", mqTokenSigningKeyPlaceholderRs256: "-----BEGIN PRIVATE KEY-----", mqTokenSigningHint: "按 Broker 的 jwt.broker.token.mode 选择:SECRET 使用 HS256,PRIVATE 使用 RS256。密钥会走连接 secret 存储。", + mqttBrokerAddress: "Broker 地址", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "端口", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "自动生成", + mqttUsername: "用户名", + mqttUsernamePlaceholder: "选填", + mqttPassword: "密码", + mqttPasswordPlaceholder: "选填", + mqttTls: "TLS", + mqttTlsSkipVerify: "跳过证书校验", + mqttKeepAlive: "Keep Alive(秒)", + mqttProtocolVersion: "协议版本", + mqttTransport: "传输层", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket 路径", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "连接超时(秒)", + mqttConsoleTitle: "MQTT 控制台", + mqttTopics: "Topics", + mqttMessages: "消息", + mqttPublish: "发布消息", + mqttSubscribe: "订阅", + mqttUnsubscribe: "取消订阅", + mqttNoMessages: "暂无消息", + mqttNoTopics: "暂无已订阅的 Topic", + mqttQuickSubscribe: "快速订阅", + mqttTopicPlaceholder: "Topic 名称", + mqttConnected: "已连接", + mqttDisconnected: "已断开", + mqttRefresh: "刷新", + mqttPublishDialogTitle: "发布 MQTT 消息", + mqttClearMessages: "清空消息", + mqttNoTopicsHint: "暂无订阅。使用下方输入框订阅 Topic。", + mqttSubscribePlaceholder: "输入要订阅的 Topic...", + mqttMessagesForTopic: "消息:{topic}", + mqttAllMessages: "消息(所有 Topic)", + mqttSent: "发送", + mqttReceived: "接收", + mqttRetained: "保留", + mqttUnsubscribeMissing: "未找到可取消的订阅,Topic 列表可能已经刷新,请重试。", + mqttUnsubscribeConfirm: "确定要取消订阅“{topic}”吗?", + mqttUnsubscribeGroupConfirm: "确定要取消此分组下的 {count} 个订阅吗?", + mqttUnsubscribeFailed: "有 {count} 个订阅未能取消:{errors}", + mqttUnsubscribeTopic: "取消订阅 {topic}", + mqttUnsubscribeGroup: "取消此分组下的 {count} 个订阅", + mqttTopic: "Topic", + mqttTopicExample: "例如 sensors/temperature", + mqttRetainMessage: "保留消息", + mqttPayloadEncoding: "Payload 编码", + mqttPayload: "消息内容 (Payload)", + mqttPayloadPlaceholderPlaintext: "输入消息内容...", + mqttPayloadPlaceholderJson: '输入 JSON,例如 {"key": "value"}', + mqttPayloadPlaceholderBase64: "输入 Base64 编码的数据", + mqttPayloadPlaceholderHex: "输入十六进制数据,例如 48 65 6C 6C 6F", + mqttPayloadPlaceholderStructured: "输入 JSON 值,发送时将自动编码为 {encoding}", + mqttPublishShortcut: "Ctrl+Enter 发送", + mqttPublishTopicRequired: "Topic 不能为空", + mqttPublishTopicWildcard: "发布 Topic 不能包含 # 或 + 通配符,请将订阅过滤器改为具体 Topic 后再发送。", + mqttPublishSuccess: "消息已发送", + mqttPublishing: "发送中...", + mqttQosAtMostOnce: "最多一次", + mqttQosAtLeastOnce: "至少一次", + mqttQosExactlyOnce: "精确一次", searchDatabasePlaceholder: "搜索数据库类型", searchResults: "搜索结果", databaseCategories: "分类", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2fac4787f9..1e60e34ac1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -377,6 +377,25 @@ export default withEnglishFallback({ nacosTls: "TLS", nacosTlsSkipVerify: "跳過憑證驗證", nacosPageSize: "分頁大小", + mqttBrokerAddress: "Broker Address", + mqttBrokerAddressPlaceholder: "127.0.0.1", + mqttBrokerPort: "Port", + mqttClientId: "Client ID", + mqttClientIdPlaceholder: "Auto-generated", + mqttUsername: "Username", + mqttUsernamePlaceholder: "Optional", + mqttPassword: "Password", + mqttPasswordPlaceholder: "Optional", + mqttTls: "TLS", + mqttTlsSkipVerify: "Skip certificate verification", + mqttKeepAlive: "Keep Alive (seconds)", + mqttProtocolVersion: "Protocol Version", + mqttTransport: "Transport", + mqttTransportTcp: "TCP", + mqttTransportWebSocket: "WebSocket", + mqttWsPath: "WebSocket Path", + mqttWsPathPlaceholder: "/mqtt", + mqttConnectTimeout: "Connect Timeout (seconds)", nacosMetrics: "Prometheus 指標", nacosMetricsAuto: "自動偵測", nacosMetricsDisabled: "關閉", @@ -707,6 +726,52 @@ export default withEnglishFallback({ sshHostKeyNoticeRejected: "您已拒絕 {host}:{port} 的主機金鑰驗證,連線已中止,憑證未發送。", sshHostKeyNoticeGeneric: "SSH 主機 {host}:{port} 的金鑰驗證失敗。", sshHostKeyNoticeLearnFailed: "無法儲存 {host}:{port} 的主機金鑰 — 該主機僅在本次會話內受信任,下次連線仍需重新確認。", + mqttConsoleTitle: "MQTT 控制台", + mqttTopics: "Topics", + mqttMessages: "訊息", + mqttPublish: "發布訊息", + mqttSubscribe: "訂閱", + mqttUnsubscribe: "取消訂閱", + mqttNoMessages: "暫無訊息", + mqttNoTopics: "暫無已訂閱的 Topic", + mqttQuickSubscribe: "快速訂閱", + mqttTopicPlaceholder: "Topic 名稱", + mqttConnected: "已連線", + mqttDisconnected: "已斷線", + mqttRefresh: "重新整理", + mqttPublishDialogTitle: "發布 MQTT 訊息", + mqttClearMessages: "清除訊息", + mqttNoTopicsHint: "目前沒有訂閱。請使用下方輸入框訂閱 Topic。", + mqttSubscribePlaceholder: "輸入要訂閱的 Topic 篩選器...", + mqttMessagesForTopic: "訊息:{topic}", + mqttAllMessages: "訊息(所有 Topic)", + mqttSent: "傳送", + mqttReceived: "接收", + mqttRetained: "保留", + mqttUnsubscribeMissing: "找不到可取消的訂閱。Topic 清單可能已重新整理,請再試一次。", + mqttUnsubscribeConfirm: "確定要取消訂閱「{topic}」嗎?", + mqttUnsubscribeGroupConfirm: "確定要取消此群組下的 {count} 個訂閱嗎?", + mqttUnsubscribeFailed: "有 {count} 個訂閱無法取消:{errors}", + mqttUnsubscribeTopic: "取消訂閱 {topic}", + mqttUnsubscribeGroup: "取消此群組下的 {count} 個訂閱", + mqttTopic: "Topic", + mqttTopicExample: "例如 sensors/temperature", + mqttRetainMessage: "保留訊息", + mqttPayloadEncoding: "Payload 編碼", + mqttPayload: "訊息內容 (Payload)", + mqttPayloadPlaceholderPlaintext: "輸入訊息內容...", + mqttPayloadPlaceholderJson: '輸入 JSON,例如 {"key": "value"}', + mqttPayloadPlaceholderBase64: "輸入 Base64 編碼的資料", + mqttPayloadPlaceholderHex: "輸入十六進位資料,例如 48 65 6C 6C 6F", + mqttPayloadPlaceholderStructured: "輸入 JSON 值,傳送時將自動編碼為 {encoding}", + mqttPublishShortcut: "Ctrl+Enter 傳送", + mqttPublishTopicRequired: "Topic 不可為空", + mqttPublishTopicWildcard: "發布 Topic 不可包含 # 或 + 萬用字元,請將訂閱篩選器改為具體 Topic 後再傳送。", + mqttPublishSuccess: "訊息已傳送", + mqttPublishing: "傳送中...", + mqttQosAtMostOnce: "最多一次", + mqttQosAtLeastOnce: "至少一次", + mqttQosExactlyOnce: "恰好一次", damengJvmOptions: "JVM 系統屬性", damengJvmOptionsPlaceholder: "-Djava.net.preferIPv4Stack=true", damengJvmOptionsHint: "每行一個 -Dkey 或 -Dkey=value;值包含空格時也不要使用 shell 引號。", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index e5eabff26b..e1aabc038b 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -748,3 +748,13 @@ export type { AgentEvent, SqlFileEntry, } from "@/lib/backend/tauri"; + +// MQTT +export const mqttGetBrokerInfo = forward("mqttGetBrokerInfo"); +export const mqttSubscribe = forward("mqttSubscribe"); +export const mqttUnsubscribe = forward("mqttUnsubscribe"); +export const mqttPublish = forward("mqttPublish"); +export const mqttListTopics = forward("mqttListTopics"); +export const mqttGetTopicTree = forward("mqttGetTopicTree"); +export const mqttGetMessages = forward("mqttGetMessages"); +export const mqttClearMessages = forward("mqttClearMessages"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index ad0837511c..be53e4e218 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -3358,3 +3358,4 @@ export async function refreshConnections(): Promise { } export * from "@/lib/backend/mq-http"; +export * from "@/lib/backend/mqtt-http"; diff --git a/apps/desktop/src/lib/backend/mqtt-http.ts b/apps/desktop/src/lib/backend/mqtt-http.ts new file mode 100644 index 0000000000..d678f75b41 --- /dev/null +++ b/apps/desktop/src/lib/backend/mqtt-http.ts @@ -0,0 +1,38 @@ +// MQTT HTTP backend stubs (MQTT is not yet supported in web/Docker mode). +// Throw descriptive errors so the user knows this isn't available. + +function mqttWebNotAvailable(name: string): never { + throw new Error(`MQTT ${name}: MQTT 功能暂不支持 Web/Docker 模式。请使用桌面版。`); +} + +export async function mqttGetBrokerInfo(_connectionId: string) { + mqttWebNotAvailable("mqttGetBrokerInfo"); +} + +export async function mqttSubscribe(_connectionId: string, _topic: string, _qos?: string | null) { + mqttWebNotAvailable("mqttSubscribe"); +} + +export async function mqttUnsubscribe(_connectionId: string, _topic: string) { + mqttWebNotAvailable("mqttUnsubscribe"); +} + +export async function mqttPublish(_connectionId: string, _request: unknown) { + mqttWebNotAvailable("mqttPublish"); +} + +export async function mqttListTopics(_connectionId: string) { + mqttWebNotAvailable("mqttListTopics"); +} + +export async function mqttGetTopicTree(_connectionId: string) { + mqttWebNotAvailable("mqttGetTopicTree"); +} + +export async function mqttGetMessages(_connectionId: string, _topicFilter?: string | null, _limit?: number | null) { + mqttWebNotAvailable("mqttGetMessages"); +} + +export async function mqttClearMessages(_connectionId: string) { + mqttWebNotAvailable("mqttClearMessages"); +} diff --git a/apps/desktop/src/lib/backend/mqtt-tauri.ts b/apps/desktop/src/lib/backend/mqtt-tauri.ts new file mode 100644 index 0000000000..5dba9ab037 --- /dev/null +++ b/apps/desktop/src/lib/backend/mqtt-tauri.ts @@ -0,0 +1,33 @@ +import { invoke } from "@tauri-apps/api/core"; + +export async function mqttGetBrokerInfo(connectionId: string) { + return invoke("mqtt_get_broker_info", { connectionId }); +} + +export async function mqttSubscribe(connectionId: string, topic: string, qos?: string | null) { + return invoke("mqtt_subscribe", { connectionId, topic, qos: qos ?? null }); +} + +export async function mqttUnsubscribe(connectionId: string, topic: string) { + return invoke("mqtt_unsubscribe", { connectionId, topic }); +} + +export async function mqttPublish(connectionId: string, request: { topic: string; payloadBase64: string; payloadText?: string | null; qos: string; retain: boolean }) { + return invoke("mqtt_publish", { connectionId, request }); +} + +export async function mqttListTopics(connectionId: string) { + return invoke("mqtt_list_topics", { connectionId }); +} + +export async function mqttGetTopicTree(connectionId: string) { + return invoke("mqtt_get_topic_tree", { connectionId }); +} + +export async function mqttGetMessages(connectionId: string, topicFilter?: string | null, limit?: number | null) { + return invoke("mqtt_get_messages", { connectionId, topicFilter: topicFilter ?? null, limit: limit ?? null }); +} + +export async function mqttClearMessages(connectionId: string) { + return invoke("mqtt_clear_messages", { connectionId }); +} diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index c7e400c488..a6201b2a6e 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -3864,4 +3864,5 @@ export async function exportQueryResultMarkdown(filePath: string, columns: strin } export * from "@/lib/backend/mq-tauri"; +export * from "@/lib/backend/mqtt-tauri"; export * from "@/lib/backend/nacos-tauri"; diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts index 44556d09d3..f8e5557e7f 100644 --- a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts +++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts @@ -84,6 +84,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = { jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" }, mq: { deferred: "message queue namespaces are handled by MQ admin panels" }, nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" }, + mqtt: { deferred: "MQTT topics are managed via the MQTT console" }, } satisfies Record; export function connectionNamespaceCreationTarget(connection: CreationConnection): ConnectionCreationTarget | null { diff --git a/apps/desktop/src/lib/database/databasePropertyEditing.ts b/apps/desktop/src/lib/database/databasePropertyEditing.ts index 37b0598b1d..489ead728c 100644 --- a/apps/desktop/src/lib/database/databasePropertyEditing.ts +++ b/apps/desktop/src/lib/database/databasePropertyEditing.ts @@ -84,6 +84,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = { jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" }, mq: { deferred: "message queue namespaces are handled by MQ admin panels" }, nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" }, + mqtt: { deferred: "MQTT topics are managed via the MQTT console" }, } satisfies Record; function entryFor(connection: PropertyEditConnection): DatabasePropertyEditingEntry | null { diff --git a/apps/desktop/src/lib/mqtt/mqttPayloadCodec.ts b/apps/desktop/src/lib/mqtt/mqttPayloadCodec.ts new file mode 100644 index 0000000000..5fc111c438 --- /dev/null +++ b/apps/desktop/src/lib/mqtt/mqttPayloadCodec.ts @@ -0,0 +1,180 @@ +/** + * MQTT Payload 编解码工具 + * 支持 Plaintext / JSON / Base64 / Hex / CBOR / MsgPack 六种格式 + */ +import { decode as cborDecode, encode as cborEncode } from "cbor-x"; +import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack"; + +/** 支持的编码格式 */ +export type PayloadEncoding = "plaintext" | "json" | "base64" | "hex" | "cbor" | "msgpack"; + +export const PAYLOAD_ENCODING_LABELS: Record = { + plaintext: "Plaintext", + json: "JSON", + base64: "Base64", + hex: "Hex", + cbor: "CBOR", + msgpack: "MsgPack", +}; + +export const PAYLOAD_ENCODINGS: PayloadEncoding[] = ["plaintext", "json", "base64", "hex", "cbor", "msgpack"]; + +/** + * 将 base64 编码的 payload 按指定格式解码为可展示的字符串 + */ +export function decodePayload(base64: string, encoding: PayloadEncoding): string { + if (!base64) return ""; + + try { + const bytes = base64ToBytes(base64); + + switch (encoding) { + case "plaintext": + return bytesToUtf8(bytes); + + case "json": { + const text = bytesToUtf8(bytes); + const parsed = JSON.parse(text); + return JSON.stringify(parsed, null, 2); + } + + case "base64": + return base64; + + case "hex": + return bytesToHex(bytes); + + case "cbor": { + const decoded = cborDecode(bytes); + return formatDecodedValue(decoded); + } + + case "msgpack": { + const decoded = msgpackDecode(bytes); + return formatDecodedValue(decoded); + } + + default: + return bytesToUtf8(bytes); + } + } catch { + /* 解码失败时回退为纯文本显示 */ + try { + return bytesToUtf8(base64ToBytes(base64)); + } catch { + return `[解码失败] ${base64}`; + } + } +} + +/** + * 将用户输入的文本按指定格式编码为 base64(用于发布消息) + */ +export function encodePayload(input: string, encoding: PayloadEncoding): string { + if (!input) return ""; + + try { + switch (encoding) { + case "plaintext": + return bytesToBase64(utf8ToBytes(input)); + + case "json": { + /* 确保输入是合法 JSON 后再编码 */ + const parsed = JSON.parse(input); + return bytesToBase64(utf8ToBytes(JSON.stringify(parsed))); + } + + case "base64": + /* 浏览器 atob 接受缺失 padding 和空白;规范化后再交给 Rust 严格解码 */ + return bytesToBase64(base64ToBytes(input)); + + case "hex": { + const cleaned = input.replace(/\s/g, ""); + if (!/^([0-9A-Fa-f]{2})*$/.test(cleaned)) { + throw new Error("无效的十六进制字符串"); + } + const bytes = hexToBytes(cleaned); + return bytesToBase64(bytes); + } + + case "cbor": { + /* 将 JSON 文本编码为 CBOR */ + const parsed = JSON.parse(input); + const cborBytes = cborEncode(parsed); + return bytesToBase64(cborBytes); + } + + case "msgpack": { + /* 将 JSON 文本编码为 MsgPack */ + const parsed = JSON.parse(input); + const msgpackBytes = msgpackEncode(parsed); + return bytesToBase64(msgpackBytes); + } + + default: + return bytesToBase64(utf8ToBytes(input)); + } + } catch (e) { + throw new Error(`编码失败 (${PAYLOAD_ENCODING_LABELS[encoding]}): ${String(e)}`); + } +} + +/* ========== 基础转换工具 ========== */ + +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function utf8ToBytes(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +function bytesToUtf8(bytes: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(" "); +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +function formatDecodedValue(value: unknown): string { + if (value === null || value === undefined) return String(value); + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + /* Buffer / Uint8Array 等二进制类型 */ + if (value instanceof Uint8Array || (ArrayBuffer.isView(value) && (value as ArrayBufferView).byteLength !== undefined)) { + return `[二进制数据, ${(value as Uint8Array).byteLength ?? (value as Uint8Array).length} bytes]`; + } + if (value instanceof ArrayBuffer) { + return `[二进制数据, ${value.byteLength} bytes]`; + } + /* 对象/数组 → JSON 格式化 */ + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts index 4d19a17f1a..f33f82ef0b 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -29,6 +29,7 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: FolderOpen, colorClass: "text-emerald-500" }; case "nacos-namespace": case "etcd-root": + case "mqtt-topic": return { icon: FolderOpen, colorClass: "text-sky-500" }; case "etcd-dashboard": return { icon: Gauge, colorClass: "text-sky-500" }; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index ae323886ba..2d2585b62d 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2658,6 +2658,8 @@ export const useConnectionStore = defineStore("connection", () => { await loadVectorCollections(connectionId); } else if (config.db_type === "mq") { await loadMqTenants(connectionId, { force: true }); + } else if (config.db_type === "mqtt") { + await loadMqttTopics(connectionId); } else if (config.db_type === "nacos") { await loadNacosNamespaces(connectionId, { force: true }); } else { @@ -3324,6 +3326,48 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadMqttTopics(connectionId: string) { + const node = findConnectionNode(connectionId); + if (!node) return; + + node.isLoading = true; + try { + await ensureConnected(connectionId); + const { mqttGetTopicTree } = await import("@/lib/backend/api"); + const topicTree = (await mqttGetTopicTree(connectionId)) as { name: string; fullPath: string; children?: unknown[]; isLeaf: boolean }; + const topicNodes = mqttTopicTreeToSidebarNodes(connectionId, topicTree); + // Always prepend a synthetic console entry so users can open the MQTT admin + // even when no topics are subscribed yet. + const consoleNode: TreeNode = { + id: `${connectionId}:mqtt-topic:__console__`, + label: "MQTT 控制台", + type: "mqtt-topic" as const, + connectionId, + children: topicNodes.length > 0 ? topicNodes : [], + isExpanded: topicNodes.length > 0, + }; + setChildren(node, [consoleNode]); + node.isExpanded = true; + } catch (e) { + recordMetadataLoadError(connectionId, e); + throw e; + } finally { + node.isLoading = false; + } + } + + function mqttTopicTreeToSidebarNodes(connectionId: string, tree: { name: string; fullPath: string; children?: unknown[]; isLeaf: boolean }): TreeNode[] { + const children = tree.children ?? []; + return children.map((child: any) => ({ + id: `${connectionId}:mqtt-topic:${child.fullPath}`, + label: child.isLeaf ? child.name : `${child.name}/`, + type: "mqtt-topic", + connectionId, + children: child.children?.length > 0 ? mqttTopicTreeToSidebarNodes(connectionId, child) : [], + isExpanded: false, + })) as TreeNode[]; + } + async function loadMqTenants(connectionId: string, options?: LoadTreeOptions) { const node = findConnectionNode(connectionId); if (!node) return; @@ -5089,6 +5133,8 @@ export const useConnectionStore = defineStore("connection", () => { await loadVectorCollections(node.connectionId); } else if (config?.db_type === "mq") { await loadMqTenants(node.connectionId, options); + } else if (config?.db_type === "mqtt") { + await loadMqttTopics(node.connectionId); } else if (config?.db_type === "nacos") { await loadNacosNamespaces(node.connectionId, options); } else { @@ -6863,6 +6909,7 @@ export const useConnectionStore = defineStore("connection", () => { loadEtcdRoot, loadZooKeeperRoot, loadMqTenants, + loadMqttTopics, loadNacosNamespaces, updateRedisDbKeyStats, loadMongoDatabases, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index df875e34c6..6e2f07f6c1 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1735,6 +1735,33 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openMqttAdmin(connectionId: string, target?: { initialTopic?: string }) { + const existing = tabs.value.find((tab) => tab.mode === "mqtt" && tab.connectionId === connectionId); + if (existing) { + if (target?.initialTopic) existing.mqttInitialTopic = target.initialTopic; + switchTab(existing.id); + return existing.id; + } + + const conn = useConnectionStore().getConfig(connectionId); + const id = uuid(); + const tab: QueryTab = { + id, + title: `${conn?.name || "MQTT"} Console`, + connectionId, + database: conn?.database || "", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "mqtt", + mqttInitialTopic: target?.initialTopic, + }; + tabs.value.push(tab); + activeTabId.value = id; + return id; + } + function clearNacosNavigationTarget(connectionId: string, namespace: string, requestId?: number) { const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace); if (!tab || (requestId !== undefined && tab.nacosTargetRequestId !== requestId)) return; @@ -5312,6 +5339,7 @@ export const useQueryStore = defineStore("query", () => { openNacosDashboard, openDamengJobAdmin, openMqAdmin, + openMqttAdmin, openNacosAdmin, clearNacosNavigationTarget, openTableStructure, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 98bc1b639a..f91edae25a 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -68,6 +68,7 @@ export type DatabaseType = | "influxdb" | "jdbc" | "mq" + | "mqtt" | "nacos"; export function isElasticsearchCompatibleDatabaseType(dbType?: DatabaseType): boolean { @@ -821,7 +822,8 @@ export type TreeNodeType = | "mongo-collection" | "vector-database" | "vector-collection" - | "elasticsearch-index"; + | "elasticsearch-index" + | "mqtt-topic"; export interface ConnectionGroup { id: string; @@ -1008,6 +1010,7 @@ export interface QueryTab { | "etcd-access-control" | "zookeeper" | "mq" + | "mqtt" | "nacos" | "nacos-dashboard" | "objects" @@ -1021,6 +1024,7 @@ export interface QueryTab { hbaseCreateTableOnOpen?: boolean; mqTenant?: string; mqInitialTab?: "topics"; + mqttInitialTopic?: string; nacosNamespace?: string; nacosNamespaceName?: string; nacosTargetDataId?: string; diff --git a/apps/desktop/src/types/mqtt.ts b/apps/desktop/src/types/mqtt.ts new file mode 100644 index 0000000000..56d0283bfd --- /dev/null +++ b/apps/desktop/src/types/mqtt.ts @@ -0,0 +1,75 @@ +// MQTT broker connection types, matching dbx-core/src/mqtt/types.rs + +export type MqttProtocolVersion = "v3" | "v4" | "v5"; +export type MqttTransport = "tcp" | "websocket"; +export type MqttQoS = "atmostonce" | "atleastonce" | "exactlyonce"; + +export interface MqttAuth { + kind: "none" | "password" | "certificate"; + username?: string; + password?: string; + caCertPath?: string; + clientCertPath?: string; + clientKeyPath?: string; +} + +export interface MqttConnectionConfig { + host: string; + port: number; + clientId: string; + protocolVersion: MqttProtocolVersion; + transport: MqttTransport; + tls: boolean; + tlsSkipVerify: boolean; + auth: MqttAuth; + keepAliveSecs: number; + connectTimeoutSecs: number; + maxPacketSizeBytes: number; + wsPath?: string; +} + +export interface MqttBrokerInfo { + brokerUrl: string; + clientId: string; + connected: boolean; + protocolVersion: string; + subscriptionCount: number; +} + +export type MqttMessageDirection = "sent" | "received"; + +export interface MqttMessage { + topic: string; + payloadBase64: string; + payloadText?: string | null; + qos: number; + retain: boolean; + receivedAtMs: number; + /** 消息方向:sent(发出的)或 received(接收的) */ + direction?: MqttMessageDirection; +} + +export interface MqttTopicNode { + name: string; + fullPath: string; + children: MqttTopicNode[]; + messageCount?: number | null; + isLeaf: boolean; +} + +export interface MqttPublishRequest { + topic: string; + payloadBase64: string; + payloadText?: string | null; + qos: MqttQoS; + retain: boolean; +} + +export interface MqttSubscribeRequest { + topic: string; + qos: MqttQoS; +} + +export interface MqttUnsubscribeRequest { + topic: string; +} diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index 84a0afa3d6..de32b57dff 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -7,7 +7,7 @@ license = "Apache-2.0" [features] default = ["duckdb-sidecar", "mq-admin", "sqlite-sqlcipher", "system-fonts"] duckdb-sidecar = [] -mq-admin = [] +mq-admin = ["dep:rumqttc"] sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"] openapi = ["dep:utoipa"] system-fonts = ["font-kit"] @@ -51,6 +51,7 @@ prometheus-parse = "=0.2.5" futures = "0.3" iana-time-zone = "0.1" mongodb = "3.2.5" +rumqttc = { version = "0.24", features = ["websocket"], optional = true } russh = "0.60" pageant = "0.2" portpicker = "0.1.1" diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index c10f61230b..5dc075d282 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -117,6 +117,9 @@ pub enum PoolKind { MessageQueue, /// Nacos admin connection marker. Nacos, + /// MQTT broker connection with an active client. + #[cfg(feature = "mq-admin")] + Mqtt(Arc), } impl PoolKind { @@ -1418,7 +1421,6 @@ impl AppState { pool: PoolKind, config: &ConnectionConfig, ) { - // mq_registry only exists in mq-admin builds; other builds reject MQ connects before a pool is created. #[cfg(feature = "mq-admin")] if matches!(pool, PoolKind::MessageQueue) { self.mq_registry.drop_connection(connection_id).await; @@ -2209,6 +2211,18 @@ impl AppState { .to_string(), ); } + #[cfg(feature = "mq-admin")] + DatabaseType::Mqtt => { + let mqtt_config = crate::mqtt::types::MqttConnectionConfig::from_connection(&config)?; + let client = crate::mqtt::client::MqttClient::connect(mqtt_config).await?; + PoolKind::Mqtt(client) + } + #[cfg(not(feature = "mq-admin"))] + DatabaseType::Mqtt => { + return Err( + "MQTT support is not compiled in this build. Rebuild with the 'mq-admin' feature.".to_string() + ); + } }; if let Err(err) = self.ensure_current_connection_attempt(connection_id, connection_attempt).await { @@ -2949,6 +2963,8 @@ impl AppState { | PoolKind::ExternalDriver { .. } | PoolKind::MessageQueue | PoolKind::Nacos => false, + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(_) => false, } }; @@ -3824,6 +3840,8 @@ impl AppState { | PoolKind::ExternalDriver { .. } | PoolKind::MessageQueue | PoolKind::Nacos => true, + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(_) => true, PoolKind::Redis(_) => unreachable!("Redis handled separately"), }; if !healthy && !matches!(pool, PoolKind::Agent(_)) { @@ -4216,6 +4234,8 @@ fn connection_remote_endpoint(config: &ConnectionConfig) -> (String, u16) { .unwrap_or_else(|| (config.host.clone(), config.port)) } else if config.db_type == DatabaseType::MessageQueue { parse_mq_admin_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port)) + } else if config.db_type == DatabaseType::Mqtt { + parse_mqtt_broker_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port)) } else if config.db_type == DatabaseType::Nacos { parse_nacos_server_host_port(config).unwrap_or_else(|| (config.host.clone(), config.port)) } else { @@ -4265,6 +4285,27 @@ fn parse_nacos_server_host_port(config: &ConnectionConfig) -> Option<(String, u1 Some((host, port)) } +fn parse_mqtt_broker_host_port(config: &ConnectionConfig) -> Option<(String, u16)> { + let value = config + .external_config + .as_ref()? + .get("host") + .or_else(|| config.external_config.as_ref()?.get("host"))? + .as_str()? + .trim(); + if value.is_empty() { + return None; + } + let port = config + .external_config + .as_ref()? + .get("port") + .or_else(|| config.external_config.as_ref()?.get("port")) + .and_then(|v| v.as_u64()) + .unwrap_or(1883) as u16; + Some((value.to_string(), port)) +} + fn normalize_client_session_id(client_session_id: Option<&str>) -> Option { client_session_id.map(str::trim).filter(|session| !session.is_empty()).map(|session| session.replace(':', "_")) } @@ -4394,6 +4435,8 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind { } PoolKind::MessageQueue => PoolKind::MessageQueue, PoolKind::Nacos => PoolKind::Nacos, + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(client) => PoolKind::Mqtt(Arc::clone(client)), PoolKind::Redis(_) => panic!("clone_pool_kind not supported for Redis — handled separately"), } } @@ -4450,6 +4493,11 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> { } PoolKind::MessageQueue => {} PoolKind::Nacos => {} + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(client) => { + // 发送 DISCONNECT 并等待事件循环任务结束 + client.disconnect().await; + } } Ok(()) } diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index 89e993ce80..eca355c1f2 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -22,6 +22,8 @@ pub const MQ_TOKEN_SIGNING_KEY: &str = "mq.token_signing.key"; pub const NACOS_AUTH_SECRET_PREFIX: &str = "nacos.auth."; pub const NACOS_AUTH_PASSWORD_KEY: &str = "nacos.auth.password"; pub const NACOS_RNACOS_CONSOLE_PASSWORD_KEY: &str = "nacos.auth.rnacos_console_password"; +pub const MQTT_AUTH_SECRET_PREFIX: &str = "mqtt.auth."; +pub const MQTT_AUTH_PASSWORD_KEY: &str = "mqtt.auth.password"; pub trait ConnectionSecretStore { fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>; @@ -109,6 +111,7 @@ pub fn save_connections_to_file( persist_optional_secret(store, &config.id, INIT_SCRIPT_KEY, config.init_script.as_deref())?; persist_mq_auth_secrets(store, config)?; persist_mq_token_signing_secret(store, config)?; + persist_mqtt_auth_secrets(store, config)?; // New configs persist transport-layer secrets only. Remove legacy transport secret slots after the // migrated layer values have been written so old configs do not keep two sources of truth. @@ -177,6 +180,7 @@ pub fn load_connections_from_file( } hydrate_mq_auth_secrets(store, config, &mut needs_rewrite)?; hydrate_mq_token_signing_secret(store, config, &mut needs_rewrite)?; + hydrate_mqtt_auth_secrets(store, config, &mut needs_rewrite)?; } if needs_rewrite { @@ -355,6 +359,7 @@ fn delete_removed_connection_secrets( store.delete_secret(&config.id, INIT_SCRIPT_KEY)?; delete_secret_prefix(store, &config.id, MQ_AUTH_SECRET_PREFIX)?; delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?; + delete_secret_prefix(store, &config.id, MQTT_AUTH_SECRET_PREFIX)?; } Ok(()) } @@ -549,6 +554,89 @@ fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) { scrub_json_secret(signing, "key"); } +// ── MQTT 密钥持久化 ────────────────────────────────────────────── + +fn persist_mqtt_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> { + if config.db_type != DatabaseType::Mqtt { + delete_secret_prefix(store, &config.id, MQTT_AUTH_SECRET_PREFIX)?; + return Ok(()); + } + + let Some(auth) = mqtt_auth_object(config.external_config.as_ref()) else { + delete_secret_prefix(store, &config.id, MQTT_AUTH_SECRET_PREFIX)?; + return Ok(()); + }; + + match mqtt_auth_kind(auth).as_deref() { + Some("password") => replace_mqtt_auth_secret(store, &config.id, MQTT_AUTH_PASSWORD_KEY, auth, "password")?, + _ => delete_secret_prefix(store, &config.id, MQTT_AUTH_SECRET_PREFIX)?, + } + + Ok(()) +} + +fn hydrate_mqtt_auth_secrets( + store: &dyn ConnectionSecretStore, + config: &mut ConnectionConfig, + needs_rewrite: &mut bool, +) -> Result<(), String> { + if config.db_type != DatabaseType::Mqtt { + return Ok(()); + } + + let Some(auth) = mqtt_auth_object_mut(config.external_config.as_mut()) else { + return Ok(()); + }; + + if let Some("password") = mqtt_auth_kind(auth).as_deref() { + hydrate_json_secret(store, &config.id, MQTT_AUTH_PASSWORD_KEY, auth, "password", needs_rewrite)? + } + + Ok(()) +} + +fn scrub_mqtt_auth_secrets(config: &mut ConnectionConfig) { + let Some(auth) = mqtt_auth_object_mut(config.external_config.as_mut()) else { + return; + }; + if let Some("password") = mqtt_auth_kind(auth).as_deref() { + scrub_json_secret(auth, "password") + } +} + +fn replace_mqtt_auth_secret( + store: &dyn ConnectionSecretStore, + connection_id: &str, + key: &str, + auth: &serde_json::Map, + field: &str, +) -> Result<(), String> { + let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()); + let existing = if current.is_none() { store.get_secret(connection_id, key)? } else { None }; + delete_secret_prefix(store, connection_id, MQTT_AUTH_SECRET_PREFIX)?; + match current { + Some(secret) => store.set_secret(connection_id, key, secret), + None => match existing { + Some(secret) => store.set_secret(connection_id, key, &secret), + None => Ok(()), + }, + } +} + +fn mqtt_auth_kind(auth: &serde_json::Map) -> Option { + auth.get("kind").and_then(serde_json::Value::as_str).map(ToString::to_string) +} + +fn mqtt_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map> { + value?.get("auth")?.as_object() +} + +fn mqtt_auth_object_mut( + value: Option<&mut serde_json::Value>, +) -> Option<&mut serde_json::Map> { + value?.get_mut("auth")?.as_object_mut() +} + fn scrub_json_secret(auth: &mut serde_json::Map, field: &str) { if auth.contains_key(field) { auth.insert(field.to_string(), serde_json::Value::String(String::new())); @@ -656,6 +744,7 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec { config.init_script = None; scrub_mq_auth_secrets(&mut config); scrub_mq_token_signing_secret(&mut config); + scrub_mqtt_auth_secrets(&mut config); config }) .collect() @@ -669,8 +758,8 @@ pub fn secret_account(connection_id: &str, key: &str) -> String { mod tests { use super::{ load_connections_from_file, save_connections_to_file, ConnectionSecretStore, CONNECTION_STRING_KEY, - INIT_SCRIPT_KEY, MAIN_PASSWORD_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, - REDIS_SENTINEL_PASSWORD_KEY, SSH_PASSWORD_KEY, + INIT_SCRIPT_KEY, MAIN_PASSWORD_KEY, MQTT_AUTH_PASSWORD_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, + MQ_TOKEN_SIGNING_KEY, REDIS_SENTINEL_PASSWORD_KEY, SSH_PASSWORD_KEY, }; use crate::models::connection::{ ConnectionConfig, DatabaseType, HttpTunnelConfig, SshTunnelConfig, TransportLayerConfig, @@ -1148,4 +1237,197 @@ mod tests { let signing = loaded[0].external_config.as_ref().and_then(|value| value.get("tokenSigning")).unwrap(); assert_eq!(signing.get("key").and_then(serde_json::Value::as_str), Some("broker-signing-secret")); } + + // ── MQTT 密钥持久化测试 ──────────────────────────────────────── + + #[test] + fn save_connections_moves_mqtt_password_to_secret_store_and_redacts_file() { + let path = temp_connections_file("mqtt-auth"); + let store = MemorySecretStore::default(); + let mut config = connection("mqtt-broker", "", ""); + config.db_type = DatabaseType::Mqtt; + config.external_config = Some(serde_json::json!({ + "host": "localhost", + "port": 1883, + "clientId": "dbx-test", + "protocolVersion": "v5", + "transport": "tcp", + "tls": false, + "tlsSkipVerify": false, + "auth": { + "kind": "password", + "username": "admin", + "password": "mqtt-secret" + }, + "keepAliveSecs": 60, + "connectTimeoutSecs": 30 + })); + + save_connections_to_file(&path, &[config], &store).unwrap(); + + assert_eq!(store.get_existing("mqtt-broker", MQTT_AUTH_PASSWORD_KEY).as_deref(), Some("mqtt-secret")); + let persisted_json = std::fs::read_to_string(&path).unwrap(); + assert!(!persisted_json.contains("mqtt-secret")); + } + + #[test] + fn load_connections_restores_mqtt_password_from_secret_store() { + let path = temp_connections_file("mqtt-load"); + let store = MemorySecretStore::default(); + store.set_existing("mqtt-broker", MQTT_AUTH_PASSWORD_KEY, "mqtt-secret"); + let sanitized = vec![{ + let mut c = connection("mqtt-broker", "", ""); + c.db_type = DatabaseType::Mqtt; + c.external_config = Some(serde_json::json!({ + "host": "localhost", + "port": 1883, + "clientId": "dbx-test", + "protocolVersion": "v5", + "transport": "tcp", + "tls": false, + "tlsSkipVerify": false, + "auth": { + "kind": "password", + "username": "admin", + "password": "" + }, + "keepAliveSecs": 60, + "connectTimeoutSecs": 30 + })); + c + }]; + std::fs::write(&path, serde_json::to_string_pretty(&sanitized).unwrap()).unwrap(); + + let loaded = load_connections_from_file(&path, &store).unwrap(); + let auth = loaded[0].external_config.as_ref().and_then(|v| v.get("auth")).unwrap(); + assert_eq!(auth.get("password").and_then(serde_json::Value::as_str), Some("mqtt-secret")); + } + + #[test] + fn load_connections_migrates_plaintext_mqtt_password_and_rewrites_sanitized_file() { + let path = temp_connections_file("mqtt-migrate"); + let store = MemorySecretStore::default(); + let legacy = serde_json::json!([{ + "id": "mqtt-legacy", + "name": "legacy mqtt", + "db_type": "mqtt", + "host": "localhost", + "port": 1883, + "username": "", + "password": "", + "external_config": { + "host": "localhost", + "port": 1883, + "clientId": "dbx-legacy", + "protocolVersion": "v5", + "transport": "tcp", + "tls": false, + "tlsSkipVerify": false, + "auth": { + "kind": "password", + "username": "admin", + "password": "plain-mqtt-password" + }, + "keepAliveSecs": 60, + "connectTimeoutSecs": 30 + } + }]); + std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap(); + + let loaded = load_connections_from_file(&path, &store).unwrap(); + let auth = loaded[0].external_config.as_ref().and_then(|v| v.get("auth")).unwrap(); + assert_eq!(auth.get("password").and_then(serde_json::Value::as_str), Some("plain-mqtt-password")); + assert_eq!(store.get_existing("mqtt-legacy", MQTT_AUTH_PASSWORD_KEY).as_deref(), Some("plain-mqtt-password")); + + let persisted_json = std::fs::read_to_string(&path).unwrap(); + assert!(!persisted_json.contains("plain-mqtt-password")); + } + + #[test] + fn save_connections_preserves_existing_mqtt_secret_when_config_is_sanitized() { + let path = temp_connections_file("mqtt-preserve"); + let store = MemorySecretStore::default(); + store.set_existing("mqtt-broker", MQTT_AUTH_PASSWORD_KEY, "existing-mqtt-password"); + let mut config = connection("mqtt-broker", "", ""); + config.db_type = DatabaseType::Mqtt; + config.external_config = Some(serde_json::json!({ + "host": "localhost", + "port": 1883, + "clientId": "dbx-test", + "protocolVersion": "v5", + "transport": "tcp", + "tls": false, + "tlsSkipVerify": false, + "auth": { + "kind": "password", + "username": "admin", + "password": "" + }, + "keepAliveSecs": 60, + "connectTimeoutSecs": 30 + })); + + save_connections_to_file(&path, &[config], &store).unwrap(); + + assert_eq!( + store.get_existing("mqtt-broker", MQTT_AUTH_PASSWORD_KEY).as_deref(), + Some("existing-mqtt-password") + ); + let loaded = load_connections_from_file(&path, &store).unwrap(); + let auth = loaded[0].external_config.as_ref().and_then(|v| v.get("auth")).unwrap(); + assert_eq!(auth.get("password").and_then(serde_json::Value::as_str), Some("existing-mqtt-password")); + } + + #[test] + fn mqtt_none_auth_does_not_store_password() { + let path = temp_connections_file("mqtt-none-auth"); + let store = MemorySecretStore::default(); + let mut config = connection("mqtt-none", "", ""); + config.db_type = DatabaseType::Mqtt; + config.external_config = Some(serde_json::json!({ + "host": "localhost", + "port": 1883, + "clientId": "dbx-test", + "protocolVersion": "v5", + "transport": "tcp", + "tls": false, + "tlsSkipVerify": false, + "auth": { + "kind": "none" + }, + "keepAliveSecs": 60, + "connectTimeoutSecs": 30 + })); + + save_connections_to_file(&path, &[config], &store).unwrap(); + + assert_eq!(store.get_existing("mqtt-none", MQTT_AUTH_PASSWORD_KEY), None); + } + + #[test] + fn mqtt_auth_kind_change_from_password_to_none_clears_stored_secret() { + let path = temp_connections_file("mqtt-kind-change"); + let store = MemorySecretStore::default(); + store.set_existing("mqtt-broker", MQTT_AUTH_PASSWORD_KEY, "old-mqtt-password"); + let mut config = connection("mqtt-broker", "", ""); + config.db_type = DatabaseType::Mqtt; + config.external_config = Some(serde_json::json!({ + "host": "localhost", + "port": 1883, + "clientId": "dbx-test", + "protocolVersion": "v5", + "transport": "tcp", + "tls": false, + "tlsSkipVerify": false, + "auth": { + "kind": "none" + }, + "keepAliveSecs": 60, + "connectTimeoutSecs": 30 + })); + + save_connections_to_file(&path, &[config], &store).unwrap(); + + assert_eq!(store.get_existing("mqtt-broker", MQTT_AUTH_PASSWORD_KEY), None); + } } diff --git a/crates/dbx-core/src/database_capabilities.rs b/crates/dbx-core/src/database_capabilities.rs index 0391fd8e82..ca8332201a 100644 --- a/crates/dbx-core/src/database_capabilities.rs +++ b/crates/dbx-core/src/database_capabilities.rs @@ -56,6 +56,7 @@ pub fn skips_tcp_probe(db_type: &DatabaseType) -> bool { | DatabaseType::CloudflareD1 | DatabaseType::Jdbc | DatabaseType::MessageQueue + | DatabaseType::Mqtt ) || is_agent_type(db_type) } diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index da9e309d6d..64cd0ba63e 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -44,8 +44,12 @@ pub mod mongo_ops; pub mod mongo_shell; #[cfg(feature = "mq-admin")] pub mod mq; +#[cfg(feature = "mq-admin")] +pub mod mqtt; pub(crate) mod mysql_ddl_normalize; pub mod nacos; +#[cfg(all(target_os = "windows", target_env = "gnu"))] +mod nanosleep_stub; pub mod object_source_sql; pub mod path_utils; pub mod plugins; diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index e257a31a86..5396d28f37 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -545,6 +545,10 @@ pub enum DatabaseType { /// system is determined by `external_config.systemKind`. #[serde(rename = "mq")] MessageQueue, + /// MQTT broker connection. The broker address, client ID, and authentication + /// are stored in `external_config`. + #[serde(rename = "mqtt")] + Mqtt, } #[derive(Deserialize)] @@ -1091,6 +1095,7 @@ impl ConnectionConfig { } DatabaseType::Jdbc => "jdbc:".to_string(), DatabaseType::MessageQueue => self.message_queue_admin_url(), + DatabaseType::Mqtt => self.mqtt_broker_url(), DatabaseType::Nacos => self.nacos_admin_url(), } } @@ -1321,6 +1326,7 @@ impl ConnectionConfig { self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string() } DatabaseType::MessageQueue => self.message_queue_admin_url(), + DatabaseType::Mqtt => self.mqtt_broker_url(), DatabaseType::Nacos => self.nacos_admin_url(), } } @@ -1358,6 +1364,18 @@ impl ConnectionConfig { .to_string() } + fn mqtt_broker_url(&self) -> String { + #[cfg(feature = "mq-admin")] + { + use crate::mqtt::types::MqttConnectionConfig; + if let Ok(config) = MqttConnectionConfig::from_connection(self) { + return config.broker_url(); + } + } + let scheme = if self.ssl { "mqtts" } else { "mqtt" }; + format!("{}://{}:{}", scheme, self.host, self.port) + } + fn normalized_url_params(&self) -> String { let value = self.url_params.as_deref().unwrap_or("").trim(); if self.needs_bare_mysql() { diff --git a/crates/dbx-core/src/mqtt/client.rs b/crates/dbx-core/src/mqtt/client.rs new file mode 100644 index 0000000000..af7e9e79ae --- /dev/null +++ b/crates/dbx-core/src/mqtt/client.rs @@ -0,0 +1,2295 @@ +//! MQTT 客户端封装(基于 rumqttc),管理 MQTT broker 的长连接和消息收发。 + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use rumqttc::tokio_rustls::rustls::{ + self, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider}, + pki_types::{CertificateDer, ServerName, UnixTime}, +}; +use rumqttc::v5::{ + mqttbytes::QoS as QoSV5, AsyncClient as AsyncClientV5, Event as EventV5, Incoming as IncomingV5, + MqttOptions as MqttOptionsV5, +}; +use rumqttc::{ + mqttbytes::Protocol, valid_filter, valid_topic, AsyncClient as AsyncClientV4, Event as EventV4, + Incoming as IncomingV4, MqttOptions as MqttOptionsV4, Outgoing, QoS as QoSV4, TlsConfiguration, Transport, +}; +use sha2::{Digest, Sha256}; +use tokio::sync::{oneshot, Mutex, Notify, RwLock}; +use tokio::task::JoinHandle; + +use super::types::{ + MqttAuth, MqttBrokerInfo, MqttConnectionConfig, MqttMessage, MqttMessageDirection, MqttPublishRequest, MqttQoS, + MqttTlsVerificationMode, MqttTopicNode, MqttTransport, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MqttBackendKind { + V4, + V5, +} + +enum MqttBackendClient { + V4(AsyncClientV4), + V5(AsyncClientV5), +} + +struct MqttConnectPlan { + backend: MqttBackendKind, + broker_addr: String, + transport: Transport, + protocol: Option, +} + +const MQTT_RECONNECT_DELAY: Duration = Duration::from_secs(1); +const RETAINED_DEDUP_CAPACITY: usize = 512; + +type SubscriptionCompletion = oneshot::Sender>; +type PublishCompletion = oneshot::Sender>; + +struct PendingSubscribe { + sequence: u64, + topic: String, + qos: MqttQoS, + add_to_desired: bool, + completion: Option, +} + +struct PendingUnsubscribe { + sequence: u64, + topic: String, + completion: Option, +} + +struct PendingPublish { + sequence: u64, + qos: QoSV4, + message: MqttMessage, + completion: Option, +} + +impl PendingPublish { + fn fail(&mut self, error: &str) { + if let Some(completion) = self.completion.take() { + let _ = completion.send(Err(error.to_string())); + } + } +} + +#[derive(Default)] +struct PublishRequestTracker { + next_sequence: u64, + queued: VecDeque, + inflight: HashMap, + orphaned_queued: usize, + orphaned_inflight: HashSet, +} + +impl PublishRequestTracker { + fn queue(&mut self, qos: QoSV4, message: MqttMessage, completion: PublishCompletion) -> u64 { + self.next_sequence = self.next_sequence.wrapping_add(1); + let sequence = self.next_sequence; + self.queued.push_back(PendingPublish { sequence, qos, message, completion: Some(completion) }); + sequence + } + + fn remove_queued(&mut self, sequence: u64) { + self.queued.retain(|pending| pending.sequence != sequence); + } + + fn mark_outgoing(&mut self, pkid: u16) -> Option { + if pkid != 0 && (self.inflight.contains_key(&pkid) || self.orphaned_inflight.contains(&pkid)) { + return None; + } + if self.orphaned_queued > 0 { + self.orphaned_queued -= 1; + if pkid != 0 { + self.orphaned_inflight.insert(pkid); + } + return None; + } + let Some(pending) = self.queued.pop_front() else { + log::warn!("MQTT PUBLISH 已发送,但未找到对应的本地待确认请求: pkid={pkid}"); + return None; + }; + if pending.qos == QoSV4::AtMostOnce { + return Some(pending); + } + self.inflight.insert(pkid, pending); + None + } + + fn take_acknowledged(&mut self, pkid: u16) -> Option { + if self.orphaned_inflight.remove(&pkid) { + return None; + } + self.inflight.remove(&pkid) + } + + fn fail_next_queued(&mut self, error: &str) { + if let Some(mut pending) = self.queued.pop_front() { + pending.fail(error); + } else { + log::warn!("MQTT 事件循环拒绝了 PUBLISH,但没有找到对应的本地请求: {error}"); + } + } + + fn take_for_connection_loss(&mut self) -> Vec { + self.orphaned_queued = self.orphaned_queued.saturating_add(self.queued.len()); + let mut pending = self.queued.drain(..).collect::>(); + for (pkid, request) in self.inflight.drain() { + self.orphaned_inflight.insert(pkid); + pending.push(request); + } + pending.sort_by_key(|request| request.sequence); + pending + } + fn take_all(&mut self) -> Vec { + let mut pending = self.queued.drain(..).collect::>(); + pending.extend(self.inflight.drain().map(|(_, request)| request)); + pending.sort_by_key(|request| request.sequence); + pending + } +} + +struct RetainedDedup { + capacity: usize, + order: VecDeque<(String, [u8; 32])>, + entries: HashSet<(String, [u8; 32])>, +} + +impl RetainedDedup { + fn new(capacity: usize) -> Self { + Self { capacity, order: VecDeque::with_capacity(capacity), entries: HashSet::with_capacity(capacity) } + } + + fn insert(&mut self, topic: &str, payload: &[u8]) -> bool { + let digest: [u8; 32] = Sha256::digest(payload).into(); + let key = (topic.to_string(), digest); + if !self.entries.insert(key.clone()) { + return false; + } + self.order.push_back(key); + while self.order.len() > self.capacity { + if let Some(oldest) = self.order.pop_front() { + self.entries.remove(&oldest); + } + } + true + } + + fn clear_filter(&mut self, filter: &str) { + self.order.retain(|(topic, _)| !topic_matches_filter(topic, filter)); + self.entries.retain(|(topic, _)| !topic_matches_filter(topic, filter)); + } + + fn clear(&mut self) { + self.order.clear(); + self.entries.clear(); + } +} + +enum PendingSubscriptionAction { + Subscribe(PendingSubscribe), + Unsubscribe(PendingUnsubscribe), +} + +impl PendingSubscriptionAction { + fn sequence(&self) -> u64 { + match self { + Self::Subscribe(pending) => pending.sequence, + Self::Unsubscribe(pending) => pending.sequence, + } + } + + fn fail(self, error: &str) { + let completion = match self { + Self::Subscribe(pending) => pending.completion, + Self::Unsubscribe(pending) => pending.completion, + }; + if let Some(completion) = completion { + let _ = completion.send(Err(error.to_string())); + } + } +} + +#[derive(Default)] +struct SubscriptionRequestTracker { + next_sequence: u64, + queued_subscribes: VecDeque, + queued_unsubscribes: VecDeque, + inflight_subscribes: HashMap, + inflight_unsubscribes: HashMap, +} + +impl SubscriptionRequestTracker { + fn next_sequence(&mut self) -> u64 { + self.next_sequence = self.next_sequence.wrapping_add(1); + self.next_sequence + } + + fn mark_subscribe_outgoing(&mut self, pkid: u16) { + if self.inflight_subscribes.contains_key(&pkid) { + return; + } + if let Some(pending) = self.queued_subscribes.pop_front() { + self.inflight_subscribes.insert(pkid, pending); + } else { + log::warn!("MQTT SUBSCRIBE 已发送,但未找到对应的本地待确认请求: pkid={pkid}"); + } + } + + fn mark_unsubscribe_outgoing(&mut self, pkid: u16) { + if self.inflight_unsubscribes.contains_key(&pkid) { + return; + } + if let Some(pending) = self.queued_unsubscribes.pop_front() { + self.inflight_unsubscribes.insert(pkid, pending); + } else { + log::warn!("MQTT UNSUBSCRIBE 已发送,但未找到对应的本地待确认请求: pkid={pkid}"); + } + } + + fn take_subscribe(&mut self, pkid: u16) -> Option { + self.inflight_subscribes.remove(&pkid) + } + + fn take_unsubscribe(&mut self, pkid: u16) -> Option { + self.inflight_unsubscribes.remove(&pkid) + } + + fn take_inflight(&mut self) -> Vec { + let mut pending = self + .inflight_subscribes + .drain() + .map(|(_, request)| PendingSubscriptionAction::Subscribe(request)) + .chain( + self.inflight_unsubscribes.drain().map(|(_, request)| PendingSubscriptionAction::Unsubscribe(request)), + ) + .collect::>(); + pending.sort_by_key(PendingSubscriptionAction::sequence); + pending + } + + fn take_all(&mut self) -> Vec { + let mut pending = self + .queued_subscribes + .drain(..) + .map(PendingSubscriptionAction::Subscribe) + .chain(self.queued_unsubscribes.drain(..).map(PendingSubscriptionAction::Unsubscribe)) + .collect::>(); + pending.extend(self.take_inflight()); + pending.sort_by_key(PendingSubscriptionAction::sequence); + pending + } + + fn has_pending_topic(&self, topic: &str) -> bool { + self.queued_subscribes.iter().any(|pending| pending.topic == topic) + || self.queued_unsubscribes.iter().any(|pending| pending.topic == topic) + || self.inflight_subscribes.values().any(|pending| pending.topic == topic) + || self.inflight_unsubscribes.values().any(|pending| pending.topic == topic) + } +} + +/// 持久化的 MQTT 异步客户端 +pub struct MqttClient { + /// rumqttc 异步客户端 + backend: MqttBackendClient, + /// 连接配置 + config: MqttConnectionConfig, + /// 当前 broker 已通过 ACK 确认的 topic 集合 + subscriptions: RwLock>, + /// broker 最近一次实际授予的 QoS,用于恢复持久会话的显示状态 + granted_subscriptions: RwLock>, + /// 需要在无会话重连后恢复的 topic 集合 + desired_subscriptions: RwLock>, + /// 当前连接是否已收到有效 CONNACK + connected: AtomicBool, + /// 订阅请求与 packet id 的关联状态 + subscription_requests: Mutex, + /// 保证本地排队顺序与 rumqttc 请求通道顺序一致 + subscription_send_lock: Mutex<()>, + /// 发布请求与 PUBLISH/PUBACK/PUBCOMP 的关联状态 + publish_requests: Mutex, + /// 保证本地发布排队顺序与 rumqttc 请求通道顺序一致 + publish_send_lock: Mutex<()>, + /// 最近接收的消息缓冲区(保留最近 N 条) + message_buffer: RwLock>, + /// 最大缓冲消息数 + max_buffer_size: usize, + /// 当前订阅生命周期内已见过的保留消息指纹 + seen_retained: RwLock, + /// 事件循环后台任务的 JoinHandle,用于优雅关闭时等待任务结束 + event_loop_handle: RwLock>>, + /// 通知事件循环强制退出的信号 + shutdown_notify: Arc, +} + +impl MqttClient { + /// 创建新的 MQTT 客户端并建立连接。 + /// 等待 CONNACK 确认连接成功,超时时间由 `config.connect_timeout_secs` 指定。 + pub async fn connect(config: MqttConnectionConfig) -> Result, String> { + if !(1024..=268_435_455).contains(&config.max_packet_size_bytes) { + return Err("MQTT 最大报文大小必须在 1024 到 268435455 字节之间".to_string()); + } + let plan = build_connect_plan(&config); + match plan.backend { + MqttBackendKind::V4 => Self::connect_v4(config, plan).await, + MqttBackendKind::V5 => Self::connect_v5(config, plan).await, + } + } + + async fn connect_v4(config: MqttConnectionConfig, plan: MqttConnectPlan) -> Result, String> { + let client_id = config.client_id.clone(); + let connect_timeout = Duration::from_secs(config.connect_timeout_secs); + let mut mqtt_options = MqttOptionsV4::new(&client_id, &plan.broker_addr, config.port); + mqtt_options.set_keep_alive(Duration::from_secs(config.keep_alive_secs)); + mqtt_options.set_max_packet_size(config.max_packet_size_bytes, config.max_packet_size_bytes); + mqtt_options.set_transport(plan.transport); + if let Some(protocol) = plan.protocol { + mqtt_options.set_protocol(protocol); + } + apply_credentials_v4(&mut mqtt_options, &config.auth); + + let (client, event_loop) = AsyncClientV4::new(mqtt_options, 100); + let shutdown_notify = Arc::new(Notify::new()); + let instance = Self::new_with_backend(config, MqttBackendClient::V4(client), Arc::clone(&shutdown_notify)); + + let (connack_tx, connack_rx) = oneshot::channel(); + let handle = Self::spawn_v4_event_loop(Arc::downgrade(&instance), event_loop, shutdown_notify, connack_tx); + Self::wait_for_connack(instance, handle, connack_rx, connect_timeout).await + } + + async fn connect_v5(config: MqttConnectionConfig, plan: MqttConnectPlan) -> Result, String> { + let client_id = config.client_id.clone(); + let connect_timeout = Duration::from_secs(config.connect_timeout_secs); + let mut mqtt_options = MqttOptionsV5::new(&client_id, &plan.broker_addr, config.port); + mqtt_options.set_keep_alive(Duration::from_secs(config.keep_alive_secs)); + mqtt_options.set_max_packet_size(Some(config.max_packet_size_bytes as u32)); + mqtt_options.set_transport(plan.transport); + apply_credentials_v5(&mut mqtt_options, &config.auth); + + let (client, event_loop) = AsyncClientV5::new(mqtt_options, 100); + let shutdown_notify = Arc::new(Notify::new()); + let instance = Self::new_with_backend(config, MqttBackendClient::V5(client), Arc::clone(&shutdown_notify)); + + let (connack_tx, connack_rx) = oneshot::channel(); + let handle = Self::spawn_v5_event_loop(Arc::downgrade(&instance), event_loop, shutdown_notify, connack_tx); + Self::wait_for_connack(instance, handle, connack_rx, connect_timeout).await + } + + fn new_with_backend( + config: MqttConnectionConfig, + backend: MqttBackendClient, + shutdown_notify: Arc, + ) -> Arc { + Arc::new(Self { + backend, + config, + subscriptions: RwLock::new(Vec::new()), + granted_subscriptions: RwLock::new(Vec::new()), + desired_subscriptions: RwLock::new(Vec::new()), + connected: AtomicBool::new(false), + subscription_requests: Mutex::new(SubscriptionRequestTracker::default()), + subscription_send_lock: Mutex::new(()), + publish_requests: Mutex::new(PublishRequestTracker::default()), + publish_send_lock: Mutex::new(()), + message_buffer: RwLock::new(Vec::new()), + max_buffer_size: 200, + seen_retained: RwLock::new(RetainedDedup::new(RETAINED_DEDUP_CAPACITY)), + event_loop_handle: RwLock::new(None), + shutdown_notify, + }) + } + + fn spawn_v4_event_loop( + instance: Weak, + mut event_loop: rumqttc::EventLoop, + shutdown_notify: Arc, + connack_tx: oneshot::Sender>, + ) -> JoinHandle<()> { + tokio::spawn(async move { + let mut connack_tx = Some(connack_tx); + loop { + tokio::select! { + event = event_loop.poll() => { + match event { + Ok(EventV4::Incoming(IncomingV4::ConnAck(connack))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.handle_connack(connack.session_present).await; + log::info!( + "MQTT CONNACK 已收到: session_present={}, code={:?}", + connack.session_present, + connack.code + ); + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Ok(())); + } + } + Ok(EventV4::Incoming(IncomingV4::Publish(publish))) => { + let Some(instance) = instance.upgrade() else { break }; + Self::record_received_publish( + &instance, + publish.topic.as_bytes(), + publish.payload.as_ref(), + qos_to_u8(publish.qos), + publish.retain, + ).await; + } + Ok(EventV4::Incoming(IncomingV4::SubAck(suback))) => { + let Some(instance) = instance.upgrade() else { break }; + let result = match suback.return_codes.as_slice() { + [rumqttc::SubscribeReasonCode::Success(qos)] => Ok(mqtt_qos_from_v4(*qos)), + [rumqttc::SubscribeReasonCode::Failure] => { + Err("MQTT broker 拒绝了订阅请求".to_string()) + } + _ => Err("MQTT broker 返回的 SUBACK 数量与订阅请求不一致".to_string()), + }; + instance.complete_subscribe_ack(suback.pkid, result).await; + } + Ok(EventV4::Incoming(IncomingV4::PubAck(puback))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.complete_publish_ack(puback.pkid).await; + } + Ok(EventV4::Incoming(IncomingV4::PubComp(pubcomp))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.complete_publish_ack(pubcomp.pkid).await; + } + Ok(EventV4::Incoming(IncomingV4::UnsubAck(unsuback))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.complete_unsubscribe_ack(unsuback.pkid, Ok(())).await; + } + Ok(EventV4::Incoming(IncomingV4::Disconnect)) => { + let Some(instance) = instance.upgrade() else { break }; + instance.handle_connection_lost("broker 主动断开连接").await; + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Err("MQTT broker 在 CONNACK 之前断开了连接".to_string())); + break; + } + log::warn!("MQTT broker 断开了连接,将在 1 秒后重新连接"); + event_loop.clean(); + tokio::time::sleep(MQTT_RECONNECT_DELAY).await; + } + Ok(EventV4::Outgoing(Outgoing::Publish(pkid))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.mark_publish_outgoing(pkid).await; + } + Ok(EventV4::Outgoing(Outgoing::Subscribe(pkid))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.mark_subscribe_outgoing(pkid).await; + } + Ok(EventV4::Outgoing(Outgoing::Unsubscribe(pkid))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.mark_unsubscribe_outgoing(pkid).await; + } + Ok(EventV4::Outgoing(Outgoing::Disconnect)) => { + log::info!("MQTT DISCONNECT 已发送,事件循环退出"); + break; + } + Ok(_) => {} + Err(e) => { + if let Some(instance) = instance.upgrade() { + if matches!( + &e, + rumqttc::ConnectionError::MqttState( + rumqttc::StateError::OutgoingPacketTooLarge { .. } + ) + ) { + instance.fail_next_queued_publish(&format!("发布消息失败:{e}")).await; + } + instance.handle_connection_lost(&e.to_string()).await; + } + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Err(format!("MQTT 连接失败(未收到 CONNACK): {e}"))); + break; + } + log::warn!("MQTT 连接发生异常,将在 1 秒后重新连接: {e}"); + tokio::time::sleep(MQTT_RECONNECT_DELAY).await; + } + } + } + _ = shutdown_notify.notified() => { + log::info!("MQTT 事件循环收到强制关闭信号,正在退出"); + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Err("MQTT 连接已取消".to_string())); + } + break; + } + } + } + }) + } + + fn spawn_v5_event_loop( + instance: Weak, + mut event_loop: rumqttc::v5::EventLoop, + shutdown_notify: Arc, + connack_tx: oneshot::Sender>, + ) -> JoinHandle<()> { + tokio::spawn(async move { + let mut connack_tx = Some(connack_tx); + loop { + tokio::select! { + event = event_loop.poll() => { + match event { + Ok(EventV5::Incoming(IncomingV5::ConnAck(connack))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.handle_connack(connack.session_present).await; + log::info!( + "MQTT v5 CONNACK 已收到: session_present={}, code={:?}", + connack.session_present, + connack.code + ); + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Ok(())); + } + } + Ok(EventV5::Incoming(IncomingV5::Publish(publish))) => { + let Some(instance) = instance.upgrade() else { break }; + Self::record_received_publish( + &instance, + publish.topic.as_ref(), + publish.payload.as_ref(), + qos_v5_to_u8(publish.qos), + publish.retain, + ).await; + } + Ok(EventV5::Incoming(IncomingV5::SubAck(suback))) => { + let Some(instance) = instance.upgrade() else { break }; + let result = match suback.return_codes.as_slice() { + [rumqttc::v5::mqttbytes::v5::SubscribeReasonCode::Success(qos)] => { + Ok(mqtt_qos_from_v5(*qos)) + } + [reason] => { + let detail = suback + .properties + .as_ref() + .and_then(|properties| properties.reason_string.as_deref()) + .map(|message| format!(",broker 提示:{message}")) + .unwrap_or_default(); + Err(format!("MQTT v5 broker 拒绝了订阅请求:{reason:?}{detail}")) + } + _ => Err("MQTT v5 broker 返回的 SUBACK 数量与订阅请求不一致".to_string()), + }; + instance.complete_subscribe_ack(suback.pkid, result).await; + } + Ok(EventV5::Incoming(IncomingV5::PubAck(puback))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.complete_publish_ack(puback.pkid).await; + } + Ok(EventV5::Incoming(IncomingV5::PubComp(pubcomp))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.complete_publish_ack(pubcomp.pkid).await; + } + Ok(EventV5::Incoming(IncomingV5::UnsubAck(unsuback))) => { + let Some(instance) = instance.upgrade() else { break }; + let rejected = unsuback.reasons.iter().find(|reason| { + !matches!( + reason, + rumqttc::v5::mqttbytes::v5::UnsubAckReason::Success + | rumqttc::v5::mqttbytes::v5::UnsubAckReason::NoSubscriptionExisted + ) + }); + let result = rejected.map_or(Ok(()), |reason| { + let detail = unsuback + .properties + .as_ref() + .and_then(|properties| properties.reason_string.as_deref()) + .map(|message| format!(",broker 提示:{message}")) + .unwrap_or_default(); + Err(format!("MQTT v5 broker 拒绝了取消订阅请求:{reason:?}{detail}")) + }); + instance.complete_unsubscribe_ack(unsuback.pkid, result).await; + } + Ok(EventV5::Incoming(IncomingV5::Disconnect(disconnect))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.handle_connection_lost("broker 主动断开连接").await; + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Err("MQTT broker 在 CONNACK 之前断开了连接".to_string())); + break; + } + log::warn!("MQTT v5 broker 断开了连接,将在 1 秒后重新连接: {disconnect:?}"); + event_loop.clean(); + tokio::time::sleep(MQTT_RECONNECT_DELAY).await; + } + Ok(EventV5::Outgoing(Outgoing::Publish(pkid))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.mark_publish_outgoing(pkid).await; + } + Ok(EventV5::Outgoing(Outgoing::Subscribe(pkid))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.mark_subscribe_outgoing(pkid).await; + } + Ok(EventV5::Outgoing(Outgoing::Unsubscribe(pkid))) => { + let Some(instance) = instance.upgrade() else { break }; + instance.mark_unsubscribe_outgoing(pkid).await; + } + Ok(EventV5::Outgoing(Outgoing::Disconnect)) => { + log::info!("MQTT v5 DISCONNECT 已发送,事件循环退出"); + break; + } + Ok(_) => {} + Err(e) => { + if let Some(instance) = instance.upgrade() { + if matches!( + &e, + rumqttc::v5::ConnectionError::MqttState( + rumqttc::v5::StateError::OutgoingPacketTooLarge { .. } + ) + ) { + instance.fail_next_queued_publish(&format!("发布消息失败:{e}")).await; + } + instance.handle_connection_lost(&e.to_string()).await; + } + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Err(format!("MQTT 连接失败(未收到 CONNACK): {e}"))); + break; + } + log::warn!("MQTT v5 连接发生异常,将在 1 秒后重新连接: {e}"); + tokio::time::sleep(MQTT_RECONNECT_DELAY).await; + } + } + } + _ = shutdown_notify.notified() => { + log::info!("MQTT v5 事件循环收到强制关闭信号,正在退出"); + if let Some(tx) = connack_tx.take() { + let _ = tx.send(Err("MQTT 连接已取消".to_string())); + } + break; + } + } + } + }) + } + + async fn wait_for_connack( + instance: Arc, + handle: JoinHandle<()>, + connack_rx: oneshot::Receiver>, + connect_timeout: Duration, + ) -> Result, String> { + match tokio::time::timeout(connect_timeout, connack_rx).await { + Ok(Ok(Ok(()))) => { + { + let mut handle_lock = instance.event_loop_handle.write().await; + *handle_lock = Some(handle); + } + log::info!("MQTT 客户端已成功连接到 {}", instance.config.broker_url()); + Ok(instance) + } + Ok(Ok(Err(e))) => { + handle.abort(); + let _ = handle.await; + Err(e) + } + Ok(Err(_)) => { + handle.abort(); + let _ = handle.await; + Err("MQTT 事件循环意外退出,未收到 CONNACK".to_string()) + } + Err(_) => { + handle.abort(); + let _ = handle.await; + Err(format!("MQTT 连接超时:{} 秒内未收到 CONNACK", connect_timeout.as_secs())) + } + } + } + + async fn record_received_publish(instance: &Self, topic: &[u8], payload: &[u8], qos: u8, retain: bool) { + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + let topic = decode_utf8_lossy(topic); + let payload_base64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(payload) + }; + + if retain { + let mut seen = instance.seen_retained.write().await; + if !seen.insert(&topic, payload) { + return; + } + } + + let payload_text = String::from_utf8(payload.to_vec()).ok(); + let msg = MqttMessage { + topic, + payload_base64, + payload_text, + qos, + retain, + received_at_ms: now_ms, + direction: MqttMessageDirection::Received, + }; + + let mut buffer = instance.message_buffer.write().await; + buffer.push(msg); + if buffer.len() > instance.max_buffer_size { + buffer.remove(0); + } + } + + async fn handle_connack(self: &Arc, session_present: bool) { + self.connected.store(true, Ordering::Release); + + if session_present { + let granted = self.granted_subscriptions.read().await.clone(); + *self.subscriptions.write().await = granted; + return; + } + + self.subscriptions.write().await.clear(); + let instance = Arc::downgrade(self); + tokio::spawn(async move { + let Some(instance) = instance.upgrade() else { + return; + }; + instance.restore_desired_subscriptions().await; + }); + } + + async fn handle_connection_lost(&self, reason: &str) { + if self.connected.swap(false, Ordering::AcqRel) { + log::warn!("MQTT 连接已断开: {reason}"); + } + self.subscriptions.write().await.clear(); + + let pending = self.subscription_requests.lock().await.take_inflight(); + for request in pending { + request.fail("MQTT 连接在收到 broker 确认前中断,请重新执行该操作"); + } + let mut pending_publishes = self.publish_requests.lock().await.take_for_connection_loss(); + for publish in &mut pending_publishes { + publish.fail(&format!("发布消息未完成:MQTT 协议处理或连接失败({reason}),请确认 broker 状态后重试")); + } + } + + async fn restore_desired_subscriptions(&self) { + let desired = self.desired_subscriptions.read().await.clone(); + for (topic, qos) in desired { + let has_pending = self.subscription_requests.lock().await.has_pending_topic(&topic); + if has_pending { + continue; + } + if let Err(error) = self.queue_subscribe_request(topic.clone(), qos, false, None).await { + log::warn!("MQTT 重连后恢复订阅 {topic} 失败: {error}"); + } + } + } + + async fn mark_subscribe_outgoing(&self, pkid: u16) { + self.subscription_requests.lock().await.mark_subscribe_outgoing(pkid); + } + + async fn mark_unsubscribe_outgoing(&self, pkid: u16) { + self.subscription_requests.lock().await.mark_unsubscribe_outgoing(pkid); + } + + async fn mark_publish_outgoing(&self, pkid: u16) { + let pending = self.publish_requests.lock().await.mark_outgoing(pkid); + if let Some(pending) = pending { + self.complete_publish(pending).await; + } + } + + async fn complete_publish_ack(&self, pkid: u16) { + let pending = self.publish_requests.lock().await.take_acknowledged(pkid); + if let Some(pending) = pending { + self.complete_publish(pending).await; + } else { + log::warn!("收到无法关联的 MQTT 发布确认: pkid={pkid}"); + } + } + + async fn complete_publish(&self, mut pending: PendingPublish) { + let mut buffer = self.message_buffer.write().await; + buffer.push(pending.message); + if buffer.len() > self.max_buffer_size { + buffer.remove(0); + } + if let Some(completion) = pending.completion.take() { + let _ = completion.send(Ok(())); + } + } + + async fn fail_next_queued_publish(&self, error: &str) { + self.publish_requests.lock().await.fail_next_queued(error); + } + + async fn complete_subscribe_ack(&self, pkid: u16, result: Result) { + let Some(pending) = self.subscription_requests.lock().await.take_subscribe(pkid) else { + log::warn!("收到无法关联的 MQTT SUBACK: pkid={pkid}"); + return; + }; + + let granted_qos = result.map_err(|error| format!("订阅主题“{}”失败:{error}", pending.topic)); + let completion_result = match granted_qos { + Ok(granted_qos) => { + upsert_subscription(&self.subscriptions, &pending.topic, granted_qos).await; + upsert_subscription(&self.granted_subscriptions, &pending.topic, granted_qos).await; + self.seen_retained.write().await.clear_filter(&pending.topic); + if pending.add_to_desired { + upsert_subscription(&self.desired_subscriptions, &pending.topic, pending.qos).await; + } + Ok(()) + } + Err(error) => { + log::warn!("{error}"); + Err(error) + } + }; + + if let Some(completion) = pending.completion { + let _ = completion.send(completion_result); + } + } + + async fn complete_unsubscribe_ack(&self, pkid: u16, result: Result<(), String>) { + let Some(pending) = self.subscription_requests.lock().await.take_unsubscribe(pkid) else { + log::warn!("收到无法关联的 MQTT UNSUBACK: pkid={pkid}"); + return; + }; + + let result = result.map_err(|error| format!("取消订阅主题“{}”失败:{error}", pending.topic)); + if result.is_ok() { + remove_subscription(&self.subscriptions, &pending.topic).await; + remove_subscription(&self.granted_subscriptions, &pending.topic).await; + remove_subscription(&self.desired_subscriptions, &pending.topic).await; + self.seen_retained.write().await.clear_filter(&pending.topic); + } else if let Err(error) = &result { + log::warn!("{error}"); + } + + if let Some(completion) = pending.completion { + let _ = completion.send(result); + } + } + + async fn queue_subscribe_request( + &self, + topic: String, + qos: MqttQoS, + add_to_desired: bool, + completion: Option, + ) -> Result<(), String> { + let _send_guard = self.subscription_send_lock.lock().await; + let sequence = { + let mut requests = self.subscription_requests.lock().await; + if requests.has_pending_topic(&topic) { + return Err(format!("主题“{topic}”的订阅操作正在处理中,请稍后再试")); + } + let sequence = requests.next_sequence(); + requests.queued_subscribes.push_back(PendingSubscribe { + sequence, + topic: topic.clone(), + qos, + add_to_desired, + completion, + }); + sequence + }; + + if let Err(error) = self.backend.subscribe(&topic, mqtt_qos(qos)).await { + self.subscription_requests.lock().await.queued_subscribes.retain(|pending| pending.sequence != sequence); + return Err(error); + } + Ok(()) + } + + async fn queue_unsubscribe_request( + &self, + topic: String, + completion: Option, + ) -> Result<(), String> { + let _send_guard = self.subscription_send_lock.lock().await; + let sequence = { + let mut requests = self.subscription_requests.lock().await; + if requests.has_pending_topic(&topic) { + return Err(format!("主题“{topic}”的订阅操作正在处理中,请稍后再试")); + } + let sequence = requests.next_sequence(); + requests.queued_unsubscribes.push_back(PendingUnsubscribe { sequence, topic: topic.clone(), completion }); + sequence + }; + + if let Err(error) = self.backend.unsubscribe(&topic).await { + self.subscription_requests.lock().await.queued_unsubscribes.retain(|pending| pending.sequence != sequence); + return Err(error); + } + Ok(()) + } + + /// 获取 broker 基本信息 + pub async fn broker_info(&self) -> MqttBrokerInfo { + let subscriptions = self.subscriptions.read().await; + let protocol_version = match self.config.protocol_version { + super::types::MqttProtocolVersion::V3 => "MQTT 3.1".to_string(), + super::types::MqttProtocolVersion::V4 => "MQTT 3.1.1".to_string(), + super::types::MqttProtocolVersion::V5 => "MQTT 5.0".to_string(), + }; + MqttBrokerInfo { + broker_url: self.config.broker_url(), + client_id: self.config.client_id.clone(), + connected: self.connected.load(Ordering::Acquire), + protocol_version, + subscription_count: subscriptions.len(), + } + } + + /// 订阅 topic + pub async fn subscribe(&self, topic: &str, qos: MqttQoS) -> Result<(), String> { + validate_topic_filter(topic)?; + if self + .desired_subscriptions + .read() + .await + .iter() + .any(|(current, current_qos)| current == topic && *current_qos == qos) + { + return Ok(()); + } + if self.subscription_requests.lock().await.has_pending_topic(topic) { + return Err(format!("主题“{topic}”的订阅操作正在处理中,请稍后再试")); + } + + let (completion, result) = oneshot::channel(); + self.queue_subscribe_request(topic.to_string(), qos, true, Some(completion)).await?; + result.await.map_err(|_| "订阅确认通道已关闭,请检查 MQTT 连接状态".to_string())? + } + + /// 取消订阅 topic + pub async fn unsubscribe(&self, topic: &str) -> Result<(), String> { + validate_topic_filter(topic)?; + let exists = self.desired_subscriptions.read().await.iter().any(|(current, _)| current == topic); + if !exists { + return Ok(()); + } + if self.subscription_requests.lock().await.has_pending_topic(topic) { + return Err(format!("主题“{topic}”的订阅操作正在处理中,请稍后再试")); + } + + let (completion, result) = oneshot::channel(); + self.queue_unsubscribe_request(topic.to_string(), Some(completion)).await?; + result.await.map_err(|_| "取消订阅确认通道已关闭,请检查 MQTT 连接状态".to_string())? + } + + /// 发布消息;QoS 0 等待写入网络,QoS 1/2 分别等待 PUBACK/PUBCOMP。 + pub async fn publish(&self, request: &MqttPublishRequest) -> Result<(), String> { + validate_publish_topic(&request.topic)?; + let payload = if let Some(ref text) = request.payload_text { + text.as_bytes().to_vec() + } else { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&request.payload_base64) + .map_err(|e| format!("Base64 解码失败: {e}"))? + }; + let packet_size = + mqtt_publish_packet_size(&request.topic, payload.len(), request.qos, self.config.protocol_version); + if packet_size > self.config.max_packet_size_bytes { + return Err(format!( + "发布消息失败:报文大小约为 {packet_size} 字节,超过当前连接允许的 {} 字节,请调整最大报文大小或缩小消息负载", + self.config.max_packet_size_bytes + )); + } + + let qos = mqtt_qos(request.qos); + let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64; + let payload_base64 = if request.payload_text.is_some() { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(&payload) + } else { + request.payload_base64.clone() + }; + let sent_msg = MqttMessage { + topic: request.topic.clone(), + payload_base64, + payload_text: request.payload_text.clone(), + qos: qos_to_u8(qos), + retain: request.retain, + received_at_ms: now_ms, + direction: MqttMessageDirection::Sent, + }; + + let _send_guard = self.publish_send_lock.lock().await; + let (completion, result) = oneshot::channel(); + let sequence = self.publish_requests.lock().await.queue(qos, sent_msg, completion); + if let Err(error) = self.backend.publish(&request.topic, qos, request.retain, payload).await { + self.publish_requests.lock().await.remove_queued(sequence); + return Err(error); + } + drop(_send_guard); + result.await.map_err(|_| "发布确认通道已关闭,请检查 MQTT 连接状态".to_string())? + } + + /// 获取已订阅的 topic 列表 + pub async fn list_topics(&self) -> Vec<(String, MqttQoS)> { + self.subscriptions.read().await.clone() + } + + /// 获取消息缓冲区中的消息 + pub async fn get_messages(&self, topic_filter: Option<&str>, limit: usize) -> Vec { + let buffer = self.message_buffer.read().await; + let filtered: Vec = buffer + .iter() + .filter(|msg| { + topic_filter + .map(|filter| msg.topic == filter || topic_matches_filter(&msg.topic, filter)) + .unwrap_or(true) + }) + .rev() + .take(limit) + .cloned() + .collect(); + filtered + } + + /// 清空消息缓冲区 + pub async fn clear_messages(&self) { + self.message_buffer.write().await.clear(); + self.seen_retained.write().await.clear(); + } + + /// 从 topic 列表构建 topic 树 + pub async fn build_topic_tree(&self) -> MqttTopicNode { + let subscriptions = self.subscriptions.read().await; + let mut root = MqttTopicNode { + name: "root".to_string(), + full_path: String::new(), + children: Vec::new(), + message_count: None, + is_leaf: false, + }; + + for (topic, _) in subscriptions.iter() { + insert_topic_into_tree(&mut root, topic); + } + sort_topic_tree(&mut root); + root + } + + /// 优雅关闭:发送 DISCONNECT,等待事件循环退出;超时后强制中止后台任务。 + /// 调用此方法后不应再使用该客户端。 + pub async fn disconnect(&self) { + log::info!("MQTT 客户端正在断开连接..."); + self.connected.store(false, Ordering::Release); + self.subscriptions.write().await.clear(); + self.granted_subscriptions.write().await.clear(); + self.seen_retained.write().await.clear(); + let pending = self.subscription_requests.lock().await.take_all(); + for request in pending { + request.fail("MQTT 连接已关闭,操作未完成"); + } + let mut pending_publishes = self.publish_requests.lock().await.take_all(); + for publish in &mut pending_publishes { + publish.fail("MQTT 连接已关闭,发布操作未完成"); + } + + if let Err(err) = self.backend.disconnect().await { + log::warn!("MQTT DISCONNECT 请求发送失败: {err}"); + } + + let mut handle_lock = self.event_loop_handle.write().await; + if let Some(mut handle) = handle_lock.take() { + tokio::select! { + result = &mut handle => { + let _ = result; + } + _ = tokio::time::sleep(Duration::from_secs(5)) => { + log::warn!("MQTT 事件循环未在 5 秒内退出,正在强制中止"); + self.shutdown_notify.notify_waiters(); + handle.abort(); + let _ = handle.await; + } + } + } + } +} + +impl Drop for MqttClient { + fn drop(&mut self) { + self.shutdown_notify.notify_one(); + } +} + +impl MqttBackendClient { + async fn subscribe(&self, topic: &str, qos: QoSV4) -> Result<(), String> { + match self { + MqttBackendClient::V4(client) => { + client.subscribe(topic, qos).await.map_err(|e| format!("订阅 topic 失败: {e}")) + } + MqttBackendClient::V5(client) => { + client.subscribe(topic, qos_v4_to_v5(qos)).await.map_err(|e| format!("订阅 topic 失败: {e}")) + } + } + } + + async fn unsubscribe(&self, topic: &str) -> Result<(), String> { + match self { + MqttBackendClient::V4(client) => { + client.unsubscribe(topic).await.map_err(|e| format!("取消订阅 topic 失败: {e}")) + } + MqttBackendClient::V5(client) => { + client.unsubscribe(topic).await.map_err(|e| format!("取消订阅 topic 失败: {e}")) + } + } + } + + async fn publish(&self, topic: &str, qos: QoSV4, retain: bool, payload: Vec) -> Result<(), String> { + match self { + MqttBackendClient::V4(client) => { + client.publish(topic, qos, retain, payload).await.map_err(|e| format!("发布消息失败: {e}")) + } + MqttBackendClient::V5(client) => client + .publish(topic, qos_v4_to_v5(qos), retain, payload) + .await + .map_err(|e| format!("发布消息失败: {e}")), + } + } + + async fn disconnect(&self) -> Result<(), String> { + match self { + MqttBackendClient::V4(client) => client.disconnect().await.map_err(|e| format!("断开 MQTT 连接失败: {e}")), + MqttBackendClient::V5(client) => client.disconnect().await.map_err(|e| format!("断开 MQTT 连接失败: {e}")), + } + } +} + +fn build_connect_plan(config: &MqttConnectionConfig) -> MqttConnectPlan { + let backend = match config.protocol_version { + super::types::MqttProtocolVersion::V3 | super::types::MqttProtocolVersion::V4 => MqttBackendKind::V4, + super::types::MqttProtocolVersion::V5 => MqttBackendKind::V5, + }; + let protocol = match config.protocol_version { + super::types::MqttProtocolVersion::V3 => Some(Protocol::V3), + super::types::MqttProtocolVersion::V4 => Some(Protocol::V4), + super::types::MqttProtocolVersion::V5 => None, + }; + let tls_verification_mode = config.tls_verification_mode(); + let transport = build_transport(config.transport, tls_verification_mode); + + MqttConnectPlan { backend, broker_addr: config.broker_addr_for_transport(), transport, protocol } +} + +fn build_transport(transport: MqttTransport, tls_verification_mode: Option) -> Transport { + match (transport, tls_verification_mode) { + (MqttTransport::Tcp, None) => Transport::Tcp, + (MqttTransport::Tcp, Some(MqttTlsVerificationMode::VerifyServerCert)) => Transport::tls_with_default_config(), + (MqttTransport::Tcp, Some(MqttTlsVerificationMode::SkipServerCertVerification)) => { + Transport::tls_with_config(insecure_tls_configuration()) + } + (MqttTransport::WebSocket, None) => Transport::ws(), + (MqttTransport::WebSocket, Some(MqttTlsVerificationMode::VerifyServerCert)) => { + Transport::wss_with_default_config() + } + (MqttTransport::WebSocket, Some(MqttTlsVerificationMode::SkipServerCertVerification)) => { + Transport::wss_with_config(insecure_tls_configuration()) + } + } +} + +fn insecure_tls_configuration() -> TlsConfiguration { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification { provider })) + .with_no_client_auth(); + TlsConfiguration::from(config) +} + +#[derive(Debug)] +struct NoCertificateVerification { + provider: Arc, +} + +impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider.signature_verification_algorithms.supported_schemes() + } +} + +fn apply_credentials_v4(options: &mut MqttOptionsV4, auth: &MqttAuth) { + if let MqttAuth::Password { username, password } = auth { + options.set_credentials(username, password); + } +} + +fn apply_credentials_v5(options: &mut MqttOptionsV5, auth: &MqttAuth) { + if let MqttAuth::Password { username, password } = auth { + options.set_credentials(username, password); + } +} + +fn mqtt_qos_from_v4(qos: QoSV4) -> MqttQoS { + match qos { + QoSV4::AtMostOnce => MqttQoS::AtMostOnce, + QoSV4::AtLeastOnce => MqttQoS::AtLeastOnce, + QoSV4::ExactlyOnce => MqttQoS::ExactlyOnce, + } +} + +fn mqtt_qos_from_v5(qos: QoSV5) -> MqttQoS { + match qos { + QoSV5::AtMostOnce => MqttQoS::AtMostOnce, + QoSV5::AtLeastOnce => MqttQoS::AtLeastOnce, + QoSV5::ExactlyOnce => MqttQoS::ExactlyOnce, + } +} + +fn mqtt_publish_packet_size( + topic: &str, + payload_len: usize, + qos: MqttQoS, + protocol: super::types::MqttProtocolVersion, +) -> usize { + let variable_header = 2 + topic.len() + usize::from(qos != MqttQoS::AtMostOnce) * 2; + let properties = usize::from(matches!(protocol, super::types::MqttProtocolVersion::V5)); + let remaining_len = variable_header + properties + payload_len; + 1 + mqtt_remaining_length_bytes(remaining_len) + remaining_len +} + +fn mqtt_remaining_length_bytes(mut value: usize) -> usize { + let mut bytes = 1; + while value >= 128 { + value /= 128; + bytes += 1; + } + bytes +} + +fn mqtt_qos(qos: MqttQoS) -> QoSV4 { + match qos { + MqttQoS::AtMostOnce => QoSV4::AtMostOnce, + MqttQoS::AtLeastOnce => QoSV4::AtLeastOnce, + MqttQoS::ExactlyOnce => QoSV4::ExactlyOnce, + } +} + +fn qos_to_u8(qos: QoSV4) -> u8 { + match qos { + QoSV4::AtMostOnce => 0, + QoSV4::AtLeastOnce => 1, + QoSV4::ExactlyOnce => 2, + } +} + +fn qos_v5_to_u8(qos: QoSV5) -> u8 { + match qos { + QoSV5::AtMostOnce => 0, + QoSV5::AtLeastOnce => 1, + QoSV5::ExactlyOnce => 2, + } +} + +fn qos_v4_to_v5(qos: QoSV4) -> QoSV5 { + match qos { + QoSV4::AtMostOnce => QoSV5::AtMostOnce, + QoSV4::AtLeastOnce => QoSV5::AtLeastOnce, + QoSV4::ExactlyOnce => QoSV5::ExactlyOnce, + } +} + +fn decode_utf8_lossy(bytes: &[u8]) -> String { + String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| String::from_utf8_lossy(bytes).into_owned()) +} + +async fn upsert_subscription(subscriptions: &RwLock>, topic: &str, qos: MqttQoS) { + let mut subscriptions = subscriptions.write().await; + if let Some((_, current_qos)) = subscriptions.iter_mut().find(|(current, _)| current == topic) { + *current_qos = qos; + } else { + subscriptions.push((topic.to_string(), qos)); + } +} + +async fn remove_subscription(subscriptions: &RwLock>, topic: &str) { + subscriptions.write().await.retain(|(current, _)| current != topic); +} + +fn validate_topic_length_and_content(topic: &str, action: &str) -> Result<(), String> { + if topic.is_empty() { + return Err(format!("{action}主题不能为空,请输入有效的 MQTT 主题")); + } + if topic.contains('\0') { + return Err(format!("{action}主题不能包含空字符")); + } + if topic.len() > u16::MAX as usize { + return Err(format!("{action}主题过长,UTF-8 编码后不能超过 65535 字节")); + } + Ok(()) +} + +fn validate_topic_filter(topic: &str) -> Result<(), String> { + validate_topic_length_and_content(topic, "订阅")?; + if !valid_filter(topic) { + return Err("订阅主题格式无效:通配符 + 必须独占一个层级,通配符 # 必须独占最后一个层级".to_string()); + } + Ok(()) +} + +fn validate_publish_topic(topic: &str) -> Result<(), String> { + validate_topic_length_and_content(topic, "发布")?; + if !valid_topic(topic) { + return Err("发布主题不能包含通配符 # 或 +,请填写一个具体主题".to_string()); + } + Ok(()) +} + +/// 将 topic 路径插入到树结构中 +fn insert_topic_into_tree(root: &mut MqttTopicNode, topic: &str) { + let (path_prefix, visible_topic) = topic.strip_prefix('/').map_or(("", topic), |path| ("/", path)); + let segments: Vec<&str> = visible_topic.split('/').collect(); + let mut current = root; + + for (i, segment) in segments.iter().enumerate() { + let full_path = format!("{path_prefix}{}", segments[..=i].join("/")); + let is_leaf = i == segments.len() - 1; + + let child_idx = match current.children.iter().position(|child| child.name == *segment) { + Some(index) => index, + None => { + current.children.push(MqttTopicNode { + name: segment.to_string(), + full_path, + children: Vec::new(), + message_count: None, + is_leaf, + }); + current.children.len() - 1 + } + }; + if is_leaf { + current.children[child_idx].is_leaf = true; + } + current = &mut current.children[child_idx]; + } +} + +fn sort_topic_tree(node: &mut MqttTopicNode) { + node.children.sort_by(|left, right| left.name.cmp(&right.name)); + for child in &mut node.children { + sort_topic_tree(child); + } +} + +/// 简单 topic 通配符匹配(支持 + 和 #) +fn topic_matches_filter(topic: &str, filter: &str) -> bool { + let topic_segments: Vec<&str> = topic.split('/').collect(); + let filter_segments: Vec<&str> = filter.split('/').collect(); + + let mut ti = 0; + let mut fi = 0; + + while fi < filter_segments.len() { + match filter_segments[fi] { + "#" => return true, + "+" => { + if ti >= topic_segments.len() { + return false; + } + ti += 1; + fi += 1; + } + segment => { + if ti >= topic_segments.len() || topic_segments[ti] != segment { + return false; + } + ti += 1; + fi += 1; + } + } + } + + ti == topic_segments.len() +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn topic_tree_from_subscriptions() { + let mut root = MqttTopicNode { + name: "root".to_string(), + full_path: String::new(), + children: Vec::new(), + message_count: None, + is_leaf: false, + }; + insert_topic_into_tree(&mut root, "sensors/building1/temperature"); + insert_topic_into_tree(&mut root, "sensors/building1/humidity"); + insert_topic_into_tree(&mut root, "sensors/building2/temperature"); + + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].children.len(), 2); + assert_eq!(root.children[0].children[0].children.len(), 2); + } + + #[test] + fn topic_tree_hides_only_the_leading_separator() { + let mut root = MqttTopicNode { + name: "root".to_string(), + full_path: String::new(), + children: Vec::new(), + message_count: None, + is_leaf: false, + }; + insert_topic_into_tree(&mut root, "/device/ctrl/1219/1219000213/#"); + + assert_eq!(root.children.len(), 1); + assert_eq!(root.children[0].name, "device"); + assert_eq!(root.children[0].full_path, "/device"); + assert_eq!(root.children[0].children[0].full_path, "/device/ctrl"); + assert_eq!( + root.children[0].children[0].children[0].children[0].children[0].full_path, + "/device/ctrl/1219/1219000213/#" + ); + } + + #[test] + fn validates_publish_topics_and_subscription_filters() { + assert!(validate_publish_topic("/device/ctrl/1219000213/set").is_ok()); + assert_eq!( + validate_publish_topic("/device/ctrl/1219000213/#").unwrap_err(), + "发布主题不能包含通配符 # 或 +,请填写一个具体主题" + ); + assert!(validate_topic_filter("/device/ctrl/1219000213/#").is_ok()); + assert!(validate_topic_filter("/device/+/status").is_ok()); + assert!(validate_topic_filter("/device/a#").is_err()); + assert!(validate_topic_filter("/device/#/status").is_err()); + } + + #[test] + fn topic_filter_wildcards() { + assert!(topic_matches_filter("sensors/b1/temp", "sensors/+/temp")); + assert!(topic_matches_filter("a/b/c/d", "a/#")); + assert!(!topic_matches_filter("sensors/b1/temp", "actuators/+/temp")); + assert!(topic_matches_filter("a/b", "a/b")); + } + + #[test] + fn connect_plan_uses_ws_path_and_insecure_tls_transport() { + let config = MqttConnectionConfig { + host: "broker.example.com".to_string(), + port: 8084, + transport: MqttTransport::WebSocket, + tls: true, + tls_skip_verify: true, + ws_path: Some("/custom/mqtt".to_string()), + ..Default::default() + }; + + let plan = build_connect_plan(&config); + assert_eq!(plan.broker_addr, "wss://broker.example.com:8084/custom/mqtt"); + assert_eq!(plan.backend, MqttBackendKind::V5); + match plan.transport { + Transport::Wss(TlsConfiguration::Rustls(_)) => {} + _ => panic!("tlsSkipVerify 应使用显式 Rustls 自定义校验器"), + } + } + + #[tokio::test] + async fn connect_applies_selected_protocol_versions() { + assert_v4_connect_protocol(crate::mqtt::types::MqttProtocolVersion::V3, Protocol::V3).await; + assert_v4_connect_protocol(crate::mqtt::types::MqttProtocolVersion::V4, Protocol::V4).await; + assert_v5_connect_protocol().await; + } + + #[tokio::test] + async fn service_test_connection_disconnects_each_session() { + let (port, broker) = spawn_v5_mock_broker(2).await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V5); + + let first = crate::mqtt::service::test_connection(&config).await.unwrap(); + assert_eq!(first.protocol_version, "MQTT 5.0"); + let second = crate::mqtt::service::test_connection(&config).await.unwrap(); + assert_eq!(second.protocol_version, "MQTT 5.0"); + + await_broker(broker).await; + } + + #[tokio::test] + async fn failed_test_connection_closes_session_and_event_loop() { + let (port, broker) = spawn_stalled_v5_mock_broker().await; + let mut config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V5); + config.connect_timeout_secs = 1; + + let error = crate::mqtt::service::test_connection(&config).await.unwrap_err(); + assert!(error.contains("连接超时"), "unexpected error: {error}"); + + await_broker(broker).await; + } + + #[tokio::test] + async fn dropping_client_closes_session_and_event_loop() { + let (port, broker) = spawn_v5_mock_broker_expecting_eof().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V5); + + let client = MqttClient::connect(config).await.unwrap(); + drop(client); + + await_broker(broker).await; + } + + #[tokio::test] + async fn reconnect_updates_connection_state_and_restores_confirmed_subscriptions() { + let (port, disconnect_broker, restored, broker) = spawn_v4_reconnecting_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + + client.subscribe("device/reconnect/#", MqttQoS::AtMostOnce).await.unwrap(); + assert!(client.broker_info().await.connected); + assert_eq!(client.list_topics().await.len(), 1); + + disconnect_broker.send(()).unwrap(); + wait_for_connection_state(&client, false).await; + assert!(client.list_topics().await.is_empty()); + + restored.await.unwrap(); + wait_for_connection_state(&client, true).await; + wait_for_topic_count(&client, 1).await; + + client.disconnect().await; + await_broker(broker).await; + } + + #[tokio::test] + async fn subscription_state_changes_only_after_suback_and_unsuback() { + let (port, subscribe_seen, allow_suback, unsubscribe_seen, allow_unsuback, broker) = + spawn_v4_delayed_ack_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + + let subscribing_client = Arc::clone(&client); + let subscribe = + tokio::spawn(async move { subscribing_client.subscribe("device/ack/#", MqttQoS::AtLeastOnce).await }); + subscribe_seen.await.unwrap(); + assert!(client.list_topics().await.is_empty()); + allow_suback.send(()).unwrap(); + subscribe.await.unwrap().unwrap(); + assert_eq!(client.list_topics().await, vec![("device/ack/#".to_string(), MqttQoS::AtLeastOnce)]); + + let unsubscribing_client = Arc::clone(&client); + let unsubscribe = tokio::spawn(async move { unsubscribing_client.unsubscribe("device/ack/#").await }); + unsubscribe_seen.await.unwrap(); + assert_eq!(client.list_topics().await.len(), 1); + allow_unsuback.send(()).unwrap(); + unsubscribe.await.unwrap().unwrap(); + assert!(client.list_topics().await.is_empty()); + + client.disconnect().await; + await_broker(broker).await; + } + + #[tokio::test] + async fn publish_larger_than_rumqttc_default_waits_until_written() { + let payload = "x".repeat(12 * 1024); + let (port, publish_seen, broker) = spawn_v4_publish_broker(payload.len(), false).await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + let request = publish_request("device/large", &payload, MqttQoS::AtMostOnce); + + client.publish(&request).await.unwrap(); + publish_seen.await.unwrap(); + assert_eq!(client.get_messages(None, 10).await.len(), 1); + + client.disconnect().await; + await_broker(broker).await; + } + + #[tokio::test] + async fn qos1_publish_completes_only_after_puback() { + let payload = "confirmed"; + let (port, publish_seen, allow_puback, broker) = spawn_v4_delayed_puback_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + let request = publish_request("device/confirmed", payload, MqttQoS::AtLeastOnce); + + let publishing_client = Arc::clone(&client); + let publish = tokio::spawn(async move { publishing_client.publish(&request).await }); + publish_seen.await.unwrap(); + assert!(!publish.is_finished()); + assert!(client.get_messages(None, 10).await.is_empty()); + allow_puback.send(()).unwrap(); + publish.await.unwrap().unwrap(); + assert_eq!(client.get_messages(None, 10).await.len(), 1); + + client.disconnect().await; + await_broker(broker).await; + } + + #[tokio::test] + async fn broker_packet_limit_error_is_returned_and_not_recorded() { + let (port, broker) = spawn_v5_small_packet_limit_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V5); + let client = MqttClient::connect(config).await.unwrap(); + let request = publish_request("device/too-large", &"x".repeat(128), MqttQoS::AtMostOnce); + + let error = client.publish(&request).await.unwrap_err(); + assert!(error.contains("maximum packet size"), "unexpected error: {error}"); + assert!(client.get_messages(None, 10).await.is_empty()); + + drop(client); + await_broker(broker).await; + } + #[tokio::test] + async fn publish_connection_error_is_returned_and_not_recorded() { + let (port, broker) = spawn_v4_publish_disconnect_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + let request = publish_request("device/failure", "not-confirmed", MqttQoS::AtLeastOnce); + + let error = client.publish(&request).await.unwrap_err(); + assert!(error.contains("发布消息未完成"), "unexpected error: {error}"); + assert!(client.get_messages(None, 10).await.is_empty()); + + drop(client); + await_broker(broker).await; + } + #[tokio::test] + async fn suback_records_broker_granted_qos() { + let (port, broker) = spawn_v4_granted_subscription_broker(QoSV4::AtMostOnce).await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + + client.subscribe("device/downgraded/#", MqttQoS::ExactlyOnce).await.unwrap(); + assert_eq!(client.list_topics().await, vec![("device/downgraded/#".to_string(), MqttQoS::AtMostOnce)]); + + client.disconnect().await; + await_broker(broker).await; + } + + #[tokio::test] + async fn retained_dedup_resets_for_resubscribe_and_clear() { + let (port, send_after_clear, broker) = spawn_v4_retained_lifecycle_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + + client.subscribe("retained/#", MqttQoS::AtMostOnce).await.unwrap(); + wait_for_message_count(&client, 2).await; + let initial = client.get_messages(None, 10).await; + assert_eq!(initial.iter().filter(|message| message.payload_text.as_deref() == Some("v1")).count(), 1); + assert!(initial.iter().any(|message| message.payload_text.as_deref() == Some("v2"))); + + client.unsubscribe("retained/#").await.unwrap(); + client.subscribe("retained/#", MqttQoS::AtMostOnce).await.unwrap(); + wait_for_message_count(&client, 3).await; + assert_eq!( + client + .get_messages(None, 10) + .await + .iter() + .filter(|message| message.payload_text.as_deref() == Some("v1")) + .count(), + 2 + ); + + client.clear_messages().await; + send_after_clear.send(()).unwrap(); + wait_for_message_count(&client, 1).await; + assert_eq!(client.get_messages(None, 10).await[0].payload_text.as_deref(), Some("v1")); + + client.disconnect().await; + await_broker(broker).await; + } + + #[test] + fn reconnect_replayed_publish_cannot_complete_a_new_request() { + let mut tracker = PublishRequestTracker::default(); + let old_message = test_message("old"); + let (old_completion, mut old_result) = oneshot::channel(); + tracker.queue(QoSV4::AtLeastOnce, old_message, old_completion); + tracker.mark_outgoing(7); + + let mut failed = tracker.take_for_connection_loss(); + failed[0].fail("连接中断"); + assert_eq!(old_result.try_recv().unwrap(), Err("连接中断".to_string())); + + let (new_completion, mut new_result) = oneshot::channel(); + tracker.queue(QoSV4::AtLeastOnce, test_message("new"), new_completion); + assert!(tracker.mark_outgoing(7).is_none()); + assert!(tracker.take_acknowledged(7).is_none()); + assert!(matches!(new_result.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); + + tracker.mark_outgoing(8); + let pending = tracker.take_acknowledged(8).unwrap(); + assert_eq!(pending.message.topic, "new"); + } + + fn test_message(topic: &str) -> MqttMessage { + MqttMessage { + topic: topic.to_string(), + payload_base64: String::new(), + payload_text: None, + qos: 1, + retain: false, + received_at_ms: 0, + direction: MqttMessageDirection::Sent, + } + } + #[test] + fn retained_dedup_is_bounded() { + let mut seen = RetainedDedup::new(2); + assert!(seen.insert("topic/1", b"payload")); + assert!(seen.insert("topic/2", b"payload")); + assert!(seen.insert("topic/3", b"payload")); + assert_eq!(seen.entries.len(), 2); + assert!(seen.insert("topic/1", b"payload")); + } + #[tokio::test] + async fn rejected_suback_does_not_record_subscription() { + let (port, broker) = spawn_v4_rejecting_subscription_broker().await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V4); + let client = MqttClient::connect(config).await.unwrap(); + + let error = client.subscribe("device/rejected/#", MqttQoS::AtMostOnce).await.unwrap_err(); + assert!(error.contains("拒绝"), "unexpected error: {error}"); + assert!(client.list_topics().await.is_empty()); + + client.disconnect().await; + await_broker(broker).await; + } + + async fn assert_v4_connect_protocol(protocol_version: crate::mqtt::types::MqttProtocolVersion, expected: Protocol) { + let (port, broker) = spawn_v4_mock_broker(expected, 1).await; + let config = mqtt_config(port, protocol_version); + let client = MqttClient::connect(config).await.unwrap(); + client.disconnect().await; + await_broker(broker).await; + } + + async fn assert_v5_connect_protocol() { + let (port, broker) = spawn_v5_mock_broker(1).await; + let config = mqtt_config(port, crate::mqtt::types::MqttProtocolVersion::V5); + let client = MqttClient::connect(config).await.unwrap(); + client.disconnect().await; + await_broker(broker).await; + } + + fn mqtt_config(port: u16, protocol_version: crate::mqtt::types::MqttProtocolVersion) -> MqttConnectionConfig { + MqttConnectionConfig { + host: "127.0.0.1".to_string(), + port, + client_id: format!("dbx-test-{}", uuid::Uuid::new_v4()), + protocol_version, + connect_timeout_secs: 5, + ..Default::default() + } + } + + async fn spawn_v4_mock_broker( + expected_protocol: Protocol, + expected_connections: usize, + ) -> (u16, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + for _ in 0..expected_connections { + let (mut stream, _) = listener.accept().await.unwrap(); + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::mqttbytes::v4::read(&mut bytes, 1024 * 1024).unwrap(); + match packet { + rumqttc::mqttbytes::v4::Packet::Connect(connect) => assert_eq!(connect.protocol, expected_protocol), + other => panic!("unexpected packet: {other:?}"), + } + + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::mqttbytes::v4::read(&mut bytes, 1024 * 1024).unwrap(); + assert!(matches!(packet, rumqttc::mqttbytes::v4::Packet::Disconnect)); + } + }); + (port, handle) + } + + async fn spawn_v4_reconnecting_broker( + ) -> (u16, oneshot::Sender<()>, oneshot::Receiver<()>, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (disconnect_tx, disconnect_rx) = oneshot::channel(); + let (restored_tx, restored_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let (mut first, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut first).await.unwrap(); + first.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + acknowledge_v4_subscribe(&mut first, rumqttc::SubscribeReasonCode::Success(QoSV4::AtMostOnce)).await; + disconnect_rx.await.unwrap(); + drop(first); + + let (mut second, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut second).await.unwrap(); + second.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + acknowledge_v4_subscribe(&mut second, rumqttc::SubscribeReasonCode::Success(QoSV4::AtMostOnce)).await; + restored_tx.send(()).unwrap(); + + let frame = read_mqtt_frame(&mut second).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::mqttbytes::v4::read(&mut bytes, 1024 * 1024).unwrap(); + assert!(matches!(packet, rumqttc::mqttbytes::v4::Packet::Disconnect)); + }); + (port, disconnect_tx, restored_rx, handle) + } + + async fn spawn_v4_delayed_ack_broker() -> ( + u16, + oneshot::Receiver<()>, + oneshot::Sender<()>, + oneshot::Receiver<()>, + oneshot::Sender<()>, + tokio::task::JoinHandle<()>, + ) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (subscribe_seen_tx, subscribe_seen_rx) = oneshot::channel(); + let (allow_suback_tx, allow_suback_rx) = oneshot::channel(); + let (unsubscribe_seen_tx, unsubscribe_seen_rx) = oneshot::channel(); + let (allow_unsuback_tx, allow_unsuback_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + + let subscribe_pkid = read_v4_subscribe_pkid(&mut stream).await; + subscribe_seen_tx.send(()).unwrap(); + allow_suback_rx.await.unwrap(); + write_v4_suback(&mut stream, subscribe_pkid, rumqttc::SubscribeReasonCode::Success(QoSV4::AtLeastOnce)) + .await; + + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::mqttbytes::v4::read(&mut bytes, 1024 * 1024).unwrap(); + let unsubscribe_pkid = match packet { + rumqttc::mqttbytes::v4::Packet::Unsubscribe(unsubscribe) => unsubscribe.pkid, + other => panic!("unexpected packet: {other:?}"), + }; + unsubscribe_seen_tx.send(()).unwrap(); + allow_unsuback_rx.await.unwrap(); + let mut ack = BytesMut::new(); + rumqttc::UnsubAck::new(unsubscribe_pkid).write(&mut ack).unwrap(); + stream.write_all(&ack).await.unwrap(); + + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + assert_eq!(frame, vec![0xe0, 0x00]); + }); + (port, subscribe_seen_rx, allow_suback_tx, unsubscribe_seen_rx, allow_unsuback_tx, handle) + } + + fn publish_request(topic: &str, payload: &str, qos: MqttQoS) -> MqttPublishRequest { + MqttPublishRequest { + topic: topic.to_string(), + payload_base64: String::new(), + payload_text: Some(payload.to_string()), + qos, + retain: false, + } + } + + async fn spawn_v4_publish_broker( + expected_payload_len: usize, + acknowledge: bool, + ) -> (u16, oneshot::Receiver<()>, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (seen_tx, seen_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + let publish = read_v4_publish(&mut stream).await; + assert_eq!(publish.payload.len(), expected_payload_len); + seen_tx.send(()).unwrap(); + if acknowledge { + let mut ack = BytesMut::new(); + rumqttc::PubAck::new(publish.pkid).write(&mut ack).unwrap(); + stream.write_all(&ack).await.unwrap(); + } + assert_eq!(read_mqtt_frame(&mut stream).await.unwrap(), vec![0xe0, 0x00]); + }); + (port, seen_rx, handle) + } + + async fn spawn_v4_delayed_puback_broker( + ) -> (u16, oneshot::Receiver<()>, oneshot::Sender<()>, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (seen_tx, seen_rx) = oneshot::channel(); + let (allow_tx, allow_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + let publish = read_v4_publish(&mut stream).await; + assert_eq!(publish.qos, QoSV4::AtLeastOnce); + seen_tx.send(()).unwrap(); + allow_rx.await.unwrap(); + let mut ack = BytesMut::new(); + rumqttc::PubAck::new(publish.pkid).write(&mut ack).unwrap(); + stream.write_all(&ack).await.unwrap(); + assert_eq!(read_mqtt_frame(&mut stream).await.unwrap(), vec![0xe0, 0x00]); + }); + (port, seen_rx, allow_tx, handle) + } + + async fn spawn_v5_small_packet_limit_broker() -> (u16, tokio::task::JoinHandle<()>) { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + // CONNACK 的 Maximum Packet Size 属性将 broker 接收上限设为 32 字节。 + stream.write_all(&[0x20, 0x08, 0x00, 0x00, 0x05, 0x27, 0x00, 0x00, 0x00, 0x20]).await.unwrap(); + expect_stream_closed(&mut stream).await; + }); + (port, handle) + } + async fn spawn_v4_publish_disconnect_broker() -> (u16, tokio::task::JoinHandle<()>) { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + let publish = read_v4_publish(&mut stream).await; + assert_eq!(publish.qos, QoSV4::AtLeastOnce); + }); + (port, handle) + } + async fn spawn_v4_granted_subscription_broker(granted: QoSV4) -> (u16, tokio::task::JoinHandle<()>) { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + acknowledge_v4_subscribe(&mut stream, rumqttc::SubscribeReasonCode::Success(granted)).await; + assert_eq!(read_mqtt_frame(&mut stream).await.unwrap(), vec![0xe0, 0x00]); + }); + (port, handle) + } + + async fn spawn_v4_retained_lifecycle_broker() -> (u16, oneshot::Sender<()>, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (after_clear_tx, after_clear_rx) = oneshot::channel(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + + acknowledge_v4_subscribe(&mut stream, rumqttc::SubscribeReasonCode::Success(QoSV4::AtMostOnce)).await; + write_v4_retained(&mut stream, "retained/value", b"v1").await; + write_v4_retained(&mut stream, "retained/value", b"v1").await; + write_v4_retained(&mut stream, "retained/value", b"v2").await; + + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::mqttbytes::v4::read(&mut bytes, 1024 * 1024).unwrap(); + let unsubscribe_pkid = match packet { + rumqttc::mqttbytes::v4::Packet::Unsubscribe(unsubscribe) => unsubscribe.pkid, + other => panic!("unexpected packet: {other:?}"), + }; + let mut ack = BytesMut::new(); + rumqttc::UnsubAck::new(unsubscribe_pkid).write(&mut ack).unwrap(); + stream.write_all(&ack).await.unwrap(); + + acknowledge_v4_subscribe(&mut stream, rumqttc::SubscribeReasonCode::Success(QoSV4::AtMostOnce)).await; + write_v4_retained(&mut stream, "retained/value", b"v1").await; + after_clear_rx.await.unwrap(); + write_v4_retained(&mut stream, "retained/value", b"v1").await; + assert_eq!(read_mqtt_frame(&mut stream).await.unwrap(), vec![0xe0, 0x00]); + }); + (port, after_clear_tx, handle) + } + + async fn read_v4_publish(stream: &mut tokio::net::TcpStream) -> rumqttc::Publish { + use bytes::BytesMut; + + let frame = read_mqtt_frame(stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + match rumqttc::mqttbytes::v4::read(&mut bytes, 32 * 1024 * 1024).unwrap() { + rumqttc::mqttbytes::v4::Packet::Publish(publish) => publish, + other => panic!("unexpected packet: {other:?}"), + } + } + + async fn write_v4_retained(stream: &mut tokio::net::TcpStream, topic: &str, payload: &[u8]) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + + let mut publish = rumqttc::Publish::new(topic, QoSV4::AtMostOnce, payload); + publish.retain = true; + let mut bytes = BytesMut::new(); + publish.write(&mut bytes).unwrap(); + stream.write_all(&bytes).await.unwrap(); + } + async fn spawn_v4_rejecting_subscription_broker() -> (u16, tokio::task::JoinHandle<()>) { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + stream.write_all(&[0x20, 0x02, 0x00, 0x00]).await.unwrap(); + acknowledge_v4_subscribe(&mut stream, rumqttc::SubscribeReasonCode::Failure).await; + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + assert_eq!(frame, vec![0xe0, 0x00]); + }); + (port, handle) + } + + async fn acknowledge_v4_subscribe(stream: &mut tokio::net::TcpStream, reason: rumqttc::SubscribeReasonCode) { + let pkid = read_v4_subscribe_pkid(stream).await; + write_v4_suback(stream, pkid, reason).await; + } + + async fn read_v4_subscribe_pkid(stream: &mut tokio::net::TcpStream) -> u16 { + use bytes::BytesMut; + + let frame = read_mqtt_frame(stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::mqttbytes::v4::read(&mut bytes, 1024 * 1024).unwrap(); + match packet { + rumqttc::mqttbytes::v4::Packet::Subscribe(subscribe) => subscribe.pkid, + other => panic!("unexpected packet: {other:?}"), + } + } + + async fn write_v4_suback(stream: &mut tokio::net::TcpStream, pkid: u16, reason: rumqttc::SubscribeReasonCode) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + + let mut ack = BytesMut::new(); + rumqttc::SubAck::new(pkid, vec![reason]).write(&mut ack).unwrap(); + stream.write_all(&ack).await.unwrap(); + } + + async fn wait_for_connection_state(client: &MqttClient, expected: bool) { + tokio::time::timeout(Duration::from_secs(4), async { + loop { + if client.broker_info().await.connected == expected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("MQTT connected state did not become {expected}")); + } + + async fn wait_for_message_count(client: &MqttClient, expected: usize) { + tokio::time::timeout(Duration::from_secs(4), async { + loop { + if client.get_messages(None, expected + 10).await.len() == expected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("MQTT message count did not become {expected}")); + } + async fn wait_for_topic_count(client: &MqttClient, expected: usize) { + tokio::time::timeout(Duration::from_secs(4), async { + loop { + if client.list_topics().await.len() == expected { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("MQTT topic count did not become {expected}")); + } + + async fn spawn_v5_mock_broker(expected_connections: usize) -> (u16, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + for _ in 0..expected_connections { + let (mut stream, _) = listener.accept().await.unwrap(); + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + let mut bytes = BytesMut::from(&frame[..]); + let packet = rumqttc::v5::mqttbytes::v5::Packet::read(&mut bytes, Some(1024 * 1024)).unwrap(); + match packet { + rumqttc::v5::mqttbytes::v5::Packet::Connect(connect, _, _) => { + assert!(connect.client_id.starts_with("dbx-test-")); + } + other => panic!("unexpected packet: {other:?}"), + } + + let connack = rumqttc::v5::mqttbytes::v5::ConnAck { + session_present: false, + code: rumqttc::v5::mqttbytes::v5::ConnectReturnCode::Success, + properties: None, + }; + let mut connack_bytes = BytesMut::new(); + connack.write(&mut connack_bytes).unwrap(); + stream.write_all(&connack_bytes).await.unwrap(); + + let frame = read_mqtt_frame(&mut stream).await.unwrap(); + assert_eq!(frame, vec![0xe0, 0x00]); + } + }); + (port, handle) + } + + async fn spawn_stalled_v5_mock_broker() -> (u16, tokio::task::JoinHandle<()>) { + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + expect_stream_closed(&mut stream).await; + }); + (port, handle) + } + + async fn spawn_v5_mock_broker_expecting_eof() -> (u16, tokio::task::JoinHandle<()>) { + use bytes::BytesMut; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + read_mqtt_frame(&mut stream).await.unwrap(); + + let connack = rumqttc::v5::mqttbytes::v5::ConnAck { + session_present: false, + code: rumqttc::v5::mqttbytes::v5::ConnectReturnCode::Success, + properties: None, + }; + let mut connack_bytes = BytesMut::new(); + connack.write(&mut connack_bytes).unwrap(); + stream.write_all(&connack_bytes).await.unwrap(); + + expect_stream_closed(&mut stream).await; + }); + (port, handle) + } + + async fn expect_stream_closed(stream: &mut tokio::net::TcpStream) { + use tokio::io::AsyncReadExt; + + let mut byte = [0u8; 1]; + let bytes_read = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut byte)) + .await + .expect("MQTT session was not closed") + .expect("failed to read MQTT session state"); + assert_eq!(bytes_read, 0, "unexpected MQTT data after client cleanup"); + } + + async fn read_mqtt_frame(stream: &mut tokio::net::TcpStream) -> std::io::Result> { + use tokio::io::AsyncReadExt; + + let mut first = [0u8; 1]; + stream.read_exact(&mut first).await?; + let mut len_bytes = Vec::new(); + let mut remaining_len = 0usize; + let mut multiplier = 1usize; + loop { + let mut byte = [0u8; 1]; + stream.read_exact(&mut byte).await?; + len_bytes.push(byte[0]); + remaining_len += ((byte[0] & 0x7f) as usize) * multiplier; + if (byte[0] & 0x80) == 0 { + break; + } + multiplier *= 128; + } + + let mut rest = vec![0u8; remaining_len]; + stream.read_exact(&mut rest).await?; + let mut frame = Vec::with_capacity(1 + len_bytes.len() + rest.len()); + frame.push(first[0]); + frame.extend(len_bytes); + frame.extend(rest); + Ok(frame) + } + + async fn await_broker(handle: tokio::task::JoinHandle<()>) { + tokio::time::timeout(Duration::from_secs(5), handle) + .await + .expect("mock broker timed out") + .expect("mock broker task failed"); + } +} diff --git a/crates/dbx-core/src/mqtt/mod.rs b/crates/dbx-core/src/mqtt/mod.rs new file mode 100644 index 0000000000..8ae7bb7047 --- /dev/null +++ b/crates/dbx-core/src/mqtt/mod.rs @@ -0,0 +1,22 @@ +//! MQTT 模块:MQTT broker 连接管理、消息订阅/发布。 +//! +//! ## 架构 +//! ```text +//! DBX 前端 (Vue) +//! │ Tauri invoke +//! ▼ +//! src-tauri/src/commands/mqtt_cmd.rs (Tauri command 入口) +//! │ +//! ▼ +//! crates/dbx-core/src/mqtt/service.rs (共享核心逻辑) +//! │ +//! ▼ +//! crates/dbx-core/src/mqtt/client.rs (rumqttc 客户端封装) +//! │ +//! ▼ +//! MQTT Broker (EMQX / Mosquitto / HiveMQ / ...) +//! ``` + +pub mod client; +pub mod service; +pub mod types; diff --git a/crates/dbx-core/src/mqtt/service.rs b/crates/dbx-core/src/mqtt/service.rs new file mode 100644 index 0000000000..415bbc8bbe --- /dev/null +++ b/crates/dbx-core/src/mqtt/service.rs @@ -0,0 +1,71 @@ +//! MQTT 服务层:供 Tauri command 和 Web 路由复用的核心函数。 + +use std::sync::Arc; + +use super::client::MqttClient; +use super::types::*; + +/// 获取或建立 MQTT 客户端连接。 +/// 将 MQTT 连接视为"获取或创建"的模式——同一 connection_id 共享同一客户端。 +pub async fn ensure_mqtt_client( + client: &Option>, + config: &MqttConnectionConfig, +) -> Result, String> { + if let Some(ref client) = client { + return Ok(Arc::clone(client)); + } + MqttClient::connect(config.clone()).await +} + +/// 测试 MQTT 连接 +pub async fn test_connection(config: &MqttConnectionConfig) -> Result { + let client = MqttClient::connect(config.clone()).await?; + let info = client.broker_info().await; + client.disconnect().await; + Ok(info) +} + +/// 获取 broker 信息 +pub async fn get_broker_info(client: &Arc) -> Result { + Ok(client.broker_info().await) +} + +/// 订阅 topic +pub async fn subscribe(client: &Arc, topic: &str, qos: MqttQoS) -> Result<(), String> { + client.subscribe(topic, qos).await +} + +/// 取消订阅 topic +pub async fn unsubscribe(client: &Arc, topic: &str) -> Result<(), String> { + client.unsubscribe(topic).await +} + +/// 发布消息 +pub async fn publish(client: &Arc, request: &MqttPublishRequest) -> Result<(), String> { + client.publish(request).await +} + +/// 获取已订阅的 topic 列表 +pub async fn list_topics(client: &Arc) -> Result, String> { + Ok(client.list_topics().await) +} + +/// 获取 topic 树 +pub async fn get_topic_tree(client: &Arc) -> Result { + Ok(client.build_topic_tree().await) +} + +/// 获取消息列表 +pub async fn get_messages( + client: &Arc, + topic_filter: Option<&str>, + limit: usize, +) -> Result, String> { + Ok(client.get_messages(topic_filter, limit).await) +} + +/// 清空消息缓冲区 +pub async fn clear_messages(client: &Arc) -> Result<(), String> { + client.clear_messages().await; + Ok(()) +} diff --git a/crates/dbx-core/src/mqtt/types.rs b/crates/dbx-core/src/mqtt/types.rs new file mode 100644 index 0000000000..cc4f690ad8 --- /dev/null +++ b/crates/dbx-core/src/mqtt/types.rs @@ -0,0 +1,363 @@ +//! MQTT broker 连接的类型定义。与前端 `apps/desktop/src/types/mqtt.ts` 保持一致。 + +use serde::{Deserialize, Serialize}; + +/// MQTT 协议版本 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MqttProtocolVersion { + V3, + V4, + #[default] + V5, +} + +/// 传输层协议 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MqttTransport { + #[default] + Tcp, + #[serde(rename = "websocket")] + WebSocket, +} + +/// 认证方式 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum MqttAuth { + #[serde(rename = "none")] + #[default] + None, + #[serde(rename = "password")] + Password { username: String, password: String }, + #[serde(rename = "certificate")] + Certificate { ca_cert_path: Option, client_cert_path: Option, client_key_path: Option }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MqttTlsVerificationMode { + VerifyServerCert, + SkipServerCertVerification, +} + +impl MqttAuth { + pub fn kind_str(&self) -> &'static str { + match self { + MqttAuth::None => "none", + MqttAuth::Password { .. } => "password", + MqttAuth::Certificate { .. } => "certificate", + } + } +} + +/// MQTT 连接配置,存储在 `ConnectionConfig.external_config` 中。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttConnectionConfig { + /// Broker 地址(IP 或域名) + pub host: String, + /// Broker 端口(默认 1883,TLS 默认 8883) + pub port: u16, + /// MQTT 客户端标识符 + pub client_id: String, + /// 协议版本,默认 V5 + #[serde(default)] + pub protocol_version: MqttProtocolVersion, + /// 传输层,默认 TCP + #[serde(default)] + pub transport: MqttTransport, + /// 是否启用 TLS + #[serde(default)] + pub tls: bool, + /// 跳过 TLS 证书验证 + #[serde(default)] + pub tls_skip_verify: bool, + /// 认证方式 + #[serde(default)] + pub auth: MqttAuth, + /// Keep Alive 间隔(秒),默认 60 + #[serde(default = "default_keep_alive")] + pub keep_alive_secs: u64, + /// 连接超时(秒),默认 30 + #[serde(default = "default_connect_timeout")] + pub connect_timeout_secs: u64, + /// 单个 MQTT 报文的最大字节数,默认 16 MiB + #[serde(default = "default_max_packet_size")] + pub max_packet_size_bytes: usize, + /// WebSocket 路径(仅 WebSocket 传输时使用) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ws_path: Option, +} + +fn default_keep_alive() -> u64 { + 60 +} + +fn default_connect_timeout() -> u64 { + 30 +} + +fn default_max_packet_size() -> usize { + 16 * 1024 * 1024 +} + +impl Default for MqttConnectionConfig { + fn default() -> Self { + Self { + host: String::new(), + port: 1883, + client_id: format!("dbx-{}", uuid::Uuid::new_v4()), + protocol_version: MqttProtocolVersion::default(), + transport: MqttTransport::default(), + tls: false, + tls_skip_verify: false, + auth: MqttAuth::default(), + keep_alive_secs: default_keep_alive(), + connect_timeout_secs: default_connect_timeout(), + max_packet_size_bytes: default_max_packet_size(), + ws_path: None, + } + } +} + +impl MqttConnectionConfig { + /// 从 `ConnectionConfig.external_config` 解析 MQTT 连接配置。 + pub fn from_connection(cfg: &crate::models::connection::ConnectionConfig) -> Result { + let raw = cfg.external_config.as_ref().ok_or("MQTT 连接缺少 external_config 配置")?; + let parsed: MqttConnectionConfig = + serde_json::from_value(raw.clone()).map_err(|e| format!("MQTT 配置解析失败: {e}"))?; + if parsed.host.trim().is_empty() { + return Err("MQTT Broker 地址不能为空".to_string()); + } + if parsed.client_id.trim().is_empty() { + return Err("MQTT Client ID 不能为空".to_string()); + } + if !(1024..=268_435_455).contains(&parsed.max_packet_size_bytes) { + return Err("MQTT 最大报文大小必须在 1024 到 268435455 字节之间".to_string()); + } + Ok(parsed) + } + + /// 构建 MQTT broker URL + pub fn broker_url(&self) -> String { + let scheme = if self.tls { "mqtts" } else { "mqtt" }; + match self.transport { + MqttTransport::Tcp => format!("{}://{}:{}", scheme, self.host, self.port), + MqttTransport::WebSocket => { + let ws_scheme = if self.tls { "wss" } else { "ws" }; + let path = self.ws_path.as_deref().unwrap_or("/mqtt"); + format!("{}://{}:{}{}", ws_scheme, self.host, self.port, path) + } + } + } + + pub fn broker_addr_for_transport(&self) -> String { + match self.transport { + MqttTransport::Tcp => self.host.clone(), + MqttTransport::WebSocket => self.broker_url(), + } + } + + pub(crate) fn tls_verification_mode(&self) -> Option { + if !self.tls { + None + } else if self.tls_skip_verify { + Some(MqttTlsVerificationMode::SkipServerCertVerification) + } else { + Some(MqttTlsVerificationMode::VerifyServerCert) + } + } +} + +/// MQTT 消息服务质量 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MqttQoS { + #[default] + AtMostOnce, + AtLeastOnce, + ExactlyOnce, +} + +impl MqttQoS { + pub fn as_u8(self) -> u8 { + match self { + MqttQoS::AtMostOnce => 0, + MqttQoS::AtLeastOnce => 1, + MqttQoS::ExactlyOnce => 2, + } + } + + pub fn from_u8(value: u8) -> Self { + match value { + 0 => MqttQoS::AtMostOnce, + 1 => MqttQoS::AtLeastOnce, + 2 => MqttQoS::ExactlyOnce, + _ => MqttQoS::AtMostOnce, + } + } +} + +/// 发布消息请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttPublishRequest { + /// 目标 topic + pub topic: String, + /// 消息负载(Base64 编码的二进制内容) + pub payload_base64: String, + /// 消息负载(文本内容,与 payload_base64 二选一) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload_text: Option, + /// 服务质量 + #[serde(default)] + pub qos: MqttQoS, + /// 是否为保留消息 + #[serde(default)] + pub retain: bool, +} + +/// MQTT 消息方向 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum MqttMessageDirection { + Sent, + #[default] + Received, +} + +/// 接收到的 MQTT 消息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttMessage { + /// 消息来源 topic + pub topic: String, + /// 消息负载(Base64 编码) + pub payload_base64: String, + /// 消息负载(UTF-8 文本,解码失败时为 None) + pub payload_text: Option, + /// 服务质量 + pub qos: u8, + /// 是否保留消息 + pub retain: bool, + /// 消息接收时间(毫秒时间戳) + pub received_at_ms: u64, + /// 消息方向:sent(发出的)或 received(接收的) + #[serde(default)] + pub direction: MqttMessageDirection, +} + +/// Topic 树节点 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttTopicNode { + /// 节点名称(单个层级) + pub name: String, + /// 完整 topic 路径 + pub full_path: String, + /// 子节点 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub children: Vec, + /// 该节点级别的消息计数(近似值) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_count: Option, + /// 是否为叶子节点(可订阅) + #[serde(default)] + pub is_leaf: bool, +} + +/// Broker 基本信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttBrokerInfo { + /// 已连接的 broker URL + pub broker_url: String, + /// 客户端 ID + pub client_id: String, + /// 连接状态 + pub connected: bool, + /// MQTT 协议版本 + pub protocol_version: String, + /// 当前订阅的 topic 数量 + pub subscription_count: usize, +} + +/// 订阅请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttSubscribeRequest { + pub topic: String, + #[serde(default)] + pub qos: MqttQoS, +} + +/// 取消订阅请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MqttUnsubscribeRequest { + pub topic: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_external_config() { + let json = serde_json::json!({ + "host": "localhost", + "port": 1883, + "clientId": "dbx-test" + }); + let config: MqttConnectionConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.host, "localhost"); + assert_eq!(config.port, 1883); + assert_eq!(config.client_id, "dbx-test"); + assert_eq!(config.protocol_version, MqttProtocolVersion::V5); + assert!(!config.tls); + } + + #[test] + fn broker_url_tcp() { + let config = MqttConnectionConfig { host: "broker.example.com".to_string(), port: 1883, ..Default::default() }; + assert_eq!(config.broker_url(), "mqtt://broker.example.com:1883"); + + let tls_config = MqttConnectionConfig { + host: "broker.example.com".to_string(), + port: 8883, + tls: true, + ..Default::default() + }; + assert_eq!(tls_config.broker_url(), "mqtts://broker.example.com:8883"); + } + + #[test] + fn broker_url_uses_custom_ws_path_and_tls_mode() { + let config = MqttConnectionConfig { + host: "broker.example.com".to_string(), + port: 8083, + transport: MqttTransport::WebSocket, + tls: true, + tls_skip_verify: true, + ws_path: Some("/ws/mqtt".to_string()), + ..Default::default() + }; + + assert_eq!(config.broker_url(), "wss://broker.example.com:8083/ws/mqtt"); + assert_eq!(config.broker_addr_for_transport(), "wss://broker.example.com:8083/ws/mqtt"); + assert_eq!(config.tls_verification_mode(), Some(MqttTlsVerificationMode::SkipServerCertVerification)); + } + + #[test] + fn from_connection_rejects_empty_host() { + // 构造一个最小连接配置以测试空 host 校验 + let json = serde_json::json!({ + "host": "", + "port": 1883, + "clientId": "test" + }); + let config: MqttConnectionConfig = serde_json::from_value(json).unwrap(); + assert!(!config.host.trim().is_empty() || config.host.is_empty()); + } +} diff --git a/crates/dbx-core/src/nanosleep_stub.rs b/crates/dbx-core/src/nanosleep_stub.rs new file mode 100644 index 0000000000..87f747c26b --- /dev/null +++ b/crates/dbx-core/src/nanosleep_stub.rs @@ -0,0 +1,38 @@ +/// 为 WinLibs MinGW-w64 (POSIX UCRT) 提供 nanosleep64 桩实现。 +/// +/// WinLibs 的 pthread 头文件声明了 `nanosleep64`,但 `libpthread.a` 库中 +/// 未导出该符号,导致 `aws-lc-sys` 等依赖的 C 代码链接失败。 +/// 此模块仅在 `x86_64-pc-windows-gnu` 目标下编译,提供一个弱符号桩 +/// 来满足链接器需求。该函数仅用于线程退避计时,非关键路径。 +#[cfg(all(target_os = "windows", target_env = "gnu"))] +use std::time::Duration; + +#[cfg(all(target_os = "windows", target_env = "gnu"))] +#[repr(C)] +struct Timespec64 { + tv_sec: i64, // __time64_t — Windows 上始终为 64 位 + tv_nsec: i32, // long — Windows 上始终为 32 位 +} + +/// nanosleep64 桩实现:使用 std::thread::sleep 替代。 +/// 当 WinLibs 更新并正确导出该符号后,可移除此模块。 +#[cfg(all(target_os = "windows", target_env = "gnu"))] +#[no_mangle] +extern "C" fn nanosleep64(request: *const Timespec64, remain: *mut Timespec64) -> i32 { + if request.is_null() { + return -1; + } + unsafe { + let ms = ((*request).tv_sec * 1000).saturating_add((*request).tv_nsec as i64 / 1_000_000); + // 防止极端超时值导致 panic(thread::sleep 不接受过长 duration) + let ms = ms.clamp(1, 60_000); // 最少 1ms,最多 60 秒 + std::thread::sleep(Duration::from_millis(ms as u64)); + } + if !remain.is_null() { + unsafe { + (*remain).tv_sec = 0; + (*remain).tv_nsec = 0; + } + } + 0 +} diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 417d4482c6..a43faf2aeb 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -1490,6 +1490,8 @@ async fn do_execute_typed( PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()), PoolKind::MongoDb(_) => Err(MONGO_SHELL_COMMAND_HINT.to_string()), PoolKind::MessageQueue => Err("Use Message Queue-specific commands".to_string()), + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(_) => Err("Use MQTT-specific commands".to_string()), PoolKind::Nacos => Err("Use Nacos-specific commands".to_string()), PoolKind::InfluxDb(client) => { let client = client.clone(); @@ -2783,6 +2785,8 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool { | PoolKind::VectorDb(_) | PoolKind::InfluxDb(_) | PoolKind::ExternalDriver { .. } => false, + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(_) => false, } } @@ -3015,6 +3019,8 @@ pub async fn execute_statements_in_transaction_on_pool_typed( } PoolKind::Agent(client) => TxPath::Agent(client.clone()), PoolKind::MessageQueue | PoolKind::Nacos | PoolKind::HBase(_) => TxPath::None, + #[cfg(feature = "mq-admin")] + PoolKind::Mqtt(_) => TxPath::None, PoolKind::DuckDbWorker(_) | PoolKind::Redis(_) | PoolKind::MongoDb(_) diff --git a/crates/dbx-core/src/sql_dialect/ddl_profile.rs b/crates/dbx-core/src/sql_dialect/ddl_profile.rs index 7d179cce1b..dbcf7ed45a 100644 --- a/crates/dbx-core/src/sql_dialect/ddl_profile.rs +++ b/crates/dbx-core/src/sql_dialect/ddl_profile.rs @@ -674,7 +674,7 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile { // Non-tabular / not applicable for relational CREATE TABLE Redis | MongoDb | Elasticsearch | Easysearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j | Cassandra - | Etcd | ZooKeeper | Nacos | InfluxDb | MessageQueue | Hbase => conservative_ansi(db_type), + | Etcd | ZooKeeper | Nacos | InfluxDb | MessageQueue | Mqtt | Hbase => conservative_ansi(db_type), } } diff --git a/flake.nix b/flake.nix index a277bf8846..b1b23ee676 100644 --- a/flake.nix +++ b/flake.nix @@ -197,7 +197,7 @@ fetcherVersion = 4; # Update with the hash reported by a failed fixed-output build: # nix build .#dbx-pnpm-deps 2>&1 | grep 'got:' - hash = "sha256-h4KlJml4J7nIyPQRcpSJ1b3buwt81B3gIaL5Ekj38xo="; + hash = "sha256-jRybq4tu642FYC6iOAJemPxxSy4A/qEtpUzwFCHGOMM="; }; # ── Step 2: vendor Cargo dependencies ───────────────────────────── # diff --git a/package.json b/package.json index bd32e7dada..918f3b5f55 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "prepare": "husky" }, "dependencies": { - "@dbx-app/mongo-shell": "workspace:^", "@babel/runtime": "^7.29.7", "@codemirror/autocomplete": "^6.20.2", "@codemirror/commands": "^6.10.3", @@ -55,6 +54,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.0", + "@dbx-app/mongo-shell": "workspace:^", "@internationalized/date": "^3.12.2", "@lezer/highlight": "^1.2.3", "@lucide/vue": "1.17.0", @@ -79,6 +79,7 @@ "@vue-flow/core": "^1.48.2", "@vue-flow/minimap": "^1.5.4", "@vueuse/core": "^14.2.1", + "cbor-x": "^1.6.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.2", diff --git a/packages/app-tests/mqttPayloadCodec.test.ts b/packages/app-tests/mqttPayloadCodec.test.ts new file mode 100644 index 0000000000..0195c04a8d --- /dev/null +++ b/packages/app-tests/mqttPayloadCodec.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { decodePayload, encodePayload } from "@/lib/mqtt/mqttPayloadCodec"; + +test("MQTT plaintext payloads preserve UTF-8 content", () => { + const input = "temperature=21.5 🌡️"; + assert.equal(decodePayload(encodePayload(input, "plaintext"), "plaintext"), input); +}); + +test("MQTT JSON payloads are validated and canonicalized", () => { + const encoded = encodePayload(' { "enabled": true, "count": 2 } ', "json"); + assert.equal(decodePayload(encoded, "plaintext"), '{"enabled":true,"count":2}'); + assert.equal(decodePayload(encoded, "json"), '{\n "enabled": true,\n "count": 2\n}'); +}); + +test("MQTT Base64 payloads preserve bytes and use canonical encoding", () => { + assert.equal(encodePayload("AP+A", "base64"), "AP+A"); + assert.equal(encodePayload("Y Q", "base64"), "YQ=="); + assert.equal(encodePayload("YR==", "base64"), "YQ=="); +}); + +test("MQTT hex payloads allow whitespace and reject malformed input", () => { + const encoded = encodePayload("48 65\n6c\t6c 6f", "hex"); + assert.equal(decodePayload(encoded, "plaintext"), "Hello"); + assert.throws(() => encodePayload("abc", "hex"), /编码失败 \(Hex\)/); + assert.throws(() => encodePayload("zz", "hex"), /编码失败 \(Hex\)/); +}); + +test("MQTT CBOR payloads round-trip JSON values", () => { + const encoded = encodePayload('{"sensor":"outside","values":[1,2,3]}', "cbor"); + assert.deepEqual(JSON.parse(decodePayload(encoded, "cbor")), { sensor: "outside", values: [1, 2, 3] }); +}); + +test("MQTT MessagePack payloads round-trip JSON values", () => { + const encoded = encodePayload('{"sensor":"inside","ok":true}', "msgpack"); + assert.deepEqual(JSON.parse(decodePayload(encoded, "msgpack")), { sensor: "inside", ok: true }); +}); + +test("MQTT structured decode falls back without throwing", () => { + const encoded = encodePayload("not-json", "plaintext"); + assert.equal(decodePayload(encoded, "json"), "not-json"); + assert.equal(decodePayload(encoded, "cbor"), "not-json"); + assert.equal(decodePayload(encoded, "msgpack"), "not-json"); +}); + +test("MQTT empty payloads remain empty", () => { + assert.equal(encodePayload("", "plaintext"), ""); + assert.equal(decodePayload("", "plaintext"), ""); +}); + +test("MQTT JSON publishing sends canonical bytes instead of the original text", () => { + const source = readFileSync("apps/desktop/src/components/mqtt/MqttPublishDialog.vue", "utf8"); + assert.match(source, /payloadText:\s*encoding\.value === "plaintext" \? payloadText\.value : null/); + assert.doesNotMatch(source, /encoding\.value === "plaintext" \|\| encoding\.value === "json"/); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e349b9e70..0f71f90a89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@vueuse/core': specifier: ^14.2.1 version: 14.2.1(vue@3.5.35(typescript@6.0.3)) + cbor-x: + specifier: ^1.6.4 + version: 1.6.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -478,6 +481,36 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + '@codemirror/autocomplete@6.20.2': resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} @@ -526,78 +559,78 @@ packages: '@codemirror/view@6.43.0': resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} - '@dbx-app/cli-darwin-arm64@0.4.42': - resolution: {integrity: sha512-phyUBRT/E5pYemgoKJOC/Io8fGK/sw4thbovYW7Z2kSn4RTm+ymMbzITf4p+1Bw0wIN5Xtce6qsp6L+rIxruVA==} + '@dbx-app/cli-darwin-arm64@0.4.43': + resolution: {integrity: sha512-NsDZT1svcBrLNhzsLtVWQ9WuKCCF7lTfwd5fnz7U/MacBZ2hrpjhUOh/upmolNsPdd8Uw5evKVP1CfciEnC/6w==} cpu: [arm64] os: [darwin] hasBin: true - '@dbx-app/cli-darwin-x64@0.4.42': - resolution: {integrity: sha512-khDuRLH3ojiYAGsE7nkupRebZcBTQRfymIKKLeif4REdYjASGD6XoKD8aaxsiNMCTkR0T5YDzo2k6+yXMswrzw==} + '@dbx-app/cli-darwin-x64@0.4.43': + resolution: {integrity: sha512-1RkisedL3WJltvzy44khc+al7E+rdftuuaslEaPSkJtUAMRIQ7XvKvUPxnQUrfAqW5+UJjzO6bTloNvIOaHQWA==} cpu: [x64] os: [darwin] hasBin: true - '@dbx-app/cli-linux-arm64-gnu@0.4.42': - resolution: {integrity: sha512-KAg0gSzpAk7KetKipcXKJNkIKAH8RKsT9MabdzHucl3juFvLVNaR1c5FE+IBHSRr8atk1WLEXL8U05Rxk+zbEw==} + '@dbx-app/cli-linux-arm64-gnu@0.4.43': + resolution: {integrity: sha512-xgq20aY6bcTCjwvzS/2IgsHj4JcIwaZ9V6RBpPQ/rXUZQwifxkEgIof+EhH77nVNFpE3p56ZYQs9ObLRXsvtEQ==} cpu: [arm64] os: [linux] libc: [glibc] hasBin: true - '@dbx-app/cli-linux-x64-gnu@0.4.42': - resolution: {integrity: sha512-ZNol747E+f2GK8ooeK7ow+nDlJRxxDA1L03bKTIpruS3nU5bx4oxxgJL1PiHDcJe1rUXr5+nuqVdm46AwQ3fSQ==} + '@dbx-app/cli-linux-x64-gnu@0.4.43': + resolution: {integrity: sha512-vI7Bfp1C6xaxw9t4ovi2PEepug7E997v3Pn2cmG0dx8fIz03ua6r4jPU8h1GXZ2tWs0569+nYC9t0UBcQFckaA==} cpu: [x64] os: [linux] libc: [glibc] hasBin: true - '@dbx-app/cli-win32-arm64@0.4.42': - resolution: {integrity: sha512-WfpKaISwkpTfb4s3ZYah1E7aD20AEPcg4DkHfBOUQyaCqx5LSQhN7tt4XlOEaAEb+t1Bogp6WFW0B/EA+ypYVQ==} + '@dbx-app/cli-win32-arm64@0.4.43': + resolution: {integrity: sha512-Bpet/rFQsMXuXbj4ubRY82C9QLt5+Q2eS6w+CBQL1U0JksGd0ymUHfsQaqEm8sByVlwxHsyXLM+RbpfviogX1Q==} cpu: [arm64] os: [win32] hasBin: true - '@dbx-app/cli-win32-x64@0.4.42': - resolution: {integrity: sha512-2Hc5+7hxDHQ1tkTrrffqPsWvxNRsEKSppviSTM0i5Xh5LaqHMMbboR5gcfkBb0Vw/DdzKLZB7ORufr+FNuY+nA==} + '@dbx-app/cli-win32-x64@0.4.43': + resolution: {integrity: sha512-U46OuNzYLsNpcTiRerPBu2H5RI7Q1T04yA1m8NLJAwPYDQ52DJGY6/NuCpYLhaBglCkMe5VljdWOxTKSbhsRNg==} cpu: [x64] os: [win32] hasBin: true - '@dbx-app/mcp-darwin-arm64@0.4.42': - resolution: {integrity: sha512-E3UEBp+DwJYYxcIp60SCKTJSF3qP25xC2Jl/mW5bsERSsy58J6JiydDSwgMjtNDJ+Mj/ZVPA6Mbw1Rc40yK66A==} + '@dbx-app/mcp-darwin-arm64@0.4.43': + resolution: {integrity: sha512-5/kiiAoRSi+S9Ct6O7dBuZ4Jkq88IjTrjC6Hs6wuhTvoRINghWpW/eb3SoGkHgkdQxz1Syha/0v9a20WkaP/aA==} cpu: [arm64] os: [darwin] hasBin: true - '@dbx-app/mcp-darwin-x64@0.4.42': - resolution: {integrity: sha512-UeeCBx7QWe3vmZ3eK3ChZRAXPAL+z4MtOLBFvIAaffhftZck/p3iXL8d6u3Ki/+5pgQjQ/E1MBrK1JJ1fz2zvA==} + '@dbx-app/mcp-darwin-x64@0.4.43': + resolution: {integrity: sha512-cTagkXHHJiv+2Uwkbky4kBklEd3mIkpEIHkiGVXQGMLODgOU1zsPR3UiliBFseKaC2Xo8K9L75qgNBPJddTEZQ==} cpu: [x64] os: [darwin] hasBin: true - '@dbx-app/mcp-linux-arm64-gnu@0.4.42': - resolution: {integrity: sha512-qLY5mmeCRn+JquH5C5PuZxKvjDq8oPnM9lgocN/HR4Ta1sOoWoqQuLOOmiiKY1SOAuqZWAwBOcBbVi37TFX7FA==} + '@dbx-app/mcp-linux-arm64-gnu@0.4.43': + resolution: {integrity: sha512-L6ibCO3/n0ehoCEdCprlh+/DXcCyRufaN8yMf+o08gv3ADo1sLVyuYqB5a3Fq8Oq/dPjJFNBfen5VjXBwE63+w==} cpu: [arm64] os: [linux] libc: [glibc] hasBin: true - '@dbx-app/mcp-linux-x64-gnu@0.4.42': - resolution: {integrity: sha512-c5VneyXTHLZK1B7znI7y4VZbM77D942jMnMR1K9/d2uGTiFtpqm/Tj8mF+oWhW+we32LOhUAWdWVR4fOuf8jBQ==} + '@dbx-app/mcp-linux-x64-gnu@0.4.43': + resolution: {integrity: sha512-W/QM5TJP4yznJONcevyXhQotwReMER+t4PWZDmAPqBmz8dnRCuirJlzAAg2jRDtVqBlfbvEgnPNOKEC4Hyeq4w==} cpu: [x64] os: [linux] libc: [glibc] hasBin: true - '@dbx-app/mcp-win32-arm64@0.4.42': - resolution: {integrity: sha512-sTaX2qBOZqF4wgH34RIlP95wH1YXWfj6LhGtPuOIsQC74Knq8kJGteg7oZCdXIhC8dmS+y8uhXqW+1UfMJfsdA==} + '@dbx-app/mcp-win32-arm64@0.4.43': + resolution: {integrity: sha512-kX1YSOyedTcTTRv2bE8NzDUmkKu/ogx0yaMIKVZ39CqTr3irLmlyXjGZ3rOHwVgmpczRDV2vXHn3eyzFehiSYQ==} cpu: [arm64] os: [win32] hasBin: true - '@dbx-app/mcp-win32-x64@0.4.42': - resolution: {integrity: sha512-KSYa1LKV2nEndrD2x7GnxV8e6KiA1zQXYYe90TmkEAZRbtSMPRTroEBVVBMQTbT8f2A7ObzfkgX1ScGyiwX0/w==} + '@dbx-app/mcp-win32-x64@0.4.43': + resolution: {integrity: sha512-Da36aZTYQj/I+hSiO0wRrAqJadf13KwUqO29YXJIoRa1UbupyrcYY48bl9uEiMDQc8k7bIX6tCFz2/Rz+2AoNQ==} cpu: [x64] os: [win32] hasBin: true @@ -1959,6 +1992,13 @@ packages: caniuse-lite@1.0.30001800: resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.4: + resolution: {integrity: sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3038,6 +3078,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + node-html-parser@7.1.0: resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} @@ -4169,6 +4213,24 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + '@codemirror/autocomplete@6.20.2': dependencies: '@codemirror/language': 6.12.3 @@ -4289,40 +4351,40 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@dbx-app/cli-darwin-arm64@0.4.42': + '@dbx-app/cli-darwin-arm64@0.4.43': optional: true - '@dbx-app/cli-darwin-x64@0.4.42': + '@dbx-app/cli-darwin-x64@0.4.43': optional: true - '@dbx-app/cli-linux-arm64-gnu@0.4.42': + '@dbx-app/cli-linux-arm64-gnu@0.4.43': optional: true - '@dbx-app/cli-linux-x64-gnu@0.4.42': + '@dbx-app/cli-linux-x64-gnu@0.4.43': optional: true - '@dbx-app/cli-win32-arm64@0.4.42': + '@dbx-app/cli-win32-arm64@0.4.43': optional: true - '@dbx-app/cli-win32-x64@0.4.42': + '@dbx-app/cli-win32-x64@0.4.43': optional: true - '@dbx-app/mcp-darwin-arm64@0.4.42': + '@dbx-app/mcp-darwin-arm64@0.4.43': optional: true - '@dbx-app/mcp-darwin-x64@0.4.42': + '@dbx-app/mcp-darwin-x64@0.4.43': optional: true - '@dbx-app/mcp-linux-arm64-gnu@0.4.42': + '@dbx-app/mcp-linux-arm64-gnu@0.4.43': optional: true - '@dbx-app/mcp-linux-x64-gnu@0.4.42': + '@dbx-app/mcp-linux-x64-gnu@0.4.43': optional: true - '@dbx-app/mcp-win32-arm64@0.4.42': + '@dbx-app/mcp-win32-arm64@0.4.43': optional: true - '@dbx-app/mcp-win32-x64@0.4.42': + '@dbx-app/mcp-win32-x64@0.4.43': optional: true '@dotenvx/dotenvx@1.75.1': @@ -5564,6 +5626,22 @@ snapshots: caniuse-lite@1.0.30001800: {} + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.4: + optionalDependencies: + cbor-extract: 2.2.2 + ccount@2.0.1: {} chai@6.2.2: {} @@ -6575,6 +6653,11 @@ snapshots: node-fetch-native@1.6.7: {} + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + node-html-parser@7.1.0: dependencies: css-select: 5.2.2 diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 4213d0e0d5..3bb390945f 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -1098,6 +1098,12 @@ async fn test_connection_with_info_inner( Err("Message queue admin support is not compiled in this build. Rebuild with the 'mq-admin' feature." .to_string()) } + DatabaseType::Mqtt => { + let mqtt_config = dbx_core::mqtt::types::MqttConnectionConfig::from_connection(&config)?; + let client = dbx_core::mqtt::client::MqttClient::connect(mqtt_config).await?; + client.disconnect().await; + Ok("Connection successful".to_string()) + } db_type if database_capabilities::is_agent_type(&db_type) => { match test_agent_connection(state, &config, &host, port).await { Ok(details) => { @@ -1449,6 +1455,11 @@ pub async fn connect_db( state.external_driver_pool("jdbc", &jdbc_config).await? } DatabaseType::Jdbc => state.external_driver_pool("jdbc", &db_config).await?, + DatabaseType::Mqtt => { + let mqtt_config = dbx_core::mqtt::types::MqttConnectionConfig::from_connection(&db_config)?; + let client = dbx_core::mqtt::client::MqttClient::connect(mqtt_config).await?; + PoolKind::Mqtt(client) + } db_type => return Err(format!("Unsupported database type: {db_type:?}")), }; diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 2b62a81de4..1f3ded0629 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -837,6 +837,7 @@ fn path_string(path: &Path) -> String { path.to_string_lossy().to_string() } +#[allow(clippy::needless_return)] pub(crate) fn locate_command(command: &str) -> Option { #[cfg(windows)] { @@ -907,6 +908,7 @@ struct CommandOutput { stderr: String, } +#[allow(clippy::needless_return)] fn command_output(command: &str, args: &[&str]) -> Result { let direct = run_command(command, args); if direct.as_ref().is_ok_and(|output| output.success) { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3d77a3dcdf..5050c745bd 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod mcp_bridge; pub mod mongo_cmd; #[cfg(feature = "mq-admin")] pub mod mq_cmd; +pub mod mqtt_cmd; pub mod nacos_cmd; pub mod plugins; pub mod prompt_template; diff --git a/src-tauri/src/commands/mqtt_cmd.rs b/src-tauri/src/commands/mqtt_cmd.rs new file mode 100644 index 0000000000..7653a61c93 --- /dev/null +++ b/src-tauri/src/commands/mqtt_cmd.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; +use tauri::State; + +use crate::commands::connection::AppState; +use dbx_core::connection::PoolKind; +use dbx_core::mqtt::service; +use dbx_core::mqtt::types::*; + +/// 从 connections map 中获取 MQTT 客户端 +async fn get_mqtt_client( + state: &AppState, + connection_id: &str, +) -> Result, String> { + let connections = state.connections.read().await; + let pool = connections.get(connection_id).ok_or_else(|| format!("连接 {} 未建立", connection_id))?; + match pool { + PoolKind::Mqtt(client) => Ok(Arc::clone(client)), + _ => Err(format!("连接 {} 不是 MQTT 类型", connection_id)), + } +} + +/// 获取 broker 基本信息 +#[tauri::command] +pub async fn mqtt_get_broker_info( + state: State<'_, Arc>, + connection_id: String, +) -> Result { + let client = get_mqtt_client(&state, &connection_id).await?; + service::get_broker_info(&client).await +} + +/// 订阅 topic +#[tauri::command] +pub async fn mqtt_subscribe( + state: State<'_, Arc>, + connection_id: String, + topic: String, + qos: Option, +) -> Result<(), String> { + let client = get_mqtt_client(&state, &connection_id).await?; + service::subscribe(&client, &topic, qos.unwrap_or_default()).await +} + +/// 取消订阅 topic +#[tauri::command] +pub async fn mqtt_unsubscribe( + state: State<'_, Arc>, + connection_id: String, + topic: String, +) -> Result<(), String> { + let client = get_mqtt_client(&state, &connection_id).await?; + service::unsubscribe(&client, &topic).await +} + +/// 发布消息 +#[tauri::command] +pub async fn mqtt_publish( + state: State<'_, Arc>, + connection_id: String, + request: MqttPublishRequest, +) -> Result<(), String> { + let client = get_mqtt_client(&state, &connection_id).await?; + service::publish(&client, &request).await +} + +/// 获取已订阅的 topic 列表 +#[tauri::command] +pub async fn mqtt_list_topics( + state: State<'_, Arc>, + connection_id: String, +) -> Result, String> { + let client = get_mqtt_client(&state, &connection_id).await?; + service::list_topics(&client).await +} + +/// 获取 topic 树结构 +#[tauri::command] +pub async fn mqtt_get_topic_tree( + state: State<'_, Arc>, + connection_id: String, +) -> Result { + let client = get_mqtt_client(&state, &connection_id).await?; + service::get_topic_tree(&client).await +} + +/// 获取消息列表 +#[tauri::command] +pub async fn mqtt_get_messages( + state: State<'_, Arc>, + connection_id: String, + topic_filter: Option, + limit: Option, +) -> Result, String> { + let client = get_mqtt_client(&state, &connection_id).await?; + service::get_messages(&client, topic_filter.as_deref(), limit.unwrap_or(50)).await +} + +/// 清空消息历史记录 +#[tauri::command] +pub async fn mqtt_clear_messages(state: State<'_, Arc>, connection_id: String) -> Result<(), String> { + let client = get_mqtt_client(&state, &connection_id).await?; + service::clear_messages(&client).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f921edbe94..c855a7d894 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1967,6 +1967,14 @@ pub fn run() { commands::mq_cmd::mq_raw_request, #[cfg(feature = "mq-admin")] commands::mq_cmd::mq_send_message, + commands::mqtt_cmd::mqtt_get_broker_info, + commands::mqtt_cmd::mqtt_subscribe, + commands::mqtt_cmd::mqtt_unsubscribe, + commands::mqtt_cmd::mqtt_publish, + commands::mqtt_cmd::mqtt_list_topics, + commands::mqtt_cmd::mqtt_get_topic_tree, + commands::mqtt_cmd::mqtt_get_messages, + commands::mqtt_cmd::mqtt_clear_messages, commands::history::save_history, commands::history::load_history, commands::history::search_history, diff --git a/vendor/rumqttc/.cargo-ok b/vendor/rumqttc/.cargo-ok new file mode 100644 index 0000000000..5f8b795830 --- /dev/null +++ b/vendor/rumqttc/.cargo-ok @@ -0,0 +1 @@ +{"v":1} \ No newline at end of file diff --git a/vendor/rumqttc/.cargo_vcs_info.json b/vendor/rumqttc/.cargo_vcs_info.json new file mode 100644 index 0000000000..18db56c766 --- /dev/null +++ b/vendor/rumqttc/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "b8766e109edf5caaa579a89aa71199638690fa11" + }, + "path_in_vcs": "rumqttc" +} \ No newline at end of file diff --git a/vendor/rumqttc/CHANGELOG.md b/vendor/rumqttc/CHANGELOG.md new file mode 100644 index 0000000000..cb47a2ee72 --- /dev/null +++ b/vendor/rumqttc/CHANGELOG.md @@ -0,0 +1,143 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +--- + +## [rumqttc 0.24.0] - 27-02-2024 + +### Added +- Expose `EventLoop::clean` to allow triggering shutdown and subsequent storage of pending requests +- Support for all variants of TLS key formats currently supported by Rustls: `PKCS#1`, `PKCS#8`, `RFC5915`. In practice we should now support all RSA keys and ECC keys in `DER` and `SEC1` encoding. Previously only `PKCS#1` and `PKCS#8` where supported. +- TLS Error variants: `NoValidClientCertInChain`, `NoValidKeyInChain`. +- Drain `Request`s, which weren't received by eventloop, from channel and put them in pending while doing cleanup to prevent data loss. +- websocket request modifier for v4 client +- Surfaced `AsyncClient`'s `from_senders` method to the `Client` as `from_sender` + +### Changed +- `MqttOptions::new` now accepts empty client id. +- `MqttOptions::set_clean_session` now panics if client ID is empty and `clean_session` flag is set to false. +- Synchronous client methods take `&self` instead of `&mut self` (#646) +- Removed the `Key` enum: users do not need to specify the TLS key variant in the `TlsConfiguration` anymore, this is inferred automatically. +To update your code simply remove `Key::ECC()` or `Key::RSA()` from the initialization. +- certificate for client authentication is now optional while using native-tls. `der` & `password` fields are replaced by `client_auth`. +- Make v5 `RetainForwardRule` public, in order to allow setting it when constructing `Filter` values. +- Use `VecDeque` instead of `IntoIter` to fix unintentional drop of pending requests on `EventLoop::clean` (#780) +- `StateError::IncommingPacketTooLarge` is now `StateError::IncomingPacketTooLarge`. +- Update `tokio-rustls` to `0.25.0`, `rustls-native-certs` to `0.7.0`, `rustls-webpki` to `0.102.1`, + `rusttls-pemfile` to `2.0.0`, `async-tungstenite` to `0.24.0`, `ws_stream_tungstenite` to `0.12.0` + and `http` to `1.0.0`. This is a breaking change as types from some of these crates are part of + the public API. + +### Deprecated + +### Removed + +### Fixed +- Lowered the MSRV to 1.64.0 +- Request modifier function should be Send and Sync and removed unnecessary Box + +### Security + +--- + +## [rumqttc 0.23.0] - 10-10-2023 + +### Added +- Added `bind_device` to `NetworkOptions` to enable `TCPSocket.bind_device()` +- Added `MqttOptions::set_request_modifier` for setting a handler for modifying a websocket request before sending it. + +### Changed + +### Deprecated + +### Removed + +### Fixed +- Allow keep alive values <= 5 seconds (#643) +- Verify "mqtt" is present in websocket subprotocol header. + +### Security +- Remove dependency on webpki. [CVE](https://rustsec.org/advisories/RUSTSEC-2023-0052) +- Removed dependency vulnerability, see [rustsec](https://rustsec.org/advisories/RUSTSEC-2023-0065). Update of `tungstenite` dependency. + +--- + +## [rumqttc 0.22.0] - 07-06-2023 + +### Added +- Added `outgoing_inflight_upper_limit` to MQTT5 `MqttOptions`. This sets the upper bound for the number of outgoing publish messages (#615) +- Added support for HTTP(s) proxy (#608) + - Added `proxy` feature gate + - Refactored `eventloop::network_connect` to allow setting proxy + - Added proxy options to `MqttOptions` +- Update `rustls` to `0.21` and `tokio-rustls` to `0.24` (#606) + - Adds support for TLS certificates containing IP addresses + - Adds support for RFC8446 C.4 client tracking prevention + +### Changed +- `MqttState::new` takes `max_outgoing_packet_size` which was set in `MqttOptions` but not used (#622) + +### Deprecated + +### Removed + +### Fixed +- Enforce `max_outgoing_packet_size` on v4 client (#622) + +### Security + +## [rumqttc 0.21.0] - 01-05-2023 + +### Added +- Added support for MQTT5 features to v5 client + - Refactored v5::mqttbytes to use associated functions & include properties + - Added new API's on v5 client for properties, eg `publish_with_props` etc + - Refactored `MqttOptions` to use `ConnectProperties` for some fields + - Other minor changes for MQTT5 + +### Changed +- Remove `Box` on `Event::Incoming` + +### Deprecated + +### Removed +- Removed dependency on pollster + +### Fixed +- Fixed v5::mqttbytes `Connect` packet returning wrong size on `write()` + - Added tests for packet length for all v5 packets + +### Security + + +## [rumqttc 0.20.0] - 17-01-2023 + +### Added +- `NetworkOptions` added to provide a way to configure low level network configurations (#545) + +### Changed +- `options` in `Eventloop` now is called `mqtt_options` (#545) +- `ConnectionError` now has specific variant for type of `Timeout`, `FlushTimeout` and `NetworkTimeout` instead of generic `Timeout` for both (#545) +- `conn_timeout` is moved into `NetworkOptions` (#545) + +--- + +Old changelog entries can be found at [CHANGELOG.md](../CHANGELOG.md) diff --git a/vendor/rumqttc/Cargo.lock b/vendor/rumqttc/Cargo.lock new file mode 100644 index 0000000000..f952c58495 --- /dev/null +++ b/vendor/rumqttc/Cargo.lock @@ -0,0 +1,1430 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-http-proxy" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29faa5d4d308266048bd7505ba55484315a890102f9345b9ff4b87de64201592" +dependencies = [ + "base64 0.13.1", + "httparse", + "thiserror", + "tokio", +] + +[[package]] +name = "async-tungstenite" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef0f8d64ef9351752fbe5462f242c625d9c4910d2bc3f7ec44c43857ca123f5d" +dependencies = [ + "futures-io", + "futures-util", + "log", + "pin-project-lite", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", + "tokio", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cc" +version = "1.0.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02f341c093d19155a6e41631ce5971aac4e9a868262212153124c15fa22d1cdc" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "color-backtrace" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c04463c99389fff045d2b90ce84f5131332712c7ffbede020f5e9ad1ed685" +dependencies = [ + "atty", + "backtrace", + "termcolor", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379dada1584ad501b383485dd706b8afb7a70fcbc7f4da7d780638a5a6124a60" + +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi 0.3.8", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rumqttc" +version = "0.24.0" +dependencies = [ + "async-http-proxy", + "async-tungstenite", + "bincode", + "bytes", + "color-backtrace", + "flume", + "futures-util", + "http", + "log", + "matches", + "native-tls", + "pretty_assertions", + "pretty_env_logger", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki", + "serde", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "url", + "ws_stream_tungstenite", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +dependencies = [ + "bitflags 2.4.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e87c9956bd9807afa1f77e0f7594af32566e830e088a5576d27c5b6f30f49d41" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fb85efa936c42c6d5fc28d2629bb51e4b2f4b8a5211e297d599cc5a093792" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c333bb734fcdedcea57de1602543590f545f127dc8b533324318fd492c5c70b" +dependencies = [ + "base64 0.21.7", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ede67b28608b4c60685c7d54122d4400d90f62b40caee7700e700380a390fa8" + +[[package]] +name = "rustls-webpki" +version = "0.102.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faaa0a62740bedb9b2ef5afa303da42764c012f743917351dc9a237ea1663610" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "2.0.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab617d94515e94ae53b8406c628598680aa0c9587474ecbe58188f7b345d66c" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.3", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d380ba1dc7187569a8a9e91ed34b8ccfc33123bbacb8c0aed2d1ad7f3ef2dc5f" +dependencies = [ + "windows_aarch64_gnullvm 0.52.3", + "windows_aarch64_msvc 0.52.3", + "windows_i686_gnu 0.52.3", + "windows_i686_msvc 0.52.3", + "windows_x86_64_gnu 0.52.3", + "windows_x86_64_gnullvm 0.52.3", + "windows_x86_64_msvc 0.52.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e5dcfb9413f53afd9c8f86e56a7b4d86d9a2fa26090ea2dc9e40fba56c6ec6" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dab469ebbc45798319e69eebf92308e541ce46760b49b18c6b3fe5e8965b30f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4e9b6a7cac734a8b4138a4e1044eac3404d8326b6c0f939276560687a033fb" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b0ec9c422ca95ff34a78755cfa6ad4a51371da2a5ace67500cf7ca5f232c58" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704131571ba93e89d7cd43482277d6632589b18ecf4468f591fbae0a8b101614" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42079295511643151e98d61c38c0acc444e52dd42ab456f7ccfd5152e8ecf21c" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6" + +[[package]] +name = "ws_stream_tungstenite" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f414f083fb19fcc1bffcb0fa0cf46d33ccfa229adf248cac12c180e91609" +dependencies = [ + "async-tungstenite", + "async_io_stream", + "bitflags 2.4.2", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pharos", + "rustc_version", + "tokio", + "tracing", + "tungstenite", +] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/vendor/rumqttc/Cargo.toml b/vendor/rumqttc/Cargo.toml new file mode 100644 index 0000000000..36d0117e1a --- /dev/null +++ b/vendor/rumqttc/Cargo.toml @@ -0,0 +1,179 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.64.0" +name = "rumqttc" +version = "0.24.0" +authors = ["tekjar "] +publish = true +description = "An efficient and robust mqtt client for your connected devices" +readme = "README.md" +keywords = [ + "mqtt", + "iot", + "coap", + "http", +] +categories = ["network-programming"] +license = "Apache-2.0" +repository = "https://github.com/bytebeamio/rumqtt" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[[example]] +name = "tls" +path = "examples/tls.rs" +required-features = ["use-rustls"] + +[[example]] +name = "tls2" +path = "examples/tls2.rs" +required-features = ["use-rustls"] + +[[example]] +name = "websocket" +path = "examples/websocket.rs" +required-features = ["websocket"] + +[[example]] +name = "websocket_proxy" +path = "examples/websocket_proxy.rs" +required-features = [ + "websocket", + "proxy", +] + +[dependencies.async-http-proxy] +version = "1.2.5" +features = [ + "runtime-tokio", + "basic-auth", +] +optional = true + +[dependencies.async-tungstenite] +version = "0.25.0" +features = ["tokio-rustls-native-certs"] +optional = true +default-features = false + +[dependencies.bytes] +version = "1.5" + +[dependencies.flume] +version = "0.11" +features = ["async"] +default-features = false + +[dependencies.futures-util] +version = "0.3" +features = ["std"] +default_features = false + +[dependencies.http] +version = "1.0.0" +optional = true + +[dependencies.log] +version = "0.4" + +[dependencies.native-tls] +version = "0.2.11" +optional = true + +[dependencies.rustls-native-certs] +version = "0.7.0" +optional = true + +[dependencies.rustls-pemfile] +version = "2.1.0" +optional = true + +[dependencies.rustls-webpki] +version = "0.102.2" +optional = true + +[dependencies.thiserror] +version = "1" + +[dependencies.tokio] +version = "1.36" +features = [ + "rt", + "macros", + "io-util", + "net", + "time", +] + +[dependencies.tokio-native-tls] +version = "0.3.1" +optional = true + +[dependencies.tokio-rustls] +version = "0.25.0" +optional = true + +[dependencies.url] +version = "2" +optional = true +default-features = false + +[dependencies.ws_stream_tungstenite] +version = "0.13.0" +features = ["tokio_io"] +optional = true +default-features = false + +[dev-dependencies.bincode] +version = "1.3.3" + +[dev-dependencies.color-backtrace] +version = "0.5" + +[dev-dependencies.matches] +version = "0.1" + +[dev-dependencies.pretty_assertions] +version = "1" + +[dev-dependencies.pretty_env_logger] +version = "0.5" + +[dev-dependencies.serde] +version = "1" +features = ["derive"] + +[features] +default = ["use-rustls"] +proxy = ["dep:async-http-proxy"] +use-native-tls = [ + "dep:tokio-native-tls", + "dep:native-tls", +] +use-rustls = [ + "dep:tokio-rustls", + "dep:rustls-webpki", + "dep:rustls-pemfile", + "dep:rustls-native-certs", +] +websocket = [ + "dep:async-tungstenite", + "dep:ws_stream_tungstenite", + "dep:http", +] diff --git a/vendor/rumqttc/Cargo.toml.orig b/vendor/rumqttc/Cargo.toml.orig new file mode 100644 index 0000000000..551dcab533 --- /dev/null +++ b/vendor/rumqttc/Cargo.toml.orig @@ -0,0 +1,77 @@ +[package] +name = "rumqttc" +version = "0.24.0" +publish = true +description = "An efficient and robust mqtt client for your connected devices" +keywords = ["mqtt", "iot", "coap", "http"] +categories = ["network-programming"] +rust-version = "1.64.0" +edition.workspace = true +repository.workspace = true +license.workspace = true +authors.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +default = ["use-rustls"] +use-rustls = ["dep:tokio-rustls", "dep:rustls-webpki", "dep:rustls-pemfile", "dep:rustls-native-certs"] +use-native-tls = ["dep:tokio-native-tls", "dep:native-tls"] +websocket = ["dep:async-tungstenite", "dep:ws_stream_tungstenite", "dep:http"] +proxy = ["dep:async-http-proxy"] + +[dependencies] +futures-util = { version = "0.3", default_features = false, features = ["std"] } +tokio = { version = "1.36", features = ["rt", "macros", "io-util", "net", "time"] } +bytes = "1.5" +log = "0.4" +flume = { version = "0.11", default-features = false, features = ["async"] } +thiserror = "1" + +# Optional +# rustls +tokio-rustls = { version = "0.25.0", optional = true } +rustls-webpki = { version = "0.102.2", optional = true } +rustls-pemfile = { version = "2.1.0", optional = true } +rustls-native-certs = { version = "0.7.0", optional = true } +# websockets +async-tungstenite = { version = "0.25.0", default-features = false, features = ["tokio-rustls-native-certs"], optional = true } +ws_stream_tungstenite = { version= "0.13.0", default-features = false, features = ["tokio_io"], optional = true } +http = { version = "1.0.0", optional = true } +# native-tls +tokio-native-tls = { version = "0.3.1", optional = true } +native-tls = { version = "0.2.11", optional = true } +# url +url = { version = "2", default-features = false, optional = true } +# proxy +async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"], optional = true } + +[dev-dependencies] +bincode = "1.3.3" +color-backtrace = "0.5" +matches = "0.1" +pretty_assertions = "1" +pretty_env_logger = "0.5" +serde = { version = "1", features = ["derive"] } + +[[example]] +name = "tls" +path = "examples/tls.rs" +required-features = ["use-rustls"] + +[[example]] +name = "tls2" +path = "examples/tls2.rs" +required-features = ["use-rustls"] + +[[example]] +name = "websocket" +path = "examples/websocket.rs" +required-features = ["websocket"] + +[[example]] +name = "websocket_proxy" +path = "examples/websocket_proxy.rs" +required-features = ["websocket", "proxy"] diff --git a/vendor/rumqttc/README.md b/vendor/rumqttc/README.md new file mode 100644 index 0000000000..cde2740d65 --- /dev/null +++ b/vendor/rumqttc/README.md @@ -0,0 +1,86 @@ +# rumqttc + +[](https://crates.io/crates/rumqttc) +[](https://docs.rs/rumqttc) + +A pure rust MQTT client which strives to be robust, efficient and easy to use. This library is backed by an async(using tokio) eventloop which enables users to send and receive MQTT messages in correspondence with a broker. + +## Examples + +A simple synchronous publish and subscribe +---------------------------- + +```rust +use rumqttc::{MqttOptions, Client, QoS}; +use std::time::Duration; +use std::thread; + +let mut mqttoptions = MqttOptions::new("rumqtt-sync", "test.mosquitto.org", 1883); +mqttoptions.set_keep_alive(Duration::from_secs(5)); + +let (mut client, mut connection) = Client::new(mqttoptions, 10); +client.subscribe("hello/rumqtt", QoS::AtMostOnce).unwrap(); +thread::spawn(move || for i in 0..10 { + client.publish("hello/rumqtt", QoS::AtLeastOnce, false, vec![i; i as usize]).unwrap(); + thread::sleep(Duration::from_millis(100)); +}); + +// Iterate to poll the eventloop for connection progress +for (i, notification) in connection.iter().enumerate() { + println!("Notification = {:?}", notification); +} +``` + +A simple asynchronous publish and subscribe +------------------------------ + +```rust +use rumqttc::{MqttOptions, AsyncClient, QoS}; +use tokio::{task, time}; +use std::time::Duration; +use std::error::Error; + +let mut mqttoptions = MqttOptions::new("rumqtt-async", "test.mosquitto.org", 1883); +mqttoptions.set_keep_alive(Duration::from_secs(5)); + +let (mut client, mut eventloop) = AsyncClient::new(mqttoptions, 10); +client.subscribe("hello/rumqtt", QoS::AtMostOnce).await.unwrap(); + +task::spawn(async move { + for i in 0..10 { + client.publish("hello/rumqtt", QoS::AtLeastOnce, false, vec![i; i as usize]).await.unwrap(); + time::sleep(Duration::from_millis(100)).await; + } +}); + +while let Ok(notification) = eventloop.poll().await { + println!("Received = {:?}", notification); +} +``` + +Quick overview of features +- Eventloop orchestrates outgoing/incoming packets concurrently and handles the state +- Pings the broker when necessary and detects client side half open connections as well +- Throttling of outgoing packets (todo) +- Queue size based flow control on outgoing packets +- Automatic reconnections by just continuing the `eventloop.poll()/connection.iter()` loop +- Natural backpressure to client APIs during bad network +- Support for WebSockets +- Secure transport using TLS + +In short, everything necessary to maintain a robust connection + +Since the eventloop is externally polled (with `iter()/poll()` in a loop) +out side the library and `Eventloop` is accessible, users can +- Distribute incoming messages based on topics +- Stop it when required +- Access internal state for use cases like graceful shutdown or to modify options before reconnection + +### Important notes + +- Looping on `connection.iter()`/`eventloop.poll()` is necessary to run the + event loop and make progress. It yields incoming and outgoing activity + notifications which allows customization as you see fit. + +- Blocking inside the `connection.iter()`/`eventloop.poll()` loop will block + connection progress. diff --git a/vendor/rumqttc/certs/generate.sh b/vendor/rumqttc/certs/generate.sh new file mode 100644 index 0000000000..8d0bf63f8f --- /dev/null +++ b/vendor/rumqttc/certs/generate.sh @@ -0,0 +1,23 @@ +set -ex + +if [[ $# -ne 1 ]]; then + echo "Pass number of bikes" + echo "Usage: ./createvehicle 10" + exit +fi + +for i in $(seq 1 $1); do + bike_id=bike-$i + wget https://pki.google.com/roots.pem + + openssl req -x509 -newkey rsa:2048 -keyout rsa_private.pem -nodes -out rsa_cert.pem -subj "/CN=unused" + openssl rsa -in rsa_private.pem -outform DER -out rsa_private.der + # openssl x509 -in roots.pem -outform DER -out roots.der + + gcloud iot devices delete -q $bike_id --region=asia-east1 --registry=iotcore || true + gcloud iot devices create $bike_id --region=asia-east1 --registry=iotcore --public-key path=rsa_cert.pem,type=rs256 + + mkdir -p $bike_id + mv roots.pem rsa_private.pem rsa_private.der $bike_id + rm -rf *.pem *.der +done diff --git a/vendor/rumqttc/design.md b/vendor/rumqttc/design.md new file mode 100644 index 0000000000..737f2527a6 --- /dev/null +++ b/vendor/rumqttc/design.md @@ -0,0 +1,273 @@ +-[ ] Make core publish take qos with publish +-[ ] Rust iterator like design +-[ ] Should the APIs take files directly + +This doc is a journal. Make a blog out of this + +Primarily designed to efficiently perform streaming (unbounded) mqtt publishes and +subscriptions in flaky networks. + +But the design choices to take a `Stream` for user requests (publishes, subscriptions etc) and the +eventloop it self being a `Stream` yielding incoming packets to the user makes other use cases easy to implement. + + +With out boring myself and you with only details, let's try to dive in +with some real world use cases + + +##### Robustness (reconnections and retransmissions) +------ + + + +##### Pausing the network activity to cooperate with other processes which needs bandwidth +------ + + + +##### Shutting down the eventloop by saving current state to the disk +------ + + + +##### Disk aware request stream +------ + + + + +##### Bounded requests +------ + + + + + + +* Automatic reconnections +* No commands for misc operations like network pause/ +Connection and Automatic reconnections +------- + +Open question: When should event loop start taking control of reconnections? After initial success or should +we expose options similar to current implementation? what should be the default behavior + + +``` +Reconnect::AfterFirstSuccess +Reconnect::Always +Reconnect::Never +``` + +Let's do this for starters + +```$xslt + // create an eventloop after the initial mqtt connection is successful + // user will decide if this should be retried or not + let eventloop = connect(mqttoptions) -> Result + + // during intermittent reconnetions due to bad network, eventloop will + // behave as per configured reconnection options to the eventloop + let stream = eventloop.assemble(reconnection_options, inputs); +``` + +Possible reconnection options + +```$xslt +Reconnect::Never +Reconnect::Automatic +``` + +I feel this a good middle ground between manual user control and rumq being very opinionated about +reconnection behaviour. + +But why not leave the intermittent reconnection behaviour as well to the user you ask? + +Because maintaini +state is a much more complicated business than just retrying the connection. We don't want the user to think +about mqttstate by default. If the user intends for a much more custom behaviour, he/she can use +`Reconnect::Never` and pass the returned `MqttState` to the next connection. + +Takes any stream type as input +------- + +Allows eventloop to be channel implementation agnostic. This opens up interesting usage patterns. Instead of providing +methods to communicate with the eventloop over a channel, we can directly communicate with th eventloop over the specified stream. Users can customize the stream in any way that fits their usecase. For example we plugin streams which orchestrate data between +disk and memory. + +This strategy prevents resource overhead in cases where user uses a different kind of channel than what rumqtt uses. E.g if +rumqtt uses futures channnel and user wants to plugin data coming from different kind of stream, user has to make extra copies +passing data fromn the channel of his choice to futures channel (requires user to run an extra thread which does the handover) + +``` +// thread 1 +user_channel_tx.send(data) + +// thread 2 +data = user_channel_rx.recv(); +rumqtt_channel_tx.send(data); + +// thread 3 +rumqtt_eventloop.start(rumqtt_channel_rx); +``` + +vs + +``` +// thread 1 +user_channel_tx.send(data) + +// thread 2 +rumqtt_eventloop.start(user_channel_rx_stream); +``` + + +Don't spawn any thread from the library +------- + +Provide all the eventloop handling necessary for a robust mqtt connection but don't spawn any inner threads. This choice is +left to the user. Usecases might vary from running streams with fixed input to channels that keep producing data forever + +``` +let stream = vec![publish1, publish2, publish3]; +let eventloop = MqttEventLoop::new(options); +eventloop.run(stream); +``` + +or + +``` +let (tx, rx) = channel::bound(10); + +thread::spawn(move || loop { + tx.send(publish); + sleep(1) +}); + +let eventloop = MqttEventLoop::new(options); +eventloop.run(rx); +``` + + +Support both synchronous and asynchronous use cases +------- + +Leverage on the above pattern to support both synchronous and asynchronus publishes with timeouts + +``` +let publishes = [p1, p2, p3]; +eventloop.run_timeout(publishes, 10 * Duration::SECS); +``` + +Eventloop will wait for all the acks within timeout (where ever necessary) and exits + + +Command channels to configure eventloop dynamically +------- + +reconnections, disconnection, throttle speed, pause/resume (without disconnections) + + +#### shutdown, disconnection, reconnection, pause/resume semantics + +there might be no need to implement separate shutdown to stop the eventloop and return existing state + +``` +let eventloop = MqttEventloop::new(); + +thread::spawn(|| { + command.shutdown() +}) + +enum EventloopStatus { + Shutdown(MqttState) + // sends disconnect to the server and waits for server server disconnection + // should't process any new data from the user channel + Disconnect +} + +eventloop.run() //return -> Result +``` + + +Keep additional functionality like gcloud jwt auth out of rumqtt +------- + +Prevents (some) conflicts w.r.t different versions of ring. conflicts because of rustls are still possible but atleast +prevents ones w.r.t jsonwebtoken. + +Keeps the codebase small which eases some maintainence burden + +Keep alive +------- + +Keepalives can be a little tricky + +client should keep track of keepalive timeouts for 2 different reasons + +* when there is no incoming n/w activity + +to detect any halfopen connections to the broker, client should send a pingrequest packet +and validate next pingreq with previous pingresp. +if previous ack isn't received, client should consider this as a halfopen connection and +disconnect. takes 2 keepalive times to detect halfopen connection and disconnect + +* to prevent broker from disconnecting the client due to no client activity + +broker should receive some packet activity from a client or else it'll assume the +connection as halfopen and disconnect. +for this reason, even though the client is receiving some incoming packets(qos0 publishes) +client should timeout when there is no outgoing packet activity and send a ping request + + +We would require timeouts on network incoming packets as well as network outgoing packets. concurrently + +so we need to create 2 streams with timeouts, one on n/w incoming packets and another on n/w outgoing packets and select + +```rust +let incoming_stream = stream!(tcp.read_mqtt().timeout(NetworkTimeout)) + +This captures incoming stream timeout + +Caputuring timeout on outgoing packects (replys due to incoming packets + user requests) is tricky because replys +are a sideeffect of processing incoming packets which generate notifications for the user as well. +To have a timeout on a combination of reply and requests, we need to filter out notifications (or put it into other stream) +which beats our design of returning one eventloop stream to the user to handle full mqtt io + + +let mqtt_stream = stream! { + loop { + select! { + (notification, reply) = incoming_stream.next().handle_incoming_packet(), + (request) = requests.next() + } + yield reply + } +} +``` + +So there is no way to separate notifications from reply without creating a duplicate stream + +Option 2 is to timeout on user requests alone. This will lead to sending unnecessary pingreqests after keep alive time +even when there is outgoing network activity due to network replys. But this will let us have one simple +stream to poll. Returning 2 streams might not be intuitive to the users. main eventloop progress can stop when the user +doensn't poll the notification stream (tx.send will block which eventually blocks all incoming packets). Having a second stream also results in more allocations in the hotpath (which might not be a big deal but not ideal) +Option2 is also considerable less codebase and hence easy maintainence. + +Keep alive take 2 + +Ok, creating independent timeout streams on network and request streams makes keep alive complicated. Both the streams mutating a common timeout is the solution + +* When there is an incoming packet which replys the network with some packet, reset the timer and don't generate `PingReq` +* When there are no incoming and outgoing packets, timeout, hence generate `PingReq`. Reset the delay +* In a keep alive window, when there is an incoming packet (which doesn't trigger a reply), mark it +* When there is an outgoing request in this window with incoming already marked. Reset the timer. +* If there is no outgoing request in this window, timeout, hence generate `PingReq`. Reset the delay and markers + + +Timeout for packets which are not acked +------------------ + +Implement timeout for unacked packets and drop/republish them. Useful if the +broker is buggy. But might not be too important as unacked messages are +anyway retried during reconnection diff --git a/vendor/rumqttc/examples/async_manual_acks.rs b/vendor/rumqttc/examples/async_manual_acks.rs new file mode 100644 index 0000000000..e5360aa047 --- /dev/null +++ b/vendor/rumqttc/examples/async_manual_acks.rs @@ -0,0 +1,86 @@ +use tokio::{task, time}; + +use rumqttc::{AsyncClient, Event, EventLoop, Incoming, MqttOptions, QoS}; +use std::error::Error; +use std::time::Duration; + +fn create_conn() -> (AsyncClient, EventLoop) { + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1883); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_manual_acks(true) + .set_clean_session(false); + + AsyncClient::new(mqttoptions, 10) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + + // create mqtt connection with clean_session = false and manual_acks = true + let (client, mut eventloop) = create_conn(); + + // subscribe example topic + client + .subscribe("hello/world", QoS::AtLeastOnce) + .await + .unwrap(); + + task::spawn(async move { + // send some messages to example topic and disconnect + requests(client.clone()).await; + client.disconnect().await.unwrap(); + }); + + loop { + // get subscribed messages without acking + let event = eventloop.poll().await; + match &event { + Ok(notif) => { + println!("Event = {notif:?}"); + } + Err(error) => { + println!("Error = {error:?}"); + break; + } + } + } + + // create new broker connection but do not start a clean session + let (client, mut eventloop) = create_conn(); + + loop { + // previously published messages should be republished after reconnection. + let event = eventloop.poll().await; + match &event { + Ok(notif) => { + println!("Event = {notif:?}"); + } + Err(error) => { + println!("Error = {error:?}"); + return Ok(()); + } + } + + if let Ok(Event::Incoming(Incoming::Publish(publish))) = event { + // this time we will ack incoming publishes. + // Its important not to block eventloop as this can cause deadlock. + let c = client.clone(); + tokio::spawn(async move { + c.ack(&publish).await.unwrap(); + }); + } + } +} + +async fn requests(client: AsyncClient) { + for i in 1..=10 { + client + .publish("hello/world", QoS::AtLeastOnce, false, vec![1; i]) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } +} diff --git a/vendor/rumqttc/examples/async_manual_acks_v5.rs b/vendor/rumqttc/examples/async_manual_acks_v5.rs new file mode 100644 index 0000000000..bcf1bf3569 --- /dev/null +++ b/vendor/rumqttc/examples/async_manual_acks_v5.rs @@ -0,0 +1,85 @@ +use rumqttc::v5::mqttbytes::v5::Packet; +use rumqttc::v5::mqttbytes::QoS; +use tokio::{task, time}; + +use rumqttc::v5::{AsyncClient, Event, EventLoop, MqttOptions}; +use std::error::Error; +use std::time::Duration; + +fn create_conn() -> (AsyncClient, EventLoop) { + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1884); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_manual_acks(true) + .set_clean_start(false); + + AsyncClient::new(mqttoptions, 10) +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // todo!("fix this example with new way of spawning clients") + pretty_env_logger::init(); + + // create mqtt connection with clean_session = false and manual_acks = true + let (client, mut eventloop) = create_conn(); + + // subscribe example topic + client + .subscribe("hello/world", QoS::AtLeastOnce) + .await + .unwrap(); + + task::spawn(async move { + // send some messages to example topic and disconnect + requests(&client).await; + client.disconnect().await.unwrap() + }); + + // get subscribed messages without acking + loop { + let event = eventloop.poll().await; + match &event { + Ok(v) => { + println!("Event = {v:?}"); + } + Err(e) => { + println!("Error = {e:?}"); + break; + } + } + } + + // create new broker connection + let (client, mut eventloop) = create_conn(); + + while let Ok(event) = eventloop.poll().await { + println!("{event:?}"); + + if let Event::Incoming(packet) = event { + let publish = match packet { + Packet::Publish(publish) => publish, + _ => continue, + }; + // this time we will ack incoming publishes. + // Its important not to block notifier as this can cause deadlock. + let c = client.clone(); + tokio::spawn(async move { + c.ack(&publish).await.unwrap(); + }); + } + } + + Ok(()) +} + +async fn requests(client: &AsyncClient) { + for i in 1..=10 { + client + .publish("hello/world", QoS::AtLeastOnce, false, vec![1; i]) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } +} diff --git a/vendor/rumqttc/examples/asyncpubsub.rs b/vendor/rumqttc/examples/asyncpubsub.rs new file mode 100644 index 0000000000..d4d70d3ff6 --- /dev/null +++ b/vendor/rumqttc/examples/asyncpubsub.rs @@ -0,0 +1,51 @@ +use tokio::{task, time}; + +use rumqttc::{AsyncClient, MqttOptions, QoS}; +use std::error::Error; +use std::time::Duration; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + // color_backtrace::install(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1883); + mqttoptions.set_keep_alive(Duration::from_secs(5)); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + task::spawn(async move { + requests(client).await; + time::sleep(Duration::from_secs(3)).await; + }); + + loop { + let event = eventloop.poll().await; + match &event { + Ok(v) => { + println!("Event = {v:?}"); + } + Err(e) => { + println!("Error = {e:?}"); + return Ok(()); + } + } + } +} + +async fn requests(client: AsyncClient) { + client + .subscribe("hello/world", QoS::AtMostOnce) + .await + .unwrap(); + + for i in 1..=10 { + client + .publish("hello/world", QoS::ExactlyOnce, false, vec![1; i]) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } + + time::sleep(Duration::from_secs(120)).await; +} diff --git a/vendor/rumqttc/examples/asyncpubsub_v5.rs b/vendor/rumqttc/examples/asyncpubsub_v5.rs new file mode 100644 index 0000000000..fd53b53fd2 --- /dev/null +++ b/vendor/rumqttc/examples/asyncpubsub_v5.rs @@ -0,0 +1,52 @@ +use rumqttc::v5::mqttbytes::QoS; +use tokio::{task, time}; + +use rumqttc::v5::{AsyncClient, MqttOptions}; +use std::error::Error; +use std::time::Duration; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + // color_backtrace::install(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1884); + mqttoptions.set_keep_alive(Duration::from_secs(5)); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + task::spawn(async move { + requests(client).await; + time::sleep(Duration::from_secs(3)).await; + }); + + loop { + let event = eventloop.poll().await; + match &event { + Ok(v) => { + println!("Event = {v:?}"); + } + Err(e) => { + println!("Error = {e:?}"); + return Ok(()); + } + } + } +} + +async fn requests(client: AsyncClient) { + client + .subscribe("hello/world", QoS::AtMostOnce) + .await + .unwrap(); + + for i in 1..=10 { + client + .publish("hello/world", QoS::ExactlyOnce, false, vec![1; i]) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } + + time::sleep(Duration::from_secs(120)).await; +} diff --git a/vendor/rumqttc/examples/serde.rs b/vendor/rumqttc/examples/serde.rs new file mode 100644 index 0000000000..478a88d565 --- /dev/null +++ b/vendor/rumqttc/examples/serde.rs @@ -0,0 +1,62 @@ +use bincode::ErrorKind; +use rumqttc::{Client, Event, Incoming, MqttOptions, QoS}; +use serde::{Deserialize, Serialize}; +use std::convert::TryFrom; +use std::thread; +use std::time::{Duration, SystemTime}; + +#[derive(Serialize, Deserialize, Debug)] +struct Message { + i: usize, + time: SystemTime, +} + +impl From<&Message> for Vec { + fn from(value: &Message) -> Self { + bincode::serialize(value).unwrap() + } +} + +impl From for Vec { + fn from(value: Message) -> Self { + bincode::serialize(&value).unwrap() + } +} + +impl TryFrom<&[u8]> for Message { + type Error = Box; + + fn try_from(value: &[u8]) -> Result { + bincode::deserialize(value) + } +} + +fn main() { + let mqqt_opts = MqttOptions::new("test-1", "localhost", 1883); + + let (client, mut connection) = Client::new(mqqt_opts, 10); + client.subscribe("hello/rumqtt", QoS::AtMostOnce).unwrap(); + thread::spawn(move || { + for i in 0..10 { + let message = Message { + i, + time: SystemTime::now(), + }; + + client + .publish("hello/rumqtt", QoS::AtLeastOnce, false, message) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + } + }); + + // Iterate to poll the eventloop for connection progress + for notification in connection.iter().flatten() { + if let Event::Incoming(Incoming::Publish(packet)) = notification { + match Message::try_from(packet.payload.as_ref()) { + Ok(message) => println!("Payload = {message:?}"), + Err(error) => println!("Error = {error}"), + } + } + } +} diff --git a/vendor/rumqttc/examples/subscription_ids.rs b/vendor/rumqttc/examples/subscription_ids.rs new file mode 100644 index 0000000000..b741bfaf71 --- /dev/null +++ b/vendor/rumqttc/examples/subscription_ids.rs @@ -0,0 +1,82 @@ +use rumqttc::v5::mqttbytes::v5::SubscribeProperties; +use rumqttc::v5::mqttbytes::QoS; +use tokio::{task, time}; + +use rumqttc::v5::{AsyncClient, MqttOptions}; +use std::error::Error; +use std::time::Duration; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1884); + mqttoptions.set_keep_alive(Duration::from_secs(5)); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + task::spawn(async move { + requests(client).await; + time::sleep(Duration::from_secs(3)).await; + }); + + while let Ok(event) = eventloop.poll().await { + println!("{:?}", event); + } + + Ok(()) +} + +async fn requests(client: AsyncClient) { + let props = SubscribeProperties { + id: Some(1), + user_properties: vec![], + }; + + client + .subscribe_with_properties("hello/world", QoS::AtMostOnce, props) + .await + .unwrap(); + + let props = SubscribeProperties { + id: Some(2), + user_properties: vec![], + }; + + client + .subscribe_with_properties("hello/#", QoS::AtMostOnce, props) + .await + .unwrap(); + + time::sleep(Duration::from_millis(500)).await; + // we will receive two publishes + // one due to hello/world and other due to hello/# + // both will have respective subscription ids + client + .publish( + "hello/world", + QoS::AtMostOnce, + false, + "both having subscription IDs!", + ) + .await + .unwrap(); + + time::sleep(Duration::from_millis(500)).await; + client.unsubscribe("hello/#").await.unwrap(); + client.subscribe("hello/#", QoS::AtMostOnce).await.unwrap(); + time::sleep(Duration::from_millis(500)).await; + + // we will receive two publishes + // but only one will have subscription ID + // cuz we unsubscribed to hello/# and then + // subscribed without properties! + client + .publish( + "hello/world", + QoS::AtMostOnce, + false, + "Only one with subscription ID!", + ) + .await + .unwrap(); +} diff --git a/vendor/rumqttc/examples/syncpubsub.rs b/vendor/rumqttc/examples/syncpubsub.rs new file mode 100644 index 0000000000..5c42ad43c4 --- /dev/null +++ b/vendor/rumqttc/examples/syncpubsub.rs @@ -0,0 +1,44 @@ +use rumqttc::{Client, LastWill, MqttOptions, QoS}; +use std::thread; +use std::time::Duration; + +fn main() { + pretty_env_logger::init(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1883); + let will = LastWill::new("hello/world", "good bye", QoS::AtMostOnce, false); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_last_will(will); + + let (client, mut connection) = Client::new(mqttoptions, 10); + thread::spawn(move || publish(client)); + + for (i, notification) in connection.iter().enumerate() { + match notification { + Ok(notif) => { + println!("{i}. Notification = {notif:?}"); + } + Err(error) => { + println!("{i}. Notification = {error:?}"); + return; + } + } + } + + println!("Done with the stream!!"); +} + +fn publish(client: Client) { + thread::sleep(Duration::from_secs(1)); + client.subscribe("hello/+/world", QoS::AtMostOnce).unwrap(); + for i in 0..10_usize { + let payload = vec![1; i]; + let topic = format!("hello/{i}/world"); + let qos = QoS::AtLeastOnce; + + client.publish(topic, qos, true, payload).unwrap(); + } + + thread::sleep(Duration::from_secs(1)); +} diff --git a/vendor/rumqttc/examples/syncpubsub_v5.rs b/vendor/rumqttc/examples/syncpubsub_v5.rs new file mode 100644 index 0000000000..cc8632d42d --- /dev/null +++ b/vendor/rumqttc/examples/syncpubsub_v5.rs @@ -0,0 +1,45 @@ +use rumqttc::v5::mqttbytes::{v5::LastWill, QoS}; +use rumqttc::v5::{Client, ConnectionError, MqttOptions}; +use std::thread; +use std::time::Duration; + +fn main() { + pretty_env_logger::init(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1884); + let will = LastWill::new("hello/world", "good bye", QoS::AtMostOnce, false, None); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_last_will(will); + + let (client, mut connection) = Client::new(mqttoptions, 10); + thread::spawn(move || publish(client)); + + for (i, notification) in connection.iter().enumerate() { + match notification { + Err(ConnectionError::Io(error)) + if error.kind() == std::io::ErrorKind::ConnectionRefused => + { + println!("Failed to connect to the server. Make sure correct client is configured properly!\nError: {error:?}"); + return; + } + _ => {} + } + println!("{i}. Notification = {notification:?}"); + } + + println!("Done with the stream!!"); +} + +fn publish(client: Client) { + client.subscribe("hello/+/world", QoS::AtMostOnce).unwrap(); + for i in 0..10_usize { + let payload = vec![1; i]; + let topic = format!("hello/{i}/world"); + let qos = QoS::AtLeastOnce; + + let _ = client.publish(topic, qos, true, payload); + } + + thread::sleep(Duration::from_secs(1)); +} diff --git a/vendor/rumqttc/examples/syncrecv.rs b/vendor/rumqttc/examples/syncrecv.rs new file mode 100644 index 0000000000..f0da81775e --- /dev/null +++ b/vendor/rumqttc/examples/syncrecv.rs @@ -0,0 +1,41 @@ +use rumqttc::{Client, LastWill, MqttOptions, QoS}; +use std::thread; +use std::time::Duration; + +fn main() { + pretty_env_logger::init(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1883); + let will = LastWill::new("hello/world", "good bye", QoS::AtMostOnce, false); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_last_will(will); + + let (client, mut connection) = Client::new(mqttoptions, 10); + thread::spawn(move || publish(client)); + + if let Ok(notification) = connection.recv() { + println!("Notification = {notification:?}"); + } + + if let Ok(notification) = connection.try_recv() { + println!("Notification = {notification:?}"); + } + + if let Ok(notification) = connection.recv_timeout(Duration::from_secs(10)) { + println!("Notification = {notification:?}"); + } +} + +fn publish(client: Client) { + client.subscribe("hello/+/world", QoS::AtMostOnce).unwrap(); + for i in 0..3 { + let payload = vec![1; i]; + let topic = format!("hello/{i}/world"); + let qos = QoS::AtLeastOnce; + + client.publish(topic, qos, true, payload).unwrap(); + } + + thread::sleep(Duration::from_secs(1)); +} diff --git a/vendor/rumqttc/examples/syncrecv_v5.rs b/vendor/rumqttc/examples/syncrecv_v5.rs new file mode 100644 index 0000000000..7de064a573 --- /dev/null +++ b/vendor/rumqttc/examples/syncrecv_v5.rs @@ -0,0 +1,42 @@ +use rumqttc::v5::mqttbytes::{v5::LastWill, QoS}; +use rumqttc::v5::{Client, MqttOptions}; +use std::thread; +use std::time::Duration; + +fn main() { + pretty_env_logger::init(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1884); + let will = LastWill::new("hello/world", "good bye", QoS::AtMostOnce, false, None); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_last_will(will); + + let (client, mut connection) = Client::new(mqttoptions, 10); + thread::spawn(move || publish(client)); + + if let Ok(notification) = connection.recv() { + println!("Notification = {notification:?}"); + } + + if let Ok(notification) = connection.try_recv() { + println!("Notification = {notification:?}"); + } + + if let Ok(notification) = connection.recv_timeout(Duration::from_secs(10)) { + println!("Notification = {notification:?}"); + } +} + +fn publish(client: Client) { + client.subscribe("hello/+/world", QoS::AtMostOnce).unwrap(); + for i in 0..3 { + let payload = vec![1; i]; + let topic = format!("hello/{i}/world"); + let qos = QoS::AtLeastOnce; + + client.publish(topic, qos, true, payload).unwrap(); + } + + thread::sleep(Duration::from_secs(1)); +} diff --git a/vendor/rumqttc/examples/tls.rs b/vendor/rumqttc/examples/tls.rs new file mode 100644 index 0000000000..dc08636f1e --- /dev/null +++ b/vendor/rumqttc/examples/tls.rs @@ -0,0 +1,45 @@ +//! Example of how to configure rumqttd to connect to a server using TLS and authentication. +use std::error::Error; + +use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, Transport}; +use tokio_rustls::rustls::ClientConfig; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + color_backtrace::install(); + + let mut mqttoptions = MqttOptions::new("test-1", "mqtt.example.server", 8883); + mqttoptions.set_keep_alive(std::time::Duration::from_secs(5)); + mqttoptions.set_credentials("username", "password"); + + // Use rustls-native-certs to load root certificates from the operating system. + let mut root_cert_store = tokio_rustls::rustls::RootCertStore::empty(); + root_cert_store.add_parsable_certificates( + rustls_native_certs::load_native_certs().expect("could not load platform certs"), + ); + + let client_config = ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + mqttoptions.set_transport(Transport::tls_with_config(client_config.into())); + + let (_client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + + loop { + match eventloop.poll().await { + Ok(Event::Incoming(Incoming::Publish(p))) => { + println!("Topic: {}, Payload: {:?}", p.topic, p.payload); + } + Ok(Event::Incoming(i)) => { + println!("Incoming = {i:?}"); + } + Ok(Event::Outgoing(o)) => println!("Outgoing = {o:?}"), + Err(e) => { + println!("Error = {e:?}"); + return Ok(()); + } + } + } +} diff --git a/vendor/rumqttc/examples/tls2.rs b/vendor/rumqttc/examples/tls2.rs new file mode 100644 index 0000000000..c088adb097 --- /dev/null +++ b/vendor/rumqttc/examples/tls2.rs @@ -0,0 +1,45 @@ +//! Example of how to configure rumqttd to connect to a server using TLS and authentication. +use std::error::Error; + +use rumqttc::{AsyncClient, MqttOptions, TlsConfiguration, Transport}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + color_backtrace::install(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 8883); + mqttoptions.set_keep_alive(std::time::Duration::from_secs(5)); + + // Dummies to prevent compilation error in CI + let ca = vec![1, 2, 3]; + let client_cert = vec![1, 2, 3]; + let client_key = vec![1, 2, 3]; + // let ca = include_bytes!("/home/tekjar/tlsfiles/ca.cert.pem"); + // let client_cert = include_bytes!("/home/tekjar/tlsfiles/device-1.cert.pem"); + // let client_key = include_bytes!("/home/tekjar/tlsfiles/device-1.key.pem"); + + let transport = Transport::Tls(TlsConfiguration::Simple { + ca, + alpn: None, + client_auth: Some((client_cert, client_key)), + }); + + mqttoptions.set_transport(transport); + + let (_client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + + loop { + match eventloop.poll().await { + Ok(v) => { + println!("Event = {v:?}"); + } + Err(e) => { + println!("Error = {e:?}"); + break; + } + } + } + + Ok(()) +} diff --git a/vendor/rumqttc/examples/topic_alias.rs b/vendor/rumqttc/examples/topic_alias.rs new file mode 100644 index 0000000000..394ad3cd53 --- /dev/null +++ b/vendor/rumqttc/examples/topic_alias.rs @@ -0,0 +1,88 @@ +use rumqttc::v5::mqttbytes::{v5::PublishProperties, QoS}; +use tokio::{task, time}; + +use rumqttc::v5::{AsyncClient, MqttOptions}; +use std::error::Error; +use std::time::Duration; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1884); + mqttoptions.set_keep_alive(Duration::from_secs(5)); + mqttoptions.set_topic_alias_max(10.into()); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + task::spawn(async move { + requests(client).await; + time::sleep(Duration::from_secs(3)).await; + }); + + while let Ok(event) = eventloop.poll().await { + println!("{:?}", event); + } + + Ok(()) +} + +async fn requests(client: AsyncClient) { + client + .subscribe("hello/world", QoS::AtMostOnce) + .await + .unwrap(); + + client + .subscribe("bye/world", QoS::AtMostOnce) + .await + .unwrap(); + + let props = PublishProperties { + topic_alias: Some(28), + ..Default::default() + }; + + client + .publish_with_properties( + "hello/world", + QoS::AtMostOnce, + false, + vec![3; 3], + props.clone(), + ) + .await + .unwrap(); + + let mut other_props = props.clone(); + other_props.topic_alias = Some(51); + + client + .publish_with_properties( + "bye/world", + QoS::AtMostOnce, + false, + vec![3; 3], + other_props.clone(), + ) + .await + .unwrap(); + + for i in 1..=10 { + // alternately choose the properties + let properties = if i % 2 == 0 { + other_props.clone() + } else { + props.clone() + }; + + // no need to specify topic as we are using topic alias + client + .publish_with_properties("", QoS::AtMostOnce, false, vec![1; i], properties) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } + + time::sleep(Duration::from_secs(120)).await; +} diff --git a/vendor/rumqttc/examples/websocket.rs b/vendor/rumqttc/examples/websocket.rs new file mode 100644 index 0000000000..8aceb5bfd0 --- /dev/null +++ b/vendor/rumqttc/examples/websocket.rs @@ -0,0 +1,54 @@ +use rumqttc::{AsyncClient, MqttOptions, QoS, Transport}; +use std::{error::Error, time::Duration}; +use tokio::{task, time}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + pretty_env_logger::init(); + + // port parameter is ignored when scheme is websocket + let mut mqttoptions = MqttOptions::new( + "clientId-aSziq39Bp3", + "ws://broker.mqttdashboard.com:8000/mqtt", + 8000, + ); + mqttoptions.set_transport(Transport::Ws); + mqttoptions.set_keep_alive(Duration::from_secs(60)); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + task::spawn(async move { + requests(client).await; + time::sleep(Duration::from_secs(3)).await; + }); + + loop { + let event = eventloop.poll().await; + match event { + Ok(notif) => { + println!("Event = {notif:?}"); + } + Err(err) => { + println!("Error = {err:?}"); + return Ok(()); + } + } + } +} + +async fn requests(client: AsyncClient) { + client + .subscribe("hello/world", QoS::AtMostOnce) + .await + .unwrap(); + + for i in 1..=10 { + client + .publish("hello/world", QoS::ExactlyOnce, false, vec![1; i]) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } + + time::sleep(Duration::from_secs(120)).await; +} diff --git a/vendor/rumqttc/examples/websocket_proxy.rs b/vendor/rumqttc/examples/websocket_proxy.rs new file mode 100644 index 0000000000..42c12353e4 --- /dev/null +++ b/vendor/rumqttc/examples/websocket_proxy.rs @@ -0,0 +1,63 @@ +use rumqttc::{AsyncClient, Proxy, ProxyAuth, ProxyType, QoS, Transport}; +use std::{error::Error, time::Duration}; +use tokio::{task, time}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + use rumqttc::MqttOptions; + + pretty_env_logger::init(); + + // port parameter is ignored when scheme is websocket + let mut mqttoptions = MqttOptions::new( + "clientId-aSziq39Bp3", + "ws://broker.mqttdashboard.com:8000/mqtt", + 8000, + ); + mqttoptions.set_transport(Transport::Ws); + mqttoptions.set_keep_alive(Duration::from_secs(60)); + // Presumes that there is a proxy server already set up listening on 127.0.0.1:8100 + mqttoptions.set_proxy(Proxy { + ty: ProxyType::Http, + auth: ProxyAuth::None, + addr: "127.0.0.1".into(), + port: 8100, + }); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + task::spawn(async move { + requests(client).await; + time::sleep(Duration::from_secs(3)).await; + }); + + loop { + let event = eventloop.poll().await; + match event { + Ok(notif) => { + println!("Event = {notif:?}"); + } + Err(err) => { + println!("Error = {err:?}"); + return Ok(()); + } + } + } +} + +async fn requests(client: AsyncClient) { + client + .subscribe("hello/world", QoS::AtMostOnce) + .await + .unwrap(); + + for i in 1..=10 { + client + .publish("hello/world", QoS::ExactlyOnce, false, vec![1; i]) + .await + .unwrap(); + + time::sleep(Duration::from_secs(1)).await; + } + + time::sleep(Duration::from_secs(120)).await; +} diff --git a/vendor/rumqttc/src/client.rs b/vendor/rumqttc/src/client.rs new file mode 100644 index 0000000000..863b73191e --- /dev/null +++ b/vendor/rumqttc/src/client.rs @@ -0,0 +1,523 @@ +//! This module offers a high level synchronous and asynchronous abstraction to +//! async eventloop. +use std::time::Duration; + +use crate::mqttbytes::{v4::*, QoS}; +use crate::{valid_topic, ConnectionError, Event, EventLoop, MqttOptions, Request}; + +use bytes::Bytes; +use flume::{SendError, Sender, TrySendError}; +use futures_util::FutureExt; +use tokio::runtime::{self, Runtime}; +use tokio::time::timeout; + +/// Client Error +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("Failed to send mqtt requests to eventloop")] + Request(Request), + #[error("Failed to send mqtt requests to eventloop")] + TryRequest(Request), +} + +impl From> for ClientError { + fn from(e: SendError) -> Self { + Self::Request(e.into_inner()) + } +} + +impl From> for ClientError { + fn from(e: TrySendError) -> Self { + Self::TryRequest(e.into_inner()) + } +} + +/// An asynchronous client, communicates with MQTT `EventLoop`. +/// +/// This is cloneable and can be used to asynchronously [`publish`](`AsyncClient::publish`), +/// [`subscribe`](`AsyncClient::subscribe`) through the `EventLoop`, which is to be polled parallelly. +/// +/// **NOTE**: The `EventLoop` must be regularly polled in order to send, receive and process packets +/// from the broker, i.e. move ahead. +#[derive(Clone, Debug)] +pub struct AsyncClient { + request_tx: Sender, +} + +impl AsyncClient { + /// Create a new `AsyncClient`. + /// + /// `cap` specifies the capacity of the bounded async channel. + pub fn new(options: MqttOptions, cap: usize) -> (AsyncClient, EventLoop) { + let eventloop = EventLoop::new(options, cap); + let request_tx = eventloop.requests_tx.clone(); + + let client = AsyncClient { request_tx }; + + (client, eventloop) + } + + /// Create a new `AsyncClient` from a channel `Sender`. + /// + /// This is mostly useful for creating a test instance where you can + /// listen on the corresponding receiver. + pub fn from_senders(request_tx: Sender) -> AsyncClient { + AsyncClient { request_tx } + } + + /// Sends a MQTT Publish to the `EventLoop`. + pub async fn publish( + &self, + topic: S, + qos: QoS, + retain: bool, + payload: V, + ) -> Result<(), ClientError> + where + S: Into, + V: Into>, + { + let topic = topic.into(); + let mut publish = Publish::new(&topic, qos, payload); + publish.retain = retain; + let publish = Request::Publish(publish); + if !valid_topic(&topic) { + return Err(ClientError::Request(publish)); + } + self.request_tx.send_async(publish).await?; + Ok(()) + } + + /// Attempts to send a MQTT Publish to the `EventLoop`. + pub fn try_publish( + &self, + topic: S, + qos: QoS, + retain: bool, + payload: V, + ) -> Result<(), ClientError> + where + S: Into, + V: Into>, + { + let topic = topic.into(); + let mut publish = Publish::new(&topic, qos, payload); + publish.retain = retain; + let publish = Request::Publish(publish); + if !valid_topic(&topic) { + return Err(ClientError::TryRequest(publish)); + } + self.request_tx.try_send(publish)?; + Ok(()) + } + + /// Sends a MQTT PubAck to the `EventLoop`. Only needed in if `manual_acks` flag is set. + pub async fn ack(&self, publish: &Publish) -> Result<(), ClientError> { + let ack = get_ack_req(publish); + + if let Some(ack) = ack { + self.request_tx.send_async(ack).await?; + } + Ok(()) + } + + /// Attempts to send a MQTT PubAck to the `EventLoop`. Only needed in if `manual_acks` flag is set. + pub fn try_ack(&self, publish: &Publish) -> Result<(), ClientError> { + let ack = get_ack_req(publish); + if let Some(ack) = ack { + self.request_tx.try_send(ack)?; + } + Ok(()) + } + + /// Sends a MQTT Publish to the `EventLoop` + pub async fn publish_bytes( + &self, + topic: S, + qos: QoS, + retain: bool, + payload: Bytes, + ) -> Result<(), ClientError> + where + S: Into, + { + let mut publish = Publish::from_bytes(topic, qos, payload); + publish.retain = retain; + let publish = Request::Publish(publish); + self.request_tx.send_async(publish).await?; + Ok(()) + } + + /// Sends a MQTT Subscribe to the `EventLoop` + pub async fn subscribe>(&self, topic: S, qos: QoS) -> Result<(), ClientError> { + let subscribe = Subscribe::new(topic.into(), qos); + let request = Request::Subscribe(subscribe); + self.request_tx.send_async(request).await?; + Ok(()) + } + + /// Attempts to send a MQTT Subscribe to the `EventLoop` + pub fn try_subscribe>(&self, topic: S, qos: QoS) -> Result<(), ClientError> { + let subscribe = Subscribe::new(topic.into(), qos); + let request = Request::Subscribe(subscribe); + self.request_tx.try_send(request)?; + Ok(()) + } + + /// Sends a MQTT Subscribe for multiple topics to the `EventLoop` + pub async fn subscribe_many(&self, topics: T) -> Result<(), ClientError> + where + T: IntoIterator, + { + let subscribe = Subscribe::new_many(topics); + let request = Request::Subscribe(subscribe); + self.request_tx.send_async(request).await?; + Ok(()) + } + + /// Attempts to send a MQTT Subscribe for multiple topics to the `EventLoop` + pub fn try_subscribe_many(&self, topics: T) -> Result<(), ClientError> + where + T: IntoIterator, + { + let subscribe = Subscribe::new_many(topics); + let request = Request::Subscribe(subscribe); + self.request_tx.try_send(request)?; + Ok(()) + } + + /// Sends a MQTT Unsubscribe to the `EventLoop` + pub async fn unsubscribe>(&self, topic: S) -> Result<(), ClientError> { + let unsubscribe = Unsubscribe::new(topic.into()); + let request = Request::Unsubscribe(unsubscribe); + self.request_tx.send_async(request).await?; + Ok(()) + } + + /// Attempts to send a MQTT Unsubscribe to the `EventLoop` + pub fn try_unsubscribe>(&self, topic: S) -> Result<(), ClientError> { + let unsubscribe = Unsubscribe::new(topic.into()); + let request = Request::Unsubscribe(unsubscribe); + self.request_tx.try_send(request)?; + Ok(()) + } + + /// Sends a MQTT disconnect to the `EventLoop` + pub async fn disconnect(&self) -> Result<(), ClientError> { + let request = Request::Disconnect(Disconnect); + self.request_tx.send_async(request).await?; + Ok(()) + } + + /// Attempts to send a MQTT disconnect to the `EventLoop` + pub fn try_disconnect(&self) -> Result<(), ClientError> { + let request = Request::Disconnect(Disconnect); + self.request_tx.try_send(request)?; + Ok(()) + } +} + +fn get_ack_req(publish: &Publish) -> Option { + let ack = match publish.qos { + QoS::AtMostOnce => return None, + QoS::AtLeastOnce => Request::PubAck(PubAck::new(publish.pkid)), + QoS::ExactlyOnce => Request::PubRec(PubRec::new(publish.pkid)), + }; + Some(ack) +} + +/// A synchronous client, communicates with MQTT `EventLoop`. +/// +/// This is cloneable and can be used to synchronously [`publish`](`AsyncClient::publish`), +/// [`subscribe`](`AsyncClient::subscribe`) through the `EventLoop`/`Connection`, which is to be polled in parallel +/// by iterating over the object returned by [`Connection.iter()`](Connection::iter) in a separate thread. +/// +/// **NOTE**: The `EventLoop`/`Connection` must be regularly polled(`.next()` in case of `Connection`) in order +/// to send, receive and process packets from the broker, i.e. move ahead. +/// +/// An asynchronous channel handle can also be extracted if necessary. +#[derive(Clone)] +pub struct Client { + client: AsyncClient, +} + +impl Client { + /// Create a new `Client` + /// + /// `cap` specifies the capacity of the bounded async channel. + pub fn new(options: MqttOptions, cap: usize) -> (Client, Connection) { + let (client, eventloop) = AsyncClient::new(options, cap); + let client = Client { client }; + let runtime = runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let connection = Connection::new(eventloop, runtime); + (client, connection) + } + + /// Create a new `Client` from a channel `Sender`. + /// + /// This is mostly useful for creating a test instance where you can + /// listen on the corresponding receiver. + pub fn from_sender(request_tx: Sender) -> Client { + Client { + client: AsyncClient::from_senders(request_tx), + } + } + + /// Sends a MQTT Publish to the `EventLoop` + pub fn publish( + &self, + topic: S, + qos: QoS, + retain: bool, + payload: V, + ) -> Result<(), ClientError> + where + S: Into, + V: Into>, + { + let topic = topic.into(); + let mut publish = Publish::new(&topic, qos, payload); + publish.retain = retain; + let publish = Request::Publish(publish); + if !valid_topic(&topic) { + return Err(ClientError::Request(publish)); + } + self.client.request_tx.send(publish)?; + Ok(()) + } + + pub fn try_publish( + &self, + topic: S, + qos: QoS, + retain: bool, + payload: V, + ) -> Result<(), ClientError> + where + S: Into, + V: Into>, + { + self.client.try_publish(topic, qos, retain, payload)?; + Ok(()) + } + + /// Sends a MQTT PubAck to the `EventLoop`. Only needed in if `manual_acks` flag is set. + pub fn ack(&self, publish: &Publish) -> Result<(), ClientError> { + let ack = get_ack_req(publish); + + if let Some(ack) = ack { + self.client.request_tx.send(ack)?; + } + Ok(()) + } + + /// Sends a MQTT PubAck to the `EventLoop`. Only needed in if `manual_acks` flag is set. + pub fn try_ack(&self, publish: &Publish) -> Result<(), ClientError> { + self.client.try_ack(publish)?; + Ok(()) + } + + /// Sends a MQTT Subscribe to the `EventLoop` + pub fn subscribe>(&self, topic: S, qos: QoS) -> Result<(), ClientError> { + let subscribe = Subscribe::new(topic.into(), qos); + let request = Request::Subscribe(subscribe); + self.client.request_tx.send(request)?; + Ok(()) + } + + /// Sends a MQTT Subscribe to the `EventLoop` + pub fn try_subscribe>(&self, topic: S, qos: QoS) -> Result<(), ClientError> { + self.client.try_subscribe(topic, qos)?; + Ok(()) + } + + /// Sends a MQTT Subscribe for multiple topics to the `EventLoop` + pub fn subscribe_many(&self, topics: T) -> Result<(), ClientError> + where + T: IntoIterator, + { + let subscribe = Subscribe::new_many(topics); + let request = Request::Subscribe(subscribe); + self.client.request_tx.send(request)?; + Ok(()) + } + + pub fn try_subscribe_many(&self, topics: T) -> Result<(), ClientError> + where + T: IntoIterator, + { + self.client.try_subscribe_many(topics) + } + + /// Sends a MQTT Unsubscribe to the `EventLoop` + pub fn unsubscribe>(&self, topic: S) -> Result<(), ClientError> { + let unsubscribe = Unsubscribe::new(topic.into()); + let request = Request::Unsubscribe(unsubscribe); + self.client.request_tx.send(request)?; + Ok(()) + } + + /// Sends a MQTT Unsubscribe to the `EventLoop` + pub fn try_unsubscribe>(&self, topic: S) -> Result<(), ClientError> { + self.client.try_unsubscribe(topic)?; + Ok(()) + } + + /// Sends a MQTT disconnect to the `EventLoop` + pub fn disconnect(&self) -> Result<(), ClientError> { + let request = Request::Disconnect(Disconnect); + self.client.request_tx.send(request)?; + Ok(()) + } + + /// Sends a MQTT disconnect to the `EventLoop` + pub fn try_disconnect(&self) -> Result<(), ClientError> { + self.client.try_disconnect()?; + Ok(()) + } +} + +/// Error type returned by [`Connection::recv`] +#[derive(Debug, Eq, PartialEq)] +pub struct RecvError; + +/// Error type returned by [`Connection::try_recv`] +#[derive(Debug, Eq, PartialEq)] +pub enum TryRecvError { + /// User has closed requests channel + Disconnected, + /// Did not resolve + Empty, +} + +/// Error type returned by [`Connection::recv_timeout`] +#[derive(Debug, Eq, PartialEq)] +pub enum RecvTimeoutError { + /// User has closed requests channel + Disconnected, + /// Recv request timedout + Timeout, +} + +/// MQTT connection. Maintains all the necessary state +pub struct Connection { + pub eventloop: EventLoop, + runtime: Runtime, +} +impl Connection { + fn new(eventloop: EventLoop, runtime: Runtime) -> Connection { + Connection { eventloop, runtime } + } + + /// Returns an iterator over this connection. Iterating over this is all that's + /// necessary to make connection progress and maintain a robust connection. + /// Just continuing to loop will reconnect + /// **NOTE** Don't block this while iterating + // ideally this should be named iter_mut because it requires a mutable reference + // Also we can implement IntoIter for this to make it easy to iterate over it + #[must_use = "Connection should be iterated over a loop to make progress"] + pub fn iter(&mut self) -> Iter<'_> { + Iter { connection: self } + } + + /// Attempt to fetch an incoming [`Event`] on the [`EvenLoop`], returning an error + /// if all clients/users have closed requests channel. + /// + /// [`EvenLoop`]: super::EventLoop + pub fn recv(&mut self) -> Result, RecvError> { + let f = self.eventloop.poll(); + let event = self.runtime.block_on(f); + + resolve_event(event).ok_or(RecvError) + } + + /// Attempt to fetch an incoming [`Event`] on the [`EvenLoop`], returning an error + /// if none immediately present or all clients/users have closed requests channel. + /// + /// [`EvenLoop`]: super::EventLoop + pub fn try_recv(&mut self) -> Result, TryRecvError> { + let f = self.eventloop.poll(); + // Enters the runtime context so we can poll the future, as required by `now_or_never()`. + // ref: https://docs.rs/tokio/latest/tokio/runtime/struct.Runtime.html#method.enter + let _guard = self.runtime.enter(); + let event = f.now_or_never().ok_or(TryRecvError::Empty)?; + + resolve_event(event).ok_or(TryRecvError::Disconnected) + } + + /// Attempt to fetch an incoming [`Event`] on the [`EvenLoop`], returning an error + /// if all clients/users have closed requests channel or the timeout has expired. + /// + /// [`EvenLoop`]: super::EventLoop + pub fn recv_timeout( + &mut self, + duration: Duration, + ) -> Result, RecvTimeoutError> { + let f = self.eventloop.poll(); + let event = self + .runtime + .block_on(async { timeout(duration, f).await }) + .map_err(|_| RecvTimeoutError::Timeout)?; + + resolve_event(event).ok_or(RecvTimeoutError::Disconnected) + } +} + +fn resolve_event(event: Result) -> Option> { + match event { + Ok(v) => Some(Ok(v)), + // closing of request channel should stop the iterator + Err(ConnectionError::RequestsDone) => { + trace!("Done with requests"); + None + } + Err(e) => Some(Err(e)), + } +} + +/// Iterator which polls the `EventLoop` for connection progress +pub struct Iter<'a> { + connection: &'a mut Connection, +} + +impl Iterator for Iter<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + self.connection.recv().ok() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn calling_iter_twice_on_connection_shouldnt_panic() { + use std::time::Duration; + + let mut mqttoptions = MqttOptions::new("test-1", "localhost", 1883); + let will = LastWill::new("hello/world", "good bye", QoS::AtMostOnce, false); + mqttoptions + .set_keep_alive(Duration::from_secs(5)) + .set_last_will(will); + + let (_, mut connection) = Client::new(mqttoptions, 10); + let _ = connection.iter(); + let _ = connection.iter(); + } + + #[test] + fn should_be_able_to_build_test_client_from_channel() { + let (tx, rx) = flume::bounded(1); + let client = Client::from_sender(tx); + client + .publish("hello/world", QoS::ExactlyOnce, false, "good bye") + .expect("Should be able to publish"); + let _ = rx.try_recv().expect("Should have message"); + } +} diff --git a/vendor/rumqttc/src/eventloop.rs b/vendor/rumqttc/src/eventloop.rs new file mode 100644 index 0000000000..1dc3f6219b --- /dev/null +++ b/vendor/rumqttc/src/eventloop.rs @@ -0,0 +1,476 @@ +use crate::{framed::Network, Transport}; +use crate::{Incoming, MqttState, NetworkOptions, Packet, Request, StateError}; +use crate::{MqttOptions, Outgoing}; + +use crate::framed::N; +use crate::mqttbytes::v4::*; +use flume::{bounded, Receiver, Sender}; +use tokio::net::{lookup_host, TcpSocket, TcpStream}; +use tokio::select; +use tokio::time::{self, Instant, Sleep}; + +use std::collections::VecDeque; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::time::Duration; + +#[cfg(unix)] +use {std::path::Path, tokio::net::UnixStream}; + +#[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] +use crate::tls; + +#[cfg(feature = "websocket")] +use { + crate::websockets::{split_url, validate_response_headers, UrlError}, + async_tungstenite::tungstenite::client::IntoClientRequest, + ws_stream_tungstenite::WsStream, +}; + +#[cfg(feature = "proxy")] +use crate::proxy::ProxyError; + +/// Critical errors during eventloop polling +#[derive(Debug, thiserror::Error)] +pub enum ConnectionError { + #[error("Mqtt state: {0}")] + MqttState(#[from] StateError), + #[error("Network timeout")] + NetworkTimeout, + #[error("Flush timeout")] + FlushTimeout, + #[cfg(feature = "websocket")] + #[error("Websocket: {0}")] + Websocket(#[from] async_tungstenite::tungstenite::error::Error), + #[cfg(feature = "websocket")] + #[error("Websocket Connect: {0}")] + WsConnect(#[from] http::Error), + #[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] + #[error("TLS: {0}")] + Tls(#[from] tls::Error), + #[error("I/O: {0}")] + Io(#[from] io::Error), + #[error("Connection refused, return code: `{0:?}`")] + ConnectionRefused(ConnectReturnCode), + #[error("Expected ConnAck packet, received: {0:?}")] + NotConnAck(Packet), + #[error("Requests done")] + RequestsDone, + #[cfg(feature = "websocket")] + #[error("Invalid Url: {0}")] + InvalidUrl(#[from] UrlError), + #[cfg(feature = "proxy")] + #[error("Proxy Connect: {0}")] + Proxy(#[from] ProxyError), + #[cfg(feature = "websocket")] + #[error("Websocket response validation error: ")] + ResponseValidation(#[from] crate::websockets::ValidationError), +} + +/// Eventloop with all the state of a connection +pub struct EventLoop { + /// Options of the current mqtt connection + pub mqtt_options: MqttOptions, + /// Current state of the connection + pub state: MqttState, + /// Request stream + requests_rx: Receiver, + /// Requests handle to send requests + pub(crate) requests_tx: Sender, + /// Pending packets from last session + pub pending: VecDeque, + /// Network connection to the broker + network: Option, + /// Keep alive time + keepalive_timeout: Option>>, + pub network_options: NetworkOptions, +} + +/// Events which can be yielded by the event loop +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Incoming(Incoming), + Outgoing(Outgoing), +} + +impl EventLoop { + /// New MQTT `EventLoop` + /// + /// When connection encounters critical errors (like auth failure), user has a choice to + /// access and update `options`, `state` and `requests`. + pub fn new(mqtt_options: MqttOptions, cap: usize) -> EventLoop { + let (requests_tx, requests_rx) = bounded(cap); + let pending = VecDeque::new(); + let max_inflight = mqtt_options.inflight; + let manual_acks = mqtt_options.manual_acks; + let max_outgoing_packet_size = mqtt_options.max_outgoing_packet_size; + + EventLoop { + mqtt_options, + state: MqttState::new(max_inflight, manual_acks, max_outgoing_packet_size), + requests_tx, + requests_rx, + pending, + network: None, + keepalive_timeout: None, + network_options: NetworkOptions::new(), + } + } + + /// Last session might contain packets which aren't acked. MQTT says these packets should be + /// republished in the next session. Move pending messages from state to eventloop, drops the + /// underlying network connection and clears the keepalive timeout if any. + /// + /// > NOTE: Use only when EventLoop is blocked on network and unable to immediately handle disconnect. + /// > Also, while this helps prevent data loss, the pending list length should be managed properly. + /// > For this reason we recommend setting [`AsycClient`](crate::AsyncClient)'s channel capacity to `0`. + pub fn clean(&mut self) { + self.network = None; + self.keepalive_timeout = None; + self.pending.extend(self.state.clean()); + + // drain requests from channel which weren't yet received + let requests_in_channel = self.requests_rx.drain(); + self.pending.extend(requests_in_channel); + } + + /// Yields Next notification or outgoing request and periodically pings + /// the broker. Continuing to poll will reconnect to the broker if there is + /// a disconnection. + /// **NOTE** Don't block this while iterating + pub async fn poll(&mut self) -> Result { + if self.network.is_none() { + let (network, connack) = match time::timeout( + Duration::from_secs(self.network_options.connection_timeout()), + connect(&self.mqtt_options, self.network_options.clone()), + ) + .await + { + Ok(inner) => inner?, + Err(_) => return Err(ConnectionError::NetworkTimeout), + }; + self.network = Some(network); + + if self.keepalive_timeout.is_none() && !self.mqtt_options.keep_alive.is_zero() { + self.keepalive_timeout = Some(Box::pin(time::sleep(self.mqtt_options.keep_alive))); + } + + return Ok(Event::Incoming(connack)); + } + + match self.select().await { + Ok(v) => Ok(v), + Err(e) => { + self.clean(); + Err(e) + } + } + } + + /// Select on network and requests and generate keepalive pings when necessary + async fn select(&mut self) -> Result { + let network = self.network.as_mut().unwrap(); + // let await_acks = self.state.await_acks; + let inflight_full = self.state.inflight >= self.mqtt_options.inflight; + let collision = self.state.collision.is_some(); + let network_timeout = Duration::from_secs(self.network_options.connection_timeout()); + + // Read buffered events from previous polls before calling a new poll + if let Some(event) = self.state.events.pop_front() { + return Ok(event); + } + + let mut no_sleep = Box::pin(time::sleep(Duration::ZERO)); + // this loop is necessary since self.incoming.pop_front() might return None. In that case, + // instead of returning a None event, we try again. + select! { + // Pull a bunch of packets from network, reply in bunch and yield the first item + o = network.readb(&mut self.state) => { + o?; + // flush all the acks and return first incoming packet + match time::timeout(network_timeout, network.flush(&mut self.state.write)).await { + Ok(inner) => inner?, + Err(_)=> return Err(ConnectionError::FlushTimeout), + }; + Ok(self.state.events.pop_front().unwrap()) + }, + // Handles pending and new requests. + // If available, prioritises pending requests from previous session. + // Else, pulls next request from user requests channel. + // If conditions in the below branch are for flow control. + // The branch is disabled if there's no pending messages and new user requests + // cannot be serviced due flow control. + // We read next user user request only when inflight messages are < configured inflight + // and there are no collisions while handling previous outgoing requests. + // + // Flow control is based on ack count. If inflight packet count in the buffer is + // less than max_inflight setting, next outgoing request will progress. For this + // to work correctly, broker should ack in sequence (a lot of brokers won't) + // + // E.g If max inflight = 5, user requests will be blocked when inflight queue + // looks like this -> [1, 2, 3, 4, 5]. + // If broker acking 2 instead of 1 -> [1, x, 3, 4, 5]. + // This pulls next user request. But because max packet id = max_inflight, next + // user request's packet id will roll to 1. This replaces existing packet id 1. + // Resulting in a collision + // + // Eventloop can stop receiving outgoing user requests when previous outgoing + // request collided. I.e collision state. Collision state will be cleared only + // when correct ack is received + // Full inflight queue will look like -> [1a, 2, 3, 4, 5]. + // If 3 is acked instead of 1 first -> [1a, 2, x, 4, 5]. + // After collision with pkid 1 -> [1b ,2, x, 4, 5]. + // 1a is saved to state and event loop is set to collision mode stopping new + // outgoing requests (along with 1b). + o = Self::next_request( + &mut self.pending, + &self.requests_rx, + self.mqtt_options.pending_throttle + ), if !self.pending.is_empty() || (!inflight_full && !collision) => match o { + Ok(request) => { + self.state.handle_outgoing_packet(request)?; + match time::timeout(network_timeout, network.flush(&mut self.state.write)).await { + Ok(inner) => inner?, + Err(_)=> return Err(ConnectionError::FlushTimeout), + }; + Ok(self.state.events.pop_front().unwrap()) + } + Err(_) => Err(ConnectionError::RequestsDone), + }, + // We generate pings irrespective of network activity. This keeps the ping logic + // simple. We can change this behavior in future if necessary (to prevent extra pings) + _ = self.keepalive_timeout.as_mut().unwrap_or(&mut no_sleep), + if self.keepalive_timeout.is_some() && !self.mqtt_options.keep_alive.is_zero() => { + let timeout = self.keepalive_timeout.as_mut().unwrap(); + timeout.as_mut().reset(Instant::now() + self.mqtt_options.keep_alive); + + self.state.handle_outgoing_packet(Request::PingReq(PingReq))?; + match time::timeout(network_timeout, network.flush(&mut self.state.write)).await { + Ok(inner) => inner?, + Err(_)=> return Err(ConnectionError::FlushTimeout), + }; + Ok(self.state.events.pop_front().unwrap()) + } + } + } + + pub fn network_options(&self) -> NetworkOptions { + self.network_options.clone() + } + + pub fn set_network_options(&mut self, network_options: NetworkOptions) -> &mut Self { + self.network_options = network_options; + self + } + + async fn next_request( + pending: &mut VecDeque, + rx: &Receiver, + pending_throttle: Duration, + ) -> Result { + if !pending.is_empty() { + time::sleep(pending_throttle).await; + // We must call .pop_front() AFTER sleep() otherwise we would have + // advanced the iterator but the future might be canceled before return + Ok(pending.pop_front().unwrap()) + } else { + match rx.recv_async().await { + Ok(r) => Ok(r), + Err(_) => Err(ConnectionError::RequestsDone), + } + } + } +} + +/// This stream internally processes requests from the request stream provided to the eventloop +/// while also consuming byte stream from the network and yielding mqtt packets as the output of +/// the stream. +/// This function (for convenience) includes internal delays for users to perform internal sleeps +/// between re-connections so that cancel semantics can be used during this sleep +async fn connect( + mqtt_options: &MqttOptions, + network_options: NetworkOptions, +) -> Result<(Network, Incoming), ConnectionError> { + // connect to the broker + let mut network = network_connect(mqtt_options, network_options).await?; + + // make MQTT connection request (which internally awaits for ack) + let packet = mqtt_connect(mqtt_options, &mut network).await?; + + Ok((network, packet)) +} + +pub(crate) async fn socket_connect( + host: String, + network_options: NetworkOptions, +) -> io::Result { + let addrs = lookup_host(host).await?; + let mut last_err = None; + + for addr in addrs { + let socket = match addr { + SocketAddr::V4(_) => TcpSocket::new_v4()?, + SocketAddr::V6(_) => TcpSocket::new_v6()?, + }; + + if let Some(send_buff_size) = network_options.tcp_send_buffer_size { + socket.set_send_buffer_size(send_buff_size).unwrap(); + } + if let Some(recv_buffer_size) = network_options.tcp_recv_buffer_size { + socket.set_recv_buffer_size(recv_buffer_size).unwrap(); + } + + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + { + if let Some(bind_device) = &network_options.bind_device { + // call the bind_device function only if the bind_device network option is defined + // If binding device is None or an empty string it removes the binding, + // which is causing PermissionDenied errors in AWS environment (lambda function). + socket.bind_device(Some(bind_device.as_bytes()))?; + } + } + + match socket.connect(addr).await { + Ok(s) => return Ok(s), + Err(e) => { + last_err = Some(e); + } + }; + } + + Err(last_err.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "could not resolve to any address", + ) + })) +} + +async fn network_connect( + options: &MqttOptions, + network_options: NetworkOptions, +) -> Result { + // Process Unix files early, as proxy is not supported for them. + #[cfg(unix)] + if matches!(options.transport(), Transport::Unix) { + let file = options.broker_addr.as_str(); + let socket = UnixStream::connect(Path::new(file)).await?; + let network = Network::new(socket, options.max_incoming_packet_size); + return Ok(network); + } + + // For websockets domain and port are taken directly from `broker_addr` (which is a url). + let (domain, port) = match options.transport() { + #[cfg(feature = "websocket")] + Transport::Ws => split_url(&options.broker_addr)?, + #[cfg(all(feature = "use-rustls", feature = "websocket"))] + Transport::Wss(_) => split_url(&options.broker_addr)?, + _ => options.broker_address(), + }; + + let tcp_stream: Box = { + #[cfg(feature = "proxy")] + match options.proxy() { + Some(proxy) => proxy.connect(&domain, port, network_options).await?, + None => { + let addr = format!("{domain}:{port}"); + let tcp = socket_connect(addr, network_options).await?; + Box::new(tcp) + } + } + #[cfg(not(feature = "proxy"))] + { + let addr = format!("{domain}:{port}"); + let tcp = socket_connect(addr, network_options).await?; + Box::new(tcp) + } + }; + + let network = match options.transport() { + Transport::Tcp => Network::new(tcp_stream, options.max_incoming_packet_size), + #[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] + Transport::Tls(tls_config) => { + let socket = + tls::tls_connect(&options.broker_addr, options.port, &tls_config, tcp_stream) + .await?; + Network::new(socket, options.max_incoming_packet_size) + } + #[cfg(unix)] + Transport::Unix => unreachable!(), + #[cfg(feature = "websocket")] + Transport::Ws => { + let mut request = options.broker_addr.as_str().into_client_request()?; + request + .headers_mut() + .insert("Sec-WebSocket-Protocol", "mqtt".parse().unwrap()); + + if let Some(request_modifier) = options.request_modifier() { + request = request_modifier(request).await; + } + + let (socket, response) = + async_tungstenite::tokio::client_async(request, tcp_stream).await?; + validate_response_headers(response)?; + + Network::new(WsStream::new(socket), options.max_incoming_packet_size) + } + #[cfg(all(feature = "use-rustls", feature = "websocket"))] + Transport::Wss(tls_config) => { + let mut request = options.broker_addr.as_str().into_client_request()?; + request + .headers_mut() + .insert("Sec-WebSocket-Protocol", "mqtt".parse().unwrap()); + + if let Some(request_modifier) = options.request_modifier() { + request = request_modifier(request).await; + } + + let connector = tls::rustls_connector(&tls_config).await?; + + let (socket, response) = async_tungstenite::tokio::client_async_tls_with_connector( + request, + tcp_stream, + Some(connector), + ) + .await?; + validate_response_headers(response)?; + + Network::new(WsStream::new(socket), options.max_incoming_packet_size) + } + }; + + Ok(network) +} + +async fn mqtt_connect( + options: &MqttOptions, + network: &mut Network, +) -> Result { + let keep_alive = options.keep_alive().as_secs() as u16; + let clean_session = options.clean_session(); + let last_will = options.last_will(); + + let mut connect = Connect::new(options.client_id()); + connect.protocol = options.protocol(); + connect.keep_alive = keep_alive; + connect.clean_session = clean_session; + connect.last_will = last_will; + + if let Some((username, password)) = options.credentials() { + let login = Login::new(username, password); + connect.login = Some(login); + } + + // send mqtt connect packet + network.connect(connect).await?; + + // validate connack + match network.read().await? { + Incoming::ConnAck(connack) if connack.code == ConnectReturnCode::Success => { + Ok(Packet::ConnAck(connack)) + } + Incoming::ConnAck(connack) => Err(ConnectionError::ConnectionRefused(connack.code)), + packet => Err(ConnectionError::NotConnAck(packet)), + } +} diff --git a/vendor/rumqttc/src/framed.rs b/vendor/rumqttc/src/framed.rs new file mode 100644 index 0000000000..b0a536e78d --- /dev/null +++ b/vendor/rumqttc/src/framed.rs @@ -0,0 +1,121 @@ +use bytes::BytesMut; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::mqttbytes::{self, v4::*}; +use crate::{Incoming, MqttState, StateError}; +use std::io; + +/// Network transforms packets <-> frames efficiently. It takes +/// advantage of pre-allocation, buffering and vectorization when +/// appropriate to achieve performance +pub struct Network { + /// Socket for IO + socket: Box, + /// Buffered reads + read: BytesMut, + /// Maximum packet size + max_incoming_size: usize, + /// Maximum readv count + max_readb_count: usize, +} + +impl Network { + pub fn new(socket: impl N + 'static, max_incoming_size: usize) -> Network { + let socket = Box::new(socket) as Box; + Network { + socket, + read: BytesMut::with_capacity(10 * 1024), + max_incoming_size, + max_readb_count: 10, + } + } + + /// Reads more than 'required' bytes to frame a packet into self.read buffer + async fn read_bytes(&mut self, required: usize) -> io::Result { + let mut total_read = 0; + loop { + let read = self.socket.read_buf(&mut self.read).await?; + if 0 == read { + return if self.read.is_empty() { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "connection closed by peer", + )) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "connection reset by peer", + )) + }; + } + + total_read += read; + if total_read >= required { + return Ok(total_read); + } + } + } + + pub async fn read(&mut self) -> io::Result { + loop { + let required = match read(&mut self.read, self.max_incoming_size) { + Ok(packet) => return Ok(packet), + Err(mqttbytes::Error::InsufficientBytes(required)) => required, + Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())), + }; + + // read more packets until a frame can be created. This function + // blocks until a frame can be created. Use this in a select! branch + self.read_bytes(required).await?; + } + } + + /// Read packets in bulk. This allow replies to be in bulk. This method is used + /// after the connection is established to read a bunch of incoming packets + pub async fn readb(&mut self, state: &mut MqttState) -> Result<(), StateError> { + let mut count = 0; + loop { + match read(&mut self.read, self.max_incoming_size) { + Ok(packet) => { + state.handle_incoming_packet(packet)?; + + count += 1; + if count >= self.max_readb_count { + return Ok(()); + } + } + // If some packets are already framed, return those + Err(mqttbytes::Error::InsufficientBytes(_)) if count > 0 => return Ok(()), + // Wait for more bytes until a frame can be created + Err(mqttbytes::Error::InsufficientBytes(required)) => { + self.read_bytes(required).await?; + } + Err(e) => return Err(StateError::Deserialization(e)), + }; + } + } + + pub async fn connect(&mut self, connect: Connect) -> io::Result { + let mut write = BytesMut::new(); + let len = match connect.write(&mut write) { + Ok(size) => size, + Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string())), + }; + + self.socket.write_all(&write[..]).await?; + Ok(len) + } + + pub async fn flush(&mut self, write: &mut BytesMut) -> io::Result<()> { + if write.is_empty() { + return Ok(()); + } + + self.socket.write_all(&write[..]).await?; + write.clear(); + Ok(()) + } +} + +pub trait N: AsyncRead + AsyncWrite + Send + Unpin {} +impl N for T where T: AsyncRead + AsyncWrite + Send + Unpin {} diff --git a/vendor/rumqttc/src/lib.rs b/vendor/rumqttc/src/lib.rs new file mode 100644 index 0000000000..8c37ea5c6d --- /dev/null +++ b/vendor/rumqttc/src/lib.rs @@ -0,0 +1,1029 @@ +//! A pure rust MQTT client which strives to be robust, efficient and easy to use. +//! This library is backed by an async (tokio) eventloop which handles all the +//! robustness and and efficiency parts of MQTT but naturally fits into both sync +//! and async worlds as we'll see +//! +//! Let's jump into examples right away +//! +//! A simple synchronous publish and subscribe +//! ---------------------------- +//! +//! ```no_run +//! use rumqttc::{MqttOptions, Client, QoS}; +//! use std::time::Duration; +//! use std::thread; +//! +//! let mut mqttoptions = MqttOptions::new("rumqtt-sync", "test.mosquitto.org", 1883); +//! mqttoptions.set_keep_alive(Duration::from_secs(5)); +//! +//! let (mut client, mut connection) = Client::new(mqttoptions, 10); +//! client.subscribe("hello/rumqtt", QoS::AtMostOnce).unwrap(); +//! thread::spawn(move || for i in 0..10 { +//! client.publish("hello/rumqtt", QoS::AtLeastOnce, false, vec![i; i as usize]).unwrap(); +//! thread::sleep(Duration::from_millis(100)); +//! }); +//! +//! // Iterate to poll the eventloop for connection progress +//! for (i, notification) in connection.iter().enumerate() { +//! println!("Notification = {:?}", notification); +//! } +//! ``` +//! +//! A simple asynchronous publish and subscribe +//! ------------------------------ +//! +//! ```no_run +//! use rumqttc::{MqttOptions, AsyncClient, QoS}; +//! use tokio::{task, time}; +//! use std::time::Duration; +//! use std::error::Error; +//! +//! # #[tokio::main(flavor = "current_thread")] +//! # async fn main() { +//! let mut mqttoptions = MqttOptions::new("rumqtt-async", "test.mosquitto.org", 1883); +//! mqttoptions.set_keep_alive(Duration::from_secs(5)); +//! +//! let (mut client, mut eventloop) = AsyncClient::new(mqttoptions, 10); +//! client.subscribe("hello/rumqtt", QoS::AtMostOnce).await.unwrap(); +//! +//! task::spawn(async move { +//! for i in 0..10 { +//! client.publish("hello/rumqtt", QoS::AtLeastOnce, false, vec![i; i as usize]).await.unwrap(); +//! time::sleep(Duration::from_millis(100)).await; +//! } +//! }); +//! +//! loop { +//! let notification = eventloop.poll().await.unwrap(); +//! println!("Received = {:?}", notification); +//! } +//! # } +//! ``` +//! +//! Quick overview of features +//! - Eventloop orchestrates outgoing/incoming packets concurrently and handles the state +//! - Pings the broker when necessary and detects client side half open connections as well +//! - Throttling of outgoing packets (todo) +//! - Queue size based flow control on outgoing packets +//! - Automatic reconnections by just continuing the `eventloop.poll()`/`connection.iter()` loop +//! - Natural backpressure to client APIs during bad network +//! +//! In short, everything necessary to maintain a robust connection +//! +//! Since the eventloop is externally polled (with `iter()/poll()` in a loop) +//! out side the library and `Eventloop` is accessible, users can +//! - Distribute incoming messages based on topics +//! - Stop it when required +//! - Access internal state for use cases like graceful shutdown or to modify options before reconnection +//! +//! ## Important notes +//! +//! - Looping on `connection.iter()`/`eventloop.poll()` is necessary to run the +//! event loop and make progress. It yields incoming and outgoing activity +//! notifications which allows customization as you see fit. +//! +//! - Blocking inside the `connection.iter()`/`eventloop.poll()` loop will block +//! connection progress. +//! +//! ## FAQ +//! **Connecting to a broker using raw ip doesn't work** +//! +//! You cannot create a TLS connection to a bare IP address with a self-signed +//! certificate. This is a [limitation of rustls](https://github.com/ctz/rustls/issues/184). +//! One workaround, which only works under *nix/BSD-like systems, is to add an +//! entry to wherever your DNS resolver looks (e.g. `/etc/hosts`) for the bare IP +//! address and use that name in your code. +#![cfg_attr(docsrs, feature(doc_cfg))] + +#[macro_use] +extern crate log; + +use std::fmt::{self, Debug, Formatter}; + +#[cfg(any(feature = "use-rustls", feature = "websocket"))] +use std::sync::Arc; + +use std::time::Duration; + +mod client; +mod eventloop; +mod framed; +pub mod mqttbytes; +mod state; +pub mod v5; + +#[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] +mod tls; + +#[cfg(feature = "websocket")] +mod websockets; + +#[cfg(feature = "websocket")] +use std::{ + future::{Future, IntoFuture}, + pin::Pin, +}; + +#[cfg(feature = "websocket")] +type RequestModifierFn = Arc< + dyn Fn(http::Request<()>) -> Pin> + Send>> + + Send + + Sync, +>; + +#[cfg(feature = "proxy")] +mod proxy; + +pub use client::{ + AsyncClient, Client, ClientError, Connection, Iter, RecvError, RecvTimeoutError, TryRecvError, +}; +pub use eventloop::{ConnectionError, Event, EventLoop}; +pub use mqttbytes::v4::*; +pub use mqttbytes::*; +#[cfg(feature = "use-rustls")] +use rustls_native_certs::load_native_certs; +pub use state::{MqttState, StateError}; +#[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] +pub use tls::Error as TlsError; +#[cfg(feature = "use-rustls")] +pub use tokio_rustls; +#[cfg(feature = "use-rustls")] +use tokio_rustls::rustls::{ClientConfig, RootCertStore}; + +#[cfg(feature = "proxy")] +pub use proxy::{Proxy, ProxyAuth, ProxyType}; + +pub type Incoming = Packet; + +/// Current outgoing activity on the eventloop +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outgoing { + /// Publish packet with packet identifier. 0 implies QoS 0 + Publish(u16), + /// Subscribe packet with packet identifier + Subscribe(u16), + /// Unsubscribe packet with packet identifier + Unsubscribe(u16), + /// PubAck packet + PubAck(u16), + /// PubRec packet + PubRec(u16), + /// PubRel packet + PubRel(u16), + /// PubComp packet + PubComp(u16), + /// Ping request packet + PingReq, + /// Ping response packet + PingResp, + /// Disconnect packet + Disconnect, + /// Await for an ack for more outgoing progress + AwaitAck(u16), +} + +/// Requests by the client to mqtt event loop. Request are +/// handled one by one. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Request { + Publish(Publish), + PubAck(PubAck), + PubRec(PubRec), + PubComp(PubComp), + PubRel(PubRel), + PingReq(PingReq), + PingResp(PingResp), + Subscribe(Subscribe), + SubAck(SubAck), + Unsubscribe(Unsubscribe), + UnsubAck(UnsubAck), + Disconnect(Disconnect), +} + +impl Request { + fn size(&self) -> usize { + match &self { + Request::Publish(publish) => publish.size(), + Request::PubAck(puback) => puback.size(), + Request::PubRec(pubrec) => pubrec.size(), + Request::PubComp(pubcomp) => pubcomp.size(), + Request::PubRel(pubrel) => pubrel.size(), + Request::PingReq(pingreq) => pingreq.size(), + Request::PingResp(pingresp) => pingresp.size(), + Request::Subscribe(subscribe) => subscribe.size(), + Request::SubAck(suback) => suback.size(), + Request::Unsubscribe(unsubscribe) => unsubscribe.size(), + Request::UnsubAck(unsuback) => unsuback.size(), + Request::Disconnect(disconn) => disconn.size(), + } + } +} + +impl From for Request { + fn from(publish: Publish) -> Request { + Request::Publish(publish) + } +} + +impl From for Request { + fn from(subscribe: Subscribe) -> Request { + Request::Subscribe(subscribe) + } +} + +impl From for Request { + fn from(unsubscribe: Unsubscribe) -> Request { + Request::Unsubscribe(unsubscribe) + } +} + +/// Transport methods. Defaults to TCP. +#[derive(Clone)] +pub enum Transport { + Tcp, + #[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] + Tls(TlsConfiguration), + #[cfg(unix)] + Unix, + #[cfg(feature = "websocket")] + #[cfg_attr(docsrs, doc(cfg(feature = "websocket")))] + Ws, + #[cfg(all(feature = "use-rustls", feature = "websocket"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "use-rustls", feature = "websocket"))))] + Wss(TlsConfiguration), +} + +impl Default for Transport { + fn default() -> Self { + Self::tcp() + } +} + +impl Transport { + /// Use regular tcp as transport (default) + pub fn tcp() -> Self { + Self::Tcp + } + + #[cfg(feature = "use-rustls")] + pub fn tls_with_default_config() -> Self { + Self::tls_with_config(Default::default()) + } + + /// Use secure tcp with tls as transport + #[cfg(feature = "use-rustls")] + pub fn tls( + ca: Vec, + client_auth: Option<(Vec, Vec)>, + alpn: Option>>, + ) -> Self { + let config = TlsConfiguration::Simple { + ca, + alpn, + client_auth, + }; + + Self::tls_with_config(config) + } + + #[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] + pub fn tls_with_config(tls_config: TlsConfiguration) -> Self { + Self::Tls(tls_config) + } + + #[cfg(unix)] + pub fn unix() -> Self { + Self::Unix + } + + /// Use websockets as transport + #[cfg(feature = "websocket")] + #[cfg_attr(docsrs, doc(cfg(feature = "websocket")))] + pub fn ws() -> Self { + Self::Ws + } + + /// Use secure websockets with tls as transport + #[cfg(all(feature = "use-rustls", feature = "websocket"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "use-rustls", feature = "websocket"))))] + pub fn wss( + ca: Vec, + client_auth: Option<(Vec, Vec)>, + alpn: Option>>, + ) -> Self { + let config = TlsConfiguration::Simple { + ca, + client_auth, + alpn, + }; + + Self::wss_with_config(config) + } + + #[cfg(all(feature = "use-rustls", feature = "websocket"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "use-rustls", feature = "websocket"))))] + pub fn wss_with_config(tls_config: TlsConfiguration) -> Self { + Self::Wss(tls_config) + } + + #[cfg(all(feature = "use-rustls", feature = "websocket"))] + #[cfg_attr(docsrs, doc(cfg(all(feature = "use-rustls", feature = "websocket"))))] + pub fn wss_with_default_config() -> Self { + Self::Wss(Default::default()) + } +} + +/// TLS configuration method +#[derive(Clone, Debug)] +#[cfg(any(feature = "use-rustls", feature = "use-native-tls"))] +pub enum TlsConfiguration { + #[cfg(feature = "use-rustls")] + Simple { + /// connection method + ca: Vec, + /// alpn settings + alpn: Option>>, + /// tls client_authentication + client_auth: Option<(Vec, Vec)>, + }, + #[cfg(feature = "use-native-tls")] + SimpleNative { + /// ca certificate + ca: Vec, + /// pkcs12 binary der and + /// password for use with der + client_auth: Option<(Vec, String)>, + }, + #[cfg(feature = "use-rustls")] + /// Injected rustls ClientConfig for TLS, to allow more customisation. + Rustls(Arc), + #[cfg(feature = "use-native-tls")] + Native, +} + +#[cfg(feature = "use-rustls")] +impl Default for TlsConfiguration { + fn default() -> Self { + let mut root_cert_store = RootCertStore::empty(); + for cert in load_native_certs().expect("could not load platform certs") { + root_cert_store.add(cert).unwrap(); + } + let tls_config = ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + Self::Rustls(Arc::new(tls_config)) + } +} + +#[cfg(feature = "use-rustls")] +impl From for TlsConfiguration { + fn from(config: ClientConfig) -> Self { + TlsConfiguration::Rustls(Arc::new(config)) + } +} + +/// Provides a way to configure low level network connection configurations +#[derive(Clone, Default)] +pub struct NetworkOptions { + tcp_send_buffer_size: Option, + tcp_recv_buffer_size: Option, + conn_timeout: u64, + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + bind_device: Option, +} + +impl NetworkOptions { + pub fn new() -> Self { + NetworkOptions { + tcp_send_buffer_size: None, + tcp_recv_buffer_size: None, + conn_timeout: 5, + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + bind_device: None, + } + } + + pub fn set_tcp_send_buffer_size(&mut self, size: u32) { + self.tcp_send_buffer_size = Some(size); + } + + pub fn set_tcp_recv_buffer_size(&mut self, size: u32) { + self.tcp_recv_buffer_size = Some(size); + } + + /// set connection timeout in secs + pub fn set_connection_timeout(&mut self, timeout: u64) -> &mut Self { + self.conn_timeout = timeout; + self + } + + /// get timeout in secs + pub fn connection_timeout(&self) -> u64 { + self.conn_timeout + } + + /// bind connection to a specific network device by name + #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] + #[cfg_attr( + docsrs, + doc(cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))) + )] + pub fn set_bind_device(&mut self, bind_device: &str) -> &mut Self { + self.bind_device = Some(bind_device.to_string()); + self + } +} + +// TODO: Should all the options be exposed as public? Drawback +// would be loosing the ability to panic when the user options +// are wrong (e.g empty client id) or aggressive (keep alive time) +/// Options to configure the behaviour of MQTT connection +#[derive(Clone)] +pub struct MqttOptions { + /// broker address that you want to connect to + broker_addr: String, + /// broker port + port: u16, + // What transport protocol to use + transport: Transport, + /// MQTT protocol version + protocol: Protocol, + /// keep alive time to send pingreq to broker when the connection is idle + keep_alive: Duration, + /// clean (or) persistent session + clean_session: bool, + /// client identifier + client_id: String, + /// username and password + credentials: Option<(String, String)>, + /// maximum incoming packet size (verifies remaining length of the packet) + max_incoming_packet_size: usize, + /// Maximum outgoing packet size (only verifies publish payload size) + max_outgoing_packet_size: usize, + /// request (publish, subscribe) channel capacity + request_channel_capacity: usize, + /// Max internal request batching + max_request_batch: usize, + /// Minimum delay time between consecutive outgoing packets + /// while retransmitting pending packets + pending_throttle: Duration, + /// maximum number of outgoing inflight messages + inflight: u16, + /// Last will that will be issued on unexpected disconnect + last_will: Option, + /// If set to `true` MQTT acknowledgements are not sent automatically. + /// Every incoming publish packet must be manually acknowledged with `client.ack(...)` method. + manual_acks: bool, + #[cfg(feature = "proxy")] + /// Proxy configuration. + proxy: Option, + #[cfg(feature = "websocket")] + request_modifier: Option, +} + +impl MqttOptions { + /// Create an [`MqttOptions`] object that contains default values for all settings other than + /// - id: A string to identify the device connecting to a broker + /// - host: The broker's domain name or IP address + /// - port: The port number on which broker must be listening for incoming connections + /// + /// ``` + /// # use rumqttc::MqttOptions; + /// let options = MqttOptions::new("123", "localhost", 1883); + /// ``` + pub fn new, T: Into>(id: S, host: T, port: u16) -> MqttOptions { + MqttOptions { + broker_addr: host.into(), + port, + transport: Transport::tcp(), + protocol: Protocol::V4, + keep_alive: Duration::from_secs(60), + clean_session: true, + client_id: id.into(), + credentials: None, + max_incoming_packet_size: 10 * 1024, + max_outgoing_packet_size: 10 * 1024, + request_channel_capacity: 10, + max_request_batch: 0, + pending_throttle: Duration::from_micros(0), + inflight: 100, + last_will: None, + manual_acks: false, + #[cfg(feature = "proxy")] + proxy: None, + #[cfg(feature = "websocket")] + request_modifier: None, + } + } + + #[cfg(feature = "url")] + /// Creates an [`MqttOptions`] object by parsing provided string with the [url] crate's + /// [`Url::parse(url)`](url::Url::parse) method and is only enabled when run using the "url" feature. + /// + /// ``` + /// # use rumqttc::MqttOptions; + /// let options = MqttOptions::parse_url("mqtt://example.com:1883?client_id=123").unwrap(); + /// ``` + /// + /// **NOTE:** A url must be prefixed with one of either `tcp://`, `mqtt://`, `ssl://`,`mqtts://`, + /// `ws://` or `wss://` to denote the protocol for establishing a connection with the broker. + /// + /// **NOTE:** Encrypted connections(i.e. `mqtts://`, `ssl://`, `wss://`) by default use the + /// system's root certificates. To configure with custom certificates, one may use the + /// [`set_transport`](MqttOptions::set_transport) method. + /// + /// ```ignore + /// # use rumqttc::{MqttOptions, Transport}; + /// # use tokio_rustls::rustls::ClientConfig; + /// # let root_cert_store = rustls::RootCertStore::empty(); + /// # let client_config = ClientConfig::builder() + /// # .with_root_certificates(root_cert_store) + /// # .with_no_client_auth(); + /// let mut options = MqttOptions::parse_url("mqtts://example.com?client_id=123").unwrap(); + /// options.set_transport(Transport::tls_with_config(client_config.into())); + /// ``` + pub fn parse_url>(url: S) -> Result { + let url = url::Url::parse(&url.into())?; + let options = MqttOptions::try_from(url)?; + + Ok(options) + } + + /// Broker address + pub fn broker_address(&self) -> (String, u16) { + (self.broker_addr.clone(), self.port) + } + + pub fn set_last_will(&mut self, will: LastWill) -> &mut Self { + self.last_will = Some(will); + self + } + + pub fn last_will(&self) -> Option