commit e24031d6c99d3330ac6b51f2ffd8b18ade0b7e8d Author: dece Date: Fri Nov 12 20:39:24 2021 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..413c24b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,675 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bumpalo" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c" + +[[package]] +name = "cc" +version = "1.0.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term", + "atty", + "bitflags", + "strsim", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "data-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" + +[[package]] +name = "der-oid-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73af209b6a5dc8ca7cbaba720732304792cddc933cfea3d74509c2b1ef2f436" +dependencies = [ + "num-bigint", + "num-traits", + "syn", +] + +[[package]] +name = "der-parser" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9807efb310ce4ea172924f3a69d82f9fd6c9c3a19336344591153e665b31c43e" +dependencies = [ + "der-oid-macro", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "env_logger" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "js-sys" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc9ffccd38c451a86bf13657df244e9c3f37493cce8e5e21e940963777acc84" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119" + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] + +[[package]] +name = "nom" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" +dependencies = [ + "memchr", + "minimal-lexical", + "version_check", +] + +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe554cb2393bc784fd678c82c84cc0599c31ceadc7f03a594911f822cb8d1815" +dependencies = [ + "der-parser", +] + +[[package]] +name = "once_cell" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" + +[[package]] +name = "opal" +version = "0.1.0" +dependencies = [ + "clap", + "env_logger", + "log", + "mio", + "percent-encoding", + "ring", + "rustls", + "rustls-pemfile", + "url", + "webpki", + "x509-parser", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "proc-macro2" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rusticata-macros" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c52377bb2288aa522a0c8208947fada1e0c76397f108cc08f57efe6077b50d" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37e5e2290f3e040b594b1a9e04377c2c671f1a1cfd9bfdef82106ac1c113f84" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eebeaeb360c87bfb72e84abdb3447159c0eaececf1bef2aecd65a8be949d1c9" +dependencies = [ + "base64", +] + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "syn" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "unicode-bidi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wasm-bindgen" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56146e7c495528bf6587663bea13a8eb588d39b36b679d83972e1a2dbbdacf9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e0eea25835f8abdc585cd3021b3deb11543c6fe226dcd30b228857c5c5ab" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0237232789cf037d5480773fe568aac745bfe2afbc11a863e97901780a6b47cc" + +[[package]] +name = "web-sys" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38eb105f1c59d9eaa6b5cdc92b859d85b926e82cb2e0945cd0c9259faa6fe9fb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "x509-parser" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc90836a84cb72e6934137b1504d0cae304ef5d83904beb0c8d773bbfe256ed" +dependencies = [ + "base64", + "chrono", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..27d681f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "opal" +version = "0.1.0" +edition = "2021" + +[profile.release] +lto = true + +[dependencies] +clap = "2.33" +env_logger = "0.9" +log = "0.4" +mio = { version = "0.7", features = ["os-poll", "net"] } +percent-encoding = "2.1" +ring = "0.16" +rustls = { version = "0.20", features = ["dangerous_configuration"] } +rustls-pemfile = "0.2" +url = "2.2" +webpki = "0.22" +x509-parser = "0.12" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d47025 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +Opal +==== + +Opal is a Gemini server written in Rust. It is meant to serve dynamic content +through CGI and does not serve static files. In a way, it is a companion project +to the [Agate][agate] Gemini server which only serves static files, trying to +focus on a smaller set of features but do them correctly. + +[agate]: https://github.com/mbrubeck/agate/ + + + +CGI environment variables +------------------------- + +Opal tries to implement RFC 3875 (CGI 1.1) and provides all the required +environment variables to processes. It also add a bunch of Gemini specific +variables, like a lot of other servers (Gemserv, Gmid, Gmnisrv, …). + +| Presence | Variable | Description | +|-------------|------------------------|------------------------------------------------------| +| always | GATEWAY_INTERFACE | "CGI/1.1" | +| always | REMOTE_ADDR | Peer IP address and port | +| always | REMOTE_HOST | Same as REMOTE_ADDR | +| always | REQUEST_METHOD | Empty string for compatibility | +| always | SCRIPT_NAME | Script name part of the URL path | +| always | SERVER_NAME | Hostname used for SNI | +| always | SERVER_PORT | Port where the request has been received | +| always | SERVER_PROTOCOL | "GEMINI" | +| always | SERVER_SOFTWARE | "opal/version", e.g. "opal/0.1.0" | +| always | GEMINI_DOCUMENT_ROOT | CGI root | +| always | GEMINI_SCRIPT_FILENAME | CGI script that matched the URL path | +| always | GEMINI_URL | Full URL, normalized | +| always | GEMINI_URL_PATH | URL path, normalized | +| always | TLS_VERSION | TLS version, e.g. "TLSv1_3" | +| always | TLS_CIPHER | TLS cipher suite, e.g. "TLS13_AES_256_GCM_SHA384" | +| optional | PATH_INFO | Path passed to the CGI process after the script name | +| optional | QUERY_STRING | Query string if provided, still URL-encoded | +| client cert | AUTH_TYPE | "Certificate" if one is provided | +| client cert | REMOTE_USER | Subject common name (empty if unavailable) | +| client cert | TLS_CLIENT_ISSUER | Issuer common name (empty if unavailable) | +| client cert | TLS_CLIENT_HASH | Digest of the DER reprensetation of the cert | +| client cert | TLS_CLIENT_NOT_AFTER | Timestamp in seconds | +| client cert | TLS_CLIENT_NOT_BEFORE | Timestamp in seconds | + +Opal does not provide `CONTENT_LENGTH`, `CONTENT_TYPE`, `REMOTE_IDENT` because +they do not make much sense in Gemini. + +The `TLS_CLIENT_HASH` is a string that starts with "SHA256:" followed by the +SHA256 digest of the DER representation of the client certificate, as a +lowercase hex-string. + +It can be a bit confusing which variable represent what data, especially those +related to the URL and the path. Take the following request as example: +`gemini://localhost/env/sub1/sub2?search=élément`. Suppose our CGI root, in +`/cgi`, contains the executable script named `env`. The variables will be: + +``` +GEMINI_DOCUMENT_ROOT=/cgi +GEMINI_SCRIPT_FILENAME=/cgi/env +GEMINI_URL=gemini://localhost/env/sub1/sub2?search=%C3%A9l%C3%A9ment +GEMINI_URL_PATH=/env/sub1/sub2 +SCRIPT_NAME=/env +PATH_INFO=/sub1/sub2 +QUERY_STRING=search=%C3%A9l%C3%A9ment +``` diff --git a/examples/cgi/env b/examples/cgi/env new file mode 100755 index 0000000..c0014ce --- /dev/null +++ b/examples/cgi/env @@ -0,0 +1,3 @@ +#!/bin/sh +printf "20 text/plain\r\n" +env diff --git a/src/cgi.rs b/src/cgi.rs new file mode 100644 index 0000000..8084ea6 --- /dev/null +++ b/src/cgi.rs @@ -0,0 +1,321 @@ +//! CGI implementation. + +use std::collections::HashMap; +use std::fmt::Write; +use std::fs; +use std::path; +use std::process; + +use log::{debug, error}; + +/// General CGI configuration. +pub struct CgiConfig { + pub root: String, +} + +/// Return true if this path has executable bits set on Unix systems. +/// +/// If for some reason we can't get the mode information (not on Unix or some error occured), +/// return false. +fn is_executable(path: &path::Path) -> bool { + match fs::metadata(path) { + Ok(metadata) => { + use std::os::unix::fs::PermissionsExt; + let mode = metadata.permissions().mode(); + mode & 0o111 != 0 + } + Err(err) => { + error!( + "Can't get metadata for \"{}\": {}", + path.to_string_lossy(), + err + ); + false + } + } +} + +impl crate::server::Connection<'_> { + /// Process a client request. + /// + /// If the CGI process returns successfully, return the requested URL with the process output + /// so that it can be sent back to the client. + /// + /// If an error occurs outside of the CGI process, return a 3-uple with the URL (if it could be + /// parsed correctly), a Gemini error code and an explanation string to provide to the client. + pub fn get_response( + &self, + request: &[u8], + ) -> Result<(String, Vec), (Option, u8, &str)> { + // Convert the URL to UTF-8. + let url_str = std::str::from_utf8(&request[..request.len() - 2]) + .map_err(|_| (None, 59, "URL is not valid UTF-8"))?; + // Parse the URL. The `url` crate normalizes ".." and "/" elements here. + let url = url::Url::parse(url_str) + .map_err(|_| (Some(url_str.to_string()), 59u8, "Invalid URL"))?; + + // Get the script path, optionally with CGI's "path info". + let (script_path, path_info) = self.validate_script_path(&url)?; + debug!("Script path: \"{}\"", script_path.to_string_lossy()); + + // Define a generic "temp failure" error for any other issue. + let cgi_error = (Some(url_str.to_string()), 40, "Temporary failure"); + + // Execute script and return its output. + let env = self + .get_cgi_envs(&url, &script_path.to_string_lossy(), &path_info) + .ok_or_else(|| { + error!("Can't get required environment variables."); + cgi_error.to_owned() + })?; + let output = process::Command::new(&script_path) + .env_clear() + .envs(env) + .output() + .map_err(|err| { + error!("Can't execute script: {}", err); + cgi_error.to_owned() + })?; + + Ok((url_str.to_string(), output.stdout)) + } + + /// Return a validated script path from the requested URL along with CGI PATH_INFO. + /// + /// A valid path points to an existing, executable file, located within the CGI scripts root. + /// If any of these condition fails, log the reason and return an appropriate 3-uple for + /// `get_response`. + fn validate_script_path( + &self, + url: &url::Url, + ) -> Result<(path::PathBuf, Option), (Option, u8, &str)> { + // Define a generic "not found" error for most path issues. + let not_found = (Some(url.as_str().to_string()), 51, "Not found"); + + // Find script path from our CGI root and the request. + let mut path = path::PathBuf::from(&self.cgi_config.root); + let mut segments = url.path_segments().ok_or_else(|| { + error!("Can't get path segments from URL"); + not_found.to_owned() + })?; + // We incrementally push path segments after our CGI root to find the first path that + // represents an executable file. + let mut found_script = false; + loop { + let segment = segments.next(); + if segment.is_none() { + break; + } + let decoded_segment = percent_encoding::percent_decode_str(segment.unwrap()) + .decode_utf8() + .map_err(|err| { + error!("Path segment decoded into invalid UTF-8: {}", err); + not_found.to_owned() + })?; + path.push(decoded_segment.into_owned()); + // If that path is not an executable file, continue with the next segment. + if path.is_file() && is_executable(&path) { + found_script = true; + break; + } + } + if !found_script { + error!("No script found along path \"{}\".", path.to_string_lossy()); + return Err(not_found); + } + + // Collect the remaining segments into the CGI "path info" value. + let rem_segments = segments.collect::>(); + let path_info = if rem_segments.len() > 0 { + Some(String::from("/") + &rem_segments.join("/")) + } else { + None + }; + + // Just for safety, check that the now-canonicalized path is within the CGI root. + if !path.starts_with(&self.cgi_config.root) { + debug!( + "Script path \"{}\" is outside of CGI root dir \"{}\".", + path.to_string_lossy(), + self.cgi_config.root + ); + return Err(not_found); + } + + Ok((path, path_info)) + } + + /// Build environment variables for the CGI process. + pub fn get_cgi_envs( + &self, + url: &url::Url, + script_path: &str, + path_info: &Option, + ) -> Option> { + // Start the envs vector with common, nothing-to-compute elements. + let mut envs = vec![ + ("GATEWAY_INTERFACE", String::from("CGI/1.1")), + ("REQUEST_METHOD", String::new()), + ("SERVER_PROTOCOL", String::from("GEMINI")), + ("SERVER_SOFTWARE", format!("opal/{}", crate_version!())), + ("GEMINI_DOCUMENT_ROOT", self.cgi_config.root.to_string()), + ("GEMINI_SCRIPT_FILENAME", script_path.to_string()), + ("GEMINI_URL", url.to_string()), + ("GEMINI_URL_PATH", url.path().to_string()), + ]; + + // Next variables must be there but might not be available for some reason: this makes the + // whole execution fail. + + let remote_addr = self + .socket + .peer_addr() + .map_err(|err| { + error!("Can't get peer address for CGI envs: {}", err); + err + }) + .ok()?; + envs.push(("REMOTE_ADDR", remote_addr.to_string())); + envs.push(("REMOTE_HOST", remote_addr.to_string())); + + let server_port = self + .socket + .local_addr() + .map(|address| address.port()) + .map_err(|err| { + error!("Can't get local address for CGI envs: {}", err); + err + }) + .ok()?; + envs.push(("SERVER_PORT", server_port.to_string())); + + let root_len = self.cgi_config.root.len(); + envs.push(("SCRIPT_NAME", script_path[root_len..].to_string())); + + let server_name = self.tls.sni_hostname().or_else(|| { + error!("Can't get SNI hostname for SERVER_NAME."); + None + })?; + envs.push(("SERVER_NAME", server_name.to_string())); + + let version = self + .tls + .protocol_version() + .and_then(|v| v.as_str()) + .or_else(|| { + error!("Can't get TLS version."); + None + })?; + envs.push(("TLS_VERSION", version.to_string())); + + let cipher = self + .tls + .negotiated_cipher_suite() + .and_then(|s| s.suite().as_str()) + .or_else(|| { + error!("Can't get TLS negociated cipher suite."); + None + })?; + envs.push(("TLS_CIPHER", cipher.to_string())); + + // Next variables are optional. + + if let Some(path_info) = path_info { + let percent_decode = percent_encoding::percent_decode_str(path_info); + match percent_decode.decode_utf8() { + Ok(path_info) => { + envs.push(("PATH_INFO", path_info.to_string())); + } + Err(err) => { + error!("CGI PATH_INFO decoded into invalid UTF-8: {}", err); + } + }; + } + if let Some(query) = url.query() { + envs.push(("QUERY_STRING", query.to_string())); + } + + // Variables related to client certificates. + if let Some(certs) = self.tls.peer_certificates() { + if certs.len() > 0 { + envs.push(("AUTH_TYPE", String::from("Certificate"))); + let der = &certs[0].0; + if let Ok((_, cert)) = x509_parser::parse_x509_certificate(der) { + envs.push(("REMOTE_USER", get_common_name(cert.subject()))); + envs.push(("TLS_CLIENT_ISSUER", get_common_name(cert.issuer()))); + + let digest = ring::digest::digest(&ring::digest::SHA256, der); + let hex_digest = hexlify(digest.as_ref()); + let client_hash = String::from("SHA256:") + &hex_digest; + envs.push(("TLS_CLIENT_HASH", client_hash)); + + let not_valid_before = cert.validity().not_before.timestamp().to_string(); + let not_valid_after = cert.validity().not_after.timestamp().to_string(); + envs.push(("TLS_CLIENT_NOT_BEFORE", not_valid_before)); + envs.push(("TLS_CLIENT_NOT_AFTER", not_valid_after)); + } + } + } + + // CGI standard + // AUTH_TYPE: OK + // CONTENT_LENGTH: not affected + // CONTENT_TYPE: not affected + // GATEWAY_INTERFACE: OK + // PATH_INFO: OK, decoded + // PATH_TRANSLATED: TODO useful? + // QUERY_STRING: OK still URL-encoded like the standard asks + // REMOTE_ADDR: OK + // REMOTE_HOST: use REMOTE_ADDR + // REMOTE_IDENT: not affected + // REMOTE_USER: OK + // REQUEST_METHOD: empty string for compatibility + // SCRIPT_NAME: OK + // SERVER_NAME: OK + // SERVER_PORT: OK + // SERVER_PROTOCOL: OK + // SERVER_SOFTWARE: OK + + // Additionally proposed by gmid + // GEMINI_DOCUMENT_ROOT: OK + // GEMINI_SCRIPT_FILENAME: OK + // GEMINI_URL: OK + // GEMINI_URL_PATH: OK + // TLS_CLIENT_ISSUER: OK + // TLS_CLIENT_HASH: OK + // TLS_VERSION: OK + // TLS_CIPHER: OK + // TLS_CIPHER_STRENGTH: pfffff + // TLS_CLIENT_NOT_AFTER: OK but timestamp + // TLS_CLIENT_NOT_BEFORE: OK but timestamp + + Some( + envs.iter() + .map(|(k, v)| (k.to_string(), v.to_owned())) + .collect::>(), + ) + } +} + +/// Helper to get the common name of an x509 name field. +/// +/// If there is no common name or it can't be easily converted into a string, return an empty +/// string instead. +fn get_common_name(x509name: &x509_parser::x509::X509Name) -> String { + x509name + .iter_common_name() + .next() + .and_then(|cn| cn.as_str().ok()) + .or(Some("")) + .unwrap() + .to_string() +} + +/// Return an hex-string representing the digest data. +fn hexlify(digest: &[u8]) -> String { + let mut s = String::with_capacity(digest.len() * 2); + for b in digest { + write!(&mut s, "{:02x}", b).unwrap(); + } + s +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ba07036 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,194 @@ +use std::fs; +use std::io; +use std::net; +use std::sync::Arc; + +#[macro_use] +extern crate clap; + +use log::{debug, error, info}; + +pub mod cgi; +pub mod server; +pub mod tls; + +use crate::cgi::CgiConfig; +use crate::server::Server; +use crate::tls::GeminiClientCertVerifier; + +/// Load a PEM certificate(s) from disk. +fn load_certificate(path: &str) -> Option> { + let cert_file = match fs::File::open(path) { + Ok(f) => f, + Err(err) => { + error!("Can't open certificate file at {}: {}", path, err); + return None; + } + }; + let mut reader = io::BufReader::new(cert_file); + Some( + rustls_pemfile::certs(&mut reader) + .unwrap() + .iter() + .map(|v| rustls::Certificate(v.clone())) + .collect(), + ) +} + +/// Load a PEM private key from disk. +fn load_private_key(path: &str) -> Option { + let key_file = match fs::File::open(path) { + Ok(f) => f, + Err(err) => { + error!("Can't open private key file at {}: {}", path, err); + return None; + } + }; + let mut reader = io::BufReader::new(key_file); + loop { + match rustls_pemfile::read_one(&mut reader) { + Ok(Some(key)) => match key { + rustls_pemfile::Item::RSAKey(key) => return Some(rustls::PrivateKey(key)), + rustls_pemfile::Item::PKCS8Key(key) => return Some(rustls::PrivateKey(key)), + _ => debug!("Ignored unknown private key type."), + }, + Ok(None) => { + error!("No key found in file."); + return None; + } + Err(err) => { + error!("Can't parse private key data: {}", err); + return None; + } + } + } +} + +fn main() { + // Get command-line args. Opal does not use config files. + let matches = clap::App::new("Opal") + .version(crate_version!()) + .about("Gemini CGI-only server") + .arg( + clap::Arg::with_name("address") + .required(true) + .short("a") + .long("address") + .help("Address to listen to") + .takes_value(true), + ) + .arg( + clap::Arg::with_name("cert") + .required(true) + .short("c") + .long("cert") + .help("Path to certificate") + .takes_value(true), + ) + .arg( + clap::Arg::with_name("key") + .required(true) + .short("k") + .long("key") + .help("Path to private key") + .takes_value(true), + ) + .arg( + clap::Arg::with_name("root_path") + .required(true) + .short("p") + .long("root-path") + .help("Path to CGI scripts root") + .takes_value(true), + ) + .get_matches(); + + // Setup logging pretty much just like Agate. + let log_config = env_logger::Env::default().default_filter_or("opal=info"); + env_logger::Builder::from_env(log_config).init(); + + // Process command-line args. + info!("Starting Opal"); + let addr = match matches + .value_of("address") + .unwrap() + .parse::() + { + Ok(a) => a, + Err(err) => { + error!( + "Invalid listening address (wrong format or port missing?): {}", + err + ); + return; + } + }; + let server_certs = match load_certificate(matches.value_of("cert").unwrap()) { + Some(v) if v.len() > 0 => v, + Some(_) => { + error!("No valid certificate found."); + return; + } + None => return, + }; + let server_key = match load_private_key(matches.value_of("key").unwrap()) { + Some(k) => k, + None => return, + }; + let cgi_root = match fs::canonicalize(matches.value_of("root_path").unwrap()) { + Ok(p) => p.to_str().unwrap().to_string(), + Err(err) => { + error!("Invalid CGI root path: {}", err); + return; + } + }; + let cgi_config = CgiConfig { root: cgi_root }; + + // Setup TLS server. + let config = Arc::new( + match rustls::ServerConfig::builder() + .with_safe_defaults() + .with_client_cert_verifier(GeminiClientCertVerifier::new()) + .with_single_cert(server_certs, server_key) + { + Ok(c) => c, + Err(err) => { + error!("Invalid certificate or private key provided: {}", err); + return; + } + }, + ); + let mut listener = match mio::net::TcpListener::bind(addr) { + Ok(l) => l, + Err(err) => { + error!("Can't listen on port: {}", err); + return; + } + }; + let listener_token = mio::Token(0); + let mut poll = mio::Poll::new().unwrap(); + if let Err(err) = + poll.registry() + .register(&mut listener, listener_token, mio::Interest::READABLE) + { + error!("Can't setup poll listener: {}", err); + return; + } + let mut server = Server::new(listener, config, &cgi_config); + let mut events = mio::Events::with_capacity(256); + loop { + if let Err(err) = poll.poll(&mut events, None) { + debug!("Poll failed: {}", err); + continue; + } + for event in events.iter() { + if event.token() == listener_token { + if let Err(err) = server.accept(poll.registry()) { + error!("Could not accept socket: {}", err); + } + } else { + server.handle_event(poll.registry(), &event); + } + } + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..2e67070 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,263 @@ +//! Server listening loop and connection basics. + +use std::collections::HashMap; +use std::io::{self, Read, Write}; +use std::net; +use std::sync::Arc; + +use log::{debug, error, info}; + +use crate::cgi::CgiConfig; + +/// TCP server, listening for clients opening TLS connections. +pub struct Server<'a> { + pub config: Arc, + pub cgi_config: &'a CgiConfig, + listener: mio::net::TcpListener, + connections: HashMap>, + next_id: usize, +} + +impl<'a> Server<'a> { + /// Create a new Server. + pub fn new( + listener: mio::net::TcpListener, + config: Arc, + cgi_config: &'a CgiConfig, + ) -> Self { + Server { + config, + listener, + connections: HashMap::new(), + next_id: 2, + cgi_config, + } + } + + /// Accept incoming client connections forever. + pub fn accept(&mut self, registry: &mio::Registry) -> Result<(), io::Error> { + loop { + match self.listener.accept() { + Ok((socket, addr)) => { + debug!("Connection from {:?}", addr); + let tls = match rustls::ServerConnection::new(Arc::clone(&self.config)) { + Ok(c) => c, + Err(err) => { + error!("Could not create server connection: {}", err); + continue; + } + }; + let token = mio::Token(self.next_id); + self.next_id += 1; + let mut connection = Connection::new(socket, token, tls, self.cgi_config); + connection.register(registry); + self.connections.insert(token, connection); + } + Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(err) => { + error!("Error while accepting connection: {}", err); + return Err(err); + } + } + } + } + + /// Pass MIO events to corresponding connections. + pub fn handle_event(&mut self, registry: &mio::Registry, event: &mio::event::Event) { + let token = event.token(); + if self.connections.contains_key(&token) { + self.connections + .get_mut(&token) + .unwrap() + .ready(registry, event); + if self.connections[&token].state == ConnectionState::Closed { + self.connections.remove(&token); + } + } + } +} + +/// Connection state, mostly used for graceful shutdowns. +#[derive(PartialEq)] +pub enum ConnectionState { + Open, + Closing, + Closed, +} + +/// A once open connection; hold the TCP and TLS states, as well as the incoming client data. +pub struct Connection<'a> { + pub cgi_config: &'a CgiConfig, + pub socket: mio::net::TcpStream, + pub tls: rustls::ServerConnection, + pub token: mio::Token, + pub state: ConnectionState, + buffer: Vec, + received: usize, +} + +impl<'a> Connection<'a> { + /// Create a new Connection. + fn new( + socket: mio::net::TcpStream, + token: mio::Token, + tls_connection: rustls::ServerConnection, + cgi_config: &'a CgiConfig, + ) -> Self { + Connection { + socket, + token, + state: ConnectionState::Open, + tls: tls_connection, + buffer: vec![0; 1026], + received: 0, + cgi_config, + } + } + + /// Process an event. + fn ready(&mut self, registry: &mio::Registry, event: &mio::event::Event) { + if event.is_readable() { + self.read_tls(); + self.read_plain(); + } + if event.is_writable() { + self.write_tls_with_errors(); + } + if self.state == ConnectionState::Closing { + if let Err(err) = self.socket.shutdown(net::Shutdown::Both) { + error!("Could not properly shutdown socket: {}", err); + } + self.state = ConnectionState::Closed; + registry.deregister(&mut self.socket).unwrap(); + } else { + let event_set = self.event_set(); + registry + .reregister(&mut self.socket, self.token, event_set) + .unwrap(); + } + } + + /// Read data from the TLS tunnel; if enough data is read, new packets are processed and can be + /// later read with `read_plain`. + fn read_tls(&mut self) { + match self.tls.read_tls(&mut self.socket) { + Err(err) => { + if err.kind() != io::ErrorKind::WouldBlock { + error!("TLS read error: {}", err); + self.state = ConnectionState::Closing; + } + return; + } + Ok(num_bytes) if num_bytes == 0 => { + self.state = ConnectionState::Closing; + return; + } + _ => {} + }; + + if let Err(err) = self.tls.process_new_packets() { + error!("Can't process packet: {}", err); + self.write_tls_with_errors(); + self.state = ConnectionState::Closing; + } + } + + /// Process packets with incoming data from a client. + fn read_plain(&mut self) { + if let Ok(io_state) = self.tls.process_new_packets() { + let to_read = io_state.plaintext_bytes_to_read(); + if to_read > 0 { + let mut buffer = vec![0u8; to_read]; + self.tls.reader().read(&mut buffer).unwrap(); + self.handle_incoming_data(&buffer); + } + } + } + + /// Process received client data as a Gemini request; it either is a self-contained + fn handle_incoming_data(&mut self, data: &[u8]) { + // The connection buffer should never exceed 1026 bytes: 1024 URL bytes plus \r\n. + if data.len() + self.received > 1026 { + error!("URL queried is longer 1024 bytes, discarding."); + self.state = ConnectionState::Closing; + return; + } + // If the URL requested is contained within that single data packet, process it without + // copying stuff. + if self.received == 0 && data.ends_with(b"\r\n") { + self.process_buffer(data); + } + // Else append received data into the connection buffer and try to process it. + else { + let buffer_end = self.received + data.len(); + self.buffer[self.received..buffer_end].copy_from_slice(data); + self.received = buffer_end; + if self.buffer[..self.received].ends_with(b"\r\n") { + self.process_buffer(&self.buffer.clone()); + } + } + } + + /// Respond to a client request. Whether the request succeeds or not, a response is sent and + /// the connection is closed. + fn process_buffer(&mut self, buffer: &[u8]) { + // Get appropriate response from either Opal or the CGI process. + let response: Vec = match self.get_response(buffer) { + Ok((url, data)) => { + info!("\"{}\" → reply {} bytes", url, data.len()); + data + } + Err((url, code, meta)) => { + info!( + "\"{}\" → {} \"{}\"", + url.or(Some("".to_string())).unwrap(), + code, + meta + ); + format!("{} {}\r\n", code, meta).as_bytes().to_vec() + } + }; + // Whether the request succeeded or not, send the response. + if let Err(err) = self.tls.writer().write_all(&response) { + error!("Error while writing TLS data: {}", err); + } + // Properly close the connection. + self.tls.send_close_notify(); + self.state = ConnectionState::Closing; + } + + /// Write TLS data in the TCP socket. + fn write_tls(&mut self) -> io::Result { + self.tls.write_tls(&mut self.socket) + } + + /// Call `write_tls` and mark connection as closing on error. + fn write_tls_with_errors(&mut self) { + if let Err(err) = self.write_tls() { + error!("TLS write error after errors: {}", err); + self.state = ConnectionState::Closing; + } + } + + /// Register the connection into the MIO registry using its own token. + fn register(&mut self, registry: &mio::Registry) { + let event_set = self.event_set(); + registry + .register(&mut self.socket, self.token, event_set) + .unwrap(); + } + + /// Return what IO events we're currently waiting for, based on wants_read/wants_write. + fn event_set(&self) -> mio::Interest { + let r = self.tls.wants_read(); + let w = self.tls.wants_write(); + if r && w { + mio::Interest::READABLE | mio::Interest::WRITABLE + } else if w { + mio::Interest::WRITABLE + } else { + mio::Interest::READABLE + } + } +} diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..7d42cb8 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,86 @@ +//! Trying desperately to not implement a security colander. + +use std::sync::Arc; + +type SignatureAlgorithms = &'static [&'static webpki::SignatureAlgorithm]; + +/// Supported signature verification mechanisms; copied from Rustls source. +static SUPPORTED_SIG_ALGS: SignatureAlgorithms = &[ + &webpki::ECDSA_P256_SHA256, + &webpki::ECDSA_P256_SHA384, + &webpki::ECDSA_P384_SHA256, + &webpki::ECDSA_P384_SHA384, + &webpki::ED25519, + &webpki::RSA_PSS_2048_8192_SHA256_LEGACY_KEY, + &webpki::RSA_PSS_2048_8192_SHA384_LEGACY_KEY, + &webpki::RSA_PSS_2048_8192_SHA512_LEGACY_KEY, + &webpki::RSA_PKCS1_2048_8192_SHA256, + &webpki::RSA_PKCS1_2048_8192_SHA384, + &webpki::RSA_PKCS1_2048_8192_SHA512, + &webpki::RSA_PKCS1_3072_8192_SHA384, +]; + +/// A `ClientCertVerifier` for Gemini. +/// +/// Client certificate is optional. When provided, we check that it is valid for use by a client. +/// No certificate chain is verified as client certs in Gemini are mostly self-signed anyway. +/// Signature verification is left to the default implementation. +pub struct GeminiClientCertVerifier {} + +impl GeminiClientCertVerifier { + pub fn new() -> Arc { + Arc::new(Self {}) + } +} + +impl rustls::server::ClientCertVerifier for GeminiClientCertVerifier { + /// Make client certificate optional. + fn client_auth_mandatory(&self) -> Option { + Some(false) + } + + /// Do not provide CA names. + fn client_auth_root_subjects(&self) -> Option { + Some(vec![]) + } + + /// “Verify” client certificates. + /// + /// Actually do not verify much, mostly that the certificate is well-formed. Like Rustls, we + /// rely on the WebPKI crate to verify the certificate. It rejects self-signed client + /// certificates early in the verification stage (we can't do much against that), and we ignore + /// that error because Gemini clients mostly use self-signed certificates, so we can miss other + /// WebPKI verifications errors. We should use a validation process that reports all issues + /// found or provide a way to filter acceptable issues, but we can't blame anyone on this… + fn verify_client_cert( + &self, + end_entity: &rustls::Certificate, + intermediates: &[rustls::Certificate], + now: std::time::SystemTime, + ) -> Result { + let cert = webpki::EndEntityCert::try_from(end_entity.0.as_ref()) + .map_err(|_| rustls::Error::InvalidCertificateEncoding)?; + let now = webpki::Time::try_from(now).map_err(|_| rustls::Error::FailedToGetCurrentTime)?; + let verified = rustls::server::ClientCertVerified::assertion(); + match cert.verify_is_valid_tls_client_cert( + SUPPORTED_SIG_ALGS, + &webpki::TlsClientTrustAnchors(&vec![]), + &intermediates + .iter() + .map(|c| c.0.as_ref()) + .collect::>(), + now, + ) { + Ok(()) => Ok(verified), + Err(e) => match e { + // It's OK for client certs to be self-signed. + webpki::Error::CaUsedAsEndEntity => Ok(verified), + // Any other error is fatal. + _ => Err(rustls::Error::InvalidCertificateData(format!( + "invalid client cert: {}", + e + ))), + }, + } + } +}