From 237959fb42db75c0b525aca46e3ed1036149741b Mon Sep 17 00:00:00 2001 From: dece Date: Wed, 24 Nov 2021 12:15:08 +0100 Subject: [PATCH] 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. --- Cargo.lock | 404 ++++++-------------------------------- Cargo.toml | 8 +- README.md | 42 ++-- src/cgi.rs | 321 ------------------------------ src/main.rs | 533 ++++++++++++++++++++++++++++++++++++++------------ src/server.rs | 263 ------------------------- src/tls.rs | 86 -------- 7 files changed, 505 insertions(+), 1152 deletions(-) delete mode 100644 src/cgi.rs delete mode 100644 src/server.rs delete mode 100644 src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 413c24b..2811765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,24 +37,12 @@ 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" @@ -73,8 +61,11 @@ version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" dependencies = [ + "libc", "num-integer", "num-traits", + "time", + "winapi", ] [[package]] @@ -92,36 +83,6 @@ dependencies = [ "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" @@ -135,6 +96,21 @@ dependencies = [ "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]] name = "form_urlencoded" version = "1.0.1" @@ -171,21 +147,6 @@ dependencies = [ "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" @@ -213,65 +174,6 @@ 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" @@ -291,15 +193,6 @@ 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" @@ -310,17 +203,40 @@ checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" name = "opal" version = "0.1.0" dependencies = [ + "chrono", "clap", "env_logger", "log", - "mio", + "openssl", "percent-encoding", - "ring", - "rustls", - "rustls-pemfile", "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]] @@ -330,22 +246,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] -name = "proc-macro2" -version = "1.0.32" +name = "pkg-config" +version = "0.3.22" 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", -] +checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" [[package]] name = "regex" @@ -364,84 +268,12 @@ 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" @@ -461,23 +293,14 @@ dependencies = [ ] [[package]] -name = "thiserror" -version = "1.0.30" +name = "time" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 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", + "libc", + "wasi", + "winapi", ] [[package]] @@ -516,18 +339,6 @@ 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" @@ -540,6 +351,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" @@ -547,84 +364,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" [[package]] -name = "version_check" -version = "0.9.3" +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" 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", -] +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" [[package]] name = "winapi" @@ -656,20 +399,3 @@ 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 index 27d681f..ca73ccf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,14 +7,10 @@ edition = "2021" lto = true [dependencies] +chrono = "0.4" clap = "2.33" env_logger = "0.9" log = "0.4" -mio = { version = "0.7", features = ["os-poll", "net"] } +openssl = "0.10" 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 index 7d47025..bd095b1 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,29 @@ focus on a smaller set of features but do them correctly. [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 -environment variables to processes. It also add a bunch of Gemini specific -variables, like a lot of other servers (Gemserv, Gmid, Gmnisrv, …). +Usage +----- + +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 | |-------------|------------------------|------------------------------------------------------| @@ -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_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" | +| always | TLS_VERSION | TLS version, e.g. "TLSv1.3" | +| 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 | 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 | 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 | +| client cert | TLS_CLIENT_NOT_AFTER | Validity end date, RFC 3339 format | +| client cert | TLS_CLIENT_NOT_BEFORE | Validity start date, RFC 3339 format | 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 -SHA256 digest of the DER representation of the client certificate, as a -lowercase hex-string. +SHA256 digest of the DER representation of the client certificate, as an +uppercase 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: diff --git a/src/cgi.rs b/src/cgi.rs deleted file mode 100644 index 8084ea6..0000000 --- a/src/cgi.rs +++ /dev/null @@ -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), (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 index ba07036..7b13ae6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,74 +1,38 @@ +use std::collections::HashMap; +use std::fmt::Write; use std::fs; -use std::io; use std::net; +use std::path; +use std::process; use std::sync::Arc; +use std::thread; #[macro_use] extern crate clap; +use chrono::offset::TimeZone; use log::{debug, error, info}; +use openssl::{asn1, ssl, x509}; -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; - } - } - } +/// General CGI configuration. +#[derive(Clone)] +struct CgiConfig { + root: String, } 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. let matches = clap::App::new("Opal") .version(crate_version!()) - .about("Gemini CGI-only server") + .about("Gemini CGI server") .arg( clap::Arg::with_name("address") .required(true) @@ -96,7 +60,7 @@ fn main() { .arg( clap::Arg::with_name("root_path") .required(true) - .short("p") + .short("r") .long("root-path") .help("Path to CGI scripts root") .takes_value(true), @@ -109,86 +73,407 @@ fn main() { // 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) => { + + let cgi_root = fs::canonicalize(matches.value_of("root_path").unwrap()) + .map(|p| p.to_str().unwrap().to_string()) + .map_err(|err| { error!("Invalid CGI root path: {}", err); - return; - } - }; + 1 + })?; 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 acceptor = create_ssl_acceptor( + matches.value_of("cert").unwrap(), + matches.value_of("key").unwrap(), + ) + .map_err(|err| run_failure("Could not create TLS acceptor", &err))?; + + let address = matches.value_of("address").unwrap(); + let listener = net::TcpListener::bind(address) + .map_err(|err| run_failure("Could not create TCP listener", &err))?; + + 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), + }); } - }, - ); - let mut listener = match mio::net::TcpListener::bind(addr) { - Ok(l) => l, + Err(err) => { + error!("Can't accept connection: {}", err); + } + } + } + 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, 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 { + 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, 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) => { - error!("Can't listen on port: {}", err); + error!("TLS read error: {}", 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); + if &request[(read_bytes - 2)..read_bytes] != b"\r\n" { + error!("Request does not end with \\r\\n."); 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; + + // Get appropriate response from either Opal or the CGI process. + let response: Vec = match get_response(&request[..read_bytes], cgi_config, &tls_stream) { + Ok((url, data)) => { + info!("\"{}\" → reply {} bytes", url, data.len()); + data } - 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); + 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) = 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, +) -> Result<(String, Vec), (Option, 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::>(); + + // 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), (Option, 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::>(); + 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 { + 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() +} diff --git a/src/server.rs b/src/server.rs deleted file mode 100644 index 2e67070..0000000 --- a/src/server.rs +++ /dev/null @@ -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, - 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 deleted file mode 100644 index 7d42cb8..0000000 --- a/src/tls.rs +++ /dev/null @@ -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 { - 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 - ))), - }, - } - } -}