diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cda643..c542d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +Tokio has been somewhat purged. + +The gemini module now includes a document builder and rendering tool. + ## 0.3.0 ### maj diff --git a/Cargo.toml b/Cargo.toml index 98025f6..d707c35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,36 +15,41 @@ num = "0.2" num-derive = "0.3" num-traits = "0.2" rustls = { version = "0.18", optional = true, features = ["dangerous_configuration"] } +tokio-rustls = { version = "0.14", features = ["dangerous_configuration"], optional = true } webpki = { version = "0.21.0", optional = true } webpki-roots = { version = "0.20", optional = true } -tokio-rustls = { version = "0.14", features = ["dangerous_configuration"], optional = true } -tokio-io-timeout = "0.4" +tokio = { version = "0.2", features = ["full"], optional = true } +async-tls = { default-features = false, optional = true, git = "https://github.com/Xe/async-tls" } +async-std = { version = "1.6", optional = true } log = "0.4" url = "2" thiserror = "1" structopt = "0.3" +once_cell = "1.4" [dev-dependencies] pretty_env_logger = "0.4" -[dependencies.tokio] -version = "0.2" -features = [ - "macros", - "net", - "tcp", - "io-util", - "rt-threaded", - "time", - "stream" -] -optional = true - [features] default = ["client", "server"] -client = ["rustls", "webpki", "webpki-roots", "tokio", "tokio-rustls"] -server = ["rustls", "webpki", "webpki-roots", "tokio", "async-trait", "tokio-rustls"] +client = [ + "tokio-rustls", + "webpki", + "webpki-roots", + "tokio", + "async-std", + "async-tls/client" +] + +server = [ + "rustls", + "webpki", + "webpki-roots", + "async-trait", + "async-std", + "async-tls/server" +] [workspace] members = [ diff --git a/VERSION b/VERSION index 0d91a54..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 diff --git a/majc/Cargo.toml b/majc/Cargo.toml index ae85c61..0b7048f 100644 --- a/majc/Cargo.toml +++ b/majc/Cargo.toml @@ -11,7 +11,6 @@ cursive = "0.15" log = "0.4" url = "2" webpki = "0.21.0" -tokio-rustls = { version = "0.14", features = ["dangerous_configuration"] } rustls = { version = "0.18", features = ["dangerous_configuration"] } smol = { version = "0.3", features = ["tokio02"] } diff --git a/majc/src/gemini.rs b/majc/src/gemini.rs index 7ccb3ac..d779b03 100644 --- a/majc/src/gemini.rs +++ b/majc/src/gemini.rs @@ -7,7 +7,7 @@ use cursive::{ }; use maj::{self, Response}; use std::str; -use tokio_rustls::rustls::ClientConfig; +use rustls::ClientConfig; /// The state of the browser. #[derive(Clone)] diff --git a/majc/src/tls.rs b/majc/src/tls.rs index 7a6e853..95a2b7b 100644 --- a/majc/src/tls.rs +++ b/majc/src/tls.rs @@ -1,4 +1,3 @@ -use tokio_rustls::rustls; use std::sync::Arc; pub fn config() -> rustls::ClientConfig { diff --git a/site/Cargo.toml b/site/Cargo.toml index 87f4df9..d129d87 100644 --- a/site/Cargo.toml +++ b/site/Cargo.toml @@ -7,12 +7,12 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -structopt = "0.3" -tokio = { version = "0.2", features = ["rt-threaded", "macros"] } -tokio-rustls = { version = "0.14", features = ["dangerous_configuration"] } -async-trait = "0" -pretty_env_logger = "0.4" -log = "0" anyhow = "1" +async-std = "1.5" +async-trait = "0" +log = "0" +pretty_env_logger = "0.4" +rustls = { version = "0.18", features = ["dangerous_configuration"] } +structopt = "0.3" -maj = { path = ".." } \ No newline at end of file +maj = { path = "..", features = ["server"], default-features = false } \ No newline at end of file diff --git a/site/src/main.rs b/site/src/main.rs index e96d57a..95e8e10 100644 --- a/site/src/main.rs +++ b/site/src/main.rs @@ -1,9 +1,19 @@ +use async_std::task; +use maj::{ + gemini::Builder, + route, seg, + server::{Error, Handler as MajHandler, Request}, + split, Response, +}; +use rustls::internal::pemfile::{certs, rsa_private_keys}; +use rustls::{ + AllowAnyAnonymousOrAuthenticatedClient, Certificate, PrivateKey, RootCertStore, ServerConfig, +}; use std::fs::File; use std::io::{self, BufReader}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use structopt::StructOpt; -use tokio_rustls::rustls::internal::pemfile::{certs, rsa_private_keys}; -use tokio_rustls::rustls::{Certificate, NoClientAuth, PrivateKey, ServerConfig}; #[derive(StructOpt, Debug)] struct Options { @@ -42,29 +52,34 @@ fn load_keys(path: &Path) -> io::Result> { .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid key")) } -#[tokio::main] -async fn main() -> Result<(), maj::server::Error> { +fn main() -> Result<(), maj::server::Error> { pretty_env_logger::init(); let opts = Options::from_args(); let certs = load_certs(&opts.cert)?; let mut keys = load_keys(&opts.key)?; - log::info!("{:?}", opts); + log::info!( + "serving gemini://{} on {}:{}", + opts.hostname, + opts.host, + opts.port + ); - let mut config = ServerConfig::new(NoClientAuth::new()); + let mut config = ServerConfig::new(AllowAnyAnonymousOrAuthenticatedClient::new( + RootCertStore::empty(), + )); config .set_single_cert(certs, keys.remove(0)) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; - maj::server::serve( - &Handler { + task::block_on(maj::server::serve( + Arc::new(Handler { hostname: opts.hostname, - }, + }), config, opts.host, opts.port, - ) - .await?; + ))?; Ok(()) } @@ -73,50 +88,48 @@ struct Handler { hostname: String, } -fn index() -> Result { +async fn index() -> Result { let msg = include_bytes!("index.gmi"); - - Ok(maj::Response { - status: maj::StatusCode::Success, - meta: "text/gemini".to_string(), - body: msg.to_vec(), - }) + Ok(Response::gemini(msg.to_vec())) } -fn majc() -> Result { +async fn majc() -> Result { let msg = include_bytes!("majc.gmi"); + Ok(Response::gemini(msg.to_vec())) +} - Ok(maj::Response { - status: maj::StatusCode::Success, - meta: "text/gemini".to_string(), - body: msg.to_vec(), - }) +async fn need_cert(req: Request) -> Result { + match req.certs { + None => Ok(Response::need_cert("test")), + Some(certs) => Ok(Response::render( + Builder::new() + .heading(1, "Cert test") + .text(format!("{:?}", certs)) + .build(), + )), + } } #[async_trait::async_trait] -impl maj::server::Handler for Handler { - async fn handle(&self, r: maj::server::Request) -> Result { - if r.url.has_host() && r.url.host_str().unwrap().to_string() != self.hostname { - return Ok(maj::Response { - status: maj::StatusCode::ProxyRequestRefused, - meta: "Wrong host".to_string(), - body: vec![], - }); +impl MajHandler for Handler { + async fn handle(&self, req: Request) -> Result { + if req.url.has_host() && req.url.host_str().unwrap().to_string() != self.hostname { + return Ok(Response::no_proxy()); } - match r.url.path() { - "" => Ok(maj::Response { - status: maj::StatusCode::PermanentRedirect, - meta: format!("gemini://{}/", self.hostname), - body: vec![], - }), - "/" => index(), - "/majc" => majc(), - _ => Ok(maj::Response { - status: maj::StatusCode::NotFound, - meta: "Not found".to_string(), - body: vec![], - }), + if req.url.path() == "" { + return Ok(Response::perm_redirect(format!( + "gemini://{}/", + self.hostname + ))); } + + route!(req.url.path(), { + (/) => index().await; + (/"cert") => need_cert(req).await; + (/"majc") => majc().await; + }); + + Ok(Response::not_found()) } } diff --git a/site/src/majc.gmi b/site/src/majc.gmi index 93ba93a..80c3f74 100644 --- a/site/src/majc.gmi +++ b/site/src/majc.gmi @@ -1,39 +1,24 @@ -# majc ``` - __ - _____ _____ |__| ____ - / \ \__ \ | |_/ ___\ -| Y Y \ / __ \_ | |\ \___ -|__|_| /(____ //\__| | \___ > - \/ \/ \______| \/ + __ + _____ _____ |__| ____ + / \ \__ \ | |_/ ___\ + | Y Y \ / __ \_ | |\ \___ + |__|_| /(____ //\__| | \___ > + \/ \/ \______| \/ ``` + A curses client for Gemini! ## Homepage The main homepage for majc is on tulpa.dev: => https://tulpa.dev/cadey/maj -## Installation -majc can be installed using Nix: - -``` -$ nix-env -if https://tulpa.dev/cadey/maj/archive/master.tar.gz -A majc -``` - -Then you can run it with `majc`: - -``` -$ majc -``` - ## Important Keys : opens the menubar c: closes the active window o: prompts to open a URL +h: shows history +l: shows active links in the page q: quits majc ?: shows this screen ~: toggles the debug logging pane - ---- - -=> / Go back diff --git a/src/gemini.rs b/src/gemini.rs index faf88a2..fee4916 100644 --- a/src/gemini.rs +++ b/src/gemini.rs @@ -1,6 +1,79 @@ /// This module implements a simple text/gemini parser based on the description /// here: https://gemini.circumlunar.space/docs/specification.html -use std::io::Write; +use std::io::{self, Write}; + +/// Build a gemini document up from a series of nodes. +#[derive(Default)] +pub struct Builder { + nodes: Vec, +} + +impl Builder { + pub fn new() -> Builder { + Builder::default() + } + + pub fn text>(mut self, data: T) -> Builder { + self.nodes.push(Node::Text(data.into())); + self + } + + pub fn link>(mut self, to: T, name: Option) -> Builder { + self.nodes.push(Node::Link { + to: to.into(), + name: name, + }); + self + } + + pub fn preformatted>(mut self, data: T) -> Builder { + self.nodes.push(Node::Preformatted(data.into())); + self + } + + pub fn heading>(mut self, level: u8, body: T) -> Builder { + self.nodes.push(Node::Heading { + level: level, + body: body.into(), + }); + self + } + + pub fn list_item>(mut self, item: T) -> Builder { + self.nodes.push(Node::ListItem(item.into())); + self + } + + pub fn quote>(mut self, body: T) -> Builder { + self.nodes.push(Node::Quote(body.into())); + self + } + + pub fn build(self) -> Vec { + self.nodes + } +} + +/// Render a set of nodes as a document to a writer. +pub fn render(nodes: Vec, out: &mut impl Write) -> io::Result<()> { + use Node::*; + + for node in nodes { + match node { + Text(body) => write!(out, "{}\n", body)?, + Link{to, name} => match name { + Some(name) => write!(out, "{} {}\n", to, name)?, + None => write!(out, "{}\n", to)?, + }, + Preformatted(body) => write!(out, "```\n{}\n```\n", body)?, + Heading { level, body} => write!(out, "{} {}\n", "#".repeat(level as usize), body)?, + ListItem(body) => write!(out, "* {}\n", body)?, + Quote(body) => write!(out, "> {}\n", body)?, + }; + } + + Ok(()) +} /// Individual nodes of the document. Each node correlates to a line in the file. #[derive(Debug, PartialEq, Eq)] diff --git a/src/response.rs b/src/response.rs index 46b0cf2..61c4fcb 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,6 +1,6 @@ -use crate::StatusCode; +use crate::{gemini, StatusCode}; use num::FromPrimitive; -use std::io::{prelude::*, ErrorKind, self}; +use std::io::{self, prelude::*, ErrorKind}; /// A Gemini response as specified in [the spec](https://gemini.circumlunar.space/docs/specification.html). #[derive(Default)] @@ -10,6 +10,67 @@ pub struct Response { pub body: Vec, } +impl Response { + pub fn gemini(body: Vec) -> Response { + Response { + status: StatusCode::Success, + meta: "text/gemini".to_string(), + body: body, + } + } + + pub fn render(body: Vec) -> Response { + let mut buf: Vec = vec![]; + gemini::render(body, &mut buf).unwrap(); + + Response { + status: StatusCode::Success, + meta: "text/gemini".to_string(), + body: buf, + } + } + + pub fn perm_redirect(to: String) -> Response { + Response { + status: StatusCode::PermanentRedirect, + meta: to, + body: vec![], + } + } + + pub fn no_proxy() -> Response { + Response { + status: StatusCode::ProxyRequestRefused, + meta: "Wrong host".to_string(), + body: vec![], + } + } + + pub fn not_found() -> Response { + Response { + status: StatusCode::NotFound, + meta: "Not found".to_string(), + body: vec![], + } + } + + pub fn input>(msg: T) -> Response { + Response { + status: StatusCode::Input, + meta: msg.into(), + body: vec![], + } + } + + pub fn need_cert>(msg: T) -> Response { + Response { + status: StatusCode::ClientCertificateRequired, + meta: msg.into(), + body: vec![], + } + } +} + /// The parser state. #[derive(Debug)] enum State { @@ -103,7 +164,7 @@ impl Response { } _ => { if data.len() == 1024 { - return Err(Error::ResponseMetaTooLong) + return Err(Error::ResponseMetaTooLong); } data.push(buf[0]); } @@ -168,11 +229,13 @@ mod tests { match Response::parse(&mut fin) { Ok(_) => panic!("wanted error but didn't get one"), - Err(why) => if let ResponseError::ResponseMetaTooLong = why { - println!("ok"); - } else { - panic!("wanted ResponseError::ResponseMetaTooLong") - }, + Err(why) => { + if let ResponseError::ResponseMetaTooLong = why { + println!("ok"); + } else { + panic!("wanted ResponseError::ResponseMetaTooLong") + } + } } } } diff --git a/src/server/mod.rs b/src/server/mod.rs index e6fad5f..7d2aba5 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,10 +1,14 @@ use crate::{Response, StatusCode}; +use async_std::{ + io::prelude::*, + net::{TcpListener, TcpStream}, + stream::StreamExt, + task, +}; +use async_tls::TlsAcceptor; use async_trait::async_trait; -use rustls::{Certificate, Session}; +use rustls::Certificate; use std::{error::Error as StdError, net::SocketAddr, sync::Arc}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::{net::TcpListener, stream::StreamExt}; -use tokio_rustls::TlsAcceptor; use url::Url; /// A Gemini request and its associated metadata. @@ -15,6 +19,16 @@ pub struct Request { } pub type Error = Box; +type Result = std::result::Result; + +#[derive(thiserror::Error, Debug)] +enum RequestParsingError { + #[error("invalid scheme {0}")] + InvalidScheme(String), + + #[error("unexpected end of request")] + UnexpectedEnd, +} #[allow(dead_code, unused_assignments, unused_mut, unused_variables)] mod routes; @@ -22,132 +36,132 @@ pub use routes::*; #[async_trait] pub trait Handler { - async fn handle(&self, r: Request) -> Result; + async fn handle(&self, r: Request) -> Result; } pub async fn serve( - h: &(dyn Handler + Sync), + h: Arc, cfg: rustls::ServerConfig, host: String, port: u16, -) -> Result<(), Error> +) -> Result where { let cfg = Arc::new(cfg); - let mut listener = TcpListener::bind(&format!("{}:{}", host, port)).await?; + let listener = TcpListener::bind(&format!("{}:{}", host, port)).await?; let mut incoming = listener.incoming(); - let acceptor = TlsAcceptor::from(cfg.clone()); - - while let Some(stream) = incoming.next().await { - let stream = stream?; + let acceptor = Arc::new(TlsAcceptor::from(cfg.clone())); + while let Some(Ok(stream)) = incoming.next().await { + let h = h.clone(); + let acceptor = acceptor.clone(); let addr = stream.peer_addr().unwrap(); - let fut = async { - let acceptor = acceptor.clone(); - let result = acceptor.accept(stream).await; + let port = port.clone(); - if result.is_err() { - return; - } - - let mut stream = result.unwrap(); - - let mut rd = BufReader::new(&mut stream); - let mut u = String::new(); - if let Err(why) = rd.read_line(&mut u).await { - log::error!("can't read request from {}: {:?}", addr, why); - let _ = stream - .write(format!("{} Invalid URL", StatusCode::BadRequest as u8).as_bytes()) - .await; - return; - } - u = u.trim().to_string(); - if u.len() >= 1025 { - let _ = stream - .write(format!("{} URL too long", StatusCode::BadRequest as u8).as_bytes()) - .await; - return; - } - - if u.starts_with("//") { - u = format!("gemini:{}", u); - } - - match Url::parse(&u) { - Err(why) => { - let _ = stream - .write( - format!("{} bad URL: {:?}", StatusCode::BadRequest as u8, why) - .as_bytes(), - ) - .await; - } - Ok(u) => { - if u.scheme() != "gemini" { - let _ = stream - .write( - format!( - "{} Cannot handle that kind of url", - StatusCode::ProxyRequestRefused as u8 - ) - .as_bytes(), - ) - .await; - return; - } - - if let Some(u_port) = u.port() { - if port != u_port { - let _ = stream - .write( - format!( - "{} Cannot handle that kind of url", - StatusCode::ProxyRequestRefused as u8 - ) - .as_bytes(), - ) - .await; - return; - } - } - - tokio::join!(handle( - h, - Request { - url: u.clone(), - certs: stream.get_ref().1.get_peer_certificates(), - }, - &mut stream, - addr, - )); - } - } - }; - - tokio::join!(fut); + task::spawn(handle_request(h, stream, acceptor, addr, port)); } Ok(()) } -async fn handle(h: &(dyn Handler + Sync), req: Request, stream: &mut T, addr: SocketAddr) +/// Handle a single client session (request + response). +async fn handle_request( + h: Arc<(dyn Handler + Send + Sync)>, + stream: TcpStream, + acceptor: Arc, + addr: SocketAddr, + port: u16, +) -> Result { + // Perform handshake. + let mut stream = acceptor.clone().accept(stream).await?; + + match parse_request(&mut stream).await { + Ok(url) => { + if let Some(u_port) = url.port() { + if port != u_port { + let _ = respond(&mut stream, "53", &["Cannot proxy"]).await; + return Ok(()); + } + } + + if url.scheme() != "gemini" { + let _ = respond(&mut stream, "53", &["Cannot proxy outside geminispace"]).await; + Err(RequestParsingError::InvalidScheme(url.scheme().to_string()))? + } + + let req = Request { + url: url, + certs: stream.peer_certificates(), + }; + handle(h, req, &mut stream, addr).await; + } + Err(e) => { + respond(&mut stream, "59", &["Invalid request."]).await?; + log::error!("error from {}: {:?}", addr, e); + } + } + Ok(()) +} + +async fn respond(mut stream: W, status: &str, meta: &[&str]) -> Result { + stream.write_all(status.as_bytes()).await?; + stream.write_all(b" ").await?; + for m in meta { + stream.write_all(m.as_bytes()).await?; + } + stream.write_all(b"\r\n").await?; + Ok(()) +} + +/// Return the URL requested by the client. +async fn parse_request(mut stream: R) -> Result { + // Because requests are limited to 1024 bytes (plus 2 bytes for CRLF), we + // can use a fixed-sized buffer on the stack, avoiding allocations and + // copying, and stopping bad clients from making us use too much memory. + let mut request = [0; 1026]; + let mut buf = &mut request[..]; + let mut len = 0; + + // Read until CRLF, end-of-stream, or there's no buffer space left. + loop { + let bytes_read = stream.read(buf).await?; + len += bytes_read; + if request[..len].ends_with(b"\r\n") { + break; + } else if bytes_read == 0 { + Err(RequestParsingError::UnexpectedEnd)? + } + buf = &mut request[len..]; + } + let request = std::str::from_utf8(&request[..len - 2])?; + + // Handle scheme-relative URLs. + let url = if request.starts_with("//") { + Url::parse(&format!("gemini:{}", request))? + } else { + Url::parse(request)? + }; + + // Validate the URL. TODO: Check the hostname and port. + Ok(url) +} + +async fn handle(h: Arc<(dyn Handler + Send + Sync)>, req: Request, stream: &mut T, addr: SocketAddr) where - T: AsyncWriteExt + Unpin, + T: Write + Unpin, { let u = req.url.clone(); match h.handle(req).await { Ok(resp) => { - stream + let _ = stream .write(format!("{} {}\r\n", resp.status as u8, resp.meta).as_bytes()) - .await - .unwrap(); - stream.write(&resp.body).await.unwrap(); - log::info!("{}: {} {:?}", addr, u, resp.status); + .await; + let _ = stream.write(&resp.body).await; + log::info!("{}: {} {} {:?}", addr, u, resp.meta, resp.status); } Err(why) => { - stream + let _ = stream .write(format!("{} {:?}\r\n", StatusCode::PermanentFailure as u8, why).as_bytes()) - .await - .unwrap(); + .await; log::error!("{}: {}: {:?}", addr, u, why); } };