diff --git a/Cargo.lock b/Cargo.lock index 16c382b..06e7f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arc-swap" version = "1.8.1" @@ -35,6 +85,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -200,7 +256,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.114", + "syn 2.0.117", "which", ] @@ -223,7 +279,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.114", + "syn 2.0.117", "which", ] @@ -264,7 +320,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -353,6 +409,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -362,6 +458,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -372,6 +474,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -407,6 +518,34 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -461,7 +600,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -483,7 +622,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -539,6 +678,28 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -558,7 +719,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -657,6 +827,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -664,6 +849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -672,6 +858,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.31" @@ -680,7 +883,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -701,21 +904,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generational-box" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557cf2cbacd0504c6bf8c29f52f8071e0de1d9783346713dc6121d7fa1e5d0e0" +dependencies = [ + "parking_lot", +] + [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", ] @@ -753,6 +969,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "grid" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82" + [[package]] name = "h2" version = "0.4.13" @@ -1088,6 +1310,33 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "iocraft" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc99ec2f29c9c5f0ae7862dd6f12b93c9122282c9fe151e8648a9cc7301aa3f" +dependencies = [ + "bitflags", + "crossterm", + "futures", + "generational-box", + "iocraft-macros", + "taffy", + "unicode-width", +] + +[[package]] +name = "iocraft-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a8b12929295d5fea225570fd69a41084e2949d56ce0c20ff22494ef13e0d56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "uuid", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1104,6 +1353,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -1195,12 +1450,33 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1253,6 +1529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1273,7 +1550,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1291,12 +1568,41 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -1346,14 +1652,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1416,9 +1722,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1458,6 +1764,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1563,6 +1878,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1572,10 +1896,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -1679,11 +2016,14 @@ dependencies = [ "axum-extra", "axum-server", "base64", + "clap", "futures-util", + "generic-array", "hmac", "http", "http-body-util", "hyper", + "iocraft", "pin-project-lite", "rand", "reqwest", @@ -1721,7 +2061,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.117", "thiserror 2.0.18", "tracing", "typify", @@ -1767,9 +2107,15 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.6.0" @@ -1830,7 +2176,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1841,7 +2187,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1877,7 +2223,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1929,6 +2275,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1945,6 +2312,15 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -1998,9 +2374,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2024,7 +2400,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2048,6 +2424,19 @@ dependencies = [ "libc", ] +[[package]] +name = "taffy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb893bff0f80ae17d3a57e030622a967b8dbc90e38284d9b4b1442e23873c94" +dependencies = [ + "arrayvec", + "grid", + "num-traits", + "serde", + "slotmap", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2074,7 +2463,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2085,7 +2474,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2146,7 +2535,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2254,7 +2643,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2352,7 +2741,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.117", "thiserror 2.0.18", "unicode-ident", ] @@ -2370,7 +2759,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.114", + "syn 2.0.117", "typify-impl", ] @@ -2380,6 +2769,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" @@ -2410,6 +2811,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" @@ -2514,7 +2921,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2583,9 +2990,25 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", +] + +[[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.11" @@ -2595,6 +3018,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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-core" version = "0.62.2" @@ -2616,7 +3045,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2627,7 +3056,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2918,7 +3347,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -2945,7 +3374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b52318880bad5d5a081f7ac4251b5144e73dfa859cbcd10562d9433eeed6b72" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2977,7 +3406,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2997,7 +3426,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -3037,7 +3466,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 356c093..ffb3589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ base64 = "0.22.1" bon = "3.8.0" chrono = { version = "0.4.42", features = ["serde"] } futures-util = "0.3.31" +generic-array = { version = "=0.14.7", features = ["serde"] } hmac = "0.12.1" http = "1.4.0" hyper = "1.8.1" @@ -39,10 +40,12 @@ schemars = "0.8.22" syn = { version = "2.0.106", features = ["fold"] } typify = "0.5.0" -# Development +# Development and examples axum-server = { version = "0.8.0", features = ["tls-rustls"] } +clap = { version = "4.6.0", features = ["derive"] } eyre = "0.6.12" http-body-util = "0.1.3" +iocraft = "0.8.0" tower = "0.5.3" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } diff --git a/README.md b/README.md index f933f2f..1ffac48 100644 --- a/README.md +++ b/README.md @@ -27,5 +27,22 @@ These crates require the avahi client libraries on linux. On debian or debian-li sudo apt install libavahi-client-dev ``` +## Running full client and server examples + +The full-client and full-server examples provide complete examples of an s2-connect client and server. Both examples assume local running, which means that the server example needs certificates for the hostname of the machine it is being run on. To generate this, run +```sh +cd s2energy-connection/testdata +./gen_cert .local +``` + +After generating the certificates, the server can be run with +```sh +cargo run --example full-server -- +``` +and the client with +```sh +cargo run --example full-client +``` + ## Documentation You can find the crate documentation at [docs.rs](https://docs.rs/s2energy). The crate documentation assumes that you are familiar with S2; if this is not the case, it may be useful to refer to [the S2 documentation website](https://docs.s2standard.org/docs/welcome/). That documentation explains S2 concepts in more detail, and contains a reference of all messages and types in the S2 specification. diff --git a/s2energy-connection/Cargo.toml b/s2energy-connection/Cargo.toml index d2f9c95..b94a23a 100644 --- a/s2energy-connection/Cargo.toml +++ b/s2energy-connection/Cargo.toml @@ -8,6 +8,7 @@ axum.workspace = true axum-extra.workspace = true base64.workspace = true futures-util.workspace = true +generic-array.workspace = true hmac.workspace = true http.workspace = true hyper.workspace = true @@ -32,7 +33,9 @@ zeroconf-tokio.workspace = true [dev-dependencies] axum-server.workspace = true +clap.workspace = true http-body-util.workspace = true +iocraft.workspace = true tokio = { workspace = true, features = ["signal", "macros", "rt", "test-util"] } tower.workspace = true tracing-subscriber.workspace = true diff --git a/s2energy-connection/examples/communication-client.rs b/s2energy-connection/examples/communication-client.rs index 6d42f5d..2b1e3ce 100644 --- a/s2energy-connection/examples/communication-client.rs +++ b/s2energy-connection/examples/communication-client.rs @@ -35,6 +35,10 @@ impl ClientPairing for &mut MemoryPairing { &self.communication_url } + fn certificate_hash(&self) -> Option { + None + } + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { self.tokens = tokens; Ok(()) diff --git a/s2energy-connection/examples/communication-server.rs b/s2energy-connection/examples/communication-server.rs index ec8a9b9..e9f9e2b 100644 --- a/s2energy-connection/examples/communication-server.rs +++ b/s2energy-connection/examples/communication-server.rs @@ -92,7 +92,7 @@ async fn main() { .with(EnvFilter::from_default_env()) .init(); - let mut server = Server::new( + let server = Server::new( ServerConfig { base_url: "localhost:8005".into(), endpoint_description: None, diff --git a/s2energy-connection/examples/full-client.rs b/s2energy-connection/examples/full-client.rs new file mode 100644 index 0000000..99aa2b3 --- /dev/null +++ b/s2energy-connection/examples/full-client.rs @@ -0,0 +1,578 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use iocraft::prelude::*; +use s2energy_common::S2Transport; +use s2energy_connection::{ + AccessToken, CertificateHash, Deployment, EndpointDescription, MessageVersion, NodeDescription, NodeId, Role, + communication::{self, ClientPairing, ConnectionInfo}, + discovery::{DiscoverableS2Endpoint, DiscoveryEvent, S2Discoverer}, + pairing::{self, LongpollHandler, PairingRemote, PairingToken, RemoteNodeIdentifier}, +}; +use tokio::sync::mpsc::UnboundedSender; +use tracing_subscriber::{ + EnvFilter, + fmt::{self, MakeWriter}, +}; + +#[derive(Default, Props)] +struct DiscoveryViewProps<'a> { + width: u16, + height: u16, + focus: bool, + discovered_nodes: Vec<(&'a str, Option<&'a str>, &'a NodeDescription)>, + want_pairing: Handler, +} + +#[component] +fn DiscoveryView<'a>(mut hooks: Hooks, props: &mut DiscoveryViewProps<'a>) -> impl Into> { + let mut selected_discovery = hooks.use_state(|| 0usize); + let focus = props.focus; + + if selected_discovery.get() != 0 && selected_discovery.get() > props.discovered_nodes.len() { + selected_discovery.set(props.discovered_nodes.len()); + } + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Up if focus => selected_discovery.set(selected_discovery.get().saturating_sub(1)), + KeyCode::Down if focus => selected_discovery.set(selected_discovery.get().saturating_add(1)), + _ => {} + }, + _ => {} + } + }); + + element! { + View( + width: props.width, + height: props.height, + border_style: if props.focus { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + Text(content: "Discovered CEMs:", weight: Weight::Bold) + ScrollView() { + View( + width: props.width, + flex_direction: FlexDirection::Column, + ) { + #(props.discovered_nodes.iter().enumerate().map(|(i, (hostname, pairing_url, node))| { + let handler = if let Some(remote) = pairing_url.map(|url| PairingRemote{url: url.to_owned(), id: RemoteNodeIdentifier::Id(node.id)}) { + props.want_pairing.bind(remote) + } else { + Handler::default() + }; + let selected = props.focus && i == selected_discovery.get(); + element! { + Button(has_focus: selected, handler) { + View(width: props.width, background_color: if selected { Color::White } else { Color::Black }) { + Text(content: format!("{} ({} from {}) at {}", node.id, node.model_name, node.brand, *hostname), color: if selected { Color::Black } else { Color::White }) + } + } + } + })) + } + } + } + } +} + +#[derive(Default, Props)] +struct ClientUIProps { + discovered_clients: Vec<(String, (DiscoverableS2Endpoint, EndpointDescription, Vec))>, + initiate_pairing: HandlerMut<'static, PairingTrigger>, + reconnect: HandlerMut<'static, ()>, + send_message: HandlerMut<'static, ()>, + unpair: HandlerMut<'static, ()>, + log: String, + token: String, + have_pairing: bool, +} + +#[component] +fn ClientUI<'a>(mut hooks: Hooks, props: &'a mut ClientUIProps) -> impl Into> { + let (width, height) = hooks.use_terminal_size(); + let mut focus_top = hooks.use_state(|| true); + let mut want_pair_with = hooks.use_state(|| None); + let mut token = hooks.use_state(|| String::default()); + let mut last_error = hooks.use_state(|| ""); + + let mut initiate_pairing = props.initiate_pairing.take(); + let mut reconnect = props.reconnect.take(); + let mut send_message = props.send_message.take(); + let mut unpair = props.unpair.take(); + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Tab => focus_top.set(!focus_top.get()), + KeyCode::Enter => { + if token.read().len() != 0 + && let Ok(token) = token.read().parse() + { + match want_pair_with.write().take() { + Some(remote) => initiate_pairing(PairingTrigger { remote, token }), + None => {} + } + } else if token.read().len() != 0 && want_pair_with.read().is_some() { + last_error.set("Invalid token"); + } + } + KeyCode::Esc => { + want_pair_with.write().take(); + } + KeyCode::Char('r') if want_pair_with.read().is_none() => reconnect(()), + KeyCode::Char('s') if want_pair_with.read().is_none() => send_message(()), + KeyCode::Char('u') if want_pair_with.read().is_none() => unpair(()), + _ => {} + }, + _ => {} + } + }); + + let discovered_nodes: Vec<_> = props + .discovered_clients + .iter() + .flat_map(|(hostname, (endpoint, _, nodes))| nodes.iter().map(|node| (hostname.as_str(), endpoint.pairing_url(), node))) + .collect(); + + element! { + View( + width, + height, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Stretch, + ) { + DiscoveryView( + width, + height: height - (height/2 + 1), + focus: want_pair_with.read().is_none() && focus_top.get(), + discovered_nodes, + want_pairing: move |remote| { want_pair_with.clone().set(Some(remote)); token.clone().set(String::default()); }, + ) + View( + width, + height: height/2, + border_style: if want_pair_with.read().is_none() && !focus_top.get() { BorderStyle::Double } else { BorderStyle::Single }, + ) { + ScrollView ( + auto_scroll: true, + keyboard_scroll: want_pair_with.read().is_none() && !focus_top.get(), + ) { + Text(content: &props.log) + } + } + #(if props.have_pairing { + element! { + View( + width, + height: 1, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ) { + Text(content: "(R)econnect") + Text(content: "(S)end message") + Text(content: "(U)npair") + Text(content: format!("Our token: {}", props.token)) + } + } + } else { + element! { + View( + width, + height: 1, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ) { + Text(content: "Start pairing by selecting a CEM with the arrow keys and pressing enter.") + Text(content: format!("Our token: {}", props.token)) + } + } + }) + #(if want_pair_with.read().is_some() { + element! { + View( + position: Position::Absolute, + flex_direction: FlexDirection::Column, + top: 5, + left: 5, + bottom: 5, + right: 5, + border_style: BorderStyle::Double, + background_color: Color::Black, + ) { + Text(content: "Token:") + View( width: 30, height: 1, background_color: Color::DarkGrey, ) { + TextInput( + has_focus: true, + value: token.to_string(), + on_change: move |new_token| token.set(new_token), + ) + } + Text(content: last_error.get()) + } + } + } else { + element! {View (display: Display::None) {}} + }) + } + } +} + +struct PairingData { + client_id: NodeId, + server_id: NodeId, + communication_url: String, + access_tokens: std::sync::Mutex>, + certificate_hash: Option, +} + +#[derive(Clone)] +struct Pairing(Arc); + +impl ClientPairing for Pairing { + type Error = std::convert::Infallible; + + fn client_id(&self) -> NodeId { + self.0.client_id + } + + fn server_id(&self) -> NodeId { + self.0.server_id + } + + fn access_tokens(&self) -> impl AsRef<[AccessToken]> { + self.0.access_tokens.lock().unwrap().clone() + } + + fn communication_url(&self) -> impl AsRef { + &self.0.communication_url + } + + fn certificate_hash(&self) -> Option { + self.0.certificate_hash.clone() + } + + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { + *self.0.access_tokens.lock().unwrap() = tokens; + Ok(()) + } +} + +struct PairingTrigger { + remote: PairingRemote, + token: PairingToken, +} + +struct LogWriter(State); +impl<'a> MakeWriter<'a> for LogWriter { + type Writer = &'a Self; + + fn make_writer(&'a self) -> Self::Writer { + self + } +} + +impl std::io::Write for &LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.clone().write().push_str(&String::from_utf8_lossy(buf)); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[component] +fn Root(mut hooks: Hooks) -> impl Into> { + let token = hooks.use_state(|| PairingToken::new_static()); + let client_config = hooks.use_state(|| pairing::ClientConfig { + additional_certificates: vec![], + endpoint_description: EndpointDescription::default(), + pairing_deployment: Deployment::Lan, + }); + + let node_description = hooks.use_state(|| NodeDescription { + id: NodeId::new(), + brand: String::from("test"), + logo_url: None, + type_: String::from("fancy"), + model_name: String::from("test client"), + user_defined_name: None, + role: Role::Rm, + }); + + let pairing_node_config = hooks.use_state(|| { + pairing::NodeConfig::builder(node_description.read().clone(), vec![MessageVersion("v1".into())]) + .build() + .unwrap() + }); + + let mut discovered_clients = hooks.use_state(|| vec![]); + + let mut log = hooks.use_state(|| String::default()); + + hooks.use_state(|| { + fmt::fmt() + .with_writer(LogWriter(log)) + .with_env_filter(EnvFilter::from_default_env()) + .init() + }); + + let mut pairing_channel = hooks.use_state(|| tokio::sync::watch::channel::>(None)); + let mut pairing_trigger_channel = hooks.use_state(|| { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + (tx, Some(rx)) + }); + let mut active_longpollers = hooks.use_state(|| HashSet::new()); + + // Handle longpolling + let handle_longpolling_url = hooks.use_async_handler(move |url: String| async move { + if !active_longpollers.write().insert(url.clone()) { + return; + } + + let client = pairing::Client::new(client_config.read().clone()).expect("Unable to create client"); + match client.longpoller(url.clone()).await { + Ok(longpoller) => { + let pairing_node_config = pairing_node_config.read().clone(); + longpoller.add_node(pairing_node_config).unwrap(); + struct LPHandler(UnboundedSender, String, PairingToken); + impl LongpollHandler for LPHandler { + async fn request_pairing(&mut self, _node: NodeId) -> bool { + self.0 + .send(PairingTrigger { + remote: PairingRemote { + url: self.1.clone(), + id: RemoteNodeIdentifier::None, + }, + token: self.2.clone(), + }) + .unwrap(); + true + } + async fn prepare_pairing(&mut self, _node: NodeId) {} + async fn cancel_prepare_pairing(&mut self, _node: NodeId) {} + } + log.write().push_str(&format!("Starting longpolling with {}.\n", url)); + let channel_sender = pairing_trigger_channel.read().0.clone(); + let token = token.read().clone(); + if let Err(error) = longpoller.run(&mut LPHandler(channel_sender, url.clone(), token)).await { + log.write().push_str(&format!("Error during longpolling with {}: {}\n", url, error)); + } else { + log.write().push_str(&format!("Finished longpolling with {}.\n", url)); + } + } + Err(error) => { + log.write().push_str(&format!("Could not longpoll with {}: {}\n", url, error)); + } + } + + active_longpollers.write().remove(&url); + }); + + // Handle discovery events + hooks.use_future(async move { + let mut discoverer = S2Discoverer::new(Role::Cem).await.unwrap(); + let client = pairing::Client::new(client_config.read().clone()).expect("Could not setup pairing client"); + loop { + let event = discoverer.next_event().await; + match event { + Ok(DiscoveryEvent::NewEndpoint { hostname, endpoint }) => { + log.write().push_str(&format!( + "New endpoint lp: {:?}, pair: {:?}\n", + endpoint.longpolling_url(), + endpoint.pairing_url() + )); + if let Some(longpolling_url) = endpoint.longpolling_url() { + handle_longpolling_url(longpolling_url.to_owned()); + } + if let Some(remote) = endpoint.pairing_url().or(endpoint.longpolling_url()) + && let Ok((endpoint_description, node_descriptions)) = client.get_endpoint_descriptors(remote.into()).await + { + let mut clients = discovered_clients.write(); + if let Some(i) = clients + .iter() + .enumerate() + .filter_map(|(i, (h, _))| if *h == *hostname { Some(i) } else { None }) + .next() + { + clients[i] = (hostname, (endpoint, endpoint_description, node_descriptions)); + } else { + clients.push((hostname, (endpoint, endpoint_description, node_descriptions))); + } + } else { + let mut clients = discovered_clients.write(); + clients.retain(|(h, _)| *h != hostname); + } + } + Ok(DiscoveryEvent::RemovedEndpoint { hostname }) => { + let mut clients = discovered_clients.write(); + clients.retain(|(h, _)| *h != hostname); + } + _ => {} + } + } + }); + + // Handle the actual pairing + hooks.use_future(async move { + let mut pair_receiver = pairing_trigger_channel.write().1.take().unwrap(); + let client = pairing::Client::new(client_config.read().clone()).expect("Unable to create client"); + while let Some(trigger) = pair_receiver.recv().await { + let pairing_node_config = pairing_node_config.read().clone(); + let client_id = pairing_node_config.node_description().id; + if let Err(error) = client + .pair( + &pairing_node_config, + trigger.remote, + trigger.token.as_slice(), + async move |pairing| { + match pairing.role { + pairing::PairingRole::CommunicationClient { initiate_url, root_hash } => { + log.write() + .push_str(&format!("Succesfully paired with {}\n", pairing.remote_node_description.id)); + pairing_channel + .write() + .0 + .send(Some(Pairing(Arc::new(PairingData { + client_id, + server_id: pairing.remote_node_description.id, + communication_url: initiate_url, + access_tokens: std::sync::Mutex::new(vec![pairing.token]), + certificate_hash: root_hash, + })))) + .unwrap(); + } + pairing::PairingRole::CommunicationServer => unreachable!("Got a server connection as LAN RM"), + }; + Ok::<_, std::convert::Infallible>(()) + }, + ) + .await + { + log.write().push_str(&format!("Error during pairing: {}\n", error)); + } + } + }); + + // Handle connecting to the remote. + let mut message_trigger_channel = hooks.use_state(|| { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + (tx, Some(rx)) + }); + hooks.use_future(async move { + let mut pairing_receiver = pairing_channel.read().1.clone(); + let mut send_message_trigger = message_trigger_channel.write().1.take().unwrap(); + let node_config = communication::NodeConfig::builder(vec![MessageVersion("v1".into())]).build(); + let client = communication::Client::new( + communication::ClientConfig { + additional_certificates: vec![], + endpoint_description: None, + }, + Arc::new(node_config), + ); + loop { + let current_pairing = pairing_receiver.borrow_and_update().clone(); + while send_message_trigger.try_recv().is_ok() {} + if let Some(pairing) = current_pairing { + log.write().push_str("Starting to connect\n"); + match client.connect(pairing).await { + Ok(ConnectionInfo { + mut transport, + .. + }) => { + log.write().push_str("Established new connection\n"); + loop { + tokio::select! { + message = transport.receive::() => { + match message { + Ok(message) => log.write().push_str(&format!("Received message: {}\n", serde_json::to_string_pretty(&message).unwrap())), + Err(error) => { + log.write().push_str(&format!("Error receiving from remote: {}\n", error)); + // Reconnect + transport.disconnect().await; + break; + } + }; + } + _ = send_message_trigger.recv() => { + if let Err(error) = transport.send("hello from client").await { + log.write().push_str(&format!("Error sending to remote: {}\n", error)); + // Reconnect + transport.disconnect().await; + break; + } else { + log.write().push_str("Sent message to remote.\n"); + } + } + _ = pairing_receiver.changed() => { + transport.disconnect().await; + log.write().push_str("Disconnected from remote\n"); + break; + } + } + } + }, + Err(error) => match error.kind() { + communication::ErrorKind::InvalidUrl | communication::ErrorKind::Unpaired | communication::ErrorKind::NotPaired => { + log.write().push_str(&format!("Failed to connect: {}\n", error)); + // Forget the pairing + pairing_channel.write().0.send(None).unwrap(); + } + communication::ErrorKind::TransportFailed + | communication::ErrorKind::ProtocolError + | communication::ErrorKind::NoSupportedVersion + | communication::ErrorKind::Storage => { + // Retry after waiting a bit + log.write().push_str(&format!("Failed to connect: {}\n", error)); + tokio::time::sleep(Duration::from_secs(1)).await; + } + }, + } + } else { + pairing_receiver.wait_for(|v| { + v.is_some() + }).await.unwrap(); + } + } + }); + + let unpair = hooks.use_async_handler(move |_| async move { + let pairing = pairing_channel.write().0.send_replace(None); + if let Some(pairing) = pairing { + log.write().push_str("Unpairing...\n"); + let node_config = communication::NodeConfig::builder(vec![MessageVersion("v1".into())]).build(); + let client = communication::Client::new( + communication::ClientConfig { + additional_certificates: vec![], + endpoint_description: None, + }, + Arc::new(node_config), + ); + if let Err(error) = client.unpair(pairing).await { + log.write().push_str(&format!("Error during unpairing: {}\n", error)); + } else { + log.write().push_str("Succesfully unpaired.\n"); + } + } + }); + + element! { + ClientUI ( + discovered_clients: discovered_clients.read().clone(), + initiate_pairing: move |trigger| {pairing_trigger_channel.read().0.send(trigger).unwrap();}, + reconnect: move |_| { pairing_channel.read().0.send_modify(|_| {}); }, + unpair, + send_message: move |_| { message_trigger_channel.read().0.send(()).unwrap(); }, + log: log.read().to_string(), + have_pairing: pairing_channel.read().0.borrow().is_some(), + token: token.to_string(), + ) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + element!(Root).render_loop().await.expect("Unexpected failure of renderer"); +} diff --git a/s2energy-connection/examples/full-server.rs b/s2energy-connection/examples/full-server.rs new file mode 100644 index 0000000..df71e0a --- /dev/null +++ b/s2energy-connection/examples/full-server.rs @@ -0,0 +1,577 @@ +use axum_server::tls_rustls::RustlsConfig; +use clap::Parser; +use iocraft::prelude::*; +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use s2energy_common::S2Transport; +use s2energy_connection::{ + EndpointDescription, MessageVersion, NodeDescription, NodeId, Role, + combined_server::{self, CombinedServerPairingStore, ServerCertificates, ServerConfig}, + communication::{self, ConnectionInfo, PairingLookupResult, ServerPairing, ServerPairingStore}, + discovery::{DiscoverableS2Endpoint, advertise}, + pairing::{self, LongpollingHandle, PairingToken}, +}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use tracing_subscriber::{ + EnvFilter, + fmt::{self, MakeWriter}, +}; + +#[derive(Default, Props)] +struct LongpollingListProps<'a> { + width: u16, + height: u16, + focus: bool, + longpolling_clients: &'a [NodeId], + want_pair: Handler, +} + +#[component] +fn LongpollingList<'a>(mut hooks: Hooks, props: &LongpollingListProps<'a>) -> impl Into> { + let mut selected = hooks.use_state(|| 0usize); + let focus = props.focus; + + if selected.get() != 0 && selected.get() > props.longpolling_clients.len() { + selected.set(props.longpolling_clients.len()); + } + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Up if focus => selected.set(selected.get().saturating_sub(1)), + KeyCode::Down if focus => selected.set(selected.get().saturating_add(1)), + _ => {} + }, + _ => {} + } + }); + + element! { + View ( + width: props.width, + height: props.height, + border_style: if props.focus { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + Text(content: "Longpolling clients:", weight: Weight::Bold) + ScrollView() { + View( + width: props.width, + flex_direction: FlexDirection::Column, + ) { + #(props.longpolling_clients.iter().enumerate().map(|(i, &id)| { + let handler = props.want_pair.bind(id); + let selected = props.focus && i == selected.get(); + element! { + Button(has_focus: selected, handler) { + View(width: props.width, background_color: if selected { Color::White } else { Color::Black }) { + Text(content: format!("{}", id), color: if selected { Color::Black } else { Color::White }) + } + } + } + })) + } + } + } + } +} + +#[derive(Default, Props)] +struct ClientUIProps { + paired_clients: Vec, + longpolling_clients: Vec, + log: String, + token: Option, + longpolling_pair: HandlerMut<'static, (NodeId, PairingToken)>, + pair: HandlerMut<'static, ()>, +} + +#[component] +fn ClientUI<'a>(mut hooks: Hooks, props: &'a mut ClientUIProps) -> impl Into> { + let (width, height) = hooks.use_terminal_size(); + let mut focus = hooks.use_state(|| 0); + let mut token = hooks.use_state(|| String::default()); + let mut want_pair_with = hooks.use_state(|| None); + let mut last_error = hooks.use_state(|| ""); + let mut available_for_pairing = hooks.use_state(|| false); + + if available_for_pairing.get() != props.token.is_some() { + available_for_pairing.set(props.token.is_some()) + } + + let mut longpolling_pair = props.longpolling_pair.take(); + let mut pair = props.pair.take(); + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Tab if want_pair_with.read().is_none() => focus.set((focus.get() + 1) % 3), + KeyCode::BackTab if want_pair_with.read().is_none() => focus.set((focus.get() + 2) % 3), + KeyCode::End => want_pair_with.set(None), + KeyCode::Enter => { + if token.read().len() != 0 + && let Ok(token) = token.read().parse() + { + match want_pair_with.write().take() { + Some(remote) => longpolling_pair((remote, token)), + None => {} + } + } else if token.read().len() != 0 && want_pair_with.read().is_some() { + last_error.set("Invalid token"); + } + } + KeyCode::Char('p') if want_pair_with.read().is_none() && !available_for_pairing.get() => { + pair(()); + } + _ => {} + }, + _ => {} + } + }); + let height_list = (height - 1) / 3; + let height_log = height - 1 - 2 * height_list; + element! { + View( + width, + height, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Stretch, + ) { + LongpollingList ( + width, + height: height_list, + focus: !available_for_pairing.get() && want_pair_with.read().is_none() && focus == 0, + longpolling_clients: props.longpolling_clients.as_slice(), + want_pair: move |id| {token.clone().set(String::default()); want_pair_with.clone().set(Some(id)); last_error.clone().set("")} + ) + View( + width, + height: height_list, + border_style: if !available_for_pairing.get() && want_pair_with.read().is_none() && focus == 1 { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + Text(content: "Paired clients:", weight: Weight::Bold) + #(props.paired_clients.iter().map(|id| element! { Text(content: id.to_string())} )) + } + View( + width, + height: height_log, + border_style: if !available_for_pairing.get() && want_pair_with.read().is_none() && focus == 2 { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + ScrollView ( + auto_scroll: true, + keyboard_scroll: want_pair_with.read().is_none() && focus == 2, + ) { + Text(content: &props.log) + } + } + View( + width, + height: 1, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ) { + Text(content: "Become available for (p)airing") + } + #(if want_pair_with.read().is_some() { + element! { + View( + position: Position::Absolute, + flex_direction: FlexDirection::Column, + top: 5, + left: 5, + bottom: 5, + right: 5, + border_style: BorderStyle::Double, + background_color: Color::Black, + ) { + Text(content: "Token:") + View( width: 30, height: 1, background_color: Color::DarkGrey, ) { + TextInput( + has_focus: true, + value: token.to_string(), + on_change: move |new_token| token.set(new_token), + ) + } + Text(content: last_error.get()) + } + } + } else if let Some(token) = props.token.as_ref() { + element! { + View( + position: Position::Absolute, + flex_direction: FlexDirection::Column, + top: 5, + left: 5, + bottom: 5, + right: 5, + border_style: BorderStyle::Double, + background_color: Color::Black, + ) { + Text(content: format!("Available for pairing with token: {}", token)) + } + } + } else { + element! {View (display: Display::None) {}} + }) + } + } +} + +struct LogWriter(State); +impl<'a> MakeWriter<'a> for LogWriter { + type Writer = &'a Self; + + fn make_writer(&'a self) -> Self::Writer { + self + } +} + +impl std::io::Write for &LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.clone().write().push_str(&String::from_utf8_lossy(buf)); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct PairingStoreData { + local_config: Arc, + pairings: HashMap, + log: State, +} + +#[derive(Clone)] +struct PairingStore(State); + +impl PairingStore { + fn hook_new(hooks: &mut Hooks, log: State, node_description: NodeDescription, message_versions: Vec) -> Self { + Self(hooks.use_state(|| { + let local_config = Arc::new( + communication::NodeConfig::builder(message_versions) + .with_node_description(node_description) + .build(), + ); + PairingStoreData { + local_config, + pairings: HashMap::new(), + log, + } + })) + } +} + +struct Pairing { + store: PairingStore, + id: NodeId, +} + +impl ServerPairingStore for PairingStore { + type Error = std::convert::Infallible; + + type Pairing<'a> + = Pairing + where + Self: 'a; + + async fn lookup( + &self, + request: s2energy_connection::communication::PairingLookup, + ) -> Result>, Self::Error> { + let this = self.0.read(); + if + /*this.local_config.node_description().map_or(true, |v| v.id == request.server) &&*/ + this.pairings.contains_key(&request.client) { + Ok(PairingLookupResult::Pairing(Pairing { + store: self.clone(), + id: request.client, + })) + } else { + Ok(PairingLookupResult::NeverPaired) + } + } +} + +impl CombinedServerPairingStore for PairingStore { + async fn store(&self, _local_node: NodeId, pairing: pairing::Pairing) -> Result<(), Self::Error> { + let mut this = self.0.clone(); + let mut this = this.write(); + this.log.write().push_str(&format!( + "New pairing with {} ({} from {})\n", + pairing.remote_node_description.id, pairing.remote_node_description.model_name, pairing.remote_node_description.brand + )); + this.pairings.insert(pairing.remote_node_description.id, pairing); + Ok(()) + } +} + +impl ServerPairing for Pairing { + type Error = std::convert::Infallible; + + fn access_token(&self) -> impl AsRef { + self.store.0.read().pairings.get(&self.id).unwrap().token.clone() + } + + fn config(&self) -> impl AsRef { + self.store.0.read().local_config.clone() + } + + async fn set_access_token(&mut self, token: s2energy_connection::AccessToken) -> Result<(), Self::Error> { + self.store.0.write().pairings.get_mut(&self.id).unwrap().token = token; + Ok(()) + } + + async fn unpair(mut self) -> Result<(), Self::Error> { + let mut this = self.store.0.write(); + if let Some(pairing) = this.pairings.remove(&self.id) { + this.log.write().push_str(&format!( + "Unpaired with {} ({} from {})\n", + pairing.remote_node_description.id, pairing.remote_node_description.model_name, pairing.remote_node_description.brand + )); + } + Ok(()) + } +} + +#[derive(Default, Props)] +struct RootProps { + hostname: String, +} + +#[derive(Clone)] +struct LongpollerActions { + pair: tokio::sync::mpsc::UnboundedSender<()>, +} + +#[component] +fn Root(mut hooks: Hooks, props: &RootProps) -> impl Into> { + let mut log = hooks.use_state(|| String::default()); + + hooks.use_state(|| { + fmt::fmt() + .with_writer(LogWriter(log)) + .with_env_filter(EnvFilter::from_default_env()) + .init() + }); + + let mut token = hooks.use_state(|| None); + let node_description = hooks.use_state(|| NodeDescription { + id: NodeId::new(), + brand: String::from("test"), + logo_url: None, + type_: String::from("fancy"), + model_name: String::from("test server"), + user_defined_name: None, + role: Role::Cem, + }); + + let store = PairingStore::hook_new(&mut hooks, log, node_description.read().clone(), vec![MessageVersion("v1".into())]); + + let store_copy = store.clone(); + let server = hooks.use_state(|| { + let hostname = props.hostname.clone(); + let cert_chain = CertificateDer::pem_file_iter( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{hostname}.local.chain.pem")), + ) + .expect(&format!("Unable to load certificates. Did you generate certificates for your hostname by running ./gen_cert {hostname}.local in the s2energy-connection/testdata folder?")) + .collect::, _>>() + .unwrap(); + + let server = combined_server::Server::new( + ServerConfig { + base_url: format!("{hostname}.local:8000"), + certificates: Some(ServerCertificates { + leaf_certificate: cert_chain.first().unwrap().clone().into_owned(), + root_certificate: cert_chain.last().unwrap().clone().into_owned(), + }), + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![node_description.read().clone()], + }, + store_copy, + ).unwrap(); + + server + }); + + let mut active_longpollers = hooks.use_state(|| HashMap::new()); + + let handle_longpoller = hooks.use_async_handler(move |mut handle: LongpollingHandle| async move { + log.write() + .push_str(&format!("New longpolling session from {}\n", handle.client_id())); + let (pair, mut pair_rx) = tokio::sync::mpsc::unbounded_channel(); + active_longpollers.write().insert(handle.client_id(), LongpollerActions { pair }); + loop { + tokio::select! { + _ = pair_rx.recv() => { + if let Err(error) = handle.request_pairing().await { + log.write().push_str(&format!("Longpolling remote indicated inability to pair: {}", error)); + } + } + _ = handle.wait_dropped() => { + log.write().push_str(&format!("Remote stopped longpolling")); + break; + } + } + } + log.write().push_str("Ended longpolling session.\n"); + active_longpollers.write().remove(&handle.client_id()); + }); + + let server_copy = server.read().clone(); + hooks.use_future(async move { + let server = server_copy; + server.enable_longpolling().await; + loop { + let handle = server.get_longpolling().await; + handle_longpoller(handle); + } + }); + + let handle_connection = hooks.use_async_handler( + move |(pairing, mut connection): (communication::PairingLookup, ConnectionInfo)| async move { + log.write() + .push_str(&format!("New communication session from {}\n", pairing.client)); + connection.transport.send("Hello from server").await.ok(); + while let Ok(value) = connection.transport.receive::().await { + log.write().push_str(&format!( + "Received message from {}: {}\n", + pairing.client, + serde_json::to_string_pretty(&value).unwrap() + )); + } + }, + ); + + let server_copy = server.read().clone(); + hooks.use_future(async move { + let server = server_copy; + loop { + let connection = server.next_connection().await; + handle_connection(connection); + } + }); + + let hostname = props.hostname.clone(); + hooks.use_future(async move { + let rustls_config = RustlsConfig::from_pem_file( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{hostname}.local.chain.pem")), + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{hostname}.local.key")), + ) + .await + .unwrap(); + + let router = server.read().get_router(); + let addr = SocketAddr::from(([0, 0, 0, 0], 8000)); + axum_server::bind_rustls(addr, rustls_config) + .serve(router.into_make_service()) + .await + .unwrap(); + }); + + let hostname = props.hostname.clone(); + hooks.use_future(async move { + let endpoint = DiscoverableS2Endpoint::build_with_pairing(vec![Role::Cem], format!("https://{hostname}.local:8000")) + .unwrap() + .with_endpoint_name("Example full server".into()) + .with_longpolling_url(format!("https://{hostname}.local:8000")) + .unwrap() + .build(); + log.write().push_str(&format!( + "Advertising with lp: {:?}, pair: {:?}\n", + endpoint.longpolling_url(), + endpoint.pairing_url() + )); + let _advertisement = advertise(8000, endpoint).await.unwrap(); + std::future::pending().await + }); + + let mut paired_clients: Vec<_> = store.0.read().pairings.keys().copied().collect(); + paired_clients.sort(); + + let mut longpolling_clients: Vec<_> = active_longpollers.read().keys().copied().collect(); + longpolling_clients.sort(); + + let longpolling_pair = hooks.use_async_handler(move |(id, token): (NodeId, PairingToken)| async move { + let Some(v) = active_longpollers.read().get(&id).cloned() else { + log.write() + .push_str("Unable to pair with requested node, it stopped longpolling.\n"); + return; + }; + + if let Err(error) = server.read().allow_pair_once( + node_description.read().clone(), + vec![MessageVersion("v1".into())], + None, + token, + async |_| {}, + ) { + log.write().push_str(&format!("Unable to pair: {}\n", error)); + return; + } + if v.pair.send(()).is_err() { + log.write() + .push_str("Unable to pair with requested node, it stopped longpolling.\n"); + } else { + log.write().push_str("Requested remote to start pairing.\n"); + } + }); + + let pair = hooks.use_async_handler(move |_: ()| async move { + let cur_token = PairingToken::new(); + token.set(Some(cur_token.clone())); + + let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); + + if let Err(error) = server.read().allow_pair_once( + node_description.read().clone(), + vec![MessageVersion("v1".into())], + None, + cur_token, + async move |_| { + finished_tx.send(()).ok(); + }, + ) { + log.write().push_str(&format!("Could not start pairing session: {}.", error)); + } else { + tokio::time::timeout(Duration::from_mins(10), finished_rx).await.ok(); + } + + token.set(None); + }); + + let token = token.read().as_ref().map(|v| v.to_string()); + element! { + ClientUI ( + log: log.to_string(), + token, + paired_clients, + longpolling_clients, + longpolling_pair, + pair + ) + } +} + +/// Demonstration server for S2-connect +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + hostname: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let hostname = Args::parse().hostname; + element!(Root(hostname)) + .render_loop() + .await + .expect("Unexpected failure of renderer"); +} diff --git a/s2energy-connection/examples/pairing-client.rs b/s2energy-connection/examples/pairing-client.rs index 3748f17..f3a01ab 100644 --- a/s2energy-connection/examples/pairing-client.rs +++ b/s2energy-connection/examples/pairing-client.rs @@ -4,7 +4,7 @@ use uuid::uuid; use s2energy_connection::{ Deployment, EndpointDescription, MessageVersion, NodeDescription, Role, - pairing::{Client, ClientConfig, NodeConfig, NodeIdAlias, PairingRemote}, + pairing::{Client, ClientConfig, NodeConfig, NodeIdAlias, PairingRemote, RemoteNodeIdentifier}, }; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; @@ -29,7 +29,7 @@ async fn main() { }, vec![MessageVersion("v1".into())], ) - .with_connection_initiate_url("client.example.com".into()) + .with_connection_initiate_url("https://client.example.com".into()) .build() .unwrap(); @@ -48,7 +48,7 @@ async fn main() { &config, PairingRemote { url: "https://localhost:8005".into(), - id: Some(NodeIdAlias("ninechars".into())), + id: RemoteNodeIdentifier::Alias(NodeIdAlias("ninechars".into())), }, PAIRING_TOKEN, async |pairing| { @@ -61,7 +61,7 @@ async fn main() { let pair_result = rx.await.unwrap(); match pair_result.role { - s2energy_connection::pairing::PairingRole::CommunicationClient { initiate_url } => { + s2energy_connection::pairing::PairingRole::CommunicationClient { initiate_url, .. } => { println!("Paired as client, url: {initiate_url}, token: {}", pair_result.token.0) } s2energy_connection::pairing::PairingRole::CommunicationServer => println!("Paired as server, token: {}", pair_result.token.0), diff --git a/s2energy-connection/examples/pairing-server.rs b/s2energy-connection/examples/pairing-server.rs index a9f8380..5d3c694 100644 --- a/s2energy-connection/examples/pairing-server.rs +++ b/s2energy-connection/examples/pairing-server.rs @@ -35,7 +35,7 @@ async fn main() { }, vec![MessageVersion("v1".into())], ) - .with_connection_initiate_url("test.example.com".into()) + .with_connection_initiate_url("https://test.example.com".into()) .build() .unwrap(); let app = server.get_router(); diff --git a/s2energy-connection/src/combined_server.rs b/s2energy-connection/src/combined_server.rs new file mode 100644 index 0000000..f03fb38 --- /dev/null +++ b/s2energy-connection/src/combined_server.rs @@ -0,0 +1,234 @@ +//! A combined communication and pairing server. +//! +//! This provides a convenient combined server for pairing and communication. +//! An complete example on how to use this can be found in the examples folder. + +use std::sync::Arc; + +use axum::{Router, routing::get}; +use rustls::pki_types::CertificateDer; + +use crate::{ + CommunicationProtocol, EndpointDescription, MessageVersion, NodeDescription, NodeId, + common::root, + communication::{self, ConnectionInfo, PairingLookup, ServerPairingStore}, + error::{Error, ErrorKind}, + pairing::{self, LongpollingHandle, NodeIdAlias, NoopPrePairingHandler, Pairing, PairingToken, PrePairingHandler}, +}; + +/// Extensions to the pairing store for combined servers +pub trait CombinedServerPairingStore: ServerPairingStore { + /// Store the result of a newly negotiated pairing, replacing an existing pairing if present. + fn store(&self, local_node: NodeId, pairing: Pairing) -> impl Future> + Send; +} + +/// Certificate +pub struct ServerCertificates { + /// Leaf certificate + pub leaf_certificate: CertificateDer<'static>, + /// Root certificate + pub root_certificate: CertificateDer<'static>, +} + +/// Configuration for a combined pairing and communication S2 server. +pub struct ServerConfig { + /// URL at which the server is reachable. + pub base_url: String, + /// The leaf and root certificates of the server, if we are using a self-signed root. + /// Must be present if this is a LAN-deployed endpoint. + pub certificates: Option, + /// Endpoint description of the server + pub endpoint_description: EndpointDescription, + /// Initial set of nodes to advertise. This is only used if the server + /// is deployed on LAN. + pub advertised_nodes: Vec, +} + +/// Combined pairing and communication server +pub struct Server { + pairing: pairing::Server, + communication: communication::Server, + base_url: String, + root_certificate: Option>, +} + +impl Clone for Server { + fn clone(&self) -> Self { + Self { + pairing: self.pairing.clone(), + communication: self.communication.clone(), + base_url: self.base_url.clone(), + root_certificate: self.root_certificate.clone(), + } + } +} + +impl Server { + /// Create a new combined pairing/communication server. + pub fn new(server_config: ServerConfig, store: Store) -> Result { + Self::new_with_prepairing(server_config, NoopPrePairingHandler, store) + } +} + +/// Either a longpolling session or a connection to the server. +pub enum Session { + /// A longpolling connection + Longpolling(LongpollingHandle), + /// A new S2 transport connection + Connection(PairingLookup, ConnectionInfo), +} + +impl Server { + /// Create a new combined pairing/communication server with custom pre-pairing handling. + pub fn new_with_prepairing(server_config: ServerConfig, handler: H, store: Store) -> Result { + if server_config.endpoint_description.deployment == Some(crate::Deployment::Lan) && server_config.certificates.is_none() { + return Err(ErrorKind::InvalidServerConfig.into()); + } + let (leaf_certificate, root_certificate) = match server_config.certificates { + Some(certificates) => (Some(certificates.leaf_certificate), Some(certificates.root_certificate)), + None => (None, None), + }; + Ok(Self { + pairing: pairing::Server::new_with_prepairing( + pairing::ServerConfig { + leaf_certificate, + endpoint_description: server_config.endpoint_description.clone(), + advertised_nodes: server_config.advertised_nodes, + }, + handler, + ), + communication: communication::Server::new( + communication::ServerConfig { + base_url: server_config.base_url.clone(), + endpoint_description: Some(server_config.endpoint_description), + }, + store, + ), + base_url: format!("https://{}", server_config.base_url), + root_certificate, + }) + } + + /// Get an [`axum::Router`] handling the endpoints for the s2 connect protocol. + /// + /// Incomming http requests can be handled by this router through the [axum-server](https://docs.rs/axum-server/0.8.0/axum_server/) crate. + pub fn get_router(&self) -> axum::Router<()> { + Router::new().route("/", get(root)).nest( + "/v1", + self.communication.get_internal_router().merge(self.pairing.get_internal_router()), + ) + } + + /// Update the nodes advertised by this server. + /// + /// These are only used when the server is on a LAN. + pub fn update_advertised_nodes(&self, advertised_nodes: Vec) { + self.pairing.update_advertised_nodes(advertised_nodes); + } + + /// Enable longpolling + pub async fn enable_longpolling(&self) { + self.pairing.enable_longpolling().await; + } + + /// Disable longpolling + pub async fn disable_longpolling(&self) { + self.pairing.disable_longpolling().await; + } + + /// Get a pending longpolling handle. + pub async fn get_longpolling(&self) -> LongpollingHandle { + self.pairing.get_longpolling().await + } + + /// Start a one-time pairing session for the given node using the given token. + /// + /// The callback will receive the result of the pairing attempt. If the server + /// S2 node also becomes server for the communication, it must ensure it is + /// ready to handle the communication requests before returning Ok(()) from + /// the callback. + pub fn allow_pair_once + Send>( + &self, + node_description: NodeDescription, + message_versions: Vec, + pairing_node_id: Option, + pairing_token: PairingToken, + completion_handler: impl (FnOnce(Result<(), Error>) -> F) + Send + 'static, + ) -> Result<(), Error> { + let local_node = node_description.id; + + let config = pairing::NodeConfig { + node_description, + supported_message_versions: message_versions, + supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], + connection_initiate_url: Some(self.base_url.clone()), + root_certificate: self.root_certificate.clone(), + }; + + let store = self.communication.store(); + Ok(self.pairing.allow_pair_once( + Arc::new(config), + pairing_node_id, + pairing_token, + async move |pairing_result| match pairing_result { + Ok(pairing) => match store.store(local_node, pairing).await { + Ok(_) => { + completion_handler(Ok(())).await; + Ok(()) + } + Err(error) => { + completion_handler(Err(ErrorKind::Storage.into())).await; + Err(error) + } + }, + Err(error) => { + completion_handler(Err(error.into())).await; + Ok(()) + } + }, + )?) + } + + /// Allow repeated pairing sessions for the given endpoing using the given token. + /// + /// The callback will receive the result of the pairing attempt. If the server + /// S2 node also becomes server for the communication, it must ensure it is + /// ready to handle the communication requests before returning Ok(()) from + /// the callback. + pub fn allow_pair_repeated( + &self, + node_description: NodeDescription, + message_versions: Vec, + pairing_node_id: Option, + pairing_token: PairingToken, + ) -> Result<(), Error> { + let local_node = node_description.id; + + let config = pairing::NodeConfig { + node_description, + supported_message_versions: message_versions, + supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], + connection_initiate_url: Some(self.base_url.clone()), + root_certificate: self.root_certificate.clone(), + }; + + let store = self.communication.store(); + Ok(self + .pairing + .allow_pair_repeated(Arc::new(config), pairing_node_id, pairing_token, move |pairing_result| { + let store = store.clone(); + async move { + if let Ok(pairing) = pairing_result { + store.store(local_node, pairing).await + } else { + Ok(()) + } + } + })?) + } + + /// Get the next connection which has been established with the server. + pub async fn next_connection(&self) -> (PairingLookup, ConnectionInfo) { + self.communication.next_connection().await + } +} diff --git a/s2energy-connection/src/communication/client.rs b/s2energy-connection/src/communication/client.rs index e02563a..e6532a4 100644 --- a/s2energy-connection/src/communication/client.rs +++ b/s2energy-connection/src/communication/client.rs @@ -7,10 +7,11 @@ use tokio_tungstenite::{Connector, connect_async_tls_with_config, tungstenite::C use tracing::{debug, trace}; use crate::{ - AccessToken, CommunicationProtocol, EndpointDescription, NodeId, + AccessToken, CertificateHash, CommunicationProtocol, EndpointDescription, NodeId, common::negotiate_version, communication::{ CommunicationResult, ConnectionInfo, Error, ErrorKind, NodeConfig, WebSocketTransport, + transport::{hash_checking_http_client, hash_checking_verifier}, wire::{ CommunicationDetails, CommunicationDetailsErrorMessage, InitiateConnectionRequest, InitiateConnectionResponse, UnpairRequest, }, @@ -42,7 +43,7 @@ pub struct Client { /// pairings is handled. pub trait ClientPairing: Send { /// Type of the errors generated by the storage. - type Error: std::error::Error + 'static; + type Error: std::error::Error + Send + 'static; /// The Node ID of the S2 node initiating communication. fn client_id(&self) -> NodeId; @@ -52,6 +53,8 @@ pub trait ClientPairing: Send { fn access_tokens(&self) -> impl AsRef<[AccessToken]>; /// The communication url the client can use to contact the server. fn communication_url(&self) -> impl AsRef; + /// Hash of the root certificate the server uses. + fn certificate_hash(&self) -> Option; /// Store a new set of access tokens for the pairing. fn set_access_tokens(&mut self, tokens: Vec) -> impl Future> + Send; @@ -71,14 +74,17 @@ impl Client { /// upon success. #[tracing::instrument(skip_all, fields(client = %pairing.client_id(), server = %pairing.server_id()), level = tracing::Level::ERROR)] pub async fn unpair(&self, pairing: impl ClientPairing) -> CommunicationResult<()> { - let client = reqwest::Client::builder() - .tls_certs_merge( - self.additional_certificates - .iter() - .filter_map(|v| reqwest::Certificate::from_der(v).ok()), - ) - .build() - .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; + let client = match pairing.certificate_hash() { + Some(hash) => hash_checking_http_client(hash)?, + None => reqwest::Client::builder() + .tls_certs_merge( + self.additional_certificates + .iter() + .filter_map(|v| reqwest::Certificate::from_der(v).ok()), + ) + .build() + .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + }; let communication_url = Url::parse(pairing.communication_url().as_ref()).map_err(|e| Error::new(ErrorKind::InvalidUrl, e))?; @@ -124,14 +130,17 @@ impl Client { #[tracing::instrument(skip_all, fields(client = %pairing.client_id(), server = %pairing.server_id()), level = tracing::Level::ERROR)] pub async fn connect(&self, mut pairing: impl ClientPairing) -> CommunicationResult { trace!("Establishing new communication connection."); - let client = reqwest::Client::builder() - .tls_certs_merge( - self.additional_certificates - .iter() - .filter_map(|v| reqwest::Certificate::from_der(v).ok()), - ) - .build() - .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; + let client = match pairing.certificate_hash() { + Some(hash) => hash_checking_http_client(hash)?, + None => reqwest::Client::builder() + .tls_certs_merge( + self.additional_certificates + .iter() + .filter_map(|v| reqwest::Certificate::from_der(v).ok()), + ) + .build() + .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + }; trace!("Prepared reqwest client."); @@ -237,7 +246,7 @@ impl Client { trace!("Confirmed new access token to server."); pairing - .set_access_tokens(dbg!(vec![initiate_response.access_token])) + .set_access_tokens(vec![initiate_response.access_token]) .await .map_err(|e| Error::new(ErrorKind::Storage, Box::new(e) as Box<_>))?; @@ -251,10 +260,16 @@ impl Client { tls_config_builder.crypto_provider().clone(), ) .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; - let tls_config = tls_config_builder - .dangerous() - .with_custom_certificate_verifier(Arc::new(cert_verifier)) - .with_no_client_auth(); + let tls_config = match pairing.certificate_hash() { + Some(root_hash) => tls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(hash_checking_verifier(root_hash)?)) + .with_no_client_auth(), + None => tls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(cert_verifier)) + .with_no_client_auth(), + }; let request = ClientRequestBuilder::new( web_socket_communication_details @@ -301,7 +316,7 @@ mod tests { use tokio::net::TcpListener; use crate::{ - AccessToken, CommunicationProtocol, EndpointDescription, MessageVersion, NodeId, Role, + AccessToken, CertificateHash, CommunicationProtocol, EndpointDescription, MessageVersion, NodeId, Role, common::wire::test::{UUID_A, UUID_B, basic_node_description}, communication::{ self, Client, ClientConfig, ClientPairing, ErrorKind, NodeConfig, PairingLookup, Server, ServerConfig, ServerPairing, @@ -355,7 +370,7 @@ mod tests { } async fn set_access_token(&mut self, token: crate::AccessToken) -> Result<(), Self::Error> { - *self.token.lock().unwrap() = dbg!(token); + *self.token.lock().unwrap() = token; Ok(()) } @@ -370,6 +385,7 @@ mod tests { server: NodeId, tokens: Arc>>, url: String, + certificate_hash: Option, } impl ClientPairing for &TestPairing { @@ -391,6 +407,10 @@ mod tests { &self.url } + fn certificate_hash(&self) -> Option { + self.certificate_hash.clone() + } + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { *self.tokens.lock().unwrap() = tokens; Ok(()) @@ -451,6 +471,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert!(client.unpair(&pairing).await.is_ok()); @@ -483,6 +504,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("invalidtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let error = client.unpair(&pairing).await.unwrap_err(); @@ -495,7 +517,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -511,6 +533,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -557,7 +580,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -577,6 +600,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -612,7 +636,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server( + let (handle, server) = setup_server( store.clone(), Some(EndpointDescription { name: Some("a".into()), @@ -637,6 +661,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -672,7 +697,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -692,6 +717,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -720,7 +746,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -740,6 +766,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -792,6 +819,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -830,6 +858,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -876,6 +905,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -922,6 +952,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -936,7 +967,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -973,6 +1004,10 @@ mod tests { &self.url } + fn certificate_hash(&self) -> Option { + None + } + async fn set_access_tokens(&mut self, _tokens: Vec) -> Result<(), Self::Error> { Err(std::io::ErrorKind::Other.into()) } @@ -995,6 +1030,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -1042,6 +1078,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::ProtocolError); @@ -1076,6 +1113,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::ProtocolError); diff --git a/s2energy-connection/src/communication/error.rs b/s2energy-connection/src/communication/error.rs index 97a9144..8ae9342 100644 --- a/s2energy-connection/src/communication/error.rs +++ b/s2energy-connection/src/communication/error.rs @@ -8,8 +8,8 @@ use crate::{ /// Error that occurred during the communication subprotocol. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - wrapped_error: WrappedError, + pub(crate) kind: ErrorKind, + pub(crate) wrapped_error: WrappedError, } impl Error { @@ -65,7 +65,7 @@ impl From for Error { } #[derive(Debug)] -enum WrappedError { +pub(crate) enum WrappedError { None, Reqwest(reqwest::Error), Rustls(rustls::Error), @@ -73,7 +73,7 @@ enum WrappedError { UrlParse(url::ParseError), UriParse(http::uri::InvalidUri), Remote(CommunicationDetailsErrorMessage), - Store(Box), + Store(Box), } impl WrappedError { @@ -136,8 +136,8 @@ impl From for WrappedError { } } -impl From> for WrappedError { - fn from(value: Box) -> Self { +impl From> for WrappedError { + fn from(value: Box) -> Self { WrappedError::Store(value) } } diff --git a/s2energy-connection/src/communication/mod.rs b/s2energy-connection/src/communication/mod.rs index ad84fa5..43bcb7e 100644 --- a/s2energy-connection/src/communication/mod.rs +++ b/s2energy-connection/src/communication/mod.rs @@ -44,12 +44,13 @@ //! # use std::sync::Arc; //! # use std::convert::Infallible; //! # use s2energy_connection::communication::{NodeConfig, Client, ClientConfig, ClientPairing}; -//! # use s2energy_connection::{MessageVersion, AccessToken, NodeId}; +//! # use s2energy_connection::{MessageVersion, AccessToken, NodeId, CertificateHash}; //! struct MemoryClientPairing { //! client_id: NodeId, //! server_id: NodeId, //! communication_url: String, //! access_tokens: Vec, +//! certificate_hash: Option, //! } //! //! impl ClientPairing for MemoryClientPairing { @@ -71,6 +72,10 @@ //! &self.access_tokens //! } //! +//! fn certificate_hash(&self) -> Option { +//! self.certificate_hash.clone() +//! } +//! //! async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Infallible> { //! self.access_tokens = tokens; //! Ok(()) @@ -84,6 +89,7 @@ //! server_id: NodeId::try_from("67e55044-10b1-426f-9247-bb680e5fe0c6").unwrap(), //! communication_url: "https://example.com".into(), //! access_tokens: vec![AccessToken("some-token-value".into())], +//! certificate_hash: None, //! }); //! ``` //! @@ -210,10 +216,12 @@ use crate::{EndpointDescription, MessageVersion, NodeDescription}; mod client; mod error; mod server; +mod transport; mod websocket; -mod wire; +pub(crate) mod wire; pub use client::{Client, ClientConfig, ClientPairing}; +pub(crate) use error::WrappedError; pub use error::{Error, ErrorKind}; pub use server::{PairingLookup, PairingLookupResult, Server, ServerConfig, ServerPairing, ServerPairingStore}; pub use websocket::{WebSocketError, WebSocketTransport}; diff --git a/s2energy-connection/src/communication/server.rs b/s2energy-connection/src/communication/server.rs index 4fd23d1..d4f03f5 100644 --- a/s2energy-connection/src/communication/server.rs +++ b/s2energy-connection/src/communication/server.rs @@ -54,7 +54,7 @@ pub enum PairingLookupResult { /// Storage provider for the pairings a communication server handles pub trait ServerPairingStore: Sync + Send + 'static { /// Type of the errors generated by this storage provider. - type Error: std::error::Error; + type Error: std::error::Error + Send + 'static; /// Type of the pairings provided by this storage provider. type Pairing<'a>: ServerPairing + 'a where @@ -96,7 +96,16 @@ pub struct ServerConfig { /// Server for handling the S2 Communication establishment subprotocol. pub struct Server { app_state: AppState, - connection_receiver: tokio::sync::mpsc::Receiver<(PairingLookup, ConnectionInfo)>, + connection_receiver: Arc>>, +} + +impl Clone for Server { + fn clone(&self) -> Self { + Self { + app_state: self.app_state.clone(), + connection_receiver: self.connection_receiver.clone(), + } + } } type AppState = Arc>; @@ -166,7 +175,7 @@ impl Server { Server { app_state, - connection_receiver, + connection_receiver: Arc::new(tokio::sync::Mutex::new(connection_receiver)), } } @@ -180,10 +189,31 @@ impl Server { .with_state(self.app_state.clone()) } + pub(crate) fn get_internal_router(&self) -> axum::Router<()> { + v1_router().with_state(self.app_state.clone()) + } + + pub(crate) fn store(&self) -> impl std::ops::Deref + Clone + Send + 'static { + struct StateWrapper(AppState); + impl std::ops::Deref for StateWrapper { + type Target = S; + + fn deref(&self) -> &Self::Target { + &self.0.store + } + } + impl Clone for StateWrapper { + fn clone(&self) -> Self { + Self(self.0.clone()) + } + } + StateWrapper(self.app_state.clone()) + } + /// Get the next connection which has been established with the server. - pub async fn next_connection(&mut self) -> (PairingLookup, ConnectionInfo) { + pub async fn next_connection(&self) -> (PairingLookup, ConnectionInfo) { // The other end will always exist. - self.connection_receiver.recv().await.unwrap() + self.connection_receiver.lock().await.recv().await.unwrap() } } @@ -1137,7 +1167,7 @@ mod tests { async fn websocket_handling() { let listener = TcpListener::bind(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); - let mut server = Server::new( + let server = Server::new( ServerConfig { base_url: format!("localhost:{}", port), endpoint_description: None, diff --git a/s2energy-connection/src/communication/transport.rs b/s2energy-connection/src/communication/transport.rs new file mode 100644 index 0000000..c1bac1c --- /dev/null +++ b/s2energy-connection/src/communication/transport.rs @@ -0,0 +1,233 @@ +use std::sync::{Arc, OnceLock}; + +use rustls::{ + RootCertStore, + client::{WebPkiServerVerifier, danger::ServerCertVerifier}, + pki_types::CertificateDer, +}; + +use crate::CertificateHash; + +use super::{CommunicationResult, Error, ErrorKind}; + +#[derive(Debug)] +struct HashedCertificateVerifier { + inner: rustls_platform_verifier::Verifier, + self_signed_state: OnceLock, + root_hash: CertificateHash, +} + +#[derive(Debug)] +struct SelfSignedState { + hash: CertificateHash, + verifier: SelfVerifier, +} + +#[derive(Debug)] +enum SelfVerifier { + WebPki(WebPkiServerVerifier), + None, +} + +impl ServerCertVerifier for SelfVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => { + web_pki_server_verifier.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.verify_tls12_signature(message, cert, dss), + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.verify_tls13_signature(message, cert, dss), + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn supported_verify_schemes(&self) -> Vec { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.supported_verify_schemes(), + SelfVerifier::None => vec![], + } + } +} + +impl ServerCertVerifier for HashedCertificateVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + let state = self.self_signed_state.get_or_init(|| { + let fallback = CertificateDer::from_slice(&[]); + let root_cert = intermediates.last().unwrap_or(&fallback); + let hash = CertificateHash::sha256(root_cert); + let mut root_store = RootCertStore::empty(); + // conciously ignore errors here, we just want to initialize + root_store.add(root_cert.clone()).ok(); + let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { + Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), + Err(_) => SelfVerifier::None, + }; + + SelfSignedState { hash, verifier } + }); + if state.hash != self.root_hash { + return Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)); + } + state + .verifier + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +pub(crate) fn hash_checking_http_client(root_hash: CertificateHash) -> CommunicationResult { + let rustls_config_builder = rustls::ClientConfig::builder(); + let crypto_provider = rustls_config_builder.crypto_provider().clone(); + let verifier = HashedCertificateVerifier { + inner: rustls_platform_verifier::Verifier::new(crypto_provider).map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + self_signed_state: OnceLock::new(), + root_hash, + }; + let client_config = rustls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + let client = reqwest::Client::builder() + .use_preconfigured_tls(client_config) + .build() + .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; + + Ok(client) +} + +pub(crate) fn hash_checking_verifier(root_hash: CertificateHash) -> CommunicationResult { + let rustls_config_builder = rustls::ClientConfig::builder(); + let crypto_provider = rustls_config_builder.crypto_provider().clone(); + Ok(HashedCertificateVerifier { + inner: rustls_platform_verifier::Verifier::new(crypto_provider).map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + self_signed_state: OnceLock::new(), + root_hash, + }) +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + + use axum::{Router, routing::get}; + use axum_server::tls_rustls::RustlsConfig; + use rustls::pki_types::{CertificateDer, pem::PemObject}; + + use crate::{CertificateHash, communication::transport::hash_checking_http_client}; + + #[tokio::test] + async fn matching_certificates() { + let rustls_config = RustlsConfig::from_pem( + include_bytes!("../../testdata/localhost.chain.pem").into(), + include_bytes!("../../testdata/localhost.key").into(), + ) + .await + .unwrap(); + let router = Router::new().route("/", get(|| async { "Hello world" })); + let https_server_handle = axum_server::Handle::new(); + let https_server_handle_clone = https_server_handle.clone(); + tokio::spawn(async move { + axum_server::bind_rustls(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0), rustls_config) + .handle(https_server_handle_clone) + .serve(router.into_make_service()) + .await + .unwrap(); + }); + let addr = https_server_handle.listening().await.unwrap(); + + let client = hash_checking_http_client(CertificateHash::sha256( + &CertificateDer::from_pem_slice(include_bytes!("../../testdata/root.pem")).unwrap(), + )) + .unwrap(); + assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); + + https_server_handle.shutdown(); + } + + #[tokio::test] + async fn mismatching_certificates() { + let rustls_config = RustlsConfig::from_pem( + include_bytes!("../../testdata/localhost.chain.pem").into(), + include_bytes!("../../testdata/localhost.key").into(), + ) + .await + .unwrap(); + let router = Router::new().route("/", get(|| async { "Hello world" })); + let https_server_handle = axum_server::Handle::new(); + let https_server_handle_clone = https_server_handle.clone(); + tokio::spawn(async move { + axum_server::bind_rustls(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0), rustls_config) + .handle(https_server_handle_clone) + .serve(router.into_make_service()) + .await + .unwrap(); + }); + let addr = https_server_handle.listening().await.unwrap(); + + let client = hash_checking_http_client(CertificateHash::sha256( + &CertificateDer::from_pem_slice(include_bytes!("../../testdata/altroot.pem")).unwrap(), + )) + .unwrap(); + assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_err()); + + https_server_handle.shutdown(); + } +} diff --git a/s2energy-connection/src/discovery/error.rs b/s2energy-connection/src/discovery/error.rs index c9f5bc1..a016a70 100644 --- a/s2energy-connection/src/discovery/error.rs +++ b/s2energy-connection/src/discovery/error.rs @@ -1,7 +1,8 @@ +/// Error that occurs during discovery. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - wrapped_error: WrappedError, + pub(crate) kind: ErrorKind, + pub(crate) wrapped_error: WrappedError, } impl Error { @@ -16,6 +17,7 @@ impl Error { } } + /// Indiciation of what kind of error occurred. pub fn kind(&self) -> ErrorKind { self.kind } @@ -47,7 +49,7 @@ impl From for Error { } #[derive(Debug)] -enum WrappedError { +pub(crate) enum WrappedError { None, Zeroconf(zeroconf_tokio::error::Error), } diff --git a/s2energy-connection/src/discovery/mod.rs b/s2energy-connection/src/discovery/mod.rs index 4a57a4b..07e238e 100644 --- a/s2energy-connection/src/discovery/mod.rs +++ b/s2energy-connection/src/discovery/mod.rs @@ -23,10 +23,9 @@ use zeroconf_tokio::{ prelude::{TMdnsBrowser, TMdnsService, TTxtRecord}, }; -use crate::{ - Deployment, Role, - discovery::error::{Error, ErrorKind}, -}; +use crate::{Deployment, Role}; +pub(crate) use error::WrappedError; +pub use error::{Error, ErrorKind}; /// Error that occurred during the process of creating a [`DiscoverableS2Endpoint`]. #[derive(Debug, Clone, Error, Eq, PartialEq)] @@ -220,7 +219,7 @@ impl DiscoverableS2EndpointBuilder { Host::Domain(domain) if (domain.ends_with(".local") || domain.ends_with(".local.")) == matches!(self.deployment, Deployment::Lan) => { - self.pairing_url = Some(longpolling_url); + self.longpolling_url = Some(longpolling_url); Ok(self) } _ => Err(BuilderError::InvalidUrl), diff --git a/s2energy-connection/src/error.rs b/s2energy-connection/src/error.rs new file mode 100644 index 0000000..eb4e0fc --- /dev/null +++ b/s2energy-connection/src/error.rs @@ -0,0 +1,344 @@ +//! Combined error type for the crate. +//! +//! Pairing, communication, and discovery are mostly disjoint, and each have +//! fairly different error types. Therefore, we have individual error types for +//! each. However, when creating a device that combines multiple of these +//! functions, it can be useful to have a single error type that combines all +//! these errors. This module provides that error type, which is also used by +//! the combined pairing and communication server. +use crate::{ + common::{BaseError, BaseErrorKind, BaseWrappedError}, + communication::{self, wire::CommunicationDetailsErrorMessage}, + discovery, + pairing::{ + self, ConfigError, + wire::{PairingResponseErrorMessage, WaitForPairingErrorMessage}, + }, +}; + +/// An error that occured during the pairing process. +#[derive(Debug)] +pub struct Error { + kind: ErrorKind, + wrapped_error: WrappedError, +} + +impl Error { + /// What kind of error occurred? + pub fn kind(&self) -> ErrorKind { + self.kind + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(inner) = self.wrapped_error.contents() { + write!(f, "{}: {inner}", self.kind) + } else { + self.kind.fmt(f) + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.wrapped_error.contents() + } +} + +impl From for Error { + fn from(kind: ErrorKind) -> Self { + Self { + kind, + wrapped_error: WrappedError::None, + } + } +} + +impl From for Error { + fn from(value: BaseError) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +impl From for Error { + fn from(value: pairing::Error) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +impl From for Error { + fn from(value: discovery::Error) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +impl From for Error { + fn from(value: communication::Error) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +#[derive(Debug)] +enum WrappedError { + None, + Reqwest(reqwest::Error), + UrlParse(url::ParseError), + UriParse(http::uri::InvalidUri), + Rustls(rustls::Error), + Tungstenite(tokio_tungstenite::tungstenite::Error), + RemotePairing(PairingResponseErrorMessage), + RemoteCommunication(CommunicationDetailsErrorMessage), + Longpolling(WaitForPairingErrorMessage), + Zeroconf(zeroconf_tokio::error::Error), + Boxed(Box), +} + +impl WrappedError { + fn contents(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::None => None, + Self::Reqwest(error) => Some(error), + Self::UrlParse(error) => Some(error), + Self::UriParse(error) => Some(error), + Self::Rustls(error) => Some(error), + Self::Tungstenite(error) => Some(error), + Self::RemotePairing(error) => Some(error), + Self::RemoteCommunication(error) => Some(error), + Self::Longpolling(error) => Some(error), + Self::Zeroconf(error) => Some(error), + Self::Boxed(error) => Some(error.as_ref()), + } + } +} + +impl From for WrappedError { + fn from(value: BaseWrappedError) -> Self { + match value { + BaseWrappedError::None => Self::None, + BaseWrappedError::Reqwest(error) => Self::Reqwest(error), + } + } +} + +impl From for WrappedError { + fn from(value: pairing::WrappedError) -> Self { + match value { + pairing::WrappedError::None => Self::None, + pairing::WrappedError::Reqwest(error) => Self::Reqwest(error), + pairing::WrappedError::UrlParse(parse_error) => Self::UrlParse(parse_error), + pairing::WrappedError::Rustls(error) => Self::Rustls(error), + pairing::WrappedError::Remote(pairing_response_error_message) => Self::RemotePairing(pairing_response_error_message), + pairing::WrappedError::Longpolling(wait_for_pairing_error_message) => Self::Longpolling(wait_for_pairing_error_message), + pairing::WrappedError::Boxed(error) => Self::Boxed(error), + } + } +} + +impl From for WrappedError { + fn from(value: discovery::WrappedError) -> Self { + match value { + discovery::WrappedError::None => Self::None, + discovery::WrappedError::Zeroconf(error) => Self::Zeroconf(error), + } + } +} + +impl From for WrappedError { + fn from(value: communication::WrappedError) -> Self { + match value { + communication::WrappedError::None => Self::None, + communication::WrappedError::Reqwest(error) => Self::Reqwest(error), + communication::WrappedError::Rustls(error) => Self::Rustls(error), + communication::WrappedError::Tungstenite(error) => Self::Tungstenite(error), + communication::WrappedError::UrlParse(parse_error) => Self::UrlParse(parse_error), + communication::WrappedError::UriParse(invalid_uri) => Self::UriParse(invalid_uri), + communication::WrappedError::Remote(communication_details_error_message) => { + Self::RemoteCommunication(communication_details_error_message) + } + communication::WrappedError::Store(error) => Self::Boxed(error), + } + } +} + +impl From for WrappedError { + fn from(value: reqwest::Error) -> Self { + Self::Reqwest(value) + } +} + +impl From for WrappedError { + fn from(value: url::ParseError) -> Self { + Self::UrlParse(value) + } +} + +impl From for WrappedError { + fn from(value: rustls::Error) -> Self { + Self::Rustls(value) + } +} + +impl From for WrappedError { + fn from(value: PairingResponseErrorMessage) -> Self { + Self::RemotePairing(value) + } +} + +impl From for WrappedError { + fn from(value: WaitForPairingErrorMessage) -> Self { + Self::Longpolling(value) + } +} + +impl From for WrappedError { + fn from(value: zeroconf_tokio::error::Error) -> Self { + WrappedError::Zeroconf(value) + } +} + +impl From> for WrappedError { + fn from(value: Box) -> Self { + Self::Boxed(value) + } +} + +/// Kind of error that occured during the pairing process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorKind { + /// Invalid URL for remote. + InvalidUrl, + /// Something went wrong in the transport layers. + TransportFailed, + /// The remote reacted outside our expectations. + ProtocolError, + /// No shared version with the remote. + NoSupportedVersion, + /// Unknown S2 Node + UnknownNode, + /// Session timed out. + Timeout, + /// Already have a pending pairing or longpolling session with that node id. + AlreadyPending, + /// Provided token was invalid. + InvalidToken, + /// Provided node alias was invalid. + InvalidNodeAlias, + /// Remote permanently rejects longpolling or querying of node information. + Rejected, + /// The pairing or longpolling session was cancelled. + Cancelled, + /// The remote is of the same type. + RemoteOfSameType, + /// The provided callback returned an error + CallbackFailed, + /// The configuration was invalid. + InvalidConfig(ConfigError), + /// The server configuration was invalid. + InvalidServerConfig, + /// Somehting went wrong with the mDNS protocol handling. + MdnsError, + /// The nodes are no longer paired. + Unpaired, + /// The nodes were not paired. + NotPaired, + /// Storage failed to persist token. + Storage, +} + +impl std::fmt::Display for ErrorKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUrl => f.write_str("Invalid URL for remote"), + Self::TransportFailed => f.write_str("Could not send or receive protocol message"), + Self::ProtocolError => f.write_str("Unexpected response from remote"), + Self::NoSupportedVersion => f.write_str("No overlap in versions"), + Self::UnknownNode => f.write_str("Requested S2 Node not known to remote"), + Self::Timeout => f.write_str("Timed out"), + Self::AlreadyPending => f.write_str("A pairing or longpolling session for this node is already pending"), + Self::InvalidToken => f.write_str("The token used does not match with that of the remote"), + Self::InvalidNodeAlias => f.write_str("The node alias provided is not valid"), + Self::Rejected => f.write_str("Longpolling was permanently rejected by remote"), + Self::Cancelled => f.write_str("Pairing or longpolling was cancelled by remote"), + Self::RemoteOfSameType => f.write_str("Remote is of same type of us"), + Self::CallbackFailed => f.write_str("Pairing could not be handled by callback"), + Self::InvalidConfig(config_error) => config_error.fmt(f), + Self::InvalidServerConfig => f.write_str(""), + Self::MdnsError => f.write_str("mDNS failed"), + Self::Unpaired => f.write_str("Remote became unpaired from us"), + Self::NotPaired => f.write_str("Remote has no knowledge of previous pairing with us"), + Self::Storage => f.write_str("Storage failed to persist access token"), + } + } +} + +impl From for ErrorKind { + fn from(value: pairing::ErrorKind) -> Self { + match value { + pairing::ErrorKind::InvalidUrl => Self::InvalidUrl, + pairing::ErrorKind::TransportFailed => Self::TransportFailed, + pairing::ErrorKind::ProtocolError => Self::ProtocolError, + pairing::ErrorKind::NoSupportedVersion => Self::NoSupportedVersion, + pairing::ErrorKind::UnknownNode => Self::UnknownNode, + pairing::ErrorKind::Timeout => Self::Timeout, + pairing::ErrorKind::AlreadyPending => Self::AlreadyPending, + pairing::ErrorKind::InvalidToken => Self::InvalidToken, + pairing::ErrorKind::InvalidNodeAlias => Self::InvalidNodeAlias, + pairing::ErrorKind::Rejected => Self::Rejected, + pairing::ErrorKind::Cancelled => Self::Cancelled, + pairing::ErrorKind::RemoteOfSameType => Self::RemoteOfSameType, + pairing::ErrorKind::CallbackFailed => Self::CallbackFailed, + pairing::ErrorKind::InvalidConfig(config_error) => Self::InvalidConfig(config_error), + } + } +} + +impl From for ErrorKind { + fn from(value: discovery::ErrorKind) -> Self { + match value { + discovery::ErrorKind::MdnsError => Self::MdnsError, + } + } +} + +impl From for ErrorKind { + fn from(value: communication::ErrorKind) -> Self { + match value { + communication::ErrorKind::InvalidUrl => Self::InvalidUrl, + communication::ErrorKind::TransportFailed => Self::TransportFailed, + communication::ErrorKind::ProtocolError => Self::ProtocolError, + communication::ErrorKind::NoSupportedVersion => Self::NoSupportedVersion, + communication::ErrorKind::Unpaired => Self::Unpaired, + communication::ErrorKind::NotPaired => Self::NotPaired, + communication::ErrorKind::Storage => Self::Storage, + } + } +} + +impl From for ErrorKind { + fn from(value: BaseErrorKind) -> Self { + match value { + BaseErrorKind::TransportFailed => Self::TransportFailed, + BaseErrorKind::ProtocolError => Self::ProtocolError, + BaseErrorKind::NoSupportedVersion => Self::NoSupportedVersion, + } + } +} + +impl From for ErrorKind { + fn from(value: ConfigError) -> Self { + Self::InvalidConfig(value) + } +} diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index b38c993..3e4603b 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -10,11 +10,46 @@ //! in the examples folder of this crate. #![warn(missing_docs)] +pub mod combined_server; pub(crate) mod common; pub mod communication; pub mod discovery; +pub mod error; pub mod pairing; pub use common::wire::{ AccessToken, CommunicationProtocol, Deployment, EndpointDescription, InvalidNodeId, MessageVersion, NodeDescription, NodeId, Role, }; +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +/// Hash of a TLS certificate. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct CertificateHash(CertificateHashInner); + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +enum CertificateHashInner { + Sha256(sha2::digest::generic_array::GenericArray::OutputSize>), +} + +impl CertificateHash { + pub(crate) fn sha256(data: &[u8]) -> Self { + Self(CertificateHashInner::Sha256(sha2::Sha256::digest(data))) + } +} + +impl AsRef for CertificateHash { + fn as_ref(&self) -> &CertificateHash { + self + } +} + +impl std::ops::Deref for CertificateHash { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + match &self.0 { + CertificateHashInner::Sha256(generic_array) => generic_array, + } + } +} diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index b05999f..8f93953 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -8,19 +8,30 @@ use crate::common::negotiate_version; use crate::common::wire::{AccessToken, Deployment, PairingVersion, Role}; use crate::pairing::transport::{HashProvider, hash_providing_https_client}; use crate::pairing::{ConfigError, Error, Pairing, PairingRole}; -use crate::{EndpointDescription, NodeDescription, NodeId}; +use crate::{CertificateHash, EndpointDescription, NodeDescription, NodeId}; use super::NodeConfig; use super::wire::*; use super::{ErrorKind, Network, PairingResult}; +/// Optional node identifier of a remote node +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RemoteNodeIdentifier { + /// A full UUID node identifier + Id(NodeId), + /// A human-entered alias for an S2 Node + Alias(NodeIdAlias), + /// No identifier + None, +} + /// Remote node to pair with. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PairingRemote { /// URL at which the remote node can be reached pub url: String, /// S2 node id of the remote node. - pub id: Option, + pub id: RemoteNodeIdentifier, } /// Remote node to pair with. @@ -33,6 +44,7 @@ pub struct PrePairingRemote { } /// Configuration for pairing clients. +#[derive(Debug, Clone)] pub struct ClientConfig { /// Additional roots of trust for TLS connections. Useful when testing during the development of WAN endpoints. /// @@ -88,14 +100,19 @@ impl PrePairing<'_> { /// When the callback returns an error, the client will be notified of the error. pub async fn pair( self, - remote_id: Option, pairing_token: &[u8], callback: impl AsyncFnOnce(Pairing) -> Result<(), E>, ) -> PairingResult<()> { async move { trace!("Start pairing after pre-pairing."); self.session - .pair(self.certhash, self.local_deployment, remote_id, pairing_token, callback) + .pair( + self.certhash, + self.local_deployment, + RemoteNodeIdentifier::Id(self.remote_id), + pairing_token, + callback, + ) .await } .instrument(self.span) @@ -392,7 +409,9 @@ impl Client { } fn prepare_reqwest_client(&self, url: &Url) -> Result<(reqwest::Client, Option), Error> { - let (client, certhash) = if url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() { + let (client, certhash) = if url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() + || url.domain().map(|v| v.ends_with(".local.")).unwrap_or_default() + { let (client, certhash) = hash_providing_https_client()?; (client, Some(certhash)) } else { @@ -469,18 +488,18 @@ impl<'a> V1Session<'a> { self, certhash: Option, local_deployment: Deployment, - id: Option, + id: RemoteNodeIdentifier, pairing_token: &[u8], callback: impl AsyncFnOnce(Pairing) -> Result<(), E>, ) -> PairingResult<()> { let our_deployment = self.endpoint_description.deployment.unwrap_or(local_deployment); let our_role = self.config.node_description.role; - let network = if self.base_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() { - if let Some(hash) = certhash.as_ref().and_then(HashProvider::hash) { - Network::Lan { - fingerprint: hash.try_into().unwrap(), - } + let network = if self.base_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() + || self.base_url.domain().map(|v| v.ends_with(".local.")).unwrap_or_default() + { + if let Some(hash) = certhash.as_ref().and_then(HashProvider::leaf_hash) { + Network::Lan { fingerprint: hash.clone() } } else { return Err(ErrorKind::ProtocolError.into()); } @@ -552,6 +571,7 @@ impl<'a> V1Session<'a> { server_hmac_challenge_response, initiate_connection_url.clone(), access_token.clone(), + self.config.root_certificate.as_deref().map(CertificateHash::sha256), ) .await { @@ -573,12 +593,26 @@ impl<'a> V1Session<'a> { return Err(e); } }; + + let initiate_url = + Url::parse(&connection_details.initiate_connection_url).map_err(|e| Error::new(ErrorKind::ProtocolError, e))?; + let root_hash = if initiate_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() + || initiate_url.domain().map(|v| v.ends_with(".local.")).unwrap_or_default() + { + connection_details + .certificate_fingerprint + .or(certhash.as_ref().and_then(HashProvider::root_hash).cloned()) + } else { + None + }; + Pairing { remote_endpoint_description: request_pairing_response.server_endpoint_description, remote_node_description: request_pairing_response.server_node_description, token: connection_details.access_token, role: PairingRole::CommunicationClient { initiate_url: connection_details.initiate_connection_url, + root_hash, }, } } @@ -644,12 +678,14 @@ impl<'a> V1Session<'a> { server_hmac_challenge_response: HmacChallengeResponse, initiate_connection_url: String, access_token: AccessToken, + certificate_fingerprint: Option, ) -> PairingResult<()> { let request = PostConnectionDetailsRequest { server_hmac_challenge_response, connection_details: ConnectionDetails { initiate_connection_url, access_token, + certificate_fingerprint, }, }; let response = self @@ -670,19 +706,31 @@ impl<'a> V1Session<'a> { async fn request_pairing( &self, - id: Option, + id: RemoteNodeIdentifier, client_hmac_challenge: &HmacChallenge, ) -> PairingResult { - let request = RequestPairing { + let bare_request = RequestPairing { node_description: self.config.node_description.clone(), endpoint_description: self.endpoint_description.clone(), - id, + id: None, + id_alias: None, supported_protocols: self.config.supported_communication_protocols.clone(), supported_versions: self.config.supported_message_versions.clone(), supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], client_hmac_challenge: client_hmac_challenge.clone(), force_pairing: false, }; + let request = match id { + RemoteNodeIdentifier::Id(node_id) => RequestPairing { + id: Some(node_id), + ..bare_request + }, + RemoteNodeIdentifier::Alias(node_id_alias) => RequestPairing { + id_alias: Some(node_id_alias), + ..bare_request + }, + RemoteNodeIdentifier::None => bare_request, + }; let response = self .client .post(self.base_url.join("requestPairing").unwrap()) @@ -738,7 +786,7 @@ mod tests { common::wire::test::{UUID_A, UUID_B, basic_node_description, pairing_s2_node_id}, pairing::{ Client, ClientConfig, ErrorKind, LongpollHandler, Longpoller, Network, NodeConfig, NoopPrePairingHandler, Pairing, - PairingRemote, PairingRole, PairingToken, PrePairingHandler, PrePairingResponse, Server, ServerConfig, + PairingRemote, PairingRole, PairingToken, PrePairingHandler, PrePairingResponse, RemoteNodeIdentifier, Server, ServerConfig, client::PrePairingRemote, wire::{ FinalizePairingRequest, HmacChallenge, HmacChallengeResponse, PairingAttemptId, PairingResponseErrorMessage, @@ -761,7 +809,7 @@ mod tests { handler: impl PrePairingHandler, overrides: Router<()>, ) -> (Handle, JoinHandle, Server) { - let mut server = Server::new_with_prepairing( + let server = Server::new_with_prepairing( ServerConfig { leaf_certificate: None, endpoint_description: EndpointDescription::default(), @@ -888,12 +936,12 @@ mod tests { #[tokio::test] async fn pairing_ok_rm_initiates() { let server_config = NodeConfig::builder(basic_node_description(UUID_A, Role::Cem), vec![MessageVersion("v1".into())]) - .with_connection_initiate_url("test.example.com".into()) + .with_connection_initiate_url("https://test.example.com".into()) .build() .unwrap(); let client_config = NodeConfig::builder(basic_node_description(UUID_B, Role::Rm), vec![MessageVersion("v1".into())]) - .with_connection_initiate_url("client.example.com".into()) + .with_connection_initiate_url("https://client.example.com".into()) .build() .unwrap(); @@ -902,7 +950,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -946,7 +994,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Id(UUID_A.into()), }; let client = Client::new(ClientConfig { @@ -1044,7 +1092,7 @@ mod tests { let client_prepair = client.prepair(&client_config, remote).await.unwrap(); let (tx, rx) = tokio::sync::oneshot::channel(); client_prepair - .pair(Some(pairing_s2_node_id()), b"testtoken", async |pairing| { + .pair(b"testtoken", async |pairing| { tx.send(pairing).unwrap(); Ok::<_, std::io::Error>(()) }) @@ -1193,7 +1241,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1260,7 +1308,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1323,7 +1371,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1375,7 +1423,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1427,7 +1475,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1477,7 +1525,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1502,7 +1550,7 @@ mod tests { #[tokio::test] async fn longpolling() { let server_config = NodeConfig::builder(basic_node_description(UUID_A, Role::Cem), vec![MessageVersion("v1".into())]) - .with_connection_initiate_url("test.example.com".into()) + .with_connection_initiate_url("https://test.example.com".into()) .build() .unwrap(); @@ -1566,7 +1614,7 @@ mod tests { let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let (tx, rx) = tokio::sync::oneshot::channel(); diff --git a/s2energy-connection/src/pairing/error.rs b/s2energy-connection/src/pairing/error.rs index e7e9f4d..4b7338d 100644 --- a/s2energy-connection/src/pairing/error.rs +++ b/s2energy-connection/src/pairing/error.rs @@ -8,8 +8,8 @@ use crate::{ /// An error that occured during the pairing process. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - wrapped_error: WrappedError, + pub(crate) kind: ErrorKind, + pub(crate) wrapped_error: WrappedError, } impl Error { @@ -65,7 +65,7 @@ impl From for Error { } #[derive(Debug)] -enum WrappedError { +pub(crate) enum WrappedError { None, Reqwest(reqwest::Error), UrlParse(url::ParseError), @@ -153,6 +153,8 @@ pub enum ErrorKind { AlreadyPending, /// Provided token was invalid. InvalidToken, + /// Provided node alias was invalid. + InvalidNodeAlias, /// Remote permanently rejects longpolling or querying of node information. Rejected, /// The pairing or longpolling session was cancelled. @@ -176,6 +178,7 @@ impl std::fmt::Display for ErrorKind { Self::Timeout => f.write_str("Timed out"), Self::AlreadyPending => f.write_str("A pairing or longpolling session for this node is already pending"), Self::InvalidToken => f.write_str("The token used does not match with that of the remote"), + Self::InvalidNodeAlias => f.write_str("The node alias provided is not valid"), Self::Rejected => f.write_str("Longpolling was permanently rejected by remote"), Self::Cancelled => f.write_str("Pairing or longpolling was cancelled by remote"), Self::RemoteOfSameType => f.write_str("Remote is of same type of us"), diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index 58ff60a..fce624c 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -46,7 +46,7 @@ //! server. For this, you will also need to know the id of the node, and the URL on which its pairing server is reachable. //! ```rust //! # use std::sync::Arc; -//! # use s2energy_connection::pairing::{Client, ClientConfig, NodeConfig, PairingRemote, NodeIdAlias}; +//! # use s2energy_connection::pairing::{Client, ClientConfig, NodeConfig, PairingRemote, NodeIdAlias, RemoteNodeIdentifier}; //! # use s2energy_connection::{Deployment, MessageVersion, NodeDescription, EndpointDescription, NodeId, Role}; //! # let local_node = NodeConfig::builder(NodeDescription { //! # id: NodeId::new(), @@ -69,7 +69,7 @@ //! //! let pairing_result = client.pair(&local_node, PairingRemote { //! url: "https://remote.example.com".into(), -//! id: Some(NodeIdAlias("test_pairing_id".into())), +//! id: RemoteNodeIdentifier::Alias(NodeIdAlias("test_pairing_id".into())), //! }, b"ABCDEF0123456", async |pairing| { /* do something with pairing */ Ok::<_, std::convert::Infallible>(())}); //! ``` //! @@ -206,28 +206,34 @@ mod client; mod error; mod server; mod transport; -mod wire; +pub(crate) mod wire; use rand::CryptoRng; +use rustls::pki_types::CertificateDer; use wire::{HmacChallenge, HmacChallengeResponse}; -pub use client::{Client, ClientConfig, LongpollHandler, Longpoller, PairingRemote, PrePairing}; +pub use client::{Client, ClientConfig, LongpollHandler, Longpoller, PairingRemote, PrePairing, RemoteNodeIdentifier}; +pub(crate) use error::WrappedError; pub use error::{ConfigError, Error, ErrorKind}; pub use server::{ LongpollingHandle, NoopPrePairingHandler, PairingToken, PairingTokenError, PrePairingHandler, PrePairingResponse, Server, ServerConfig, }; pub use wire::NodeIdAlias; -use crate::{CommunicationProtocol, Deployment, EndpointDescription, MessageVersion, NodeDescription, Role, common::wire::AccessToken}; +use crate::{ + CertificateHash, CommunicationProtocol, Deployment, EndpointDescription, MessageVersion, NodeDescription, Role, + common::wire::AccessToken, +}; /// Full description of an S2 node. #[derive(Debug, Clone)] pub struct NodeConfig { - node_description: NodeDescription, - supported_message_versions: Vec, - supported_communication_protocols: Vec, - connection_initiate_url: Option, + pub(crate) node_description: NodeDescription, + pub(crate) supported_message_versions: Vec, + pub(crate) supported_communication_protocols: Vec, + pub(crate) connection_initiate_url: Option, + pub(crate) root_certificate: Option>, } impl NodeConfig { @@ -251,6 +257,11 @@ impl NodeConfig { self.connection_initiate_url.as_deref() } + /// Root certificate used by the node in communication, if known. + pub fn root_certificate(&self) -> Option<&CertificateDer<'static>> { + self.root_certificate.as_ref() + } + /// Create a builder for a new [`NodeConfig`]. /// /// All node configurations must at least contain description of the node and supported message versions. Additional @@ -261,6 +272,7 @@ impl NodeConfig { supported_message_versions, supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], connection_initiate_url: None, + root_certificate: None, } } } @@ -271,6 +283,7 @@ pub struct ConfigBuilder { supported_message_versions: Vec, supported_communication_protocols: Vec, connection_initiate_url: Option, + root_certificate: Option>, } impl ConfigBuilder { @@ -288,6 +301,12 @@ impl ConfigBuilder { self } + /// Set the root certificate used in communication by this node. + pub fn with_root_certificate(mut self, root_certificate: CertificateDer<'static>) -> Self { + self.root_certificate = Some(root_certificate); + self + } + /// Create the actual [`NodeConfig`], validating that it is reasonable. pub fn build(self) -> Result { if self.node_description.role == Role::Cem && self.connection_initiate_url.is_none() { @@ -298,6 +317,7 @@ impl ConfigBuilder { supported_message_versions: self.supported_message_versions, supported_communication_protocols: self.supported_communication_protocols, connection_initiate_url: self.connection_initiate_url, + root_certificate: self.root_certificate, }) } } @@ -309,6 +329,8 @@ pub enum PairingRole { CommunicationClient { /// URL to be used for initiating the connection. initiate_url: String, + /// Hash of the root certificate of the communication server + root_hash: Option, }, /// This node gets contacted by the other node to initiate a connection. CommunicationServer, @@ -372,7 +394,7 @@ impl HmacChallenge { Self(bytes) } - pub fn sha256(&self, network: &Network, pairing_token: &[u8]) -> HmacChallengeResponse { + fn sha256(&self, network: &Network, pairing_token: &[u8]) -> HmacChallengeResponse { use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -400,7 +422,7 @@ pub type PairingResult = Result; #[derive(Debug)] enum Network { Wan, - Lan { fingerprint: [u8; 32] }, + Lan { fingerprint: CertificateHash }, } impl Network { diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 3efeaa0..51b861a 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -28,6 +28,7 @@ use tokio::{ use tracing::{Instrument, info, trace}; use crate::{ + CertificateHash, common::{ AbortingJoinHandle, root, wire::{AccessToken, EndpointDescription, NodeDescription, NodeId, PairingVersion}, @@ -53,6 +54,13 @@ const TIMEOUT_SLACK: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] pub struct PairingToken(pub Box<[u8]>); +impl PairingToken { + /// Get the raw token bytes. + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + impl std::fmt::Display for PairingToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Base64Display::new(&self.0, &BASE64_STANDARD).fmt(f) @@ -133,7 +141,7 @@ impl Clone for Server { /// Configuration for the S2 pairing server. pub struct ServerConfig { - /// The root certificate of the server, if we are using a self-signed root. + /// The leaf certificate of the server, if we are using a self-signed root. /// Presence of this field indicates we are deployed on LAN. pub leaf_certificate: Option>, /// Endpoint description of the server @@ -212,13 +220,12 @@ impl Server { network: server_config .leaf_certificate .map(|v| Network::Lan { - fingerprint: sha2::Sha256::digest(v).into(), + fingerprint: CertificateHash::sha256(&v), }) .unwrap_or(Network::Wan), advertised_nodes: Mutex::new(server_config.advertised_nodes), endpoint_description: server_config.endpoint_description, - permanent_pairings: Mutex::new(HashMap::new()), - open_pairings: Mutex::new(HashMap::new()), + pending_pairings: Mutex::new(PendingPairings::default()), attempts: Mutex::new(HashMap::default()), prepairing_handler: Arc::new(handler), longpolling_enabled: longpolling_enabled_receiver, @@ -255,6 +262,10 @@ impl Server { .with_state(self.state.clone()) } + pub(crate) fn get_internal_router(&self) -> axum::Router<()> { + v1_router().with_state(self.state.clone()) + } + /// Update the nodes advertised by this server. /// /// These are only used when the server is on a LAN. @@ -263,12 +274,12 @@ impl Server { } /// Enable longpolling - pub async fn enable_longpolling(&mut self) { + pub async fn enable_longpolling(&self) { self.longpolling_enabled.send_replace(true); } /// Disable longpolling - pub async fn disable_longpolling(&mut self) { + pub async fn disable_longpolling(&self) { self.longpolling_enabled.send_replace(false); let mut receiver = self.pending_longpolling_handles.lock().await; // Wait until all existing sessions are stopped. We need to drain any new @@ -303,27 +314,33 @@ impl Server { pairing_node_id: Option, pairing_token: PairingToken, callback: impl (FnOnce(PairingResult) -> F) + Send + 'static, - ) -> Result<(), ErrorKind> { + ) -> Result<(), Error> { if config.connection_initiate_url.is_none() { - return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl)); + return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl).into()); + } + + if pairing_node_id.as_ref().map(|v| v.0.is_empty()).unwrap_or(false) { + return Err(ErrorKind::InvalidNodeAlias.into()); } let pairing_node_id = pairing_node_id.unwrap_or(NodeIdAlias(String::default())); + let node_id = config.node_description.id; - // We hit issues here, because the node node_description only has S2NodeId with no - // efficient way of mapping that back. - let mut open_pairings = self.state.open_pairings.lock().unwrap(); - let mut permanent_pairings = self.state.permanent_pairings.lock().unwrap(); - if open_pairings.contains_key(&pairing_node_id) || permanent_pairings.contains_key(&pairing_node_id) { - return Err(ErrorKind::AlreadyPending); + let mut pending_pairings = self.state.pending_pairings.lock().unwrap(); + if pending_pairings.alias_mappings.contains_key(&pairing_node_id) + || pending_pairings.open_pairings.contains_key(&node_id) + || pending_pairings.permanent_pairings.contains_key(&node_id) + { + return Err(ErrorKind::AlreadyPending.into()); } - drop(permanent_pairings); - open_pairings.insert( - pairing_node_id, + pending_pairings.alias_mappings.insert(pairing_node_id.clone(), node_id); + pending_pairings.open_pairings.insert( + node_id, PairingRequest { config, handler: ResultHandler::Oneshot(Box::new(|result| Box::pin(async move { callback(result).await.map_err(|_| ()) }))), token: pairing_token, + alias: pairing_node_id, age: Instant::now(), }, ); @@ -344,22 +361,25 @@ impl Server { pairing_node_id: Option, pairing_token: PairingToken, callback: impl (Fn(PairingResult) -> F) + Send + Sync + 'static, - ) -> Result<(), ErrorKind> { + ) -> Result<(), Error> { if config.connection_initiate_url.is_none() { - return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl)); + return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl).into()); } let pairing_node_id = pairing_node_id.unwrap_or(NodeIdAlias(String::default())); + let node_id = config.node_description.id; - let mut open_pairings = self.state.open_pairings.lock().unwrap(); - let mut permanent_pairings = self.state.permanent_pairings.lock().unwrap(); - if open_pairings.contains_key(&pairing_node_id) || permanent_pairings.contains_key(&pairing_node_id) { - return Err(ErrorKind::AlreadyPending); + let mut pending_pairings = self.state.pending_pairings.lock().unwrap(); + if pending_pairings.alias_mappings.contains_key(&pairing_node_id) + || pending_pairings.open_pairings.contains_key(&node_id) + || pending_pairings.permanent_pairings.contains_key(&node_id) + { + return Err(ErrorKind::AlreadyPending.into()); } - drop(open_pairings); let callback = Arc::new(callback); - permanent_pairings.insert( - pairing_node_id, + pending_pairings.alias_mappings.insert(pairing_node_id.clone(), node_id); + pending_pairings.permanent_pairings.insert( + node_id, PermanentPairingRequest { config, handler: Arc::new(move |result| { @@ -380,6 +400,7 @@ pub struct LongpollingHandle { node: tokio::sync::watch::Receiver>, last_pairing_response: tokio::sync::watch::Receiver>>, client_id: NodeId, + active: tokio::sync::oneshot::Receiver<()>, } impl LongpollingHandle { @@ -456,6 +477,11 @@ impl LongpollingHandle { .await .map_err(|_| ErrorKind::Cancelled.into()) } + + /// Wait for the session to be dropped by the remote. + pub fn wait_dropped(&mut self) -> impl Future { + poll_fn(|cx| Pin::new(&mut self.active).poll(cx).map(|_| ())) + } } enum LongpollingState { @@ -503,6 +529,7 @@ struct LongpollingStateInner { endpoint: tokio::sync::watch::Sender>, node: tokio::sync::watch::Sender>, last_pairing_response: tokio::sync::watch::Sender>>, + _active_sender: tokio::sync::oneshot::Sender<()>, } /// These don't keep the original errors in the results to limit the amount of boxing needed here. @@ -533,6 +560,7 @@ struct PairingRequest { config: Arc, handler: ResultHandler, token: PairingToken, + alias: NodeIdAlias, age: Instant, } @@ -583,14 +611,6 @@ impl ExpiringPairingState { Some(&mut self.state) } } - - fn into_state(self) -> Option { - if self.start_time.elapsed() > Duration::from_secs(15) { - None - } else { - Some(self.state) - } - } } type AppState = Arc>; @@ -599,8 +619,7 @@ struct AppStateInner { network: Network, endpoint_description: EndpointDescription, advertised_nodes: Mutex>, - permanent_pairings: Mutex>, - open_pairings: Mutex>, + pending_pairings: Mutex, attempts: Mutex>, prepairing_handler: Arc, longpolling_enabled: tokio::sync::watch::Receiver, @@ -614,6 +633,13 @@ struct AppStateInner { cleanup_task: OnceLock>, } +#[derive(Default)] +struct PendingPairings { + alias_mappings: HashMap, + permanent_pairings: HashMap, + open_pairings: HashMap, +} + // Holder for longpolling sessions during a session. Returns // them to the appstate on drop. struct LocalLongpollingCache { @@ -681,15 +707,39 @@ async fn periodic_cleanup(state: Weak>) { } // Active pairing sessions - { + let handlers: Vec<_> = { let mut attempts = state.attempts.lock().unwrap(); - attempts.retain(|_, value| now.duration_since(value.start_time) < SESSION_TIMEOUT + TIMEOUT_SLACK); + attempts + .extract_if(|_, value| now.duration_since(value.start_time) >= SESSION_TIMEOUT + TIMEOUT_SLACK) + .filter_map(|(_, state)| match state.state { + PairingState::Empty => None, + PairingState::Initial(InitialPairingState { sender, .. }) + | PairingState::Complete(CompletePairingState { sender, .. }) => Some(sender), + }) + .collect() + }; + for handler in handlers { + handler.handle(Err(ErrorKind::Timeout.into())).await; } // Open pairing sessions - { - let mut open_pairings = state.open_pairings.lock().unwrap(); - open_pairings.retain(|_, value| now.duration_since(value.age) < ONCEPAIR_TIMEOUT + TIMEOUT_SLACK); + let handlers: Vec<_> = { + let mut pending_pairings = state.pending_pairings.lock().unwrap(); + let PendingPairings { + alias_mappings, + open_pairings, + .. + } = &mut *pending_pairings; + open_pairings + .extract_if(|_, value| now.duration_since(value.age) >= ONCEPAIR_TIMEOUT + TIMEOUT_SLACK) + .map(|(_, request)| { + alias_mappings.remove(&request.alias); + request.handler + }) + .collect() + }; + for handler in handlers { + handler.handle(Err(ErrorKind::Timeout.into())).await; } drop(state); @@ -741,12 +791,14 @@ async fn v1_wait_for_pairing( let (endpoint_sender, endpoint_receiver) = tokio::sync::watch::channel(None); let (node_sender, node_receiver) = tokio::sync::watch::channel(None); let (last_pairing_sender, last_pairing_receiver) = tokio::sync::watch::channel(None); + let (active_tx, active_rx) = tokio::sync::oneshot::channel(); pending_handles.push(LongpollingHandle { commands: commands_sender, endpoint: endpoint_receiver, node: node_receiver, last_pairing_response: last_pairing_receiver, client_id: request.client_node_id, + active: active_rx, }); vacant_entry.insert(LongpollingState::Running { since: Instant::now(), @@ -757,6 +809,7 @@ async fn v1_wait_for_pairing( endpoint: endpoint_sender, node: node_sender, last_pairing_response: last_pairing_sender, + _active_sender: active_tx, } } }; @@ -870,21 +923,35 @@ async fn v1_request_pairing( let server_hmac_challenge = HmacChallenge::new(&mut rand::rng(), HMAC_CHALLENGE_BYTES); - let pairing_s2_node_id = request_pairing.id.unwrap_or(NodeIdAlias(String::default())); let open_pairing = { - let mut open_pairings = state.open_pairings.lock().unwrap(); - if let Some((_, request)) = open_pairings.remove_entry(&pairing_s2_node_id) { + let mut pending_pairings = state.pending_pairings.lock().unwrap(); + + let node_id = request_pairing.id.map(Ok).unwrap_or_else(|| { + let pairing_s2_node_id = request_pairing.id_alias.clone().unwrap_or(NodeIdAlias(String::default())); + pending_pairings + .alias_mappings + .get(&pairing_s2_node_id) + .copied() + .ok_or(if request_pairing.id_alias.is_some() { + PairingResponseErrorMessage::S2NodeNotFound + } else { + PairingResponseErrorMessage::S2NodeNotProvided + }) + })?; + + if let Some((_, request)) = pending_pairings.open_pairings.remove_entry(&node_id) { + pending_pairings.alias_mappings.remove(&request.alias); request } else { - drop(open_pairings); - let permanent_pairings = state.permanent_pairings.lock().unwrap(); - let entry = permanent_pairings - .get(&pairing_s2_node_id) + let entry = pending_pairings + .permanent_pairings + .get(&node_id) .ok_or(PairingResponseErrorMessage::S2NodeNotFound)?; PairingRequest { config: entry.config.clone(), handler: ResultHandler::Multi(entry.handler.clone()), token: PairingToken(entry.token.0.clone()), + alias: NodeIdAlias(String::default()), age: Instant::now(), } } @@ -903,6 +970,7 @@ async fn v1_request_pairing( } if open_pairing.config.node_description.role == request_pairing.node_description.role { + open_pairing.handler.handle(Err(ErrorKind::RemoteOfSameType.into())).await; return Err(PairingResponseErrorMessage::InvalidCombinationOfRoles); } @@ -915,6 +983,7 @@ async fn v1_request_pairing( } } if !communication_overlap { + open_pairing.handler.handle(Err(ErrorKind::NoSupportedVersion.into())).await; return Err(PairingResponseErrorMessage::IncompatibleCommunicationProtocols); } let mut connection_overlap = false; @@ -925,6 +994,7 @@ async fn v1_request_pairing( } } if !connection_overlap { + open_pairing.handler.handle(Err(ErrorKind::NoSupportedVersion.into())).await; return Err(PairingResponseErrorMessage::IncompatibleS2MessageVersions); } } @@ -994,51 +1064,73 @@ async fn v1_request_connection_details( return (Err(StatusCode::UNAUTHORIZED), None); }; - if let Some(state_entry) = state.get_state() - && let PairingState::Initial(state) = std::mem::replace(state_entry, PairingState::Empty) - { - // It is ok to manually enter the span here as this closure is not async. - let session_span_clone = state.session_span.clone(); - let _entered_span = session_span_clone.enter(); - - trace!("Found pairing session."); - - let expected = state.challenge.sha256(&app_state.network, &state.token.0); - if expected != req.server_hmac_challenge_response { - attempts.remove(&pairing_attempt_id); - return ( - Err(StatusCode::FORBIDDEN), - Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), - ); - } - - trace!("Validated remote's response to pairing token challenge."); - - let mut rng = rand::rng(); - let connection_details = ConnectionDetails { - initiate_connection_url: match &state.config.connection_initiate_url { - Some(url) => url.clone(), - None => return (Err(StatusCode::BAD_REQUEST), None), - }, - access_token: AccessToken::new(&mut rng), - }; + if let Some(state_entry) = state.get_state() { + match std::mem::replace(state_entry, PairingState::Empty) { + PairingState::Empty => { + info!("Pairing session was in unexpected state for requesting connection details (A)."); + attempts.remove(&pairing_attempt_id); + (Err(StatusCode::UNAUTHORIZED), None) + } + PairingState::Complete(complete_pairing_state) => { + info!("Pairing session was in unexpected state for requesting connection details (B)."); + attempts.remove(&pairing_attempt_id); + ( + Err(StatusCode::UNAUTHORIZED), + Some(complete_pairing_state.sender.handle(Err(ErrorKind::ProtocolError.into()))), + ) + } + PairingState::Initial(state) => { + // It is ok to manually enter the span here as this closure is not async. + let session_span_clone = state.session_span.clone(); + let _entered_span = session_span_clone.enter(); + + trace!("Found pairing session."); + + let expected = state.challenge.sha256(&app_state.network, &state.token.0); + if expected != req.server_hmac_challenge_response { + attempts.remove(&pairing_attempt_id); + return ( + Err(StatusCode::FORBIDDEN), + Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), + ); + } - trace!("Generated connection details"); + trace!("Validated remote's response to pairing token challenge."); - *state_entry = PairingState::Complete(CompletePairingState { - session_span: state.session_span, - sender: state.sender, - remote_node_description: state.remote_node_description, - remote_endpoint_description: state.remote_endpoint_description, - access_token: connection_details.access_token.clone(), - role: PairingRole::CommunicationServer, - }); + let mut rng = rand::rng(); + let connection_details = ConnectionDetails { + initiate_connection_url: match &state.config.connection_initiate_url { + Some(url) => url.clone(), + None => return (Err(StatusCode::BAD_REQUEST), None), + }, + access_token: AccessToken::new(&mut rng), + certificate_fingerprint: state.config.root_certificate.as_deref().map(CertificateHash::sha256), + }; + + trace!("Generated connection details"); + + *state_entry = PairingState::Complete(CompletePairingState { + session_span: state.session_span, + sender: state.sender, + remote_node_description: state.remote_node_description, + remote_endpoint_description: state.remote_endpoint_description, + access_token: connection_details.access_token.clone(), + role: PairingRole::CommunicationServer, + }); - (Ok(Json(connection_details)), None) + (Ok(Json(connection_details)), None) + } + } } else { - info!("Pairing session was expired, or in unexpected state for requesting connection details."); + info!("Pairing session was expired."); + let action = match std::mem::replace(&mut state.state, PairingState::Empty) { + PairingState::Empty => None, + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + Some(sender.handle(Err(ErrorKind::Timeout.into()))) + } + }; attempts.remove(&pairing_attempt_id); - (Err(StatusCode::UNAUTHORIZED), None) + (Err(StatusCode::UNAUTHORIZED), action) } })(); @@ -1065,45 +1157,67 @@ async fn v1_post_connection_details( return (StatusCode::UNAUTHORIZED, None); }; - if let Some(state_entry) = state.get_state() - && let PairingState::Initial(state) = std::mem::replace(state_entry, PairingState::Empty) - { - // It is ok to manually enter the span here as this closure is not async. - let session_span_clone = state.session_span.clone(); - let _entered_span = session_span_clone.enter(); - - trace!("Found pairing session."); - - let expected = state.challenge.sha256(&app_state.network, &state.token.0); - if expected != req.server_hmac_challenge_response { - attempts.remove(&pairing_attempt_id); - return ( - StatusCode::FORBIDDEN, - Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), - ); - } + if let Some(state_entry) = state.get_state() { + match std::mem::replace(state_entry, PairingState::Empty) { + PairingState::Empty => { + info!("Pairing session was in unexpected state for posting connection details."); + attempts.remove(&pairing_attempt_id); + (StatusCode::UNAUTHORIZED, None) + } + PairingState::Complete(complete_pairing_state) => { + info!("Pairing session was in unexpected state for posting connection details."); + attempts.remove(&pairing_attempt_id); + ( + StatusCode::UNAUTHORIZED, + Some(complete_pairing_state.sender.handle(Err(ErrorKind::ProtocolError.into()))), + ) + } + PairingState::Initial(state) => { + // It is ok to manually enter the span here as this closure is not async. + let session_span_clone = state.session_span.clone(); + let _entered_span = session_span_clone.enter(); + + trace!("Found pairing session."); + + let expected = state.challenge.sha256(&app_state.network, &state.token.0); + if expected != req.server_hmac_challenge_response { + attempts.remove(&pairing_attempt_id); + return ( + StatusCode::FORBIDDEN, + Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), + ); + } - trace!("Validated remote's response to pairing token challenge."); - - // Do better error handling here than unwrap - *state_entry = PairingState::Complete(CompletePairingState { - session_span: state.session_span, - sender: state.sender, - remote_node_description: state.remote_node_description, - remote_endpoint_description: state.remote_endpoint_description, - access_token: req.connection_details.access_token, - role: PairingRole::CommunicationClient { - initiate_url: req.connection_details.initiate_connection_url, - }, - }); + trace!("Validated remote's response to pairing token challenge."); + + // Do better error handling here than unwrap + *state_entry = PairingState::Complete(CompletePairingState { + session_span: state.session_span, + sender: state.sender, + remote_node_description: state.remote_node_description, + remote_endpoint_description: state.remote_endpoint_description, + access_token: req.connection_details.access_token, + role: PairingRole::CommunicationClient { + initiate_url: req.connection_details.initiate_connection_url, + root_hash: req.connection_details.certificate_fingerprint, + }, + }); - trace!("Stored received connection details in session state."); + trace!("Stored received connection details in session state."); - (StatusCode::NO_CONTENT, None) + (StatusCode::NO_CONTENT, None) + } + } } else { - info!("Pairing session was expired, or in unexpected state for posting connection details."); + info!("Pairing session was expired."); + let action = match std::mem::replace(&mut state.state, PairingState::Empty) { + PairingState::Empty => None, + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + Some(sender.handle(Err(ErrorKind::Timeout.into()))) + } + }; attempts.remove(&pairing_attempt_id); - (StatusCode::UNAUTHORIZED, None) + (StatusCode::UNAUTHORIZED, action) } })(); @@ -1122,7 +1236,7 @@ async fn v1_finalize_pairing( ) -> StatusCode { trace!("Received request to finalize pairing session."); - let Some(state) = ({ + let Some(mut state) = ({ let mut attempts = state.attempts.lock().unwrap(); attempts.remove(&pairing_attempt_id) }) else { @@ -1130,33 +1244,41 @@ async fn v1_finalize_pairing( return StatusCode::UNAUTHORIZED; }; - if let Some(state) = state.into_state() { + if let Some(state) = state.get_state() { let session_span_clone = state.get_session_span().cloned(); let completion = async move { if success { - if let PairingState::Complete(state) = state { - let result = state - .sender - .handle(Ok(Pairing { - remote_endpoint_description: state.remote_endpoint_description, - remote_node_description: state.remote_node_description, - token: state.access_token, - role: state.role, - })) - .await; - - trace!("Finalized pairing session."); - if result.is_ok() { - StatusCode::NO_CONTENT - } else { - StatusCode::INTERNAL_SERVER_ERROR + match std::mem::replace(state, PairingState::Empty) { + PairingState::Empty => { + info!("Remote tried to finalize pairing session in invalid state."); + StatusCode::BAD_REQUEST + } + PairingState::Initial(state) => { + info!("Remote tried to finalize pairing session that did not yet have all the data exchanged."); + state.sender.handle(Err(ErrorKind::ProtocolError.into())).await; + StatusCode::BAD_REQUEST + } + PairingState::Complete(state) => { + let result = state + .sender + .handle(Ok(Pairing { + remote_endpoint_description: state.remote_endpoint_description, + remote_node_description: state.remote_node_description, + token: state.access_token, + role: state.role, + })) + .await; + + trace!("Finalized pairing session."); + if result.is_ok() { + StatusCode::NO_CONTENT + } else { + StatusCode::INTERNAL_SERVER_ERROR + } } - } else { - info!("Remote tried to finalize pairing session that did not yet have all the data exchanged."); - StatusCode::BAD_REQUEST } } else { - match state { + match std::mem::replace(state, PairingState::Empty) { PairingState::Empty => { /* should never happen, but fine to ignore */ } PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { @@ -1176,6 +1298,12 @@ async fn v1_finalize_pairing( } } else { info!("Pairing session was expired during finalization."); + match std::mem::replace(&mut state.state, PairingState::Empty) { + PairingState::Empty => { /* should never happen, but fine to ignore */ } + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + sender.handle(Err(ErrorKind::Cancelled.into())).await; + } + } StatusCode::UNAUTHORIZED } } @@ -1554,7 +1682,116 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), + supported_protocols: vec![CommunicationProtocol("WebSocket".into())], + supported_versions: vec![MessageVersion("v1".into())], + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: challenge.clone(), + force_pairing: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let response_data: RequestPairingResponse = serde_json::from_slice(&body).unwrap(); + let expected_response = challenge.sha256(&Network::Wan, b"testtoken"); + assert_eq!(expected_response, response_data.client_hmac_challenge_response); + assert!(server.state.pending_pairings.lock().unwrap().alias_mappings.is_empty()); + } + + #[tokio::test] + async fn pair_attempt_node_id() { + let server = Server::new(ServerConfig { + leaf_certificate: None, + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![], + }); + server + .allow_pair_repeated( + Arc::new( + NodeConfig::builder(basic_node_description(UUID_A, Role::Rm), vec![MessageVersion("v1".into())]) + .with_connection_initiate_url("https://example.com/".into()) + .build() + .unwrap(), + ), + Some(pairing_s2_node_id()), + PairingToken(b"testtoken".as_slice().into()), + async |_| Ok::<_, std::io::Error>(()), + ) + .unwrap(); + + let challenge = HmacChallenge::new(&mut rand::rng(), 64); + let response = server + .get_router() + .oneshot( + http::Request::post("/v1/requestPairing") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&RequestPairing { + node_description: basic_node_description(UUID_B, Role::Cem), + endpoint_description: EndpointDescription::default(), + id: Some(UUID_A.into()), + id_alias: None, + supported_protocols: vec![CommunicationProtocol("WebSocket".into())], + supported_versions: vec![MessageVersion("v1".into())], + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: challenge.clone(), + force_pairing: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let response_data: RequestPairingResponse = serde_json::from_slice(&body).unwrap(); + let expected_response = challenge.sha256(&Network::Wan, b"testtoken"); + assert_eq!(expected_response, response_data.client_hmac_challenge_response); + assert!(!server.state.pending_pairings.lock().unwrap().alias_mappings.is_empty()); + } + + #[tokio::test] + async fn pair_attempt_no_node_identifier() { + let server = Server::new(ServerConfig { + leaf_certificate: None, + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![], + }); + server + .allow_pair_once( + Arc::new( + NodeConfig::builder(basic_node_description(UUID_A, Role::Rm), vec![MessageVersion("v1".into())]) + .with_connection_initiate_url("https://example.com/".into()) + .build() + .unwrap(), + ), + None, + PairingToken(b"testtoken".as_slice().into()), + async |_| Ok::<_, std::io::Error>(()), + ) + .unwrap(); + + let challenge = HmacChallenge::new(&mut rand::rng(), 64); + let response = server + .get_router() + .oneshot( + http::Request::post("/v1/requestPairing") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&RequestPairing { + node_description: basic_node_description(UUID_B, Role::Cem), + endpoint_description: EndpointDescription::default(), + id: None, + id_alias: None, supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1573,6 +1810,59 @@ mod tests { let response_data: RequestPairingResponse = serde_json::from_slice(&body).unwrap(); let expected_response = challenge.sha256(&Network::Wan, b"testtoken"); assert_eq!(expected_response, response_data.client_hmac_challenge_response); + assert!(server.state.pending_pairings.lock().unwrap().alias_mappings.is_empty()); + } + + #[tokio::test] + async fn pair_attempt_need_node_identifier() { + let server = Server::new(ServerConfig { + leaf_certificate: None, + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![], + }); + server + .allow_pair_once( + Arc::new( + NodeConfig::builder(basic_node_description(UUID_A, Role::Rm), vec![MessageVersion("v1".into())]) + .with_connection_initiate_url("https://example.com/".into()) + .build() + .unwrap(), + ), + Some(pairing_s2_node_id()), + PairingToken(b"testtoken".as_slice().into()), + async |_| Ok::<_, std::io::Error>(()), + ) + .unwrap(); + + let challenge = HmacChallenge::new(&mut rand::rng(), 64); + let response = server + .get_router() + .oneshot( + http::Request::post("/v1/requestPairing") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&RequestPairing { + node_description: basic_node_description(UUID_B, Role::Cem), + endpoint_description: EndpointDescription::default(), + id: None, + id_alias: None, + supported_protocols: vec![CommunicationProtocol("WebSocket".into())], + supported_versions: vec![MessageVersion("v1".into())], + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: challenge.clone(), + force_pairing: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let error: PairingResponseErrorMessage = serde_json::from_slice(&body).unwrap(); + assert_eq!(error, PairingResponseErrorMessage::S2NodeNotProvided); } #[tokio::test] @@ -1606,7 +1896,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("HTTP/3".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1657,7 +1948,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v0".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1708,7 +2000,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("HTTP/3".into())], supported_versions: vec![MessageVersion("v0".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1746,7 +2039,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_A, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1797,7 +2091,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Rm), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1812,7 +2107,7 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = dbg!(response.into_body().collect().await.unwrap().to_bytes()); + let body = response.into_body().collect().await.unwrap().to_bytes(); let error: PairingResponseErrorMessage = serde_json::from_slice(&body).unwrap(); assert_eq!(error, PairingResponseErrorMessage::InvalidCombinationOfRoles); } @@ -2018,6 +2313,7 @@ mod tests { connection_details: ConnectionDetails { initiate_connection_url: "https://example.com/".into(), access_token: AccessToken::new(&mut rand::rng()), + certificate_fingerprint: None, }, }) .unwrap(), @@ -2073,6 +2369,7 @@ mod tests { connection_details: ConnectionDetails { initiate_connection_url: "https://example.com/".into(), access_token: AccessToken::new(&mut rand::rng()), + certificate_fingerprint: None, }, }) .unwrap(), @@ -2130,6 +2427,7 @@ mod tests { connection_details: ConnectionDetails { initiate_connection_url: "https://example.com/".into(), access_token: AccessToken::new(&mut rand::rng()), + certificate_fingerprint: None, }, }) .unwrap(), @@ -2677,7 +2975,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_A, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], diff --git a/s2energy-connection/src/pairing/transport.rs b/s2energy-connection/src/pairing/transport.rs index 06b40cc..082ed91 100644 --- a/s2energy-connection/src/pairing/transport.rs +++ b/s2energy-connection/src/pairing/transport.rs @@ -5,9 +5,8 @@ use rustls::{ client::{WebPkiServerVerifier, danger::ServerCertVerifier}, pki_types::CertificateDer, }; -use sha2::Digest; -use crate::pairing::Error; +use crate::{CertificateHash, pairing::Error}; use super::{ErrorKind, PairingResult}; @@ -19,7 +18,8 @@ struct HashingCertificateVerifier { #[derive(Debug)] struct SelfSignedState { - hash: CertificateHash, + root_hash: CertificateHash, + leaf_hash: CertificateHash, verifier: SelfVerifier, } @@ -78,8 +78,6 @@ impl ServerCertVerifier for SelfVerifier { } } -type CertificateHash = sha2::digest::generic_array::GenericArray::OutputSize>; - impl ServerCertVerifier for HashingCertificateVerifier { fn verify_server_cert( &self, @@ -89,31 +87,28 @@ impl ServerCertVerifier for HashingCertificateVerifier { ocsp_response: &[u8], now: rustls::pki_types::UnixTime, ) -> Result { - match self - .inner - .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) - { - Ok(v) => Ok(v), - Err(_) => { - let state = self.self_signed_state.get_or_init(|| { - let fallback = CertificateDer::from_slice(&[]); - let root_cert = intermediates.last().unwrap_or(&fallback); - let hash = sha2::Sha256::digest(end_entity); - let mut root_store = RootCertStore::empty(); - // conciously ignore errors here, we just want to initialize - root_store.add(root_cert.clone()).ok(); - let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { - Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), - Err(_) => SelfVerifier::None, - }; - - SelfSignedState { hash, verifier } - }); - state - .verifier - .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + let state = self.self_signed_state.get_or_init(|| { + let fallback = CertificateDer::from_slice(&[]); + let root_cert = intermediates.last().unwrap_or(&fallback); + let root_hash = CertificateHash::sha256(root_cert); + let leaf_hash = CertificateHash::sha256(end_entity); + let mut root_store = RootCertStore::empty(); + // conciously ignore errors here, we just want to initialize + root_store.add(root_cert.clone()).ok(); + let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { + Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), + Err(_) => SelfVerifier::None, + }; + + SelfSignedState { + root_hash, + leaf_hash, + verifier, } - } + }); + state + .verifier + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) } fn verify_tls12_signature( @@ -144,9 +139,16 @@ pub(crate) struct HashProvider { } impl HashProvider { - pub(crate) fn hash(&self) -> Option<&[u8]> { + pub(crate) fn leaf_hash(&self) -> Option<&CertificateHash> { + match self.state.get() { + Some(state) => Some(&state.leaf_hash), + None => None, + } + } + + pub(crate) fn root_hash(&self) -> Option<&CertificateHash> { match self.state.get() { - Some(state) => Some(&state.hash), + Some(state) => Some(&state.root_hash), None => None, } } @@ -205,7 +207,7 @@ mod tests { let (client, hash_provider) = hash_providing_https_client().unwrap(); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); - assert!(hash_provider.hash().is_some()); + assert!(hash_provider.leaf_hash().is_some()); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); https_server_handle.shutdown(); @@ -233,7 +235,7 @@ mod tests { let (client, hash_provider) = hash_providing_https_client().unwrap(); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); - assert!(hash_provider.hash().is_some()); + assert!(hash_provider.leaf_hash().is_some()); https_server_handle.shutdown(); @@ -280,7 +282,7 @@ mod tests { let (client, hash_provider) = hash_providing_https_client().unwrap(); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); - assert!(hash_provider.hash().is_some()); + assert!(hash_provider.leaf_hash().is_some()); https_server_handle.shutdown(); diff --git a/s2energy-connection/src/pairing/wire.rs b/s2energy-connection/src/pairing/wire.rs index f5bd5f2..843afb1 100644 --- a/s2energy-connection/src/pairing/wire.rs +++ b/s2energy-connection/src/pairing/wire.rs @@ -2,13 +2,13 @@ use axum::{Json, extract::FromRequestParts, response::IntoResponse}; use axum_extra::{TypedHeader, headers}; use http::StatusCode; use rand::distr::{Alphanumeric, SampleString}; -use serde::*; +use serde::{ser::SerializeMap, *}; use subtle::ConstantTimeEq; use thiserror::Error; use tracing::info; use crate::{ - NodeId, + CertificateHash, CertificateHashInner, NodeId, common::wire::{AccessToken, CommunicationProtocol, EndpointDescription, MessageVersion, NodeDescription}, }; @@ -132,7 +132,10 @@ pub(crate) struct RequestPairing { /// A server-assigned identifier of the S2Node that this server represents. #[serde(rename = "nodeIdAlias")] #[serde(default)] - pub id: Option, + pub id_alias: Option, + #[serde(rename = "nodeId")] + #[serde(default)] + pub id: Option, #[serde(rename = "supportedCommunicationProtocols")] pub supported_protocols: Vec, /// The versions of the S2 JSON message schemas this S2Node implementation currently supports. @@ -197,12 +200,12 @@ impl FromRequestParts for PairingAttemptId { #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RequestPairingResponse { - pub pairing_attempt_id: PairingAttemptId, - pub server_node_description: NodeDescription, - pub server_endpoint_description: EndpointDescription, - pub selected_hmac_hashing_algorithm: HmacHashingAlgorithm, - pub client_hmac_challenge_response: HmacChallengeResponse, - pub server_hmac_challenge: HmacChallenge, + pub(super) pairing_attempt_id: PairingAttemptId, + pub(super) server_node_description: NodeDescription, + pub(super) server_endpoint_description: EndpointDescription, + pub(super) selected_hmac_hashing_algorithm: HmacHashingAlgorithm, + pub(super) client_hmac_challenge_response: HmacChallengeResponse, + pub(super) server_hmac_challenge: HmacChallenge, } #[derive(Serialize, Deserialize)] @@ -234,6 +237,38 @@ pub(crate) struct CancelPrePairingRequest { pub(crate) struct ConnectionDetails { pub initiate_connection_url: String, pub access_token: AccessToken, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_fingerprint", + deserialize_with = "deserialize_fingerprint" + )] + pub certificate_fingerprint: Option, +} + +pub(crate) fn serialize_fingerprint(value: &Option, serializer: S) -> Result { + use base64::{Engine, engine::general_purpose::STANDARD}; + // Unwrap is ok here as we serialize only when not none. + let encoded = STANDARD.encode(value.as_deref().unwrap() as &[u8]); + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("SHA256", &encoded)?; + map.end() +} + +pub(crate) fn deserialize_fingerprint<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + use base64::{Engine, engine::general_purpose::STANDARD}; + use std::{borrow::Cow, collections::HashMap}; + let data = HashMap::, Cow<'de, str>>::deserialize(deserializer)?; + if let Some(hash) = data.get("SHA256") { + let decoded = STANDARD.decode(hash.as_ref()).map_err(de::Error::custom)?; + Ok(Some(CertificateHash(CertificateHashInner::Sha256( + <[u8; 32]>::try_from(decoded) + .map_err(|_| de::Error::custom("Hash is wrong length"))? + .into(), + )))) + } else { + Err(de::Error::custom("Missing SHA256 hash")) + } } #[derive(Serialize, Deserialize)]