diff --git a/Cargo.lock b/Cargo.lock index 70bf768..9b2b035 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,11 +2,70 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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 = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "autocfg" @@ -14,16 +73,105 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "cc" -version = "1.2.37" +version = "1.2.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -32,9 +180,38 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "find-msvc-tools" -version = "0.1.1" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] [[package]] name = "heck" @@ -44,21 +221,70 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] [[package]] name = "libc" -version = "0.2.175" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -81,12 +307,24 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "once_cell" 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 = "papaya" version = "0.2.3" @@ -97,17 +335,42 @@ dependencies = [ "seize", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "portable-atomic" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -186,34 +449,181 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rloop" version = "0.2.0" dependencies = [ "anyhow", + "env_logger", "libc", + "log", "mio", "papaya", "pyo3", "pyo3-build-config", + "rcgen", + "rustls", + "rustls-pemfile", "socket2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "seize" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4b8d813387d566f627f3ea1b914c068aac94c40ae27ec43f5f33bde65abefe7" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -224,19 +634,25 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" -version = "2.0.106" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -249,11 +665,30 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unindent" @@ -261,19 +696,46 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -282,7 +744,25 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -291,14 +771,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -307,44 +804,113 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index e2c7367..3d0d826 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,9 +31,14 @@ crate-type = ["cdylib", "rlib"] [dependencies] anyhow = "=1.0" +env_logger = "0.11" +log = "0.4" mio = { version = "=1.0", features = ["net", "os-ext", "os-poll"] } papaya = "=0.2" pyo3 = { version = "=0.26", features = ["anyhow", "extension-module", "generate-import-lib"] } +rcgen = "=0.13" +rustls = { version = "0.23", features = ["std"] } +rustls-pemfile = "2.0" socket2 = { version = "=0.6", features = ["all"] } [target.'cfg(unix)'.dependencies] diff --git a/pyproject.toml b/pyproject.toml index d807a9a..8a942d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ lint = [ test = [ 'pytest~=8.3', 'pytest-asyncio~=0.26', + 'pytest-timeout~=2.4', + 'requests~=2.32', ] all = [ @@ -105,5 +107,24 @@ known-first-party = ['rloop', 'tests'] [tool.pytest.ini_options] asyncio_mode = 'auto' +log_cli = true +log_cli_level = "INFO" + +doctest_optionflags = [ + "NORMALIZE_WHITESPACE", + "ALLOW_UNICODE", + "ALLOW_BYTES", + "NUMBER", +] +addopts = [ + "--continue-on-collection-errors", + "--ignore-glob=*_BACKUP_*", + "-v", + "-rxX", + "--doctest-modules", + "--doctest-ignore-import-errors", +] + + [tool.uv] package = false diff --git a/rloop/loop.py b/rloop/loop.py index 78479a9..fcf5441 100644 --- a/rloop/loop.py +++ b/rloop/loop.py @@ -1,5 +1,6 @@ import asyncio as __asyncio import errno +import logging import os import signal import socket @@ -32,6 +33,9 @@ from .utils import _can_use_pidfd, _HAS_IPv6, _interleave_addrinfos, _ipaddr_info, _noop, _set_reuseport +logger = logging.getLogger(__name__) + + class RLoop(__BaseLoop, __asyncio.AbstractEventLoop): def __init__(self): super().__init__() @@ -316,10 +320,6 @@ async def create_connection( interleave=None, all_errors=False, ): - # TODO - if ssl: - raise NotImplementedError - if server_hostname is not None and not ssl: raise ValueError('server_hostname is only meaningful with ssl') @@ -385,7 +385,7 @@ async def create_connection( happy_eyeballs_delay, loop=self, ) - )[0] # can't use sock, _, _ as it keeks a reference to exceptions + )[0] # can't use sock, _, _ as it keeps a reference to exceptions if sock is None: exceptions = [exc for sub in exceptions for exc in sub] @@ -421,16 +421,8 @@ async def create_connection( rsock = (sock.fileno(), sock.family) sock.detach() - # TODO: ssl - transport, protocol = self._tcp_conn(rsock, protocol_factory) - # transport, protocol = await self._create_connection_transport( - # sock, - # protocol_factory, - # ssl, - # server_hostname, - # ssl_handshake_timeout=ssl_handshake_timeout, - # ssl_shutdown_timeout=ssl_shutdown_timeout, - # ) + logger.debug('Creating %s connection', 'SSL' if ssl else 'TCP') + transport, protocol = self._tcp_conn(rsock, protocol_factory, ssl, server_hostname if ssl else None) return transport, protocol @@ -491,10 +483,6 @@ async def create_server( ssl_shutdown_timeout=None, start_serving=True, ): - # TODO - if ssl: - raise NotImplementedError - if isinstance(ssl, bool): raise TypeError('ssl argument must be an SSLContext or None') @@ -580,11 +568,12 @@ async def create_server( rsocks.append((sock.fileno(), sock.family)) sock.detach() - # TODO: ssl - # server = self._tcp_server(sockets, rsocks, protocol_factory, backlog, - # ssl, ssl_handshake_timeout, - # ssl_shutdown_timeout) - server = Server(self._tcp_server(sockets, rsocks, protocol_factory, backlog)) + if ssl: + logger.debug('Creating SSL server') + server = Server(self._tcp_server_ssl(sockets, rsocks, protocol_factory, backlog, ssl)) + else: + logger.debug('Creating TCP server') + server = Server(self._tcp_server(sockets, rsocks, protocol_factory, backlog)) if start_serving: await server.start_serving() diff --git a/src/event_loop.rs b/src/event_loop.rs index 5bb6ecb..da9eff4 100644 --- a/src/event_loop.rs +++ b/src/event_loop.rs @@ -346,6 +346,7 @@ impl EventLoop { let guard_poll = self.io.lock().unwrap(); _ = guard_poll.registry().reregister(&mut source, token, interests); } + self.wake(); // interest changed return IOHandle::TCPStream(interests); } unreachable!() @@ -357,6 +358,7 @@ impl EventLoop { let guard_poll = self.io.lock().unwrap(); _ = guard_poll.registry().register(&mut source, token, interest); } + self.wake(); // interest registered IOHandle::TCPStream(interest) }, ); @@ -396,16 +398,20 @@ impl EventLoop { #[inline(always)] pub(crate) fn tcp_stream_close(&self, py: Python, fd: usize) { - if let Some(transport) = self.tcp_transports.pin().remove(&fd) - && let Some(lfd) = transport.borrow(py).lfd - { - self.tcp_lstreams.pin().get(&lfd).map(|v| v.pin().remove(&fd)); + if let Some(transport) = self.tcp_transports.pin().remove(&fd) { + // ensure TCPTransport::close() called, as it sends TLS close + if transport.borrow(py).is_tls() { + transport.borrow(py).close(py); + } + if let Some(lfd) = transport.borrow(py).lfd { + self.tcp_lstreams.pin().get(&lfd).map(|v| v.pin().remove(&fd)); + } } } #[inline(always)] - pub(crate) fn get_tcp_transport(&self, fd: usize, py: Python) -> Py { - self.tcp_transports.pin().get(&fd).unwrap().clone_ref(py) + pub(crate) fn get_tcp_transport(&self, fd: usize, py: Python) -> Option> { + self.tcp_transports.pin().get(&fd).map(|t| t.clone_ref(py)) } pub(crate) fn with_tcp_listener_streams(&self, fd: usize, func: T) @@ -1171,10 +1177,19 @@ impl EventLoop { py: Python, sock: (i32, i32), protocol_factory: Py, + ssl_context: Option>, + server_hostname: Option, ) -> PyResult<(Py, Py)> { let rself = pyself.get(); let transport = TCPTransport::from_py(py, &pyself, sock, protocol_factory); let fd = transport.fd; + + if let (Some(ssl_context), Some(server_hostname)) = (ssl_context, server_hostname) { + // Initialize TLS client connection + let ssl_config = crate::ssl::create_ssl_client_config_from_context(&ssl_context.bind(py))?; + transport.initialize_tls_client(ssl_config, server_hostname); + } + let pytransport = Py::new(py, transport)?; let proto = TCPTransport::attach(&pytransport, py)?; rself.tcp_transports.pin().insert(fd, pytransport.clone_ref(py)); @@ -1198,6 +1213,30 @@ impl EventLoop { Py::new(py, server) } + fn _tcp_server_ssl( + pyself: Py, + py: Python, + socks: Py, + rsocks: Vec<(i32, i32)>, + protocol_factory: Py, + backlog: i32, + ssl_context: Py, + ) -> PyResult> { + let ssl_config = crate::ssl::create_ssl_config_from_context(&ssl_context.bind(py))?; + let mut servers = Vec::new(); + for (fd, family) in rsocks { + servers.push(TCPServer::from_fd_ssl( + fd, + family, + backlog, + protocol_factory.clone_ref(py), + ssl_config.clone(), + )); + } + let server = Server::tcp(pyself.clone_ref(py), socks, servers); + Py::new(py, server) + } + fn _tcp_stream_bound(&self, fd: usize) -> bool { self.tcp_transports.pin().contains_key(&fd) } diff --git a/src/lib.rs b/src/lib.rs index 0dc8db2..692f4ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ mod log; mod py; mod server; mod sock; +mod ssl; mod tcp; mod time; mod udp; @@ -24,11 +25,15 @@ pub(crate) fn get_lib_version() -> &'static str { #[pymodule(gil_used = false)] fn _rloop(_py: Python, module: &Bound) -> PyResult<()> { + // Configure logs via RUST_LOG= (default to INFO) + env_logger::init(); + module.add("__version__", get_lib_version())?; event_loop::init_pymodule(module)?; handles::init_pymodule(module)?; server::init_pymodule(module)?; + ssl::init_pymodule(module)?; Ok(()) } diff --git a/src/ssl.rs b/src/ssl.rs new file mode 100644 index 0000000..1710cb1 --- /dev/null +++ b/src/ssl.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use pyo3::prelude::*; +use rustls::{ + ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer}, +}; +use rustls_pemfile::Item; +use std::fs; + +#[pyclass] +#[derive(Clone, Copy)] +pub enum TLSVersion { + TLS12, + TLS12_PLUS, + TLS13, +} + +static TLS12_ONLY: [&'static rustls::SupportedProtocolVersion; 1] = [&rustls::version::TLS12]; +static TLS12_PLUS: [&'static rustls::SupportedProtocolVersion; 2] = [&rustls::version::TLS12, &rustls::version::TLS13]; +static TLS13_ONLY: [&'static rustls::SupportedProtocolVersion; 1] = [&rustls::version::TLS13]; +static TLS_DEFAULT: [&'static rustls::SupportedProtocolVersion; 1] = TLS12_ONLY; + +fn get_tls_version_from_env() -> Option { + match std::env::var("RLOOP_TLS_VERSION").as_deref() { + Ok("1.2") => Some(TLSVersion::TLS12), + Ok("1.2+") => Some(TLSVersion::TLS12_PLUS), + Ok("1.3") => Some(TLSVersion::TLS13), + Ok(_) => None, // Invalid value, use default + Err(_) => None, // Not set, use default + } +} + +/// Create a basic SSL server configuration with self-signed certificate +pub(crate) fn create_ssl_config_with_version(tls_version: Option) -> Result { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()])?; + let cert_der = CertificateDer::from(cert.cert); + let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); + let key_der = PrivateKeyDer::from(key_der); + + let versions = match tls_version { + Some(TLSVersion::TLS12) => &TLS12_ONLY[..], + Some(TLSVersion::TLS12_PLUS) => &TLS12_PLUS[..], + Some(TLSVersion::TLS13) => &TLS13_ONLY[..], + None => &TLS_DEFAULT[..], + }; + + let config = ServerConfig::builder_with_protocol_versions(versions) + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der)?; + + Ok(config) +} + +/// Create a basic SSL server configuration with self-signed certificate +pub(crate) fn create_ssl_config() -> Result { + create_ssl_config_with_version(get_tls_version_from_env()) +} + +/// Debug function to list supported cipher suites +#[pyfunction] +pub fn list_rustls_cipher_suites() -> PyResult> { + // List the default cipher suites that rustls supports + let default_suites = rustls::crypto::aws_lc_rs::DEFAULT_CIPHER_SUITES; + let cipher_suites = default_suites.iter().map(|cs| format!("{:?}", cs)).collect::>(); + + Ok(cipher_suites) +} + +/// Debug function to list all available cipher suites +#[pyfunction] +pub fn list_all_rustls_cipher_suites() -> PyResult> { + // List all cipher suites that rustls supports + let all_suites = rustls::crypto::aws_lc_rs::ALL_CIPHER_SUITES; + let cipher_suites = all_suites.iter().map(|cs| format!("{cs:?}")).collect::>(); + + Ok(cipher_suites) +} + +/// Create SSL server configuration from an SSL context with TLS version +pub(crate) fn create_ssl_config_from_context_with_version( + ssl_context: &Bound, + tls_version: Option, +) -> Result { + // Try to extract certificate and key file paths from the SSL context + // These are test-specific attributes + if let (Ok(certfile_attr), Ok(keyfile_attr)) = (ssl_context.getattr("_certfile"), ssl_context.getattr("_keyfile")) { + let certfile: String = certfile_attr.extract()?; + let keyfile: String = keyfile_attr.extract()?; + + // Load certificate from PEM + let cert_data = fs::read(&certfile)?; + let mut cert_reader = std::io::Cursor::new(&cert_data); + let cert_der = match rustls_pemfile::read_one(&mut cert_reader)? { + Some(Item::X509Certificate(cert)) => cert, + _ => return Err(anyhow::anyhow!("failed to parse certificate")), + }; + + // Load private key from PEM + let key_data = fs::read(&keyfile)?; + let mut key_reader = std::io::Cursor::new(&key_data); + let key_der = match rustls_pemfile::read_one(&mut key_reader)? { + Some(Item::Pkcs8Key(key)) => PrivateKeyDer::from(key), + Some(Item::Pkcs1Key(key)) => PrivateKeyDer::from(key), + Some(Item::Sec1Key(key)) => PrivateKeyDer::from(key), + _ => return Err(anyhow::anyhow!("failed to parse private key")), + }; + + let versions = match tls_version { + Some(TLSVersion::TLS12) => &TLS12_ONLY[..], + Some(TLSVersion::TLS12_PLUS) => &TLS12_PLUS[..], + Some(TLSVersion::TLS13) => &TLS13_ONLY[..], + None => &TLS_DEFAULT[..], + }; + + let config = ServerConfig::builder_with_protocol_versions(versions) + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der)?; + + Ok(config) + } else { + // Fallback: generate a self-signed certificate for testing + create_ssl_config_with_version(tls_version) + } +} + +/// Create SSL server configuration from an SSL context +pub(crate) fn create_ssl_config_from_context(ssl_context: &Bound) -> Result { + create_ssl_config_from_context_with_version(ssl_context, get_tls_version_from_env()) +} + +/// Create SSL client configuration from an SSL context with TLS version +pub(crate) fn create_ssl_client_config_from_context_with_version( + _ssl_context: &Bound, + tls_version: Option, +) -> Result { + let versions = match tls_version { + Some(TLSVersion::TLS12) => &TLS12_ONLY[..], + Some(TLSVersion::TLS12_PLUS) => &TLS12_PLUS[..], + Some(TLSVersion::TLS13) => &TLS13_ONLY[..], + None => &TLS_DEFAULT[..], + }; + + let config = rustls::ClientConfig::builder_with_protocol_versions(versions) + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(NoCertificateVerification)) + .with_no_client_auth(); + + Ok(config) +} + +/// Create SSL client configuration from an SSL context +pub(crate) fn create_ssl_client_config_from_context(ssl_context: &Bound) -> Result { + create_ssl_client_config_from_context_with_version(ssl_context, get_tls_version_from_env()) +} + +#[derive(Debug)] +struct NoCertificateVerification; + +impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { + 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 { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA1, + rustls::SignatureScheme::ECDSA_SHA1_Legacy, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::ED448, + ] + } +} + +pub(crate) fn init_pymodule(module: &Bound) -> PyResult<()> { + module.add_function(wrap_pyfunction!(list_rustls_cipher_suites, module)?)?; + module.add_function(wrap_pyfunction!(list_all_rustls_cipher_suites, module)?)?; + module.add_class::()?; + Ok(()) +} diff --git a/src/tcp.rs b/src/tcp.rs index 2c8d2ee..b582662 100644 --- a/src/tcp.rs +++ b/src/tcp.rs @@ -12,7 +12,7 @@ use std::{ cell::RefCell, collections::{HashMap, VecDeque}, io::Read, - sync::atomic, + sync::{Arc, atomic}, }; use crate::{ @@ -24,11 +24,28 @@ use crate::{ utils::syscall, }; +use rustls::Connection as TLSConnection; + +/// No-op used for SSL connections where connection_made is called later. +struct NoOpHandle; + +impl Handle for NoOpHandle { + fn run(&self, _py: Python, _event_loop: &EventLoop, _state: &mut EventLoopRunState) { + log::debug!("NoOpHandle::run() called"); + // Doing nothing + } + + fn cancelled(&self) -> bool { + false + } +} + pub(crate) struct TCPServer { pub fd: i32, sfamily: i32, backlog: i32, protocol_factory: Py, + ssl_config: Option, } impl TCPServer { @@ -38,6 +55,23 @@ impl TCPServer { sfamily, backlog, protocol_factory, + ssl_config: None, + } + } + + pub(crate) fn from_fd_ssl( + fd: i32, + sfamily: i32, + backlog: i32, + protocol_factory: Py, + ssl_config: rustls::ServerConfig, + ) -> Self { + Self { + fd, + sfamily, + backlog, + protocol_factory, + ssl_config: Some(ssl_config), } } @@ -52,6 +86,7 @@ impl TCPServer { pyloop: pyloop.clone_ref(py), sfamily: self.sfamily, proto_factory: self.protocol_factory.clone_ref(py), + ssl_config: self.ssl_config.clone(), }; pyloop.get().tcp_listener_add(listener, sref); @@ -69,7 +104,9 @@ impl TCPServer { let mut transports = Vec::new(); event_loop.with_tcp_listener_streams(self.fd as usize, |streams| { for stream_fd in &streams.pin() { - transports.push(event_loop.get_tcp_transport(*stream_fd, py)); + if let Some(transport) = event_loop.get_tcp_transport(*stream_fd, py) { + transports.push(transport); + } } }); for transport in transports { @@ -81,7 +118,9 @@ impl TCPServer { let mut transports = Vec::new(); event_loop.with_tcp_listener_streams(self.fd as usize, |streams| { for stream_fd in &streams.pin() { - transports.push(event_loop.get_tcp_transport(*stream_fd, py)); + if let Some(transport) = event_loop.get_tcp_transport(*stream_fd, py) { + transports.push(transport); + } } }); for transport in transports { @@ -95,11 +134,13 @@ pub(crate) struct TCPServerRef { pyloop: Py, sfamily: i32, proto_factory: Py, + ssl_config: Option, } impl TCPServerRef { #[inline] pub(crate) fn new_stream(&self, py: Python, stream: TcpStream) -> (Py, BoxedHandle) { + log::debug!("SSL server: accepting new connection"); let proto = self.proto_factory.bind(py).call0().unwrap(); let transport = TCPTransport::new( @@ -110,24 +151,64 @@ impl TCPServerRef { self.sfamily, Some(self.fd), ); - let conn_made = transport - .proto - .getattr(py, pyo3::intern!(py, "connection_made")) - .unwrap(); + + // Initialize TLS if this is an SSL server + if let Some(ref ssl_config) = self.ssl_config { + log::debug!("SSL server: initializing TLS for new connection"); + transport.initialize_tls_server(ssl_config.clone()); + } + let pytransport = Py::new(py, transport).unwrap(); - let conn_handle = Py::new( - py, - CBHandle::new1(conn_made, pytransport.clone_ref(py).into_any(), copy_context(py)), - ) - .unwrap(); - (pytransport, Box::new(conn_handle)) + // For SSL connections, delay connection_made until handshake completes + let is_ssl = self.ssl_config.is_some(); + log::debug!( + "new_stream: is_ssl = {}, ssl_config.is_some() = {}", + is_ssl, + self.ssl_config.is_some() + ); + let conn_handle: BoxedHandle = if is_ssl { + // For SSL connections, wait the handshake before scheduling callbacks + // connection_made will be called later when handshake completes + log::debug!("Creating NoOpHandle for SSL connection"); + Box::new(NoOpHandle) + } else { + // For non-SSL connections, call connection_made immediately + let conn_made = pytransport + .borrow(py) + .proto + .getattr(py, pyo3::intern!(py, "connection_made")) + .unwrap(); + Box::new( + Py::new( + py, + CBHandle::new1(conn_made, pytransport.clone_ref(py).into_any(), copy_context(py)), + ) + .unwrap(), + ) + }; + + (pytransport, conn_handle) + } + + #[inline] + fn get_ssl_config(&self, py: Python) -> Option { + None // TODO: Pass SSL config through the server reference } } + struct TCPTransportState { stream: TcpStream, + tls_conn: Option, + handshake_complete: bool, + connection_made_called: bool, write_buf: VecDeque>, write_buf_dsize: usize, + tls_close_sent: bool, + tls_close_received: bool, + tls_close_sent_time: Option, + tls_pending_close: bool, + ssl_close_timeout: u16, } #[pyclass(frozen, unsendable, module = "rloop._rloop")] @@ -164,27 +245,43 @@ impl TCPTransport { lfd: Option, ) -> Self { let fd = stream.as_raw_fd() as usize; + + let ssl_close_timeout = std::env::var("RLOOP_SSL_CLOSE_TIMEOUT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1000); + let state = TCPTransportState { stream, + tls_conn: None, + handshake_complete: false, + connection_made_called: false, write_buf: VecDeque::new(), write_buf_dsize: 0, + tls_close_sent: false, + tls_close_received: false, + tls_close_sent_time: None, + tls_pending_close: false, + ssl_close_timeout, }; let wh = 1024 * 64; let wl = wh / 4; - let mut proto_buffered = false; + let proto_buffered = pyproto.is_instance(asyncio_proto_buf(py).unwrap()).unwrap(); + let protom_conn_lost = pyproto.getattr(pyo3::intern!(py, "connection_lost")).unwrap().unbind(); + let protom_buf_get: Py; let protom_recv_data: Py; - if pyproto.is_instance(asyncio_proto_buf(py).unwrap()).unwrap() { - proto_buffered = true; + + if proto_buffered { protom_buf_get = pyproto.getattr(pyo3::intern!(py, "get_buffer")).unwrap().unbind(); protom_recv_data = pyproto.getattr(pyo3::intern!(py, "buffer_updated")).unwrap().unbind(); } else { protom_buf_get = py.None(); protom_recv_data = pyproto.getattr(pyo3::intern!(py, "data_received")).unwrap().unbind(); } - let protom_conn_lost = pyproto.getattr(pyo3::intern!(py, "connection_lost")).unwrap().unbind(); + let proto = pyproto.unbind(); Self { @@ -221,12 +318,52 @@ impl TCPTransport { pub(crate) fn attach(pyself: &Py, py: Python) -> PyResult> { let rself = pyself.borrow(py); - rself - .proto - .call_method1(py, pyo3::intern!(py, "connection_made"), (pyself.clone_ref(py),))?; + // For SSL connections, delay connection_made until handshake completes + if rself.state.borrow().tls_conn.is_none() { + rself + .proto + .call_method1(py, pyo3::intern!(py, "connection_made"), (pyself.clone_ref(py),))?; + } Ok(rself.proto.clone_ref(py)) } + pub(crate) fn initialize_tls_server(&self, ssl_config: rustls::ServerConfig) { + let mut state = self.state.borrow_mut(); + state.tls_conn = Some(TLSConnection::Server( + rustls::ServerConnection::new(Arc::new(ssl_config)).unwrap(), + )); + state.handshake_complete = false; + } + + pub(crate) fn initialize_tls_client(&self, ssl_config: rustls::ClientConfig, server_name: String) { + log::debug!( + "SSL client: Initializing TLS for fd {} with server '{}'", + self.fd, + server_name + ); + let mut state = self.state.borrow_mut(); + let server_name = rustls::pki_types::ServerName::try_from(server_name).unwrap(); + let conn = rustls::ClientConnection::new(Arc::new(ssl_config), server_name).unwrap(); + state.tls_conn = Some(TLSConnection::Client(conn)); + state.handshake_complete = false; + + // Check if the client needs to send initial handshake data + if let Some(ref tls_conn) = state.tls_conn { + if tls_conn.wants_write() { + log::debug!( + "SSL client: fd {} wants to write immediately after handshake init", + self.fd + ); + self.pyloop.get().tcp_stream_add(self.fd, Interest::WRITABLE); + } else { + log::debug!( + "SSL client: fd {} does not want to write immediately after handshake init", + self.fd + ); + } + } + } + #[inline] fn write_buf_size_decr(pyself: &Py, py: Python) { let rself = pyself.borrow(py); @@ -250,10 +387,22 @@ impl TCPTransport { return false; } + // For TLS connections, call close() to send TLS close alerts + if self.state.borrow().tls_conn.is_some() { + self.close(py); + return true; // Handled by TLS specific close path + } + if !self.state.borrow_mut().write_buf.is_empty() { + // Need mutable borrow for check + log::debug!( + "TCP close_from_read_handle: fd {} has pending write data, not closing yet", + self.fd + ); return false; } + log::debug!("TCP close_from_read_handle: fd {} closing now", self.fd); event_loop.tcp_stream_rem(self.fd, Interest::WRITABLE); _ = self.protom_conn_lost.call1(py, (py.None(),)); true @@ -262,6 +411,11 @@ impl TCPTransport { #[inline] fn close_from_write_handle(&self, py: Python, errored: bool) -> Option { if self.closing.load(atomic::Ordering::Relaxed) { + log::debug!( + "TCP close_from_write_handle: fd {} already closing. Errored: {}", + self.fd, + errored + ); _ = self.protom_conn_lost.call1( py, #[allow(clippy::obfuscated_if_else)] @@ -275,61 +429,158 @@ impl TCPTransport { ); return Some(true); } - self.weof.load(atomic::Ordering::Relaxed).then_some(false) + let weof = self.weof.load(atomic::Ordering::Relaxed); // Store to avoid multiple loads + if weof { + log::debug!( + "TCP close_from_write_handle: fd {} WEOF true. Errored: {}", + self.fd, + errored + ); + } else { + log::debug!( + "TCP close_from_write_handle: fd {} WEOF false. Errored: {}. Closing due to write EOF.", + self.fd, + errored + ); + } + weof.then_some(false) // if weof is true, return Some(false), else None } #[inline(always)] fn call_conn_lost(&self, py: Python, err: Option) { - _ = self.protom_conn_lost.call1(py, (err,)); + log::debug!( + "TCPTransport::call_conn_lost called for fd {}. Error present: {:?}", + self.fd, + err.is_some() + ); + let err_arg = match err { + Some(e) => e.into_py_any(py).unwrap(), + None => py.None(), + }; + _ = self.protom_conn_lost.call1(py, (err_arg,)); + // tcp_stream_close will trigger actual socket closure and subsequent Python callback self.pyloop.get().tcp_stream_close(py, self.fd); } + fn call_conn_lost_py(&self, py: Python) { + self.call_conn_lost(py, None); + } + fn try_write(pyself: &Py, py: Python, data: &[u8]) -> PyResult<()> { let rself = pyself.borrow(py); if rself.weof.load(atomic::Ordering::Relaxed) { + log::debug!("TCP/SSL try_write: fd {} EOF set for write", rself.fd); return Err(pyo3::exceptions::PyRuntimeError::new_err("Cannot write after EOF")); } if data.is_empty() { + log::debug!("TCP/SSL try_write: fd {} empty data", rself.fd); return Ok(()); } + let is_tls = rself.state.borrow().tls_conn.is_some(); + if is_tls { + log::debug!( + "SSL write (try_write): called for fd {} with {} bytes of application data", + rself.fd, + data.len() + ); + } else { + log::debug!( + "TCP write (try_write): called for fd {} with {} bytes of application data", + rself.fd, + data.len() + ); + } + let mut state = rself.state.borrow_mut(); - let buf_added = match state.write_buf_dsize { - #[allow(clippy::cast_possible_wrap)] - 0 => match syscall!(write(rself.fd as i32, data.as_ptr().cast(), data.len())) { - Ok(written) if written as usize == data.len() => 0, - Ok(written) => { - let written = written as usize; - state.write_buf.push_back((&data[written..]).into()); - data.len() - written - } - Err(err) - if err.kind() == std::io::ErrorKind::Interrupted - || err.kind() == std::io::ErrorKind::WouldBlock => - { - state.write_buf.push_back(data.into()); - data.len() - } - Err(err) => { - if state.write_buf_dsize > 0 { - // reset buf_dsize? - rself.pyloop.get().tcp_stream_rem(rself.fd, Interest::WRITABLE); + + // For TLS connections, never write directly to socket - always buffer for encryption + let buf_added = if is_tls { + log::debug!( + "SSL write (try_write): buffering {} bytes for encryption on fd {}", + data.len(), + rself.fd + ); + state.write_buf.push_back(data.into()); + data.len() + } else { + match state.write_buf_dsize { + #[allow(clippy::cast_possible_wrap)] + 0 => match syscall!(write(rself.fd as i32, data.as_ptr().cast(), data.len())) { + Ok(written) if written as usize == data.len() => { + log::debug!( + "TCP write (try_write): wrote all {} bytes directly on fd {}", + data.len(), + rself.fd + ); + 0 // All data written } - if rself - .closing - .compare_exchange(false, true, atomic::Ordering::Relaxed, atomic::Ordering::Relaxed) - .is_ok() - { - rself.pyloop.get().tcp_stream_rem(rself.fd, Interest::READABLE); + Ok(written) => { + let written = written as usize; + log::debug!( + "TCP write (try_write): partial direct write on fd {}: {}/{}", + rself.fd, + written, + data.len() + ); + state.write_buf.push_back((&data[written..]).into()); + // Amount buffered + data.len() - written + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => { + log::debug!( + "TCP write (try_write): interrupted on fd {}. Buffering all {} bytes.", + rself.fd, + data.len() + ); + state.write_buf.push_back(data.into()); // Buffer all + data.len() } - rself.call_conn_lost(py, Some(pyo3::exceptions::PyRuntimeError::new_err(err.to_string()))); - 0 + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + log::debug!( + "TCP write (try_write): would block on fd {}. Buffering all {} bytes.", + rself.fd, + data.len() + ); + state.write_buf.push_back(data.into()); // Buffer all + data.len() + } + Err(err) => { + log::error!("TCP write (try_write): syscall error for fd {}: {:?}", rself.fd, err); + if state.write_buf_dsize > 0 { + // reset buf_dsize? + rself.pyloop.get().tcp_stream_rem(rself.fd, Interest::WRITABLE); + } + if rself + .closing + .compare_exchange(false, true, atomic::Ordering::Relaxed, atomic::Ordering::Relaxed) + .is_ok() + { + log::debug!( + "TCP write (try_write): error on fd {}, setting closing and removing READ interest", + rself.fd + ); + rself.pyloop.get().tcp_stream_rem(rself.fd, Interest::READABLE); + } + rself.call_conn_lost( + py, + Some(PyErr::new::(err.to_string())), + ); + // Connection closed + 0 + } + }, + _ => { + // Buffer already had data, append new data + log::debug!( + "SSL write (try_write): appending {} bytes to existing buffer on fd {}", + data.len(), + rself.fd + ); + state.write_buf.push_back(data.into()); + data.len() } - }, - _ => { - state.write_buf.push_back(data.into()); - data.len() } }; if buf_added > 0 { @@ -352,6 +603,7 @@ impl TCPTransport { fn proto_pause(pyself: &Py, py: Python) { let rself = pyself.borrow(py); + log::debug!("TCP/SSL proto_pause called for fd {}", rself.fd); // Use rself.fd if let Err(err) = rself.proto.call_method0(py, pyo3::intern!(py, "pause_writing")) { let err_ctx = LogExc::transport( err, @@ -365,6 +617,7 @@ impl TCPTransport { fn proto_resume(pyself: &Py, py: Python) { let rself = pyself.borrow(py); + log::debug!("TCP/SSL proto_resume called for fd {}", rself.fd); // Use rself.fd if let Err(err) = rself.proto.call_method0(py, pyo3::intern!(py, "resume_writing")) { let err_ctx = LogExc::transport( err, @@ -393,19 +646,73 @@ impl TCPTransport { self.closing.load(atomic::Ordering::Relaxed) } - fn close(&self, py: Python) { + pub(crate) fn is_tls(&self) -> bool { + self.state.borrow().tls_conn.is_some() + } + + pub(crate) fn close(&self, py: Python) { + log::debug!("TCPTransport::close() called for fd {}", self.fd); if self .closing .compare_exchange(false, true, atomic::Ordering::Relaxed, atomic::Ordering::Relaxed) .is_err() { + log::debug!("TCPTransport::close() already closing, returning"); return; } + // For TLS connections + if self.state.borrow().tls_conn.is_some() { + let has_pending_data = !self.state.borrow().write_buf.is_empty(); + if has_pending_data { + log::debug!( + "SSL close: pending data in write buffer, deferring close alert for fd {}", + self.fd + ); + // Mark that we want to close after pending data is sent + self.state.borrow_mut().tls_pending_close = true; + return; + } else { + log::debug!("SSL close: no pending data, sending TLS close alert for fd {}", self.fd); + // Send close alert immediately since no pending data + let mut tls_buf = Vec::new(); + { + let mut state = self.state.borrow_mut(); + if let Some(ref mut tls_conn) = state.tls_conn { + // Send close notify to initiate TLS close handshake + tls_conn.send_close_notify(); + let _ = tls_conn.write_tls(&mut tls_buf); + // Mark that we've sent our close alert + state.tls_close_sent = true; + state.tls_close_sent_time = Some(std::time::Instant::now()); + } + } + if !tls_buf.is_empty() { + log::trace!("SSL close: TLS buffer: {:02x?}", &tls_buf[..tls_buf.len().min(64)]); + let fd = self.fd as i32; + let _ = syscall!(write(fd, tls_buf.as_ptr().cast(), tls_buf.len())); + } + + log::debug!("SSL close: sent close alert, waiting for peer response"); + let event_loop = self.pyloop.get(); + event_loop.tcp_stream_rem(self.fd, Interest::WRITABLE); + // Keep readable interest to detect peer's close + + // Notify the connection closing + let pytransport = event_loop.get_tcp_transport(self.fd, py).unwrap(); + pytransport + .getattr(py, pyo3::intern!(py, "call_connection_lost")) + .unwrap(); + + return; + } + } + let event_loop = self.pyloop.get(); event_loop.tcp_stream_rem(self.fd, Interest::READABLE); - if self.state.borrow().write_buf_dsize == 0 { - // set conn lost? + if self.state.borrow().write_buf_dsize == 0 || self.state.borrow().tls_conn.is_some() { + // For TLS connections, close immediately after sending close alert + // even if write buffer is not empty (close alert will be sent by TCPWriteHandle) event_loop.tcp_stream_rem(self.fd, Interest::WRITABLE); self.call_conn_lost(py, None); } @@ -468,6 +775,12 @@ impl TCPTransport { }; if wh < wl { + log::error!( + "TCPTransport::set_write_buffer_limits for fd {}: Error: high ({}) must be >= low ({}). Current values not changed.", + pyself.borrow(py).fd, + wh, + wl + ); return Err(pyo3::exceptions::PyValueError::new_err( "high must be >= low must be >= 0", )); @@ -490,31 +803,60 @@ impl TCPTransport { } fn get_write_buffer_size(&self) -> usize { - self.state.borrow().write_buf_dsize + let size = self.state.borrow().write_buf_dsize; + log::debug!( + "TCPTransport::get_write_buffer_size called for fd {}. Size: {}", + self.fd, + size + ); + size } fn get_write_buffer_limits(&self) -> (usize, usize) { - ( + let limits = ( self.water_lo.load(atomic::Ordering::Relaxed), self.water_hi.load(atomic::Ordering::Relaxed), - ) + ); + log::debug!( + "TCPTransport::get_write_buffer_limits called for fd {}. Limits: {:?}", + self.fd, + limits + ); + limits } fn write(pyself: Py, py: Python, data: Cow<[u8]>) -> PyResult<()> { + log::debug!( + "TCPTransport::write (PyO3) called for fd {:?} with {} bytes", + pyself.borrow(py).fd, + data.len() + ); Self::try_write(&pyself, py, &data) } fn writelines(pyself: Py, py: Python, data: &Bound) -> PyResult<()> { + log::debug!( + "TCPTransport::writelines (PyO3) called for fd {:?}", + pyself.borrow(py).fd + ); let pybytes = PyBytes::new(py, &[0; 0]); let pybytesj = pybytes.call_method1(pyo3::intern!(py, "join"), (data,))?; - let bytes = pybytesj.extract::>()?; + let bytes: Cow<[u8]> = pybytesj.extract()?; + log::debug!( + "TCPTransport::writelines (PyO3) for fd {:?} joined to {} bytes", + pyself.borrow(py).fd, + bytes.len() + ); Self::try_write(&pyself, py, &bytes) } fn write_eof(&self) { + log::debug!("TCPTransport::write_eof called for fd {}", self.fd); if self.closing.load(atomic::Ordering::Relaxed) { + log::debug!("TCPTransport::write_eof: fd {} closing, returning.", self.fd); return; } + // weof -> write end of file: no more writes will be done. if self .weof .compare_exchange(false, true, atomic::Ordering::Relaxed, atomic::Ordering::Relaxed) @@ -530,11 +872,18 @@ impl TCPTransport { } fn can_write_eof(&self) -> bool { - true + let can = !self.weof.load(atomic::Ordering::Relaxed); // Can write EOF if not already set + log::debug!("TCPTransport::can_write_eof called for fd {}. Value: {}", self.fd, can); + can } fn abort(&self, py: Python) { + log::debug!("TCPTransport::abort called for fd {}", self.fd); if self.state.borrow().write_buf_dsize > 0 { + log::debug!( + "TCPTransport::abort: fd {} has write_buf_dsize > 0. Removing WRITABLE interest.", + self.fd + ); self.pyloop.get().tcp_stream_rem(self.fd, Interest::WRITABLE); } if self @@ -542,10 +891,28 @@ impl TCPTransport { .compare_exchange(false, true, atomic::Ordering::Relaxed, atomic::Ordering::Relaxed) .is_ok() { + log::debug!( + "TCPTransport::abort: fd {} set closing. Removing READ interest.", + self.fd + ); self.pyloop.get().tcp_stream_rem(self.fd, Interest::READABLE); + } else { + log::debug!("TCPTransport::abort: fd {} was already closing.", self.fd); } + log::debug!( + "TCPTransport::abort: fd {} calling call_conn_lost due to abort.", + self.fd + ); self.call_conn_lost(py, None); } + + fn call_connection_lost(&self, py: Python) { + self.call_conn_lost_py(py); + log::debug!( + "TCPTransport::call_connection_lost (Python API) called for fd {}", + self.fd + ); + } } pub(crate) struct TCPReadHandle { @@ -555,7 +922,156 @@ pub(crate) struct TCPReadHandle { impl TCPReadHandle { #[inline] fn recv_direct(&self, py: Python, transport: &TCPTransport, buf: &mut [u8]) -> (Option>, bool) { - let (read, closed) = self.read_into(&mut transport.state.borrow_mut().stream, buf); + // Check if this is a TLS connection first + let is_tls = transport.state.borrow().tls_conn.is_some(); + + if is_tls { + log::debug!("SSL read: processing TLS data for fd {}", self.fd); + // Handle TLS connections + let read = { + let mut state = transport.state.borrow_mut(); + let (read, _) = self.read_into(&mut state.stream, buf); + read + }; + + log::debug!("SSL read: received {read} bytes of raw data"); + + if read > 0 { + // Process TLS data + { + let mut state = transport.state.borrow_mut(); + let tls_conn = state.tls_conn.as_mut().unwrap(); + + // Feed raw bytes to TLS connection + let mut rd = std::io::Cursor::new(&buf[..read]); + if let Err(e) = tls_conn.read_tls(&mut rd) { + log::debug!("SSL read: TLS read_tls error: {e:?}"); + // TLS error - close connection + return (None, true); + } + + // Process the new packets + match tls_conn.process_new_packets() { + Ok(io_state) => { + // Check if we received a close alert from the peer + if io_state.peer_has_closed() { + log::debug!("SSL read: peer has closed the connection (received close alert)"); + // FIX: Always close immediately when receiving close alert to prevent hanging + // This handles both TLS 1.2 and TLS 1.3 properly + log::debug!("SSL read: closing connection immediately to prevent hanging"); + tls_conn.send_close_notify(); + state.tls_close_received = true; + return (None, true); + } + } + Err(e) => { + log::debug!("SSL read: TLS process_new_packets error: {e:?}"); + // TLS error - close connection + return (None, true); + } + } + } + + // Check and update handshake status + { + let mut state = transport.state.borrow_mut(); + let tls_conn = state.tls_conn.as_ref().unwrap(); + if !state.handshake_complete && !tls_conn.is_handshaking() { + state.handshake_complete = true; + log::debug!("SSL read: handshake completed"); + log::trace!( + "RLOOP_TLS_DBG_HANDSHAKE_CMPL: fd={}, connection_made_called={}", + self.fd, + state.connection_made_called + ); + + // For SSL connections, call connection_made after handshake completes + if !state.connection_made_called { + state.connection_made_called = true; + // Schedule connection_made callback through the event loop + let transport_arg = transport + .pyloop + .get() + .get_tcp_transport(self.fd, py) + .map(|t| t.clone_ref(py).into_any()) + .unwrap_or_else(|| py.None()); + if let Ok(conn_made) = transport.proto.getattr(py, pyo3::intern!(py, "connection_made")) { + let _ = transport.pyloop.get().schedule1( + conn_made, + transport_arg, + None, // Use default context + ); + } + } + } else if !state.handshake_complete { + log::debug!("SSL read: still handshaking"); + } + } + + // Check if there is pending TLS data to write (handshake, etc.) + { + let state = transport.state.borrow(); + if let Some(ref tls_conn) = state.tls_conn + && tls_conn.wants_write() + { + log::debug!("SSL read: server wants to write (handshake data), adding writable interest"); + transport.pyloop.get().tcp_stream_add(transport.fd, Interest::WRITABLE); + } + } + + // Check if handshake is complete and read decrypted data + let handshake_complete = transport.state.borrow().handshake_complete; + if handshake_complete { + let mut app_data = Vec::new(); + { + let mut state = transport.state.borrow_mut(); + let tls_conn = state.tls_conn.as_mut().unwrap(); + + let mut temp_buf = [0u8; 4096]; + loop { + match tls_conn.reader().read(&mut temp_buf) { + Ok(0) => break, + Ok(n) => app_data.extend_from_slice(&temp_buf[..n]), + Err(_) => break, + } + } + } + + if !app_data.is_empty() { + log::debug!("SSL read: decrypted {} bytes of application data", app_data.len()); + let pydata = PyBytes::new(py, &app_data); + return (Some(pydata.into_any().unbind()), false); + } + } + } + + // Check if connection is closed + let closed = { + let mut state = transport.state.borrow_mut(); + let (bytes_read, closed) = self.read_into(&mut state.stream, &mut []); + log::debug!( + "SSL read: connection closed check - bytes_read={}, closed={}, tls_close_sent={}", + bytes_read, + closed, + state.tls_close_sent + ); + // If we read 0 bytes and we've sent our close alert, consider the connection closed + let peer_closed_after_our_alert = bytes_read == 0 && state.tls_close_sent; + if peer_closed_after_our_alert { + log::debug!( + "SSL read: peer closed TCP connection after receiving our close alert - completing handshake" + ); + // Peer closed TCP after receiving our close alert - this completes the handshake + return (None, true); + } + closed + }; + return (None, closed); + } + + // Non-TLS connection + let mut state = transport.state.borrow_mut(); + let (read, closed) = self.read_into(&mut state.stream, buf); if read > 0 { let rbuf = &buf[..read]; let pydata = unsafe { PyBytes::from_ptr(py, rbuf.as_ptr(), read) }; @@ -610,13 +1126,24 @@ impl TCPReadHandle { { return false; } + + // For TLS connections that have sent a close alert, call connection_lost when TCP closes + if transport.state.borrow().tls_conn.is_some() && transport.state.borrow().tls_close_sent { + transport.call_conn_lost(py, None); + return true; + } + transport.close_from_read_handle(py, event_loop) } } impl Handle for TCPReadHandle { fn run(&self, py: Python, event_loop: &EventLoop, state: &mut EventLoopRunState) { - let pytransport = event_loop.get_tcp_transport(self.fd, py); + // if None: transport was closed + let Some(pytransport) = event_loop.get_tcp_transport(self.fd, py) else { + return; + }; + let transport = pytransport.borrow(py); // NOTE: we need to consume all the data coming from the socket even when it exceeds the buffer, @@ -655,10 +1182,125 @@ pub(crate) struct TCPWriteHandle { impl TCPWriteHandle { #[inline] fn write(&self, transport: &TCPTransport) -> Option { + log::debug!("DEBUG: TCPWriteHandle::write called for fd {}", self.fd); #[allow(clippy::cast_possible_wrap)] let fd = self.fd as i32; - let mut ret = 0; + + // Check if this is a TLS connection first + let is_tls = transport.state.borrow().tls_conn.is_some(); + + if is_tls { + log::debug!("SSL write: handling TLS write for fd {}", self.fd); + // Handle TLS connections + let mut tls_buf = Vec::new(); + { + let mut state = transport.state.borrow_mut(); + let tls_conn = state.tls_conn.as_mut().unwrap(); + + // First, handle any pending TLS writes (handshake or encrypted data) + if let Err(e) = tls_conn.write_tls(&mut tls_buf) { + log::debug!("SSL write: TLS write_tls error: {e:?}"); + // TLS error + return None; + } + } + + if tls_buf.is_empty() { + log::debug!("SSL write: no TLS data to send"); + } else { + log::trace!("SSL write: TLS buffer: {:02x?}", &tls_buf[..tls_buf.len().min(64)]); + log::debug!("SSL write: sending {} bytes of TLS data", tls_buf.len()); + match syscall!(write(fd, tls_buf.as_ptr().cast(), tls_buf.len())) { + Ok(written) if written as usize == tls_buf.len() => { + log::debug!("SSL write: TLS data sent successfully"); + // TLS data written successfully + } + Ok(_written) => { + log::debug!("SSL write: partial TLS write"); + // Partial write - this is complex for TLS, just fail for now + return None; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + log::debug!("SSL write: TLS write would block"); + // Would block - need to retry later + return Some(0); + } + _ => { + log::debug!("SSL write: TLS write failed"); + return None; + } + } + } + // Check if handshake is complete + let handshake_complete = transport.state.borrow().handshake_complete; + + if handshake_complete { + // Write data one by one to avoid borrowing conflicts + let mut ret = 0; + loop { + let data = { + let state = transport.state.borrow(); + state.write_buf.front().cloned() + }; + + if let Some(data) = data { + { + let mut state = transport.state.borrow_mut(); + let tls_conn = state.tls_conn.as_mut().unwrap(); + + if std::io::Write::write_all(&mut tls_conn.writer(), &data).is_err() { + // TLS write error - put data back + return None; + } + } + + { + let mut state = transport.state.borrow_mut(); + state.write_buf.pop_front(); + state.write_buf_dsize -= data.len(); + } + + ret += data.len(); + } else { + break; + } + } + + // Write any newly encrypted data + let mut tls_buf = Vec::new(); + { + let mut state = transport.state.borrow_mut(); + let tls_conn = state.tls_conn.as_mut().unwrap(); + if tls_conn.write_tls(&mut tls_buf).is_err() { + return None; + } + } + + if !tls_buf.is_empty() { + log::trace!( + "SSL write: Application data TLS buffer: {:02x?}", + &tls_buf[..tls_buf.len().min(64)] + ); + match syscall!(write(fd, tls_buf.as_ptr().cast(), tls_buf.len())) { + Ok(written) if written as usize == tls_buf.len() => {} + Ok(_) => return None, // Partial write + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + return Some(ret); + } + _ => return None, + } + } + + return Some(ret); + } else { + // Handshake not complete, just wrote handshake data + return Some(0); + } + } + + // Non-TLS connection let mut state = transport.state.borrow_mut(); + let mut ret = 0; while let Some(data) = state.write_buf.pop_front() { match syscall!(write(fd, data.as_ptr().cast(), data.len())) { Ok(written) if (written as usize) < data.len() => { @@ -689,16 +1331,74 @@ impl TCPWriteHandle { impl Handle for TCPWriteHandle { fn run(&self, py: Python, event_loop: &EventLoop, _state: &mut EventLoopRunState) { - let pytransport = event_loop.get_tcp_transport(self.fd, py); + let Some(pytransport) = event_loop.get_tcp_transport(self.fd, py) else { + return; + }; let transport = pytransport.borrow(py); let stream_close; + // Check if we need to timeout waiting for peer's close alert + { + let state = transport.state.borrow(); + if state.tls_close_sent && state.tls_conn.is_some() { + log::debug!("SSL close: already sent. Waiting a response with TCP open."); + if let Some(sent_time) = state.tls_close_sent_time { + let elapsed = sent_time.elapsed(); + if elapsed > std::time::Duration::from_millis(transport.state.borrow().ssl_close_timeout.into()) { + log::debug!( + "SSL close: timeout waiting for peer's close alert ({}ms), closing connection", + elapsed.as_millis() + ); + // Force close the connection + drop(state); + event_loop.tcp_stream_rem(self.fd, Interest::READABLE); + event_loop.tcp_stream_rem(self.fd, Interest::WRITABLE); + transport.call_conn_lost(py, None); + return; + } + } + } + } + if let Some(written) = self.write(&transport) { if written > 0 { TCPTransport::write_buf_size_decr(&pytransport, py); } - stream_close = match transport.state.borrow().write_buf.is_empty() { - true => transport.close_from_write_handle(py, false), + let (write_buf_empty, pending_ssl_close) = { + let state = transport.state.borrow(); + (state.write_buf.is_empty(), state.tls_pending_close) + }; + stream_close = match write_buf_empty { + true => { + if pending_ssl_close { + log::debug!( + "SSL write: write buffer empty, sending pending close alert for fd {}", + self.fd + ); + // Send the close alert now that buffer is empty + let mut tls_buf = Vec::new(); + { + let mut state = transport.state.borrow_mut(); + if let Some(ref mut tls_conn) = state.tls_conn { + // Send close notify to initiate TLS close handshake + tls_conn.send_close_notify(); + let _ = tls_conn.write_tls(&mut tls_buf); + state.tls_close_sent = true; + state.tls_close_sent_time = Some(std::time::Instant::now()); + state.tls_pending_close = false; // Clear the flag + } + } + if !tls_buf.is_empty() { + log::trace!("SSL close: TLS buffer: {:02x?}", &tls_buf[..tls_buf.len().min(64)]); + let fd = self.fd as i32; + let _ = syscall!(write(fd, tls_buf.as_ptr().cast(), tls_buf.len())); + } + // Now close the connection + Some(true) + } else { + transport.close_from_write_handle(py, false) + } + } false => None, }; } else { diff --git a/tests/ssl_/__init__.py b/tests/ssl_/__init__.py new file mode 100644 index 0000000..685d476 --- /dev/null +++ b/tests/ssl_/__init__.py @@ -0,0 +1,82 @@ +import asyncio +import logging + + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +class SSLProtocol(asyncio.Protocol): + def __init__(self, create_future=None): + self.state = 'INITIAL' + self.transport = None + self.data = b'' + if create_future: + self._done = create_future() + + def _assert_state(self, *expected): + if self.state not in expected: + raise AssertionError(f'state: {self.state!r}, expected: {expected!r}') + + def connection_made(self, transport): + logger.debug(f'{self.__class__.__name__}: connection_made') + self.transport = transport + self._assert_state('INITIAL') + self.state = 'CONNECTED' + + def data_received(self, data): + logger.debug(f'{self.__class__.__name__}: data_received {len(data)} bytes') + self._assert_state('CONNECTED') + self.data += data + + def eof_received(self): + logger.debug(f'{self.__class__.__name__}: eof_received') + self._assert_state('CONNECTED') + self.state = 'EOF' + self.transport.close() + + def connection_lost(self, exc): + logger.debug(f'{self.__class__.__name__}: connection_lost') + self._assert_state('CONNECTED', 'EOF') + self.transport = None + self.state = 'CLOSED' + if hasattr(self, '_done'): + self._done.set_result(None) + + +class SSLEchoServerProtocol(SSLProtocol): + def data_received(self, data): + super().data_received(data) + if self.transport: + self.transport.write(b'echo: ' + data) + + +class SSLHTTPServerProtocol(SSLProtocol): + def data_received(self, data): + logger.debug('received data=%s', data) + super().data_received(data) + if self.transport and b'GET' in data: + # Send a proper HTTP 200 response + response = ( + b'HTTP/1.1 200 OK\r\n' + b'Content-Type: text/plain\r\n' + b'Content-Length: 15\r\n' + b'Connection: close\r\n' + b'\r\n' + b'hello SSL world' + ) + logger.debug('sending response (len=%s)', len(response)) + self.transport.write(response) + logger.debug('response sent, closing connection immediately') + # Close connection immediately after sending response + self.transport.close() + + +class SSLEchoClientProtocol(SSLProtocol): + def connection_made(self, transport): + super().connection_made(transport) + transport.write(b'hello SSL world') + + def data_received(self, data): + super().data_received(data) + self.transport.close() diff --git a/tests/ssl_/certs/cert.pem b/tests/ssl_/certs/cert.pem new file mode 100644 index 0000000..cc50349 --- /dev/null +++ b/tests/ssl_/certs/cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDkzCCAnugAwIBAgIUFUl37ZvVC96cXT+uI6i/p1c14wowDQYJKoZIhvcNAQEL +BQAwTjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx +DTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNTExMTIxNTAy +NDVaFw0yNjExMTIxNTAyNDVaME4xCzAJBgNVBAYTAlVTMQ0wCwYDVQQIDARUZXN0 +MQ0wCwYDVQQHDARUZXN0MQ0wCwYDVQQKDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhv +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDGvmzo+uwDJgymHRVI +x6Z7J5NmLLG2sGbl5fml1bMBWGg47egPUihEIlkvwTAsAs2Y9fsO7IvA3TV5P3gj +r+mnBg8E5U7P2PDFfRQriWL5jNnBjmlgwv7qMxOqe/29IqgA0y89uwYerQ49kyuP +Wb1a2rFP3pATaVwa5patUATEn3rUHvHhKA6TP/j9wMGMlRU6kb1KN6emXfGqV4an +zaj668T4H0ruRVydxmJf8rBAv3SJ91uGAFfZ9IJUQx3Ey8B0axiruQZxvw9TB0cr +mccAtDNk+K0n2Y8nC5srF3jDEyatViHoFZCxgA1M/28pzI7ZhJVtcnvnB42QEqd0 +smmPAgMBAAGjaTBnMB0GA1UdDgQWBBRqXedeTrtoB9TO+5QRzQJrjk7C4zAfBgNV +HSMEGDAWgBRqXedeTrtoB9TO+5QRzQJrjk7C4zAJBgNVHRMEAjAAMBoGA1UdEQQT +MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAQhkqe7hEQw3K +/xjugUZJw77OWYS5RAchmuQfj9PfO//AwcAufM/i1BMyA7eA741INgtQ3KDvpKjT +eV37oRVLEWxFCNrHtcKWvpklukHgCltyStauEw33q4UTly98xysXfg0QEydDjUsv +k+Mc75IbFcNY0k6OYHtpj45XGYbr2M1szLvjeWX4uW2gZYmo+HpS+cTa9VhtV3tO +PO/sxyc2kudcP/o0iyVzICuvaTxAlHrSSapHU+JqrD1bDLLVK6fSTnoMpcghmF4e +jPTXXXuZsUqU46zZRKbyChNfl1CXZRyJOmhYZ1zNmZj0NlTkwot1l54Wi6AUUlWT +Gt4Y0/8GiQ== +-----END CERTIFICATE----- diff --git a/tests/ssl_/certs/key.pem b/tests/ssl_/certs/key.pem new file mode 100644 index 0000000..d61671d --- /dev/null +++ b/tests/ssl_/certs/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDGvmzo+uwDJgym +HRVIx6Z7J5NmLLG2sGbl5fml1bMBWGg47egPUihEIlkvwTAsAs2Y9fsO7IvA3TV5 +P3gjr+mnBg8E5U7P2PDFfRQriWL5jNnBjmlgwv7qMxOqe/29IqgA0y89uwYerQ49 +kyuPWb1a2rFP3pATaVwa5patUATEn3rUHvHhKA6TP/j9wMGMlRU6kb1KN6emXfGq +V4anzaj668T4H0ruRVydxmJf8rBAv3SJ91uGAFfZ9IJUQx3Ey8B0axiruQZxvw9T +B0crmccAtDNk+K0n2Y8nC5srF3jDEyatViHoFZCxgA1M/28pzI7ZhJVtcnvnB42Q +Eqd0smmPAgMBAAECggEAGq0gt+ebmj3si2IdTew5H7q2LDa/kS1TAnTAnPkhfS1n +/+GJuSoRQY6iuKg5AQ9MmBrG8hU0xP8W2F7Oj2u4nwn50McUBw4Fc1yl7RoUcOPu +WUP2R7NPaBF8XZvQTnzCXtSkjf1L2v47im719S3ZyWzP8/qEYuFM0vI0iLApk600 +9x+23VPugCrIorgkNg+bsnIJe9M+Yyl5ogHQCqVovnTHLvKpUTqrm5IH31/ojXYt +jbbfg+0yLKD3D/0mVYCICc81gpeTCJiQMwAqTpDlSQeDwPpPfUfzRXsbIwHuwjhx +h5Wqr69dSueE7aw2ukMEH3FOA6yFhQaoMa5u6BdWwQKBgQDzo+BMw2HJsriTnk4r +ORFWcO5wspOkP+d/6a46O9E8gH2sQfGUXFQPX0K1qJeLPYcb3qsYvPXH7NeTELrg +2n6L/9k7krOrhmsPAB6/3kG/mZFaIVfjQ2XoIH3BofJeVbsRBT8EnWiWtoNMJvkE +ufx3uoGUvPOqQPl7LU28NfG50QKBgQDQ03yc7/vSVDhe3lQtfsPuiFw7/SV9xZJg +43bGsd21uNapGp+hO1rg63fMQrjL9aggXqZ/0WHsW+tfkcpx9XOYEjA3pjsRncEN +WtU0oaB6R7PA5raj6qM/Q8ESEVmDTlg6SREXllHg3glP3lfF614ipsxxUwEfP2DS +VRKzc39lXwKBgQDkgXxrQoxCeca3XK//xeRG6GAZfsMON4lN5MMthtC1J+W2W5rS +BM4qJLQSYG7RfwFq2CosZ3005yNAoV5EaWhqsajyQKMWalmalghA95k+tC8pE0C0 +u4+maGLJ6rPAWjO6wOrbzy46vC7ki2DeV/k76caC07zMn2fdaR4ROZ7fIQKBgQC8 +F6HIYch6vG1B1hQQHnwwoBYj2nIohQrBxmA3vAGtKt3+1wItYZ8LtEvlabu1yoEz +Fs31lw0SrgClxlWIq8MAmHFhzpKp3WQDuWsMywAW4/qep7CemDuOQmLm+UWdJbYG +WcXRbw408wmELQr2NHhH0eGXuWHrWVTGXuZHSKLZFQKBgC4Wj/iq0YIdllaCuXVz +bUo8ubdie5Eexp851+3J0HRiR98+5OO4atJt6XikMwj/kuUBxnuLOnkAQjRZEyhq +JqpVu2GqbEzYSkiolA3FAIklHlUmbrhIE/B/zAboRAsl/yGA21TNFXnE70Pa4uJv +7woSoxsOTJOnRh/LuefmEtAd +-----END PRIVATE KEY----- diff --git a/tests/ssl_/test_ssl_conn.py b/tests/ssl_/test_ssl_conn.py new file mode 100644 index 0000000..f6b8e4b --- /dev/null +++ b/tests/ssl_/test_ssl_conn.py @@ -0,0 +1,542 @@ +import asyncio +import logging +import multiprocessing +import os +import random +import socket +import ssl +import threading +from multiprocessing import Event, Process + +import pytest + +import rloop + +from . import SSLEchoClientProtocol, SSLEchoServerProtocol, SSLHTTPServerProtocol + + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.timeout(5)] + + +@pytest.fixture +def ssl_context(): + """Create a basic SSL context for testing.""" + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + # For testing with self-signed certificates, load the server's cert as trusted + + cert_dir = os.path.join(os.path.dirname(__file__), 'certs') + certfile = os.path.join(cert_dir, 'cert.pem') + ctx.load_verify_locations(cafile=certfile) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # Disable verification for testing + return ctx + + +@pytest.fixture +def server_ssl_context(): + """Create an SSL context for the server.""" + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # For testing, load test certificates for asyncio compatibility + # The Rust implementation generates its own dummy certificate when no certs are loaded + cert_dir = os.path.join(os.path.dirname(__file__), 'certs') + # Set attributes that Rust code expects + ctx._certfile = os.path.join(cert_dir, 'cert.pem') + ctx._keyfile = os.path.join(cert_dir, 'key.pem') + ctx.load_cert_chain(ctx._certfile, ctx._keyfile) + return ctx + + +EVENT_LOOPS = [ + asyncio.new_event_loop, + rloop.new_event_loop, +] + +SSL_BACKENDS = ['direct'] # , 'futures'] +TLS_VERSIONS = ['', '1.2', '1.2+', '1.3'] + + +def start_ssl_http_server( + loop, server_ssl_context, host='localhost', port=None, lifetime=10, protocol=SSLHTTPServerProtocol +) -> tuple[Process, Event, tuple[str, int]]: + """Helper function to start SSL HTTP server for testing.""" + port = port or random.randint(10000, 20000) # noqa: S311 + + # Be sure that the port is available + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + while True: + try: + sock.bind((host, port)) + # Port is available + sock.close() + break + except OSError as e: + if e.errno == 98: # Address already in use + sock.close() + # Try another port + port += 1 + continue + else: # Re-raise other errors + sock.close() + raise + + server_ready = multiprocessing.Event() + server_stop = multiprocessing.Event() + server_addr = (host, port) + + async def run_server(): + nonlocal server_addr + loopclass = type(loop).__name__ + sock = socket.socket() + sock.setblocking(False) + + with sock: + sock.bind((host, port)) + server_addr = sock.getsockname() + logger.debug(f'[server] Creating {loopclass} SSL server on {server_addr}') + server = await loop.create_server(lambda: protocol(), sock=sock, ssl=server_ssl_context) + logger.debug(f'[server] {loopclass} SSL server created') + + server_ready.set() + + i = 0 + for i in range(lifetime): # noqa: B007 + await asyncio.sleep(1) + if server_stop.is_set(): + break + + logger.debug(f'[server] {loopclass} server closing [lifetime={i} should_stop={server_stop.is_set()}]') + server.close() + logger.debug(f'[server] {loopclass} server closed') + + coro = run_server() + mp = multiprocessing.get_context('fork') + server_process = mp.Process(target=lambda: loop.run_until_complete(coro)) + server_process.start() + logger.debug('Waiting for server_ready event') + server_ready.wait() + logger.debug(f'Server ready event received, server_addr = {server_addr}') + return server_process, server_stop, server_addr + + +@pytest.mark.parametrize('evloop', EVENT_LOOPS, ids=lambda x: type(x())) +@pytest.mark.parametrize('tls_version', TLS_VERSIONS) +@pytest.mark.parametrize('ssl_backend', SSL_BACKENDS) +def test_ssl_connection_echo(evloop, ssl_context, server_ssl_context, tls_version, ssl_backend, monkeypatch): + """Test basic connection with echo server.""" + monkeypatch.setenv('RLOOP_TLS_VERSION', tls_version) + monkeypatch.setenv('RLOOP_TLS_BACKEND', ssl_backend) + loop = evloop() + + if tls_version and type(loop).__name__ != 'RLoop': + # Standard Asyncio reactor should be tested only w/ tls_version unset. + # TLS versions are only useful with RLoop reactor. + pytest.skip('Duplicated test') + + server_proto = SSLEchoServerProtocol() + client_proto = SSLEchoClientProtocol(loop.create_future) + + async def main(): + sock = socket.socket() + sock.setblocking(False) + + with sock: + sock.bind(('127.0.0.1', 0)) + addr = sock.getsockname() + server = await loop.create_server(lambda: server_proto, sock=sock) + transport, protocol = await loop.create_connection(lambda: client_proto, *addr) + await client_proto._done + server.close() + + loop.run_until_complete(main()) + assert client_proto.state == 'CLOSED' + assert server_proto.state == 'CLOSED' + + assert server_proto.data == b'hello SSL world' + assert client_proto.data.startswith(b'echo: hello SSL world') + + +@pytest.mark.parametrize('evloop', EVENT_LOOPS, ids=lambda x: type(x())) +def test_ssl_protocol_without_ssl(evloop): + """Test that non-SSL connection works with the tests protocol.""" + loop = evloop() + + host = '127.0.0.1' + port = random.randint(10000, 20000) # noqa: S311 + + server_proto = SSLEchoServerProtocol() + client_proto = SSLEchoClientProtocol(loop.create_future) + + async def main(): + sock = socket.socket() + sock.setblocking(False) + + with sock: + sock.bind((host, port)) + addr = sock.getsockname() + server = await loop.create_server(lambda: server_proto, sock=sock) + transport, protocol = await loop.create_connection(lambda: client_proto, *addr) + await client_proto._done + server.close() + + loop.run_until_complete(main()) + assert client_proto.state == 'CLOSED' + assert server_proto.state == 'CLOSED' + + +@pytest.mark.parametrize('evloop', EVENT_LOOPS, ids=lambda x: type(x())) +@pytest.mark.parametrize('tls_version', TLS_VERSIONS) +@pytest.mark.parametrize('ssl_backend', SSL_BACKENDS) +def test_ssl_server(evloop, ssl_context, server_ssl_context, tls_version, ssl_backend, monkeypatch): + """Test SSL server functionality.""" + + monkeypatch.setenv('RLOOP_TLS_VERSION', tls_version) + monkeypatch.setenv('RLOOP_TLS_BACKEND', ssl_backend) + loop = evloop() + + if tls_version and type(loop).__name__ != 'RLoop': + # Standard Asyncio reactor should be tested only w/ tls_version unset. + # TLS versions are only useful with RLoop reactor. + pytest.skip('Duplicated test') + + host = '127.0.0.1' + port = random.randint(10000, 20000) # noqa: S311 + + server_proto = SSLEchoServerProtocol() + client_proto = SSLEchoClientProtocol(loop.create_future) + + async def main(): + sock = socket.socket() + sock.setblocking(False) + + with sock: + sock.bind((host, port)) + addr = sock.getsockname() + logger.debug(f'[TEST] Creating server on {addr} with ssl={server_ssl_context is not None}') + server = await loop.create_server(lambda: server_proto, sock=sock, ssl=server_ssl_context) + logger.debug('[TEST] Server created') + # Give server time to start + await asyncio.sleep(0.01) + logger.debug(f'[TEST] Creating client connection to {addr} with ssl={ssl_context is not None}') + transport, protocol = await loop.create_connection(lambda: client_proto, *addr, ssl=ssl_context) + logger.debug('[TEST] Client connected') + await client_proto._done + logger.debug('[TEST] Client done, closing server') + server.close() + + loop.run_until_complete(main()) + logger.debug(f'[TEST] Final states - client: {client_proto.state}, server: {server_proto.state}') + logger.debug(f'[TEST] Server received: {server_proto.data!r}') + logger.debug(f'[TEST] Client received: {client_proto.data!r}') + assert client_proto.state == 'CLOSED' + assert server_proto.state == 'CLOSED' + # Check that SSL was actually used + assert server_proto.data == b'hello SSL world' + assert client_proto.data.startswith(b'echo: hello SSL world') + + +@pytest.mark.timeout(20) +@pytest.mark.parametrize('evloop_client', EVENT_LOOPS, ids=lambda x: f'{type(x()).__name__}[cli]') +@pytest.mark.parametrize('evloop_server', EVENT_LOOPS, ids=lambda x: f'{type(x()).__name__}[srv]') +@pytest.mark.parametrize('tls_version', TLS_VERSIONS) +@pytest.mark.parametrize('ssl_backend', SSL_BACKENDS) +def test_cross_implementation_server_client( + evloop_server, evloop_client, ssl_context, server_ssl_context, tls_version, ssl_backend, monkeypatch +): + """Test RLoop SSL client against asyncio SSL server.""" + + monkeypatch.setenv('RLOOP_TLS_VERSION', tls_version) + monkeypatch.setenv('RLOOP_TLS_BACKEND', ssl_backend) + # Use asyncio for server, RLoop for client + server_loop = evloop_server() + server_loop_name = type(server_loop).__name__ + client_loop = evloop_client() + client_loop_name = type(client_loop).__name__ + + if tls_version and 'RLoop' not in [server_loop_name, client_loop_name]: + # When RLoop is not involved, should run only once: tls_version == ''. + pytest.skip('Duplicated test') + + if tls_version in ['1.2+', '1.3'] and server_loop_name == 'RLoop': + pytest.xfail('Flaky w/ TLS 1.3') + + client_proto = SSLEchoClientProtocol(client_loop.create_future) + + async def run_client(): + logger.debug(f'[client] Creating {client_loop_name} SSL client to server') + for i in range(3): + try: + transport, protocol = await client_loop.create_connection( + lambda: client_proto, host, port, ssl=ssl_context + ) # type: ignore + logger.debug(f'[client [{i}]] {client_loop_name} SSL client connected') + await client_proto._done + logger.debug(f'[client [{i}]] {client_loop_name} client done') + break + except Exception as e: + logger.debug(f'[client [{i}]] {client_loop_name} client failed: {e}') + + server_process, server_stop, (host, port) = start_ssl_http_server( + server_loop, server_ssl_context, protocol=SSLEchoServerProtocol + ) + + client_thread = threading.Thread(target=lambda: client_loop.run_until_complete(run_client())) + client_thread.start() + client_thread.join(timeout=10) + + # Signal and wait server to stop + logger.debug('[test] Signaling the server to stop') + server_stop.set() + server_process.join(timeout=3) + + # Check results + logger.debug(f'[test] Client state: {client_proto.state}') + logger.debug(f'[test] Client received: {client_proto.data!r}') + + assert client_proto.state == 'CLOSED' + assert client_proto.data == b'echo: hello SSL world' + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize('evloop', EVENT_LOOPS, ids=lambda x: type(x())) +@pytest.mark.parametrize('tls_version', TLS_VERSIONS) +@pytest.mark.parametrize('ssl_backend', SSL_BACKENDS) +def test_ssl_server_with_requests_client(evloop, server_ssl_context, tls_version, ssl_backend, monkeypatch): + """Test EventLoop SSL server with external requests client.""" + + import requests + + monkeypatch.setenv('RLOOP_TLS_VERSION', tls_version) + monkeypatch.setenv('RLOOP_TLS_BACKEND', ssl_backend) + # Use EventLoop for server, raw SSL socket for client + loop = evloop() + + if tls_version and type(loop).__name__ != 'RLoop': + # Standard Asyncio reactor should be tested only w/ tls_version unset. + # TLS versions are only useful with RLoop reactor. + pytest.skip('Duplicated test') + + server_process, server_stop, (host, port) = start_ssl_http_server(loop, server_ssl_context) + + url = f'https://{host}:{port}' + # Create raw SSL client + logger.debug(f'[client] Connecting to {url} via requests') + + try: + result = requests.get(url, verify=False, timeout=5) + except requests.exceptions.ReadTimeout: + if tls_version in ['1.2+', '1.3'] and type(loop).__name__ == 'RLoop': + pytest.xfail('Flaky w/ TLS 1.3') + else: + raise + + result.raise_for_status() + assert result.status_code == 200 + assert result.text == 'hello SSL world' + + # Signal and wait server to stop + logger.debug('[client] Signaling the server to stop') + server_stop.set() + server_process.join(timeout=3) + + +@pytest.mark.timeout(10) +@pytest.mark.parametrize('evloop', EVENT_LOOPS, ids=lambda x: type(x())) +@pytest.mark.parametrize('tls_version', TLS_VERSIONS) +@pytest.mark.parametrize('ssl_backend', SSL_BACKENDS) +def test_ssl_server_with_raw_ssl_client(evloop, server_ssl_context, tls_version, ssl_backend, monkeypatch): + """Test EventLoop SSL server with raw SSL socket client.""" + + monkeypatch.setenv('RLOOP_TLS_VERSION', tls_version) + monkeypatch.setenv('RLOOP_TLS_BACKEND', ssl_backend) + # Use EventLoop for server, raw SSL socket for client + loop = evloop() + + if tls_version and type(loop).__name__ != 'RLoop': + # Standard Asyncio reactor should be tested only w/ tls_version unset. + # TLS versions are only useful with RLoop reactor. + pytest.skip('Duplicated test') + + server_process, server_stop, (host, port) = start_ssl_http_server(loop, server_ssl_context) + + # Create raw SSL client + logger.debug(f'[client] Connecting to {host}:{port} via raw SSL socket') + + # Create SSL context for client + client_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + cert_dir = os.path.join(os.path.dirname(__file__), 'certs') + client_ctx.load_verify_locations(cafile=os.path.join(cert_dir, 'cert.pem')) + client_ctx.check_hostname = False + client_ctx.verify_mode = ssl.CERT_NONE + + # Create raw SSL connection + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + success = False + try: + # Connect socket + sock.connect((host, port)) + logger.debug('[client] Socket connected') + + # Wrap with SSL + ssl_sock = client_ctx.wrap_socket(sock, server_hostname=host) + logger.debug('[client] SSL handshake completed') + + # Send HTTP request + request = b'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' + ssl_sock.send(request) + logger.debug('[client] HTTP request sent') + + # Read response + response_data = b'' + while True: + data = ssl_sock.recv(4096) + if not data: + break + response_data += data + + logger.debug(f'[client] Received {len(response_data)} bytes of response') + + # Parse response + if response_data.startswith(b'HTTP/1.1 200 OK'): + logger.debug('[client] Got 200 OK response') + # Check for our expected content + if b'hello SSL world' in response_data: + logger.debug('[client] Response contains expected content') + success = True + else: + logger.debug('[client] Response missing expected content') + success = False + else: + logger.debug(f'[client] Unexpected response: {response_data[:100]!r}') + success = False + + except Exception as e: + logger.debug(f'[client] SSL connection failed: {e}') + success = False + finally: + try: + ssl_sock.close() + except Exception: + logger.warning('Failed to close the SSL socket %s', ssl_sock) + + # Signal and wait server to stop + logger.debug('[client] Signaling the server to stop') + server_stop.set() + server_process.join(timeout=3) + + assert success, 'Raw SSL client test failed' + + +@pytest.mark.timeout(60) +@pytest.mark.parametrize('evloop', EVENT_LOOPS, ids=lambda x: type(x())) +@pytest.mark.parametrize('tls_version', TLS_VERSIONS) +@pytest.mark.parametrize('ssl_backend', SSL_BACKENDS) +@pytest.mark.parametrize('hacks', [False, True], ids=lambda x: 'hacks' if x else 'nohacks') +@pytest.mark.parametrize('zip', [False, True], ids=lambda x: 'zip' if x else 'nozip') +def test_ssl_server_with_openssl_client(evloop, server_ssl_context, tls_version, ssl_backend, hacks, zip, monkeypatch): + """Test EventLoop SSL server with openssl s_client command-line tool.""" + + import subprocess + + monkeypatch.setenv('RLOOP_TLS_VERSION', tls_version) + monkeypatch.setenv('RLOOP_TLS_BACKEND', ssl_backend) + # Use EventLoop for server, openssl s_client for client + loop = evloop() + loop_name = type(loop).__name__ + + if tls_version and loop_name != 'RLoop': + # Standard Asyncio reactor should be tested only w/ tls_version unset. + # TLS versions are only useful with RLoop reactor. + pytest.skip('Duplicated test') + + logger.debug('Starting SSL HTTP server') + server_process, server_stop, (host, port) = start_ssl_http_server(loop, server_ssl_context, lifetime=30) + logger.debug(f'Server started on {host}:{port}') + + # Create openssl s_client command with handshake debugging + cert_dir = os.path.join(os.path.dirname(__file__), 'certs') + cmd = [ + 'openssl', + 's_client', + '-connect', + f'{host}:{port}', + '-servername', + host, + '-CAfile', + os.path.join(cert_dir, 'cert.pem'), + '-ign_eof', + '-msg', # Show handshake messages + '-state', # Show SSL state + '-tlsextdebug', # Show TLS extensions + ] + if hacks: + ## From docs: + # There are several known bugs in SSL and TLS implementations. + # Adding this option enables various workarounds. + cmd.append('-bugs') + if zip: + # From docs: Enables support for SSL/TLS compression + cmd.append('-comp') + + logger.debug(f'[client] Running: {" ".join(cmd)}') + + success = False + try: + logger.debug('Starting subprocess.Popen: %s', cmd) + # Start openssl s_client process + proc = subprocess.Popen( # noqa: S602 + ' '.join(cmd), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + shell=True, + ) + logger.debug('subprocess.Popen completed') + + # Send HTTP request + http_request = 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' + stdout, stderr = proc.communicate(input=http_request, timeout=10) + + logger.debug(f'[client] openssl exit code: {proc.returncode}') + + # Log stdout line by line + for line in stdout.splitlines(): + logger.debug(f'[client] openssl stdout: {line[:200]}') + + # Log stderr line by line + for line in stderr.splitlines(): + logger.debug(f'[client] openssl stderr: {line[:500]}') + + logger.debug('proc.returncode = %s', proc.returncode) + logger.debug(f"'hello SSL world' in stdout = {'hello SSL world' in stdout}") + + # Check if connection was successful and response contains expected content + if proc.returncode == 0 and 'hello SSL world' in stdout: + logger.debug('[client] openssl client test passed') + success = True + else: + logger.debug(f'[client] openssl client test failed - exit code: {proc.returncode}') + + except subprocess.TimeoutExpired as e: + logger.debug('[client] openssl s_client timed out') + proc.kill() + logger.debug('TimeoutExpired exception caught: %r', e) + if tls_version in ['1.2+', '1.3'] and loop_name == 'RLoop': + pytest.xfail('Flaky w/ TLS 1.3') + except FileNotFoundError: + logger.debug('[client] openssl command not found') + pytest.skip('openssl command not available') + except Exception as e: + logger.debug(f'[client] openssl client failed: {e}') + logger.debug(f'Exception caught: {type(e).__name__}: {e}', stack_info=True, exc_info=True) + finally: + # Signal and wait server to stop + logger.debug('[client] Signaling the server to stop') + server_stop.set() + server_process.join(timeout=5) + + assert success, 'openssl s_client test failed'