use OpenSSL bindings instead of Rustls
Rustls is fine but it's hard to change the default "secure" behaviour for Gemini usage. Not that Gemini is unsecure but things like self-signed client certificates should not be such a stupidly hard thing to accept.
This commit is contained in:
parent
e24031d6c9
commit
237959fb42
404
Cargo.lock
generated
404
Cargo.lock
generated
|
@ -37,24 +37,12 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bumpalo"
|
|
||||||
version = "3.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.0.72"
|
version = "1.0.72"
|
||||||
|
@ -73,8 +61,11 @@ version = "0.4.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
|
checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"time",
|
||||||
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -92,36 +83,6 @@ dependencies = [
|
||||||
"vec_map",
|
"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]]
|
[[package]]
|
||||||
name = "env_logger"
|
name = "env_logger"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
@ -135,6 +96,21 @@ dependencies = [
|
||||||
"termcolor",
|
"termcolor",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
@ -171,21 +147,6 @@ dependencies = [
|
||||||
"unicode-normalization",
|
"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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.108"
|
version = "0.2.108"
|
||||||
|
@ -213,65 +174,6 @@ version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
|
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]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
@ -291,15 +193,6 @@ dependencies = [
|
||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
@ -310,17 +203,40 @@ checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
|
||||||
name = "opal"
|
name = "opal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
"mio",
|
"openssl",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"ring",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pemfile",
|
|
||||||
"url",
|
"url",
|
||||||
"webpki",
|
]
|
||||||
"x509-parser",
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.71"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7df13d165e607909b363a4757a6f133f8a818a74e9d3a98d09c6128e15fa4c73"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -330,22 +246,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
|
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "pkg-config"
|
||||||
version = "1.0.32"
|
version = "0.3.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43"
|
checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f"
|
||||||
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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
|
@ -364,84 +268,12 @@ version = "0.6.25"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
|
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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
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]]
|
[[package]]
|
||||||
name = "termcolor"
|
name = "termcolor"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
|
@ -461,23 +293,14 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "time"
|
||||||
version = "1.0.30"
|
version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417"
|
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"libc",
|
||||||
]
|
"wasi",
|
||||||
|
"winapi",
|
||||||
[[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]]
|
[[package]]
|
||||||
|
@ -516,18 +339,6 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
|
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]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.2.2"
|
version = "2.2.2"
|
||||||
|
@ -540,6 +351,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vec_map"
|
name = "vec_map"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
@ -547,84 +364,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "wasi"
|
||||||
version = "0.9.3"
|
version = "0.10.0+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
|
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
|
@ -656,20 +399,3 @@ name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
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",
|
|
||||||
]
|
|
||||||
|
|
|
@ -7,14 +7,10 @@ edition = "2021"
|
||||||
lto = true
|
lto = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = "0.4"
|
||||||
clap = "2.33"
|
clap = "2.33"
|
||||||
env_logger = "0.9"
|
env_logger = "0.9"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
mio = { version = "0.7", features = ["os-poll", "net"] }
|
openssl = "0.10"
|
||||||
percent-encoding = "2.1"
|
percent-encoding = "2.1"
|
||||||
ring = "0.16"
|
|
||||||
rustls = { version = "0.20", features = ["dangerous_configuration"] }
|
|
||||||
rustls-pemfile = "0.2"
|
|
||||||
url = "2.2"
|
url = "2.2"
|
||||||
webpki = "0.22"
|
|
||||||
x509-parser = "0.12"
|
|
||||||
|
|
42
README.md
42
README.md
|
@ -8,14 +8,29 @@ focus on a smaller set of features but do them correctly.
|
||||||
|
|
||||||
[agate]: https://github.com/mbrubeck/agate/
|
[agate]: https://github.com/mbrubeck/agate/
|
||||||
|
|
||||||
|
Opal uses the `openssl` Rust bindings, which work with OpenSSL and LibreSSL, so
|
||||||
|
it should work properly on those platforms. I only support Linux systems but
|
||||||
|
feel free to patch stuff!
|
||||||
|
|
||||||
|
|
||||||
CGI environment variables
|
|
||||||
-------------------------
|
|
||||||
|
|
||||||
Opal tries to implement RFC 3875 (CGI 1.1) and provides all the required
|
Usage
|
||||||
environment variables to processes. It also add a bunch of Gemini specific
|
-----
|
||||||
variables, like a lot of other servers (Gemserv, Gmid, Gmnisrv, …).
|
|
||||||
|
Use `opal -h` to get a list of options. There is no config file.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CGI support
|
||||||
|
-----------
|
||||||
|
|
||||||
|
Opal tries to implement [RFC 3875][rfc3875] (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, …). The
|
||||||
|
environment for the subprocess is cleaned and should only contain those
|
||||||
|
variables.
|
||||||
|
|
||||||
|
[rfc3875]: https://datatracker.ietf.org/doc/html/rfc3875
|
||||||
|
|
||||||
| Presence | Variable | Description |
|
| Presence | Variable | Description |
|
||||||
|-------------|------------------------|------------------------------------------------------|
|
|-------------|------------------------|------------------------------------------------------|
|
||||||
|
@ -32,23 +47,24 @@ variables, like a lot of other servers (Gemserv, Gmid, Gmnisrv, …).
|
||||||
| always | GEMINI_SCRIPT_FILENAME | CGI script that matched the URL path |
|
| always | GEMINI_SCRIPT_FILENAME | CGI script that matched the URL path |
|
||||||
| always | GEMINI_URL | Full URL, normalized |
|
| always | GEMINI_URL | Full URL, normalized |
|
||||||
| always | GEMINI_URL_PATH | URL path, normalized |
|
| always | GEMINI_URL_PATH | URL path, normalized |
|
||||||
| always | TLS_VERSION | TLS version, e.g. "TLSv1_3" |
|
| always | TLS_VERSION | TLS version, e.g. "TLSv1.3" |
|
||||||
| always | TLS_CIPHER | TLS cipher suite, e.g. "TLS13_AES_256_GCM_SHA384" |
|
| always | TLS_CIPHER | TLS cipher suite, e.g. "TLS_AES_256_GCM_SHA384" |
|
||||||
| optional | PATH_INFO | Path passed to the CGI process after the script name |
|
| optional | PATH_INFO | Path passed to the CGI process after the script name |
|
||||||
| optional | QUERY_STRING | Query string if provided, still URL-encoded |
|
| optional | QUERY_STRING | Query string if provided, still URL-encoded |
|
||||||
| client cert | AUTH_TYPE | "Certificate" if one is provided |
|
| client cert | AUTH_TYPE | "CERTIFICATE" if one is provided |
|
||||||
| client cert | REMOTE_USER | Subject common name (empty if unavailable) |
|
| 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_ISSUER | Issuer common name (empty if unavailable) |
|
||||||
| client cert | TLS_CLIENT_HASH | Digest of the DER reprensetation of the cert |
|
| 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_AFTER | Validity end date, RFC 3339 format |
|
||||||
| client cert | TLS_CLIENT_NOT_BEFORE | Timestamp in seconds |
|
| client cert | TLS_CLIENT_NOT_BEFORE | Validity start date, RFC 3339 format |
|
||||||
|
|
||||||
Opal does not provide `CONTENT_LENGTH`, `CONTENT_TYPE`, `REMOTE_IDENT` because
|
Opal does not provide `CONTENT_LENGTH`, `CONTENT_TYPE`, `REMOTE_IDENT` because
|
||||||
they do not make much sense in Gemini.
|
they do not make much sense in Gemini. `PATH_TRANSLATED` is also not implemented
|
||||||
|
by pure laziness.
|
||||||
|
|
||||||
The `TLS_CLIENT_HASH` is a string that starts with "SHA256:" followed by the
|
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
|
SHA256 digest of the DER representation of the client certificate, as an
|
||||||
lowercase hex-string.
|
uppercase hex-string.
|
||||||
|
|
||||||
It can be a bit confusing which variable represent what data, especially those
|
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:
|
related to the URL and the path. Take the following request as example:
|
||||||
|
|
321
src/cgi.rs
321
src/cgi.rs
|
@ -1,321 +0,0 @@
|
||||||
//! 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<u8>), (Option<String>, 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<String>), (Option<String>, 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::<Vec<&str>>();
|
|
||||||
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<String>,
|
|
||||||
) -> Option<HashMap<String, String>> {
|
|
||||||
// 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::<HashMap<String, String>>(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
}
|
|
533
src/main.rs
533
src/main.rs
|
@ -1,74 +1,38 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::Write;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
|
||||||
use std::net;
|
use std::net;
|
||||||
|
use std::path;
|
||||||
|
use std::process;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate clap;
|
extern crate clap;
|
||||||
|
|
||||||
|
use chrono::offset::TimeZone;
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
|
use openssl::{asn1, ssl, x509};
|
||||||
|
|
||||||
pub mod cgi;
|
/// General CGI configuration.
|
||||||
pub mod server;
|
#[derive(Clone)]
|
||||||
pub mod tls;
|
struct CgiConfig {
|
||||||
|
root: String,
|
||||||
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<Vec<rustls::Certificate>> {
|
|
||||||
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<rustls::PrivateKey> {
|
|
||||||
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() {
|
fn main() {
|
||||||
|
process::exit(match run() {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(err) => err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run Opal: parse CLI args and listen for clients.
|
||||||
|
fn run() -> Result<(), i32> {
|
||||||
// Get command-line args. Opal does not use config files.
|
// Get command-line args. Opal does not use config files.
|
||||||
let matches = clap::App::new("Opal")
|
let matches = clap::App::new("Opal")
|
||||||
.version(crate_version!())
|
.version(crate_version!())
|
||||||
.about("Gemini CGI-only server")
|
.about("Gemini CGI server")
|
||||||
.arg(
|
.arg(
|
||||||
clap::Arg::with_name("address")
|
clap::Arg::with_name("address")
|
||||||
.required(true)
|
.required(true)
|
||||||
|
@ -96,7 +60,7 @@ fn main() {
|
||||||
.arg(
|
.arg(
|
||||||
clap::Arg::with_name("root_path")
|
clap::Arg::with_name("root_path")
|
||||||
.required(true)
|
.required(true)
|
||||||
.short("p")
|
.short("r")
|
||||||
.long("root-path")
|
.long("root-path")
|
||||||
.help("Path to CGI scripts root")
|
.help("Path to CGI scripts root")
|
||||||
.takes_value(true),
|
.takes_value(true),
|
||||||
|
@ -109,86 +73,407 @@ fn main() {
|
||||||
|
|
||||||
// Process command-line args.
|
// Process command-line args.
|
||||||
info!("Starting Opal");
|
info!("Starting Opal");
|
||||||
let addr = match matches
|
|
||||||
.value_of("address")
|
let cgi_root = fs::canonicalize(matches.value_of("root_path").unwrap())
|
||||||
.unwrap()
|
.map(|p| p.to_str().unwrap().to_string())
|
||||||
.parse::<net::SocketAddr>()
|
.map_err(|err| {
|
||||||
{
|
|
||||||
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);
|
error!("Invalid CGI root path: {}", err);
|
||||||
return;
|
1
|
||||||
}
|
})?;
|
||||||
};
|
|
||||||
let cgi_config = CgiConfig { root: cgi_root };
|
let cgi_config = CgiConfig { root: cgi_root };
|
||||||
|
|
||||||
// Setup TLS server.
|
// Setup TLS server.
|
||||||
let config = Arc::new(
|
let acceptor = create_ssl_acceptor(
|
||||||
match rustls::ServerConfig::builder()
|
matches.value_of("cert").unwrap(),
|
||||||
.with_safe_defaults()
|
matches.value_of("key").unwrap(),
|
||||||
.with_client_cert_verifier(GeminiClientCertVerifier::new())
|
)
|
||||||
.with_single_cert(server_certs, server_key)
|
.map_err(|err| run_failure("Could not create TLS acceptor", &err))?;
|
||||||
{
|
|
||||||
Ok(c) => c,
|
let address = matches.value_of("address").unwrap();
|
||||||
Err(err) => {
|
let listener = net::TcpListener::bind(address)
|
||||||
error!("Invalid certificate or private key provided: {}", err);
|
.map_err(|err| run_failure("Could not create TCP listener", &err))?;
|
||||||
return;
|
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
match stream {
|
||||||
|
Ok(stream) => {
|
||||||
|
let acceptor = acceptor.clone();
|
||||||
|
let cgi_config = cgi_config.clone();
|
||||||
|
thread::spawn(move || match acceptor.accept(stream) {
|
||||||
|
Ok(mut tls_stream) => handle_client(&mut tls_stream, &cgi_config),
|
||||||
|
Err(err) => error!("Can't initiate TLS stream: {}", err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
Err(err) => {
|
||||||
);
|
error!("Can't accept connection: {}", err);
|
||||||
let mut listener = match mio::net::TcpListener::bind(addr) {
|
}
|
||||||
Ok(l) => l,
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log an error message consisting of `msg` followed by `err` and return 1.
|
||||||
|
fn run_failure(msg: &str, err: &impl std::fmt::Display) -> i32 {
|
||||||
|
error!("{}: {}", msg, err);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new TLS acceptor that can be cloned for incoming connections.
|
||||||
|
fn create_ssl_acceptor(
|
||||||
|
cert_path: &str,
|
||||||
|
key_path: &str,
|
||||||
|
) -> Result<Arc<ssl::SslAcceptor>, ssl::Error> {
|
||||||
|
let mut acceptor = ssl::SslAcceptor::mozilla_intermediate_v5(ssl::SslMethod::tls())?;
|
||||||
|
acceptor.set_certificate_chain_file(cert_path)?;
|
||||||
|
acceptor.set_private_key_file(key_path, ssl::SslFiletype::PEM)?;
|
||||||
|
acceptor.check_private_key()?;
|
||||||
|
acceptor.set_verify_callback(ssl::SslVerifyMode::PEER, |ver, store| {
|
||||||
|
ver || match verify_client_cert(store) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Can't validate client cert: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Arc::new(acceptor.build()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a Gemini client certificate.
|
||||||
|
///
|
||||||
|
/// This is standard certificate verification but we use the certificate's own public key to verify
|
||||||
|
/// the signature, so self-signed certificates are as valid as those with a chain.
|
||||||
|
fn verify_client_cert(store: &mut x509::X509StoreContextRef) -> Result<bool, String> {
|
||||||
|
let cert = store.current_cert().ok_or_else(|| "no cert in store")?;
|
||||||
|
cert.verify(
|
||||||
|
cert.public_key()
|
||||||
|
.map_err(|_| "can't use cert public key")?
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("verification failed: {}", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a new client's request.
|
||||||
|
fn handle_client(tls_stream: &mut ssl::SslStream<net::TcpStream>, cgi_config: &CgiConfig) {
|
||||||
|
// The connection buffer should never exceed 1026 bytes: 1024 URL bytes plus \r\n.
|
||||||
|
let mut request = vec![0u8; 1026];
|
||||||
|
let read_bytes = match tls_stream.ssl_read(&mut request) {
|
||||||
|
Ok(n) if n > 0 => n,
|
||||||
|
Ok(_) => {
|
||||||
|
error!("Empty request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Can't listen on port: {}", err);
|
error!("TLS read error: {}", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let listener_token = mio::Token(0);
|
if &request[(read_bytes - 2)..read_bytes] != b"\r\n" {
|
||||||
let mut poll = mio::Poll::new().unwrap();
|
error!("Request does not end with \\r\\n.");
|
||||||
if let Err(err) =
|
|
||||||
poll.registry()
|
|
||||||
.register(&mut listener, listener_token, mio::Interest::READABLE)
|
|
||||||
{
|
|
||||||
error!("Can't setup poll listener: {}", err);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut server = Server::new(listener, config, &cgi_config);
|
|
||||||
let mut events = mio::Events::with_capacity(256);
|
// Get appropriate response from either Opal or the CGI process.
|
||||||
loop {
|
let response: Vec<u8> = match get_response(&request[..read_bytes], cgi_config, &tls_stream) {
|
||||||
if let Err(err) = poll.poll(&mut events, None) {
|
Ok((url, data)) => {
|
||||||
debug!("Poll failed: {}", err);
|
info!("\"{}\" → reply {} bytes", url, data.len());
|
||||||
continue;
|
data
|
||||||
}
|
}
|
||||||
for event in events.iter() {
|
Err((url, code, meta)) => {
|
||||||
if event.token() == listener_token {
|
info!(
|
||||||
if let Err(err) = server.accept(poll.registry()) {
|
"\"{}\" → {} \"{}\"",
|
||||||
error!("Could not accept socket: {}", err);
|
url.or(Some("<invalid URL>".to_string())).unwrap(),
|
||||||
}
|
code,
|
||||||
} else {
|
meta
|
||||||
server.handle_event(poll.registry(), &event);
|
);
|
||||||
|
format!("{} {}\r\n", code, meta).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whether the request succeeded or not, send the response.
|
||||||
|
if let Err(err) = tls_stream.ssl_write(&response) {
|
||||||
|
error!("Error while writing TLS data: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Properly close the connection with a close notify.
|
||||||
|
match tls_stream.shutdown() {
|
||||||
|
Ok(shutdown) => debug!("Connection shutdown (state: {:?})", shutdown),
|
||||||
|
Err(err) => error!("Could not properly shutdown: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn get_response(
|
||||||
|
request: &[u8],
|
||||||
|
cgi_config: &CgiConfig,
|
||||||
|
tls: &ssl::SslStream<net::TcpStream>,
|
||||||
|
) -> Result<(String, Vec<u8>), (Option<String>, u8, &'static 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) = validate_script_path(&url, cgi_config)?;
|
||||||
|
let script_path = script_path.to_string_lossy().into_owned();
|
||||||
|
debug!("Script path: \"{}\"", script_path);
|
||||||
|
|
||||||
|
// Define a generic "temp failure" error for any other issue.
|
||||||
|
let cgi_error = (Some(url_str.to_string()), 40, "Temporary failure");
|
||||||
|
|
||||||
|
// 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", cgi_config.root.to_string()),
|
||||||
|
("GEMINI_SCRIPT_FILENAME", script_path.clone()),
|
||||||
|
("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 = tls.get_ref().peer_addr().map_err(|err| {
|
||||||
|
error!("Can't get peer address for CGI envs: {}", err);
|
||||||
|
cgi_error.to_owned()
|
||||||
|
})?;
|
||||||
|
envs.push(("REMOTE_ADDR", remote_addr.to_string()));
|
||||||
|
envs.push(("REMOTE_HOST", remote_addr.to_string()));
|
||||||
|
envs.push((
|
||||||
|
"SERVER_PORT",
|
||||||
|
tls.get_ref()
|
||||||
|
.local_addr()
|
||||||
|
.map(|address| address.port())
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("Can't get local address for CGI envs: {}", err);
|
||||||
|
cgi_error.to_owned()
|
||||||
|
})?
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
envs.push((
|
||||||
|
"SCRIPT_NAME",
|
||||||
|
script_path[cgi_config.root.len()..].to_string(),
|
||||||
|
));
|
||||||
|
envs.push((
|
||||||
|
"SERVER_NAME",
|
||||||
|
tls.ssl()
|
||||||
|
.servername(ssl::NameType::HOST_NAME)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
error!("Can't get SNI hostname for SERVER_NAME.");
|
||||||
|
cgi_error.to_owned()
|
||||||
|
})?
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
envs.push(("TLS_VERSION", tls.ssl().version_str().to_string()));
|
||||||
|
envs.push((
|
||||||
|
"TLS_CIPHER",
|
||||||
|
tls.ssl()
|
||||||
|
.current_cipher()
|
||||||
|
.and_then(|c| c.standard_name())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
error!("Can't get TLS negociated cipher suite.");
|
||||||
|
cgi_error.to_owned()
|
||||||
|
})?
|
||||||
|
.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(cert) = tls.ssl().peer_certificate() {
|
||||||
|
envs.push(("AUTH_TYPE", String::from("CERTIFICATE")));
|
||||||
|
envs.push(("REMOTE_USER", get_x509_cn(cert.subject_name())));
|
||||||
|
envs.push(("TLS_CLIENT_ISSUER", get_x509_cn(cert.issuer_name())));
|
||||||
|
let digest = cert
|
||||||
|
.digest(openssl::hash::MessageDigest::sha256())
|
||||||
|
.map_err(|err| {
|
||||||
|
error!("Can't digest certificate: {}", err);
|
||||||
|
cgi_error.to_owned()
|
||||||
|
})?;
|
||||||
|
let hex_digest = hexlify(digest.as_ref());
|
||||||
|
let client_hash = String::from("SHA256:") + &hex_digest;
|
||||||
|
envs.push(("TLS_CLIENT_HASH", client_hash));
|
||||||
|
let dt_format_failed = || {
|
||||||
|
error!("Can't format date/time");
|
||||||
|
cgi_error.to_owned()
|
||||||
|
};
|
||||||
|
envs.push((
|
||||||
|
"TLS_CLIENT_NOT_BEFORE",
|
||||||
|
format_rfc3339(cert.not_before()).ok_or_else(dt_format_failed)?,
|
||||||
|
));
|
||||||
|
envs.push((
|
||||||
|
"TLS_CLIENT_NOT_AFTER",
|
||||||
|
format_rfc3339(cert.not_after()).ok_or_else(dt_format_failed)?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect our variables into a hashmap before execution.
|
||||||
|
let envs = envs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_owned()))
|
||||||
|
.collect::<HashMap<String, String>>();
|
||||||
|
|
||||||
|
// Run the subprocess!
|
||||||
|
let output = process::Command::new(script_path)
|
||||||
|
.env_clear()
|
||||||
|
.envs(envs)
|
||||||
|
.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(
|
||||||
|
url: &url::Url,
|
||||||
|
cgi_config: &CgiConfig,
|
||||||
|
) -> Result<(path::PathBuf, Option<String>), (Option<String>, u8, &'static 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(&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::<Vec<&str>>();
|
||||||
|
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(&cgi_config.root) {
|
||||||
|
debug!(
|
||||||
|
"Script path \"{}\" is outside of CGI root dir \"{}\".",
|
||||||
|
path.to_string_lossy(),
|
||||||
|
cgi_config.root
|
||||||
|
);
|
||||||
|
return Err(not_found);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((path, path_info))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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_x509_cn(name_ref: &x509::X509NameRef) -> String {
|
||||||
|
for entry in name_ref.entries_by_nid(openssl::nid::Nid::COMMONNAME) {
|
||||||
|
match entry.data().as_utf8() {
|
||||||
|
Ok(s) => return s.to_string(),
|
||||||
|
Err(err) => {
|
||||||
|
error!("Can't convert ASN.1 string to UTF-8: {}", err);
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an hex-string representing the digest data.
|
||||||
|
fn hexlify(digest: &[u8]) -> String {
|
||||||
|
let mut s = String::with_capacity(digest.len() * 2);
|
||||||
|
digest
|
||||||
|
.iter()
|
||||||
|
.for_each(|b| write!(&mut s, "{:02X}", b).unwrap());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an ASN.1 time into an RFC 3339 representation. What the hell?
|
||||||
|
fn format_rfc3339(asn1_time: &asn1::Asn1TimeRef) -> Option<String> {
|
||||||
|
asn1::Asn1Time::from_unix(0)
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
.and_then(|epoch| epoch.diff(asn1_time).map_err(|err| err.to_string()))
|
||||||
|
.and_then(|diff| {
|
||||||
|
let secs = diff.days as i64 * 86400i64 + diff.secs as i64;
|
||||||
|
chrono::offset::Utc
|
||||||
|
.timestamp_opt(secs, 0)
|
||||||
|
.single()
|
||||||
|
.ok_or_else(|| "invalid timestamp".to_owned())
|
||||||
|
})
|
||||||
|
.and_then(|dt| Ok(dt.to_rfc3339()))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
263
src/server.rs
263
src/server.rs
|
@ -1,263 +0,0 @@
|
||||||
//! 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<rustls::ServerConfig>,
|
|
||||||
pub cgi_config: &'a CgiConfig,
|
|
||||||
listener: mio::net::TcpListener,
|
|
||||||
connections: HashMap<mio::Token, Connection<'a>>,
|
|
||||||
next_id: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Server<'a> {
|
|
||||||
/// Create a new Server.
|
|
||||||
pub fn new(
|
|
||||||
listener: mio::net::TcpListener,
|
|
||||||
config: Arc<rustls::ServerConfig>,
|
|
||||||
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<u8>,
|
|
||||||
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<u8> = match self.get_response(buffer) {
|
|
||||||
Ok((url, data)) => {
|
|
||||||
info!("\"{}\" → reply {} bytes", url, data.len());
|
|
||||||
data
|
|
||||||
}
|
|
||||||
Err((url, code, meta)) => {
|
|
||||||
info!(
|
|
||||||
"\"{}\" → {} \"{}\"",
|
|
||||||
url.or(Some("<invalid URL>".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<usize> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
86
src/tls.rs
86
src/tls.rs
|
@ -1,86 +0,0 @@
|
||||||
//! 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<dyn rustls::server::ClientCertVerifier> {
|
|
||||||
Arc::new(Self {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl rustls::server::ClientCertVerifier for GeminiClientCertVerifier {
|
|
||||||
/// Make client certificate optional.
|
|
||||||
fn client_auth_mandatory(&self) -> Option<bool> {
|
|
||||||
Some(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Do not provide CA names.
|
|
||||||
fn client_auth_root_subjects(&self) -> Option<rustls::DistinguishedNames> {
|
|
||||||
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<rustls::server::ClientCertVerified, rustls::Error> {
|
|
||||||
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::<Vec<&[u8]>>(),
|
|
||||||
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
|
|
||||||
))),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Reference in a new issue