From e989633e03616a9e371865dea8535021b7663c0e Mon Sep 17 00:00:00 2001 From: antanst Date: Sat, 17 Aug 2024 13:49:53 +0300 Subject: [PATCH] Initial commit. --- .gitignore | 8 + .idea/.gitignore | 5 + .idea/gemini-grs.iml | 11 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + Cargo.lock | 551 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 14 ++ README.md | 51 ++++ src/fs.rs | 9 + src/gemini/mod.rs | 63 +++++ src/gemini/server.rs | 233 ++++++++++++++++++ src/gemini/tls.rs | 70 ++++++ src/lib.rs | 4 + src/main.rs | 61 +++++ src/nanoid.rs | 11 + src/time.rs | 9 + 16 files changed, 1114 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/gemini-grs.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/fs.rs create mode 100644 src/gemini/mod.rs create mode 100644 src/gemini/server.rs create mode 100644 src/gemini/tls.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/nanoid.rs create mode 100644 src/time.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5ff07f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target + + +# Added by cargo +# +# already existing elements were commented out + +#/target diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/gemini-grs.iml b/.idea/gemini-grs.iml new file mode 100644 index 0000000..cf84ae4 --- /dev/null +++ b/.idea/gemini-grs.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..221467b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..93aaa41 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,551 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72db2f7947ecee9b03b510377e8bb9077afa27176fdbff55c51027e976fdcc48" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + +[[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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "gemini-grs" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "hex", + "log", + "nanoid", + "openssl", + "path-clean", + "url", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "libc" +version = "0.2.156" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5f43f184355eefb8d17fc948dbecf6c13be3c141f20d834ae842193a448c72a" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-sys" +version = "0.9.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a8fd0be --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gemini-grs" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.86" +env_logger = "0.11.5" +hex = "0.4.3" +log = "0.4.22" +nanoid = "0.4.0" +openssl = "0.10.66" +path-clean = "1.0.1" +url = "2.5.2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2424037 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# gemini-grs + +A Gemini protocol server written in Rust. + +## TODO + +- [x] Configuration via environment variables +- [x] Proper logging +- [x] Serve images +- [ ] Read and send file in chunks instead of serving whole file from memory +- [ ] Expand to serve other protocols (spartan, scroll, titan etc.) +- [ ] Async I/O? + +## External dependencies + +OpenSSL + +## Building + +```shell +cargo build --release +``` + +## Running + +Generate your TLS keys: + +```shell +openssl genrsa -out key.pem 2048 +openssl req -new -key key.pem -out request.pem +openssl x509 -req -days 36500 -in request.pem -signkey key.pem -out cert.pem +``` + +Environment variables with examples: + +- GEMINI_SERVER_HOSTNAME="0.0.0.0:1965" +- GEMINI_SERVER_TLS_KEY_FILENAME="/where/you/put/key.pem" +- GEMINI_SERVER_TLS_CERT_FILENAME="/where/you/put/cert.pem" +- GEMINI_SERVER_ROOT_DIRECTORY="/files/to/serve" +- RUST_LOG="debug" + +Example command: + +```shell +GEMINI_SERVER_TLS_KEY_FILENAME=/home/user/server/key.pem \ +GEMINI_SERVER_TLS_CERT_FILENAME=/home/user/server/cert.pem \ +GEMINI_SERVER_ROOT_DIRECTORY=/gemini-root \ +RUST_LOG=debug \ +./target/release/gemini-grs +``` + diff --git a/src/fs.rs b/src/fs.rs new file mode 100644 index 0000000..7dba72c --- /dev/null +++ b/src/fs.rs @@ -0,0 +1,9 @@ +use std::fs::File; +use std::io::{self, Read}; + +pub fn read_file_as_bytes(path: &str) -> io::Result> { + let mut file = File::open(path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + Ok(buffer) +} diff --git a/src/gemini/mod.rs b/src/gemini/mod.rs new file mode 100644 index 0000000..bb15654 --- /dev/null +++ b/src/gemini/mod.rs @@ -0,0 +1,63 @@ +use std::fmt; +use std::fmt::Debug; + +use anyhow::{anyhow, Context, Result}; +use url::Url; + +pub mod server; +pub mod tls; + +#[derive(Debug)] +pub struct GeminiUrl { + pub scheme: String, + pub hostname: String, + pub port: u16, + pub path: String, +} + +impl fmt::Display for GeminiUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}://{}:{}{}", + self.scheme, self.hostname, self.port, self.path + ) + } +} + +impl GeminiUrl { + /// Default scheme: "gemini://" + /// Port: 1965 + /// Path: "/" + pub fn new(input: &str) -> Result { + let input_with_scheme = if input.contains("://") { + input.to_string() + } else { + format!("gemini://{}", input) + }; + let parsed = Url::parse(&input_with_scheme) + .with_context(|| format!("Invalid request URL {}", &input_with_scheme))?; + if parsed.scheme() != "gemini" { + return Err(anyhow!( + "Invalid request (URL protocol should be gemini://)" + )); + } + let hostname = parsed + .host_str() + .ok_or_else(|| anyhow!("Invalid request (URL must have a host)"))?; + let mut parsed = GeminiUrl { + scheme: parsed.scheme().to_string(), + hostname: hostname.to_string(), + port: parsed.port().unwrap_or(1965), + path: parsed.path().to_string(), + }; + if parsed.path.is_empty() { + parsed.path = String::from("/") + }; + Ok(parsed) + } + + pub fn to_url(&self) -> Url { + Url::parse(&self.to_string()).unwrap() + } +} diff --git a/src/gemini/server.rs b/src/gemini/server.rs new file mode 100644 index 0000000..2d1c4be --- /dev/null +++ b/src/gemini/server.rs @@ -0,0 +1,233 @@ +use std::io::{Read, Write}; +use std::net::{IpAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::thread; + +use anyhow::{anyhow, Context, Result}; +use log; +use log::{error, info}; +use openssl::ssl::{SslAcceptor, SslStream}; +use path_clean::PathClean; + +use crate::fs::read_file_as_bytes; +use crate::gemini::{GeminiUrl, tls}; +use crate::nanoid::nanoid; +use crate::time::now_unix_millis; + +struct GeminiSession { + pub ip: IpAddr, + pub tls_id: Option, + pub stream: SslStream, + pub id: String, + pub timestamp: u128, +} + +struct GeminiResponse { + code: u8, + mime_type: Option, + data: Option>, +} + +impl GeminiResponse { + pub fn new( + srv_root: &str, + gemini_session: &GeminiSession, + path: &String, + ) -> Result { + let filename = resolve_path(srv_root, path)?; + if !filename.is_file() { + return Ok(GeminiResponse { + code: 51, // NOT FOUND + mime_type: None, + data: None, + }); + } + let mime_type = Self::guess_mime_type(PathBuf::from(&filename)); + if mime_type.is_none() { + log_info( + gemini_session, + &format!("Cannot guess mime type {:?}", filename), + ); + return Ok(GeminiResponse { + code: 51, + mime_type: None, + data: None, + }); + } + log_info(gemini_session, &format!("Opening file {:?}", filename)); + let file_str = filename + .to_str() + .ok_or_else(|| anyhow!("Failed to convert path to string"))?; + // let data = read_file_to_string(file_str) + // .with_context(|| format!("Failed to read file {}", path))?; + let data = read_file_as_bytes(file_str) + .with_context(|| format!("Failed to read file {}", path))?; + Ok(GeminiResponse { + code: 20, + mime_type, + data: Some(data), + }) + } + + fn guess_mime_type(path: PathBuf) -> Option { + match path.extension() { + Some(e) => match e.to_str().unwrap_or("") { + "gmi" => Some(String::from("text/gemini")), + "jpg" => Some(String::from("image/jpeg")), + "jpeg" => Some(String::from("image/jpeg")), + "png" => Some(String::from("image/png")), + _ => None, + }, + None => None, + } + } + + pub fn get_bytes(&self) -> Vec { + let header = match &self.mime_type { + Some(e) => format!("{}\t{}\r\n", self.code, e), + None => format!("{}\r\n", self.code), + }; + // Convert the header to bytes and prepare the response vector + let mut response_bytes = header.into_bytes(); + + // Extend the response with the data if it exists + if let Some(e) = &self.data { + response_bytes.extend(e); + } + response_bytes + } +} + +pub fn start_server( + bind_address: &str, + key_file: &str, + cert_file: &str, + srv_root: &str, +) -> Result<()> { + let acceptor = tls::create_tls_acceptor(key_file, cert_file).context("TLS error")?; + let listener = TcpListener::bind(bind_address).context("TCP bind error")?; + info!("Gemini server listening to {}", &bind_address); + + for stream in listener.incoming() { + // The stream will be dropped at the end of + // each loop iteration, which will close + // the stream automatically. No housekeeping + // necessary. + // Each stream is handled in a new thread. + // Async is under consideration for now. + match stream { + Err(e) => { + error!("Failed to accept connection: {:?}", e); + } + Ok(stream) => { + let acceptor = acceptor.clone(); + let srv_root = srv_root.to_string(); + + let handle = thread::spawn(move || { + if let Err(e) = initiate_connection(&acceptor, stream, &srv_root) { + error!("Connection handling error: {:?}", e); + } + }); + if let Err(e) = handle.join() { + error!("Thread join error while handling stream: {:?}", e); + } + } + } + } + Ok(()) +} + +fn log_info(session: &GeminiSession, message: &str) { + info!("{} {} {}", session.id, session.ip.to_string(), message); +} + +/// Resolve final absolute path +/// of the file to serve. +/// Final path must always begin with +/// root path (no escape outside root!). +/// If final path is a directory, append +/// index.gmi. +fn resolve_path(root_path: &str, input: &String) -> Result { + let path1 = PathBuf::from(root_path); + let mut path2 = PathBuf::from(input); + if path2.is_absolute() { + path2 = PathBuf::from(path2.strip_prefix("/")?); + } + let mut final_path = path1.join(path2).clean(); + if !final_path.starts_with(path1) { + return Err(anyhow!("Invalid path {:?} -> {:?}", input, final_path)); + } + if final_path.is_dir() { + final_path = final_path.join("index.gmi"); + } + Ok(final_path) +} + +fn handle_gemini_session(srv_root: &str, gemini_session: &mut GeminiSession) -> Result<()> { + let mut buffer = [0; 1024]; + let bytes_read = gemini_session + .stream + .read(&mut buffer) + .context("Invalid request (failed to read input stream)")?; + let received = String::from_utf8(Vec::from(&buffer[..bytes_read])) + .context("Invalid request (failed to convert input to UTF8)")?; + let request_data = received.trim(); + let url = GeminiUrl::new(request_data)?; + log_info( + gemini_session, + &format!( + "New request from {}{} {}", + gemini_session.ip, + match &gemini_session.tls_id { + Some(x) => format!(" TLS digest {}", x), + None => String::from(""), + }, + request_data + ), + ); + let response = GeminiResponse::new(srv_root, gemini_session, &url.path)?; + let response_raw = response.get_bytes(); + log_info( + gemini_session, + &format!( + "Reply Code {} Response length {} bytes", + response.code, + response_raw.len() + ), + ); + gemini_session + .stream + .write_all(&response_raw) + .context("Failed to write response to stream")?; + Ok(()) +} + +fn initiate_connection(acceptor: &SslAcceptor, stream: TcpStream, srv_root: &str) -> Result<()> { + let timestamp1 = now_unix_millis(); + + let ip = stream + .peer_addr() + .context("Failed to get peer IP address from TCP stream")? + .ip(); + + let stream = acceptor + .accept(stream) + .with_context(|| format!("{} Failed to establish SSL connection", ip))?; + + let mut gemini_session = GeminiSession { + ip, + tls_id: tls::get_peer_certificate_digest(&stream), + stream, + id: nanoid(), + timestamp: now_unix_millis(), + }; + + handle_gemini_session(srv_root, &mut gemini_session)?; + + log_info( + &gemini_session, + &format!("Finished ({}ms)", now_unix_millis() - timestamp1), + ); + + Ok(()) +} diff --git a/src/gemini/tls.rs b/src/gemini/tls.rs new file mode 100644 index 0000000..a1519e5 --- /dev/null +++ b/src/gemini/tls.rs @@ -0,0 +1,70 @@ +use std::net::TcpStream; + +use hex::ToHex; +use openssl::hash::MessageDigest; +use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream, SslVerifyMode}; + +pub fn create_tls_acceptor(key_filename: &str, cert_filename: &str) -> anyhow::Result { + let mut acceptor = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; + acceptor.set_private_key_file(key_filename, SslFiletype::PEM)?; + acceptor.set_certificate_chain_file(cert_filename)?; + + // Enable peer verification + acceptor.set_verify(SslVerifyMode::PEER); + + // Custom verification callback + acceptor.set_verify_callback(SslVerifyMode::PEER, |preverify_ok, x509_ctx| { + if preverify_ok { + return true; + } + // Check if it's a self-signed certificate + let cert = match x509_ctx.current_cert() { + Some(cert) => cert, + None => return false, + }; + + let issuer_name = cert.issuer_name().to_der(); + let subject_name = cert.subject_name().to_der(); + + let is_self_signed = match (issuer_name, subject_name) { + (Ok(issuer), Ok(subject)) => issuer == subject, + _ => return false, + }; + + if is_self_signed { + let public_key = match cert.public_key() { + Ok(key) => key, + Err(_) => return false, + }; + + if cert.verify(&public_key).is_ok() { + let now = match openssl::asn1::Asn1Time::days_from_now(0) { + Ok(time) => time, + Err(_) => return false, + }; + + if cert.not_before() <= now && cert.not_after() >= now { + return true; + } + } + } + false + }); + // Initialize the session ID context + let context = &crate::nanoid::nanoid().into_bytes(); + acceptor.set_session_id_context(context)?; + acceptor.check_private_key()?; + Ok(acceptor.build()) +} + +pub fn get_peer_certificate_digest(stream: &SslStream) -> Option { + if let Some(cert) = stream.ssl().peer_certificate() { + if let Ok(digest) = cert.digest(MessageDigest::ripemd160()) { + Some(digest.encode_hex::()) + } else { + None + } + } else { + None + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e06ae35 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod fs; +pub mod gemini; +pub mod nanoid; +pub mod time; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b037a9e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,61 @@ +use std::env; + +use anyhow::{Context, Result}; +use log::error; + +use gemini_grs::gemini; + +#[derive(Debug)] +pub struct EnvConfig { + pub hostname: String, + pub key_path: String, + pub cert_path: String, + pub server_root: String, +} + +impl EnvConfig { + pub fn from_env() -> Result { + let hostname = env::var("GEMINI_SERVER_HOSTNAME").unwrap_or("127.0.0.1:1965".to_string()); + let key_path = env::var("GEMINI_SERVER_TLS_KEY_FILENAME").with_context(|| { + "Missing environment variable GEMINI_SERVER_TLS_KEY_FILENAME".to_string() + })?; + let cert_path = env::var("GEMINI_SERVER_TLS_CERT_FILENAME").with_context(|| { + "Missing environment variable GEMINI_SERVER_TLS_CERT_FILENAME".to_string() + })?; + let server_root = env::var("GEMINI_SERVER_ROOT_DIRECTORY").with_context(|| { + "Missing environment variable GEMINI_SERVER_ROOT_DIRECTORY".to_string() + })?; + Ok(Self { + hostname, + key_path, + cert_path, + server_root, + }) + } +} + +fn main() { + let exit_code = run(); + std::process::exit(exit_code); +} + +fn run() -> i32 { + env_logger::init(); + let env_config = match EnvConfig::from_env() { + Err(e) => { + error!("{:#?}", e); + return 1; + } + Ok(x) => x, + }; + if let Err(e) = gemini::server::start_server( + &env_config.hostname, + &env_config.key_path, + &env_config.cert_path, + &env_config.server_root, + ) { + error!("{:#?}", e); + return 1; + } + 0 +} diff --git a/src/nanoid.rs b/src/nanoid.rs new file mode 100644 index 0000000..8c5b46f --- /dev/null +++ b/src/nanoid.rs @@ -0,0 +1,11 @@ +use nanoid::nanoid; + +pub fn nanoid() -> String { + let alphabet = [ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', + 'g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z', + 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T', + 'U','V','W','X','Y','Z', + ]; + nanoid!(16, &alphabet) +} \ No newline at end of file diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..9240d38 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,9 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +// returns the time since the epoch in milliseconds +pub fn now_unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() +}