From c8176c819ff40c3f430fca6d819923f3cef03dca Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Fri, 21 Jul 2023 18:10:48 +0200 Subject: [PATCH] Got basic gemini listening working. Proxying does not yet work, nor does serving the origin --- .dev-config.yml | 4 + Cargo.lock | 4 +- Cargo.toml | 2 +- flake.nix | 2 + src/main.rs | 6 ++ src/service.rs | 1 + src/service/config.rs | 4 +- src/service/gemini.rs | 147 +++++++++++++++++++++++++++++++++++ src/service/gemini/config.rs | 79 +++++++++++++++++++ src/service/http/config.rs | 12 +-- 10 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 src/service/gemini.rs create mode 100644 src/service/gemini/config.rs diff --git a/.dev-config.yml b/.dev-config.yml index 74efa3c..39b3d1a 100644 --- a/.dev-config.yml +++ b/.dev-config.yml @@ -21,6 +21,10 @@ service: value: hi - name: user-agent value: "" + gemini: + proxied_domains: + localhost: + url: gemini://127.0.0.1:1965 passphrase: foobar dns_records: - kind: A diff --git a/Cargo.lock b/Cargo.lock index 46bd766..4507cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2963,9 +2963,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls", "tokio", diff --git a/Cargo.toml b/Cargo.toml index c43a8c7..ecc3faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ serde_json = "1.0.96" trust-dns-client = "0.22.0" mockall = "0.11.4" thiserror = "1.0.40" -tokio = { version = "1.28.1", features = [ "full" ]} +tokio = { version = "1.28.1", features = [ "full", "net" ]} signal-hook = "0.3.15" futures = "0.3.28" signal-hook-tokio = { version = "0.3.1", features = [ "futures-v0_3" ]} diff --git a/flake.nix b/flake.nix index c8be193..b5d32cf 100644 --- a/flake.nix +++ b/flake.nix @@ -44,6 +44,8 @@ pkgs.stdenv.cc pkgs.openssl toolchain + + pkgs.gmni ]; shellHook = '' source $(pwd)/.env.dev diff --git a/src/main.rs b/src/main.rs index a28c321..888128a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,6 +142,12 @@ async fn main() { &mut task_stack, domain_manager.clone(), domain_manager.clone(), + config.service.clone(), + ); + + let _ = domani::service::gemini::Service::new( + &mut task_stack, + domain_manager.clone(), config.service, ); diff --git a/src/service.rs b/src/service.rs index f038173..280977c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,4 +1,5 @@ mod config; +pub mod gemini; pub mod http; pub use config::*; diff --git a/src/service/config.rs b/src/service/config.rs index 7dc9ffc..1de8a2f 100644 --- a/src/service/config.rs +++ b/src/service/config.rs @@ -24,7 +24,7 @@ impl From for domain::checker::DNSRecord { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct Config { pub passphrase: String, pub dns_records: Vec, @@ -32,4 +32,6 @@ pub struct Config { pub primary_domain: domain::Name, #[serde(default)] pub http: service::http::Config, + #[serde(default)] + pub gemini: service::gemini::Config, } diff --git a/src/service/gemini.rs b/src/service/gemini.rs new file mode 100644 index 0000000..7ddcc00 --- /dev/null +++ b/src/service/gemini.rs @@ -0,0 +1,147 @@ +mod config; + +pub use config::*; + +use crate::error::unexpected::{self, Mappable}; +use crate::{domain, service, task_stack}; + +use std::sync; +use tokio_util::sync::CancellationToken; + +pub struct Service { + cert_resolver: sync::Arc, + config: service::Config, +} + +#[derive(thiserror::Error, Debug)] +enum HandleConnError { + #[error("client error: {0}")] + ClientError(String), + + #[error(transparent)] + Unexpected(#[from] unexpected::Error), +} + +impl Service { + pub fn new( + task_stack: &mut task_stack::TaskStack, + cert_resolver: sync::Arc, + config: service::Config, + ) -> sync::Arc { + let service = sync::Arc::new(Service { + cert_resolver, + config, + }); + task_stack.push_spawn(|canceller| listen(service.clone(), canceller)); + service + } + + async fn proxy_conn( + &self, + proxied_domain: &ConfigProxiedDomain, + mut conn: tokio::net::TcpStream, + ) -> unexpected::Result<()> { + let mut proxy_conn = tokio::net::TcpStream::connect(&proxied_domain.url.addr) + .await + .map_unexpected_while(|| { + format!("failed to connect to proxy {}", proxied_domain.url.url,) + })?; + + _ = tokio::io::copy_bidirectional(&mut conn, &mut proxy_conn).await; + + Ok(()) + } + + async fn handle_conn( + &self, + conn: tokio::net::TcpStream, + tls_conn: rustls::ServerConnection, + ) -> Result<(), HandleConnError> { + let acceptor = + tokio_rustls::LazyConfigAcceptor::new(rustls::server::Acceptor::default(), conn); + futures::pin_mut!(acceptor); + + match acceptor.as_mut().await { + Ok(start) => { + let client_hello = start.client_hello(); + + let domain = client_hello.server_name().ok_or_else(|| { + HandleConnError::ClientError("missing SNI in ClientHello".to_string()) + })?; + + let domain: domain::Name = domain.parse().map_err(|e| { + HandleConnError::ClientError(format!( + "parsing domain {domain}, provided in SNI: {e}" + )) + })?; + + // If the domain should be proxied, then proxy it + if let Some(proxied_domain) = self.config.gemini.proxied_domains.get(&domain) { + let conn = acceptor + .take_io() + .expect("failed to take back underlying TCP connection"); + + self.proxy_conn(proxied_domain, conn).await?; + return Ok(()); + } + + return Err(HandleConnError::ClientError(format!( + "unknown domain {domain}" + ))); + } + Err(err) => { + return Err(unexpected::Error::from( + format!("failed to accept TLS connection: {err}").as_str(), + ) + .into()) + } + } + } +} + +async fn listen( + service: sync::Arc, + canceller: CancellationToken, +) -> unexpected::Result<()> { + let tls_config = sync::Arc::new( + rustls::server::ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() // TODO maybe this isn't right? + .with_cert_resolver(service.cert_resolver.clone()), + ); + + log::info!( + "Listening on gemini://{}:{}", + &service.config.primary_domain.clone(), + &service.config.gemini.gemini_addr.port(), + ); + + let listener = tokio::net::TcpListener::bind(service.config.gemini.gemini_addr) + .await + .or_unexpected_while("binding tcp socket")?; + + loop { + let (conn, addr) = tokio::select! { + res = listener.accept() => res.or_unexpected_while("accepting connection")?, + _ = canceller.cancelled() => return Ok(()), + }; + + let service = service.clone(); + let tls_config = tls_config.clone(); + + tokio::spawn(async move { + let tls_conn = rustls::ServerConnection::new(tls_config) + .expect("failed to initialize TLS connection state"); + + match service.handle_conn(conn, tls_conn).await { + Ok(_) => (), + Err(HandleConnError::ClientError(e)) => { + log::warn!("Bad request from connection {addr}: {e}") + } + Err(HandleConnError::Unexpected(e)) => { + log::error!("Server error handling connection {addr}: {e}") + } + } + }); + } +} diff --git a/src/service/gemini/config.rs b/src/service/gemini/config.rs new file mode 100644 index 0000000..649ee40 --- /dev/null +++ b/src/service/gemini/config.rs @@ -0,0 +1,79 @@ +use crate::domain; +use crate::error::unexpected::{self, Mappable}; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, TryFromInto}; + +use std::{collections, net, str::FromStr}; + +fn default_gemini_addr() -> net::SocketAddr { + net::SocketAddr::from_str("[::]:3965").unwrap() +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct ConfigProxiedDomainUrl { + pub url: String, + pub addr: String, +} + +impl From for String { + fn from(url: ConfigProxiedDomainUrl) -> Self { + url.url + } +} + +impl TryFrom for ConfigProxiedDomainUrl { + type Error = unexpected::Error; + + fn try_from(url: String) -> Result { + // use http's implementation, should be the same + let parsed = http::Uri::from_str(url.as_str()) + .map_unexpected_while(|| format!("parsing proxy url {url}"))?; + + let scheme = parsed.scheme().map_unexpected_while(|| { + format!("expected a scheme of gemini in the proxy url {url}") + })?; + + if scheme != "gemini" { + return Err(unexpected::Error::from( + format!("scheme of proxy url {url} should be 'gemini'",).as_str(), + )); + } + + match parsed.authority() { + None => Err(unexpected::Error::from( + format!("proxy url {url} should have a host",).as_str(), + )), + Some(authority) => { + let port = authority.port().map(|p| p.as_u16()).unwrap_or(1965); + Ok(ConfigProxiedDomainUrl { + url: url, + addr: format!("{}:{port}", authority.host()), + }) + } + } + } +} + +#[serde_as] +#[derive(Deserialize, Serialize, Clone)] +pub struct ConfigProxiedDomain { + #[serde_as(as = "TryFromInto")] + pub url: ConfigProxiedDomainUrl, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct Config { + #[serde(default = "default_gemini_addr")] + pub gemini_addr: net::SocketAddr, + pub proxied_domains: collections::HashMap, +} + +impl Default for Config { + fn default() -> Self { + Self { + gemini_addr: default_gemini_addr(), + proxied_domains: Default::default(), + } + } +} diff --git a/src/service/http/config.rs b/src/service/http/config.rs index a41ee5e..7212efa 100644 --- a/src/service/http/config.rs +++ b/src/service/http/config.rs @@ -7,10 +7,10 @@ use serde_with::{serde_as, TryFromInto}; use std::{collections, net, str::FromStr}; fn default_http_addr() -> net::SocketAddr { - net::SocketAddr::from_str("[::]:3030").unwrap() + net::SocketAddr::from_str("[::]:3080").unwrap() } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub enum ConfigFormMethod { GET, POST, @@ -60,7 +60,7 @@ impl TryFrom for ConfigProxiedDomainUrl { fn try_from(url: String) -> Result { let parsed = http::Uri::from_str(url.as_str()) - .or_unexpected_while("parsing proxy url {proxy_url}")?; + .map_unexpected_while(|| format!("parsing proxy url {url}"))?; let scheme = parsed .scheme() @@ -76,7 +76,7 @@ impl TryFrom for ConfigProxiedDomainUrl { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct ConfigProxiedDomainRequestHeader { pub name: String, pub value: String, @@ -129,7 +129,7 @@ impl TryFrom> for ConfigProxiedDomainReque } #[serde_as] -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct ConfigProxiedDomain { #[serde_as(as = "TryFromInto")] pub url: ConfigProxiedDomainUrl, @@ -139,7 +139,7 @@ pub struct ConfigProxiedDomain { pub request_headers: ConfigProxiedDomainRequestHeaders, } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct Config { #[serde(default = "default_http_addr")] pub http_addr: net::SocketAddr,