From 96b38f2c97093f5a038dbb0eb36798d3aa1e5c33 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Mon, 31 Jul 2023 20:46:54 +0200 Subject: [PATCH] Support for gemini fully fleshed out --- Cargo.lock | 100 ++++++++++++++++++++++++++++++++-- Cargo.toml | 4 +- README.md | 65 ++++++++++++++++++---- src/domain/gemini.rs | 78 ++++++++++++-------------- src/domain/manager.rs | 89 ++++++++++++++++++++++++++++-- src/main.rs | 33 +++++++---- src/origin/git.rs | 2 +- src/service.rs | 29 ++++++++++ src/service/gemini.rs | 103 +++++++++++++++++++++++++++++++---- src/service/gemini/config.rs | 6 +- src/service/http.rs | 77 +++++++++++--------------- src/util.rs | 2 +- 12 files changed, 451 insertions(+), 137 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b4f3867..9e66c8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.2" @@ -158,6 +164,18 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813" +[[package]] +name = "bitvec" +version = "0.19.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55f93d0ef3363c364d5976646a38f04cf67cfe1d4c8d160cdea02cab2c116b33" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -458,9 +476,11 @@ name = "domani" version = "0.1.0" dependencies = [ "acme2", + "bytes", "clap", "env_logger", "futures", + "gemini", "gix", "handlebars", "hex", @@ -660,6 +680,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" +[[package]] +name = "funty" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" + [[package]] name = "futures" version = "0.3.28" @@ -749,6 +775,18 @@ dependencies = [ "slab", ] +[[package]] +name = "gemini" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a820f5a9ac6f433b34944dc8d17b759d5009275c8fe12f73b873153dbcd4e0" +dependencies = [ + "nom 6.1.2", + "paste", + "thiserror", + "url", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -828,7 +866,7 @@ dependencies = [ "btoi", "gix-date", "itoa", - "nom", + "nom 7.1.3", "thiserror", ] @@ -891,7 +929,7 @@ dependencies = [ "gix-sec", "log", "memchr", - "nom", + "nom 7.1.3", "once_cell", "smallvec", "thiserror", @@ -1100,7 +1138,7 @@ dependencies = [ "gix-validate", "hex", "itoa", - "nom", + "nom 7.1.3", "smallvec", "thiserror", ] @@ -1195,7 +1233,7 @@ dependencies = [ "gix-hash", "gix-transport", "maybe-async", - "nom", + "nom 7.1.3", "thiserror", ] @@ -1226,7 +1264,7 @@ dependencies = [ "gix-tempfile", "gix-validate", "memmap2", - "nom", + "nom 7.1.3", "thiserror", ] @@ -1767,6 +1805,19 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lexical-core" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec 0.5.2", + "bitflags 1.3.2", + "cfg-if", + "ryu", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.142" @@ -1954,6 +2005,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nom" +version = "6.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" +dependencies = [ + "bitvec", + "funty", + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "7.1.3" @@ -2081,6 +2145,12 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "pem" version = "2.0.1" @@ -2249,6 +2319,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + [[package]] name = "radix_trie" version = "0.2.1" @@ -2805,6 +2881,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempdir" version = "0.3.7" @@ -3121,7 +3203,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "794a32261a1f5eb6a4462c81b59cec87b5c27d5deea7dd1ac8fc781c41d226db" dependencies = [ - "arrayvec", + "arrayvec 0.7.2", ] [[package]] @@ -3508,3 +3590,9 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] + +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" diff --git a/Cargo.toml b/Cargo.toml index 937cf7e..7fcf078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ mime_guess = "2.0.4" hyper = { version = "0.14.26", features = [ "server", "stream" ]} http = "0.2.9" serde_urlencoded = "0.7.1" -tokio-util = "0.7.8" +tokio-util = { version = "0.7.8", features = [ "io" ]} acme2 = "0.5.1" openssl = "0.10.52" rustls = "0.21.1" @@ -45,6 +45,8 @@ serde_yaml = "0.9.22" rand = "0.8.5" reqwest = "0.11.18" hyper-reverse-proxy = "0.5.1" +gemini = "0.0.5" +bytes = "1.4.0" [patch.crates-io] tokio-rustls = { git = "https://code.betamike.com/micropelago/tokio-rustls.git", branch = "start-handshake-into-inner" } diff --git a/README.md b/README.md index ae8367a..a58add3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,17 @@ their DNS server. [Demo which may or may not be live](https://domani.mediocregopher.com) +Domani supports serving domains using the following protocols: + +- HTTP +- HTTPS (with SSL certificates automatically retrieved using LetsEncrypt) +- [Gemini](https://gemini.circumlunar.space/) + +Files are served as-is, with their extension being used to determine +Content-Type. If a directory is requested (e.g. `/some/dir/`) then `index.html` +will be requested if the protocol is HTTP, or `index.gmi` if the protocol is +gemini. + ## Build Domani uses nix flakes for building and setting up the development environment. @@ -60,13 +71,13 @@ domain: # An example built-in domain backed by a git repo. #git.example.com: - # kind: git - # url: "https://somewhere.com/some/repo.git" - # branch_name: main - # - # # If true then the built-in will be included in the web interface's - # # domain list, but will not be configurable in the web interface - # public: false + #kind: git + #url: "https://somewhere.com/some/repo.git" + #branch_name: main + + # If true then the built-in will be included in the web interface's + # domain list, but will not be configurable in the web interface + #public: false service: @@ -102,8 +113,9 @@ service: # https_addr is set. #http_addr: "[::]:3080" - # The address to listen for HTTPS requests on. This is optional. - #https_addr: "[::]:443" + # The address to listen for HTTPS requests on. Defaults to not having HTTP + # enabled. You can enable HTTPS by setting this to "[::]:443". + #https_addr: null #proxied_domains: @@ -117,7 +129,6 @@ service: # * dns.resolver_addr is ignored and the system-wide dns is used # #proxy.example.com: - # kind: http_proxy # url: "http://some.other.service.com" # # # Extra headers to add to proxied requests @@ -126,6 +137,25 @@ service: # value: "yet.another.service.com" # - name: X-HEADER-TO-DELETE # value: "" + + #gemini: + + # The address to listen for gemini requests on. Set this to null to disable + # gemini support. + #gemini_addr: "[::]:3965" + + #proxied_domains: + + # An example built-in domain backed by a reverse-proxy to some other + # gemini server. Requests to this domain will have connections + # transparently proxied to the backing server. + # + # Proxies are currently limited in the following ways: + # * url must be to a gemini endpoint + # * dns.resolver_addr is ignored and the system-wide dns is used + # + #proxy.example.com: + # url: "gemini://some.other.service.com" ``` The YAML config file can be passed to the Domani process via the `--config-path` @@ -159,10 +189,25 @@ nix develop Within the shell which opens you can do `cargo run` to start a local instance. +Using the default configuration, the domain `domani-test.localhost` should be +immediately available at: + +* `http://domani-test.localhost:3080` +* `gemini://domani-test.localhost:3965` + ## Roadmap +* Better web interface design. +* Tutorials aimed at beginner users. + * Support for more backends than just git repositories, including: * IPFS/IPNS * Small static files (e.g. for well-knowns) * Google Drive * Dropbox + +* Automatic HTTP/gemtext rendering for markdown files. +* Automatic HTTP rendering for gemtext files. + +* Ability to disable the web interface. +* Ability to disable HTTP completely. diff --git a/src/domain/gemini.rs b/src/domain/gemini.rs index 3dd335d..927b109 100644 --- a/src/domain/gemini.rs +++ b/src/domain/gemini.rs @@ -4,7 +4,21 @@ use crate::{domain, util}; use serde::{Deserialize, Serialize}; -use std::{fs, path, sync}; +use std::{fs, path}; + +pub trait Store { + fn get_certificate( + &self, + domain: &domain::Name, + ) -> unexpected::Result>; + + fn set_certificate( + &self, + domain: &domain::Name, + pkey: PrivateKey, + cert: Certificate, + ) -> unexpected::Result<()>; +} #[derive(Debug, Serialize, Deserialize)] struct StoredPKeyCert { @@ -30,11 +44,13 @@ impl FSStore { domain.push_str(".json"); self.cert_dir_path.join(domain) } +} +impl Store for FSStore { fn get_certificate( &self, domain: &domain::Name, - ) -> unexpected::Result<(PrivateKey, Certificate)> { + ) -> unexpected::Result> { let path = self.pkey_cert_path(domain); let file = match util::open_file(path.as_path()) @@ -42,60 +58,34 @@ impl FSStore { { Some(file) => file, None => { - let pkey = PrivateKey::new(); - let cert = Certificate::new_self_signed(&pkey, domain) - .or_unexpected_while("creating self-signed cert")?; - - let file = fs::File::create(path.as_path()) - .map_unexpected_while(|| format!("creating file {}", path.display()))?; - - let stored = StoredPKeyCert { - private_key: pkey, - cert, - }; - - serde_json::to_writer(file, &stored).or_unexpected_while("writing cert to file")?; - - return Ok((stored.private_key, stored.cert)); + return Ok(None); } }; let stored: StoredPKeyCert = serde_json::from_reader(file).or_unexpected_while("parsing json")?; - Ok((stored.private_key, stored.cert)) + Ok(Some((stored.private_key, stored.cert))) } -} -impl rustls::server::ResolvesServerCert for FSStore { - fn resolve( + fn set_certificate( &self, - client_hello: rustls::server::ClientHello<'_>, - ) -> Option> { - let domain = client_hello.server_name()?; + domain: &domain::Name, + pkey: PrivateKey, + cert: Certificate, + ) -> unexpected::Result<()> { + let path = self.pkey_cert_path(domain); - let res: unexpected::Result>> = (|| { - let domain: domain::Name = domain - .parse() - .map_unexpected_while(|| format!("parsing domain {domain}"))?; + let file = fs::File::create(path.as_path()) + .map_unexpected_while(|| format!("creating file {}", path.display()))?; - let (pkey, cert) = self - .get_certificate(&domain) - .or_unexpected_while("fetching pkey/cert")?; + let stored = StoredPKeyCert { + private_key: pkey, + cert, + }; - let pkey = rustls::sign::any_supported_type(&pkey.into()).or_unexpected()?; + serde_json::to_writer(file, &stored).or_unexpected_while("writing cert to file")?; - Ok(Some(sync::Arc::new(rustls::sign::CertifiedKey { - cert: vec![cert.into()], - key: pkey, - ocsp: None, - sct_list: None, - }))) - })(); - - res.unwrap_or_else(|err| { - log::error!("Unexpected error getting cert for domain {domain}: {err}"); - None - }) + return Ok(()); } } diff --git a/src/domain/manager.rs b/src/domain/manager.rs index 1ac1d56..74bf725 100644 --- a/src/domain/manager.rs +++ b/src/domain/manager.rs @@ -1,4 +1,4 @@ -use crate::domain::{self, acme, checker, store}; +use crate::domain::{self, acme, checker, gemini, store, tls}; use crate::error::unexpected::{self, Mappable}; use crate::{origin, task_stack, util}; @@ -140,7 +140,7 @@ impl From for SyncWithSettingsError { pub type GetAcmeHttp01ChallengeKeyError = acme::manager::GetHttp01ChallengeKeyError; //#[mockall::automock] -pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert { +pub trait Manager: Sync + Send { fn get_settings(&self, domain: &domain::Name) -> Result; fn get_file<'store>( @@ -178,6 +178,7 @@ pub struct ManagerImpl { domain_store: Box, domain_checker: checker::DNSChecker, acme_manager: Option>, + gemini_store: Option>, } impl ManagerImpl { @@ -185,12 +186,14 @@ impl ManagerImpl { OriginStore: origin::Store + Send + Sync + 'static, DomainStore: store::Store + Send + Sync + 'static, AcmeManager: acme::manager::Manager + Send + Sync + 'static, + GeminiStore: gemini::Store + Send + Sync + 'static, >( task_stack: &mut task_stack::TaskStack, origin_store: OriginStore, domain_store: DomainStore, domain_checker: checker::DNSChecker, acme_manager: Option, + gemini_store: Option, ) -> sync::Arc { let manager = sync::Arc::new(ManagerImpl { origin_store: Box::from(origin_store), @@ -198,6 +201,7 @@ impl ManagerImpl { domain_checker, acme_manager: acme_manager .map(|m| Box::new(m) as Box), + gemini_store: gemini_store.map(|m| Box::new(m) as Box), }); task_stack.push_spawn(|canceller| { @@ -248,6 +252,23 @@ impl ManagerImpl { } } } + + fn sync_gemini_cert(&self, domain: &domain::Name) -> unexpected::Result<()> { + if let Some(ref gemini_store) = self.gemini_store { + if let Some(_) = gemini_store.get_certificate(domain).or_unexpected()? { + return Ok(()); + } + + // no cert/key stored for the domain, generate and store it + let pkey = tls::PrivateKey::new(); + let cert = tls::Certificate::new_self_signed(&pkey, domain) + .or_unexpected_while("creating self-signed cert")?; + + gemini_store.set_certificate(domain, pkey, cert)?; + } + + Ok(()) + } } impl Manager for ManagerImpl { @@ -300,6 +321,8 @@ impl Manager for ManagerImpl { self.domain_store.set(&domain, &settings)?; + self.sync_gemini_cert(&domain)?; + self.sync_https_cert(domain).await?; Ok(()) @@ -329,14 +352,22 @@ impl Manager for ManagerImpl { } } -impl rustls::server::ResolvesServerCert for ManagerImpl { +pub struct HttpsCertResolver(sync::Arc); + +impl From> for HttpsCertResolver { + fn from(mgr: sync::Arc) -> Self { + Self(mgr) + } +} + +impl rustls::server::ResolvesServerCert for HttpsCertResolver { fn resolve( &self, client_hello: rustls::server::ClientHello<'_>, ) -> Option> { let domain = client_hello.server_name()?; - match self.acme_manager.as_ref()?.get_certificate(domain) { + match (self.0).acme_manager.as_ref()?.get_certificate(domain) { Err(acme::manager::GetCertificateError::NotFound) => { log::warn!("No cert found for domain {domain}"); Ok(None) @@ -360,3 +391,53 @@ impl rustls::server::ResolvesServerCert for ManagerImpl { }) } } + +pub struct GeminiCertResolver(sync::Arc); + +impl From> for GeminiCertResolver { + fn from(mgr: sync::Arc) -> Self { + Self(mgr) + } +} + +impl rustls::server::ResolvesServerCert for GeminiCertResolver { + fn resolve( + &self, + client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + let domain = client_hello.server_name()?; + + let domain: domain::Name = match domain.parse() { + Ok(domain) => domain, + Err(e) => { + log::warn!("failed to parse domain name {domain}: {e}"); + return None; + } + }; + + let res: unexpected::Result>> = (|| { + (self.0) + .gemini_store + .as_ref() + .or_unexpected_while("gemini store is not enabled")? + .get_certificate(&domain) + .or_unexpected_while("fetching pkey/cert")? + .map(|(pkey, cert)| { + let pkey = rustls::sign::any_supported_type(&pkey.into()).or_unexpected()?; + + Ok(sync::Arc::new(rustls::sign::CertifiedKey { + cert: vec![cert.into()], + key: pkey, + ocsp: None, + sct_list: None, + })) + }) + .transpose() + })(); + + res.unwrap_or_else(|err| { + log::error!("Unexpected error getting cert for domain {domain}: {err}"); + None + }) + } +} diff --git a/src/main.rs b/src/main.rs index 3b950d5..ab34d16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use clap::Parser; use futures::stream::StreamExt; use signal_hook_tokio::Signals; -use std::{path, sync}; +use std::path; #[derive(Parser, Debug)] #[command(version)] @@ -87,6 +87,8 @@ async fn main() { return; }; + let gemini_enabled = config.service.gemini.gemini_addr.is_some(); + let origin_store = domani::origin::git::FSStore::new(&config.origin) .expect("git origin store initialization failed"); @@ -128,9 +130,14 @@ async fn main() { None }; - let domain_gemini_store = - domani::domain::gemini::FSStore::new(&config.domain.store_dir_path.join("gemini")) - .unwrap_or_else(|e| panic!("domain gemini store initialization failed: {e}")); + let domain_gemini_store = if gemini_enabled { + Some( + domani::domain::gemini::FSStore::new(&config.domain.store_dir_path.join("gemini")) + .unwrap_or_else(|e| panic!("domain gemini store initialization failed: {e}")), + ) + } else { + None + }; let mut task_stack = domani::task_stack::TaskStack::new(); @@ -140,20 +147,24 @@ async fn main() { domain_store, domain_checker, domain_acme_manager, + domain_gemini_store, ); - let _ = domani::service::http::new( + let _ = domani::service::http::Service::new( &mut task_stack, domain_manager.clone(), - domain_manager.clone(), + domani::domain::manager::HttpsCertResolver::from(domain_manager.clone()), config.service.clone(), ); - let _ = domani::service::gemini::Service::new( - &mut task_stack, - sync::Arc::new(domain_gemini_store), - config.service, - ); + if gemini_enabled { + let _ = domani::service::gemini::Service::new( + &mut task_stack, + domain_manager.clone(), + domani::domain::manager::GeminiCertResolver::from(domain_manager.clone()), + config.service, + ); + } let mut signals = Signals::new(signal_hook::consts::TERM_SIGNALS).expect("initializing signals failed"); diff --git a/src/origin/git.rs b/src/origin/git.rs index 3d00758..6535be4 100644 --- a/src/origin/git.rs +++ b/src/origin/git.rs @@ -332,7 +332,7 @@ impl super::Store for FSStore { // TODO this is very not ideal, the whole file is first read totally into memory, and then // that is cloned. - let data = file_object.data.clone(); + let data = bytes::Bytes::copy_from_slice(file_object.data.as_slice()); Ok(Box::pin(stream::once(async move { Ok(data) }))) } } diff --git a/src/service.rs b/src/service.rs index 280977c..724137f 100644 --- a/src/service.rs +++ b/src/service.rs @@ -3,3 +3,32 @@ pub mod gemini; pub mod http; pub use config::*; + +use std::borrow; + +fn append_index_to_path<'path, 'index>( + path: &'path str, + index: &'index str, +) -> borrow::Cow<'path, str> { + if path.len() == 0 { + let mut path = String::with_capacity(1 + index.len()); + path.push('/'); + path.push_str(index); + return borrow::Cow::Owned(path); + } + + if path.ends_with('/') { + let mut indexed_path = String::with_capacity(path.len() + index.len()); + indexed_path.push_str(path.as_ref()); + indexed_path.push_str(index); + return borrow::Cow::Owned(indexed_path); + } + + borrow::Cow::Borrowed(path) +} + +fn guess_mime(path: &str) -> String { + mime_guess::from_path(path) + .first_or_octet_stream() + .to_string() +} diff --git a/src/service/gemini.rs b/src/service/gemini.rs index 0f24a74..904bc0f 100644 --- a/src/service/gemini.rs +++ b/src/service/gemini.rs @@ -4,12 +4,13 @@ mod proxy; pub use config::*; use crate::error::unexpected::{self, Mappable}; -use crate::{domain, service, task_stack}; +use crate::{domain, service, task_stack, util}; use std::sync; use tokio_util::sync::CancellationToken; pub struct Service { + domain_manager: sync::Arc, cert_resolver: sync::Arc, config: service::Config, } @@ -24,19 +25,92 @@ enum HandleConnError { } impl Service { - pub fn new( + pub fn new( task_stack: &mut task_stack::TaskStack, - cert_resolver: sync::Arc, + domain_manager: sync::Arc, + cert_resolver: CertResolver, config: service::Config, - ) -> sync::Arc { + ) -> sync::Arc + where + CertResolver: rustls::server::ResolvesServerCert + 'static, + { let service = sync::Arc::new(Service { - cert_resolver, + domain_manager, + cert_resolver: sync::Arc::from(cert_resolver), config, }); task_stack.push_spawn(|canceller| listen(service.clone(), canceller)); service } + async fn respond_conn( + &self, + w: W, + code: &str, + meta: &str, + body: Option, + ) -> unexpected::Result<()> + where + W: tokio::io::AsyncWrite + Unpin, + { + use tokio::io::{copy, AsyncWriteExt, BufWriter}; + let mut w = BufWriter::new(w); + + w.write_all(code.as_bytes()).await.or_unexpected()?; + w.write_all(" ".as_bytes()).await.or_unexpected()?; + w.write_all(meta.as_bytes()).await.or_unexpected()?; + w.write_all("\r\n".as_bytes()).await.or_unexpected()?; + + if let Some(body) = body { + let mut body = tokio_util::io::StreamReader::new(body); + copy(&mut body, &mut w).await.or_unexpected()?; + } + + w.flush().await.or_unexpected()?; + + Ok(()) + } + + async fn serve_conn(&self, domain: &domain::Name, conn: IO) -> Result<(), HandleConnError> + where + IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + use tokio::io::*; + + let (r, w) = split(conn); + let mut r = BufReader::new(r); + + let mut req = String::with_capacity(64); + r.read_line(&mut req) + .await + .map_err(|e| HandleConnError::ClientError(format!("failed to read request: {e}")))?; + + let req = gemini::request::parse::request(req.as_bytes()) + .map(|(_, req)| req) + .map_err(|e| HandleConnError::ClientError(format!("failed to parse request: {e}")))? + .into_gemini_request() + .map_err(|e| HandleConnError::ClientError(format!("failed to parse request: {e}")))?; + + let path = service::append_index_to_path(req.path(), "index.gmi"); + + let f = match self.domain_manager.get_file(domain, &path) { + Ok(f) => f, + Err(domain::manager::GetFileError::DomainNotFound) => { + return Err(unexpected::Error::from("domain not found when serving file").into()) + } + Err(domain::manager::GetFileError::FileNotFound) => { + return Ok(self.respond_conn(w, "51", "File not found", None).await?) + } + Err(domain::manager::GetFileError::Unexpected(e)) => return Err(e.into()), + }; + + let content_type = service::guess_mime(&path); + + Ok(self + .respond_conn(w, "20", content_type.as_str(), Some(f)) + .await?) + } + async fn proxy_conn( &self, proxied_domain: &ConfigProxiedDomain, @@ -59,7 +133,7 @@ impl Service { async fn handle_conn( &self, conn: tokio::net::TcpStream, - _tls_config: sync::Arc, + tls_config: sync::Arc, ) -> Result<(), HandleConnError> { let teed_conn = { let (r, w) = tokio::io::split(conn); @@ -92,9 +166,8 @@ impl Service { return Ok(()); } - return Err(HandleConnError::ClientError(format!( - "unknown domain {domain}" - ))); + let conn = start.into_stream(tls_config).await.or_unexpected()?; + self.serve_conn(&domain, conn).await } Err(err) => { return Err(unexpected::Error::from( @@ -110,20 +183,26 @@ async fn listen( service: sync::Arc, canceller: CancellationToken, ) -> unexpected::Result<()> { + let addr = &service + .config + .gemini + .gemini_addr + .expect("listen called with gemini_addr not set"); + let tls_config = sync::Arc::new( rustls::server::ServerConfig::builder() .with_safe_defaults() - .with_no_client_auth() // TODO maybe this isn't right? + .with_no_client_auth() .with_cert_resolver(service.cert_resolver.clone()), ); log::info!( "Listening on gemini://{}:{}", &service.config.primary_domain.clone(), - &service.config.gemini.gemini_addr.port(), + addr, ); - let listener = tokio::net::TcpListener::bind(service.config.gemini.gemini_addr) + let listener = tokio::net::TcpListener::bind(addr) .await .or_unexpected_while("binding tcp socket")?; diff --git a/src/service/gemini/config.rs b/src/service/gemini/config.rs index 649ee40..d49283e 100644 --- a/src/service/gemini/config.rs +++ b/src/service/gemini/config.rs @@ -6,8 +6,8 @@ use serde_with::{serde_as, TryFromInto}; use std::{collections, net, str::FromStr}; -fn default_gemini_addr() -> net::SocketAddr { - net::SocketAddr::from_str("[::]:3965").unwrap() +fn default_gemini_addr() -> Option { + Some(net::SocketAddr::from_str("[::]:3965").unwrap()) } #[derive(Deserialize, Serialize, Clone)] @@ -65,7 +65,7 @@ pub struct ConfigProxiedDomain { #[derive(Deserialize, Serialize, Clone)] pub struct Config { #[serde(default = "default_gemini_addr")] - pub gemini_addr: net::SocketAddr, + pub gemini_addr: Option, pub proxied_domains: collections::HashMap, } diff --git a/src/service/http.rs b/src/service/http.rs index b7206a0..7605f44 100644 --- a/src/service/http.rs +++ b/src/service/http.rs @@ -23,31 +23,6 @@ pub struct Service { config: service::Config, } -pub fn new( - task_stack: &mut task_stack::TaskStack, - domain_manager: sync::Arc, - cert_resolver: sync::Arc, - config: service::Config, -) -> sync::Arc { - let https_enabled = config.http.https_addr.is_some(); - - let service = sync::Arc::new(Service { - domain_manager: domain_manager.clone(), - cert_resolver, - handlebars: tpl::get(), - config, - }); - - task_stack.push_spawn(|canceller| tasks::listen_http(service.clone(), canceller)); - - if https_enabled { - task_stack.push_spawn(|canceller| tasks::listen_https(service.clone(), canceller)); - task_stack.push_spawn(|canceller| tasks::cert_refresher(service.clone(), canceller)); - } - - service -} - #[derive(Serialize)] struct BasePresenter<'a, T> { page_name: &'a str, @@ -77,15 +52,39 @@ struct DomainSyncArgs { url_encoded_domain_settings: util::UrlEncodedDomainSettings, } -impl<'svc> Service { - fn serve(&self, status_code: u16, path: &str, body: Body) -> Response { - let content_type = mime_guess::from_path(path) - .first_or_octet_stream() - .to_string(); +impl Service { + pub fn new( + task_stack: &mut task_stack::TaskStack, + domain_manager: sync::Arc, + cert_resolver: CertResolver, + config: service::Config, + ) -> sync::Arc + where + CertResolver: rustls::server::ResolvesServerCert + 'static, + { + let https_enabled = config.http.https_addr.is_some(); + let service = sync::Arc::new(Service { + domain_manager: domain_manager.clone(), + cert_resolver: sync::Arc::from(cert_resolver), + handlebars: tpl::get(), + config, + }); + + task_stack.push_spawn(|canceller| tasks::listen_http(service.clone(), canceller)); + + if https_enabled { + task_stack.push_spawn(|canceller| tasks::listen_https(service.clone(), canceller)); + task_stack.push_spawn(|canceller| tasks::cert_refresher(service.clone(), canceller)); + } + + service + } + + fn serve(&self, status_code: u16, path: &str, body: Body) -> Response { match Response::builder() .status(status_code) - .header("Content-Type", content_type) + .header("Content-Type", service::guess_mime(path)) .body(body) { Ok(res) => res, @@ -161,20 +160,10 @@ impl<'svc> Service { } async fn serve_origin(&self, domain: domain::Name, req: Request) -> Response { - let mut path_owned; - let path = req.uri().path(); + let path = service::append_index_to_path(req.uri().path(), "index.html"); - let path = match path.ends_with('/') { - true => { - path_owned = String::from(path); - path_owned.push_str("index.html"); - path_owned.as_str() - } - false => path, - }; - - match self.domain_manager.get_file(&domain, path) { - Ok(f) => self.serve(200, path, Body::wrap_stream(f)), + match self.domain_manager.get_file(&domain, &path) { + Ok(f) => self.serve(200, &path, Body::wrap_stream(f)), Err(domain::manager::GetFileError::DomainNotFound) => { return self.render_error_page(404, "Domain not found") } diff --git a/src/util.rs b/src/util.rs index c29a995..e8674ac 100644 --- a/src/util.rs +++ b/src/util.rs @@ -10,6 +10,6 @@ pub fn open_file(path: &path::Path) -> io::Result> { } } -pub type BoxByteStream = futures::stream::BoxStream<'static, io::Result>>; +pub type BoxByteStream = futures::stream::BoxStream<'static, io::Result>; pub type BoxFuture<'a, O> = pin::Pin + Send + 'a>>;