commit acef45c75cff88fad60ad871234107d2f259f791 Author: panicbit Date: Sat Oct 31 20:53:03 2020 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f7250a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +/cert/ +/public/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f3e233a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "northstar" +version = "0.1.0" +authors = ["panicbit "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.33" +tokio-rustls = "0.20.0" +tokio = { version = "0.3.1", features = ["full"] } +mime = "0.3.16" +uriparse = "0.6.3" +percent-encoding = "2.1.0" +futures = "0.3.7" +itertools = "0.9.0" +log = "0.4.11" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f038a0 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +``` + __ __ __ + ____ ____ _____/ /_/ /_ _____/ /_____ ______ + / __ \/ __ \/ ___/ __/ __ \/ ___/ __/ __ `/ ___/ + / / / / /_/ / / / /_/ / / (__ ) /_/ /_/ / / +/_/ /_/\____/_/ \__/_/ /_/____/\__/\__,_/_/ +``` + +- [Documentation](https://docs.rs/northstar) +- [GitHub](https://github.com/panicbit/northstar) + +# Usage + +Add the latest version of northstar to your `Cargo.toml`. + +## Manually + +```toml +northstar = "0.1.0" # check crates.io for the latest version +``` + +## Automatically + +```sh +cargo add northstar +``` + +# Generating a key & certificate + +```sh +mkdir cert && cd cert +openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 +``` diff --git a/examples/serve_dir.rs b/examples/serve_dir.rs new file mode 100644 index 0000000..d4a6bb8 --- /dev/null +++ b/examples/serve_dir.rs @@ -0,0 +1,20 @@ +use anyhow::*; +use futures::{future::BoxFuture, FutureExt}; +use northstar::{Server, Request, Response, GEMINI_PORT}; + +#[tokio::main] +async fn main() -> Result<()> { + Server::bind(("localhost", GEMINI_PORT)) + .serve(handle_request) + .await +} + +fn handle_request(request: Request) -> BoxFuture<'static, Result> { + async move { + let path = request.path_segments(); + let response = northstar::util::serve_dir("public", &path).await?; + + Ok(response) + } + .boxed() +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..03468ce --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,199 @@ +#[macro_use] extern crate log; + +use std::{panic::AssertUnwindSafe, convert::TryFrom, io::BufReader, sync::Arc}; +use futures::{future::BoxFuture, FutureExt}; +use mime::Mime; +use tokio::{ + prelude::*, + io::{self, BufStream}, + net::{TcpStream, ToSocketAddrs}, +}; +use tokio::net::TcpListener; +use tokio_rustls::{rustls, TlsAcceptor}; +use rustls::*; +use anyhow::*; +use uri::URIReference; + +pub mod types; +pub mod util; + +pub use mime; +pub use uriparse as uri; +pub use types::*; + +pub const REQUEST_URI_MAX_LEN: usize = 1024; +pub const GEMINI_PORT: u16 = 1965; + +type Handler = Arc HandlerResponse + Send + Sync>; +type HandlerResponse = BoxFuture<'static, Result>; + +#[derive(Clone)] +pub struct Server { + tls_acceptor: TlsAcceptor, + listener: Arc, + handler: Handler, +} + +impl Server { + pub fn bind(addr: A) -> Builder { + Builder::bind(addr) + } + + async fn serve(self) -> Result<()> { + loop { + let (stream, _addr) = self.listener.accept().await?; + let this = self.clone(); + + tokio::spawn(async move { + if let Err(err) = this.serve_client(stream).await { + error!("{}", err); + } + }); + } + } + + async fn serve_client(self, stream: TcpStream) -> Result<()> { + let stream = self.tls_acceptor.accept(stream).await?; + let mut stream = BufStream::new(stream); + + let request = receive_request(&mut stream).await?; + debug!("Client requested: {}", request.uri()); + + let handler = (self.handler)(request); + let handler = AssertUnwindSafe(handler); + + let response = handler.catch_unwind().await + .unwrap_or_else(|_| Response::server_error("")) + .or_else(|err| { + error!("Handler: {}", err); + Response::server_error("") + })?; + + send_response(response, &mut stream).await?; + + stream.flush().await?; + + Ok(()) + } +} + +pub struct Builder { + addr: A, +} + +impl Builder { + fn bind(addr: A) -> Self { + Self { addr } + } + + pub async fn serve(self, handler: F) -> Result<()> + where + F: Fn(Request) -> HandlerResponse + Send + Sync + 'static, + { + let config = tls_config()?; + + let server = Server { + tls_acceptor: TlsAcceptor::from(config), + listener: Arc::new(TcpListener::bind(self.addr).await?), + handler: Arc::new(handler), + }; + + server.serve().await + } +} + +async fn receive_request(stream: &mut (impl AsyncBufRead + Unpin)) -> Result { + let limit = REQUEST_URI_MAX_LEN + "\r\n".len(); + let mut stream = stream.take(limit as u64); + let mut uri = Vec::new(); + + stream.read_until(b'\n', &mut uri).await?; + + if !uri.ends_with(b"\r\n") { + if uri.len() < REQUEST_URI_MAX_LEN { + bail!("Request header not terminated with CRLF") + } else { + bail!("Request URI too long") + } + } + + // Strip CRLF + uri.pop(); + uri.pop(); + + let uri = URIReference::try_from(&*uri)?.into_owned(); + let request = Request::from_uri(uri)?; + + Ok(request) +} + +async fn send_response(mut response: Response, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { + send_response_header(response.header(), stream).await?; + + if let Some(body) = response.take_body() { + send_response_body(body, stream).await?; + } + + Ok(()) +} + +async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { + let header = format!( + "{status} {meta}\r\n", + status = header.status.code(), + meta = header.meta.as_str(), + ); + + stream.write_all(header.as_bytes()).await?; + + Ok(()) +} + +async fn send_response_body(body: Body, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { + match body { + Body::Bytes(bytes) => stream.write_all(&bytes).await?, + Body::Reader(mut reader) => { io::copy(&mut reader, stream).await?; }, + } + + Ok(()) +} + +fn tls_config() -> Result> { + let mut config = ServerConfig::new(NoClientAuth::new()); + + let cert_chain = load_cert_chain()?; + let key = load_key()?; + config.set_single_cert(cert_chain, key)?; + + Ok(config.into()) +} + +fn load_cert_chain() -> Result> { + let certs = std::fs::File::open("cert/cert.pem")?; + let mut certs = BufReader::new(certs); + let certs = rustls::internal::pemfile::certs(&mut certs) + .map_err(|_| anyhow!("failed to load certs"))?; + + Ok(certs) +} + +fn load_key() -> Result { + let mut keys = BufReader::new(std::fs::File::open("cert/key.pem")?); + let mut keys = rustls::internal::pemfile::pkcs8_private_keys(&mut keys) + .map_err(|_| anyhow!("failed to load key"))?; + + ensure!(!keys.is_empty(), "no key found"); + + let key = keys.swap_remove(0); + + Ok(key) +} + +const GEMINI_MIME: &str = "text/gemini"; + +pub fn gemini_mime() -> Result { + let mime = GEMINI_MIME.parse()?; + Ok(mime) +} + + diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..cabadcd --- /dev/null +++ b/src/types.rs @@ -0,0 +1,287 @@ +use std::ops; +use anyhow::*; +use mime::Mime; +use percent_encoding::percent_decode_str; +use tokio::{io::AsyncRead, fs::File}; +use uriparse::URIReference; + +pub struct Request { + uri: URIReference<'static>, + input: Option, +} + +impl Request { + pub fn from_uri(mut uri: URIReference<'static>) -> Result { + uri.normalize(); + + let input = match uri.query() { + None => None, + Some(query) => { + let input = percent_decode_str(query.as_str()) + .decode_utf8()? + .into_owned(); + Some(input) + } + }; + + Ok(Self { + uri, + input, + }) + } + + pub fn uri(&self) -> &URIReference { + &self.uri + } + + pub fn path_segments(&self) -> Vec { + self.uri() + .path() + .segments() + .iter() + .map(|segment| percent_decode_str(segment.as_str()).decode_utf8_lossy().into_owned()) + .collect::>() + } + + pub fn input(&self) -> Option<&str> { + self.input.as_deref() + } +} + +impl ops::Deref for Request { + type Target = URIReference<'static>; + + fn deref(&self) -> &Self::Target { + &self.uri + } +} + +#[derive(Debug,Clone)] +pub struct ResponseHeader { + pub status: Status, + pub meta: Meta, +} + +impl ResponseHeader { + pub fn input(prompt: impl AsRef + Into) -> Result { + Ok(Self { + status: Status::INPUT, + meta: Meta::new(prompt)?, + }) + } + + pub fn success(mime: &Mime) -> Result { + Ok(Self { + status: Status::SUCCESS, + meta: Meta::new(mime.to_string())?, + }) + } + + pub fn server_error(reason: impl AsRef + Into) -> Result { + Ok(Self { + status: Status::PERMANENT_FAILURE, + meta: Meta::new(reason)?, + }) + } + + pub fn not_found() -> Result { + Ok(Self { + status: Status::NOT_FOUND, + meta: Meta::new("Not found")?, + }) + } + + pub fn status(&self) -> &Status { + &self.status + } + + pub fn meta(&self) -> &Meta { + &self.meta + } +} + +#[derive(Debug,Copy,Clone,PartialEq,Eq)] +pub struct Status(u8); + +impl Status { + pub const INPUT: Self = Self(10); + pub const SENSITIVE_INPUT: Self = Self(11); + pub const SUCCESS: Self = Self(20); + pub const REDIRECT_TEMPORARY: Self = Self(30); + pub const REDIRECT_PERMANENT: Self = Self(31); + pub const TEMPORARY_FAILURE: Self = Self(40); + pub const SERVER_UNAVAILABLE: Self = Self(41); + pub const CGI_ERROR: Self = Self(42); + pub const PROXY_ERROR: Self = Self(43); + pub const SLOW_DOWN: Self = Self(44); + pub const PERMANENT_FAILURE: Self = Self(50); + pub const NOT_FOUND: Self = Self(51); + pub const GONE: Self = Self(52); + pub const PROXY_REQUEST_REFUSED: Self = Self(53); + pub const BAD_REQUEST: Self = Self(59); + pub const CLIENT_CERTIFICATE_REQUIRED: Self = Self(60); + + pub fn code(&self) -> u8 { + self.0 + } + + pub fn is_success(&self) -> bool { + self.category().is_success() + } + + pub fn category(&self) -> StatusCategory { + let class = self.0 / 10; + + match class { + 1 => StatusCategory::Input, + 2 => StatusCategory::Success, + 3 => StatusCategory::Redirect, + 4 => StatusCategory::TemporaryFailure, + 5 => StatusCategory::PermanentFailure, + 6 => StatusCategory::ClientCertificateRequired, + _ => StatusCategory::PermanentFailure, + } + } +} + +#[derive(Copy,Clone,PartialEq,Eq)] +pub enum StatusCategory { + Input, + Success, + Redirect, + TemporaryFailure, + PermanentFailure, + ClientCertificateRequired, +} + +impl StatusCategory { + pub fn is_input(&self) -> bool { + *self == Self::Input + } + + pub fn is_success(&self) -> bool { + *self == Self::Success + } + + pub fn redirect(&self) -> bool { + *self == Self::Redirect + } + + pub fn is_temporary_failure(&self) -> bool { + *self == Self::TemporaryFailure + } + + pub fn is_permanent_failure(&self) -> bool { + *self == Self::PermanentFailure + } + + pub fn is_client_certificate_required(&self) -> bool { + *self == Self::ClientCertificateRequired + } +} + +#[derive(Debug,Clone,PartialEq,Eq,Default)] +pub struct Meta(String); + +impl Meta { + pub fn new(meta: impl AsRef + Into) -> Result { + ensure!(!meta.as_ref().contains("\n"), "Meta must not contain newlines"); + + Ok(Self(meta.into())) + } + + pub fn empty() -> Self { + Self::default() + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn to_mime(&self) -> Result { + let mime = self.as_str().parse::()?; + Ok(mime) + } +} + +pub struct Response { + header: ResponseHeader, + body: Option, +} + +impl Response { + pub fn new(header: ResponseHeader) -> Self { + Self { + header, + body: None, + } + } + + pub fn input(prompt: impl AsRef + Into) -> Result { + let header = ResponseHeader::input(prompt)?; + Ok(Self::new(header)) + } + + pub fn success(mime: &Mime) -> Result { + let header = ResponseHeader::success(&mime)?; + Ok(Self::new(header)) + } + + pub fn server_error(reason: impl AsRef + Into) -> Result { + let header = ResponseHeader::server_error(reason)?; + Ok(Self::new(header)) + } + + pub fn not_found() -> Result { + let header = ResponseHeader::not_found()?; + Ok(Self::new(header)) + } + + pub fn with_body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + pub fn header(&self) -> &ResponseHeader { + &self.header + } + + pub fn take_body(&mut self) -> Option { + self.body.take() + } +} + +pub enum Body { + Bytes(Vec), + Reader(Box), +} + +impl From> for Body { + fn from(bytes: Vec) -> Self { + Self::Bytes(bytes) + } +} + +impl<'a> From<&'a [u8]> for Body { + fn from(bytes: &[u8]) -> Self { + Self::Bytes(bytes.to_owned()) + } +} + +impl From for Body { + fn from(text: String) -> Self { + Self::Bytes(text.into_bytes()) + } +} + +impl<'a> From<&'a str> for Body { + fn from(text: &str) -> Self { + Self::Bytes(text.to_owned().into_bytes()) + } +} + +impl From for Body { + fn from(file: File) -> Self { + Self::Reader(Box::new(file)) + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..dcd7b09 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,103 @@ +use std::path::Path; +use mime::Mime; +use percent_encoding::utf8_percent_encode; +use anyhow::*; +use tokio::{ + fs::{self, File}, + io, +}; +use crate::{GEMINI_MIME, Response, gemini_mime}; +use itertools::Itertools; + +pub async fn serve_file>(path: P, mime: &Mime) -> Result { + let path = path.as_ref(); + + let file = match File::open(path).await { + Ok(file) => file, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => return Ok(Response::not_found()?), + _ => return Err(err.into()), + } + }; + + Ok(Response::success(&mime)?.with_body(file)) +} + +pub async fn serve_dir, P: AsRef>(dir: D, virtual_path: &[P]) -> Result { + debug!("Dir: {}", dir.as_ref().display()); + let dir = dir.as_ref().canonicalize()?; + let mut path = dir.to_path_buf(); + + for segment in virtual_path { + path.push(segment); + } + + let path = path.canonicalize()?; + + if !path.starts_with(&dir) { + return Ok(Response::not_found()?); + } + + if !path.is_dir() { + let mime = guess_mime_from_path(&path); + return serve_file(path, &mime).await; + } + + serve_dir_listing(path, virtual_path).await +} + +async fn serve_dir_listing, B: AsRef>(path: P, virtual_path: &[B]) -> Result { + use std::fmt::Write; + + let mut dir = match fs::read_dir(path).await { + Ok(dir) => dir, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => return Ok(Response::not_found()?), + _ => return Err(err.into()), + } + }; + + let breadcrumbs = virtual_path.iter().map(|segment| segment.as_ref().display()).join("/"); + let mut listing = String::new(); + + writeln!(listing, "# Index of /{}", breadcrumbs)?; + writeln!(listing)?; + + if virtual_path.get(0).map(<_>::as_ref) != Some(Path::new("")) { + writeln!(listing, "=> .. 📁 ../")?; + } + + while let Some(entry) = dir.next_entry().await? { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + let is_dir = entry.file_type().await?.is_dir(); + + writeln!( + listing, + "=> {link}{trailing_slash} {icon} {name}{trailing_slash}", + icon = if is_dir { '📁' } else { '📄' }, + link = utf8_percent_encode(&file_name, percent_encoding::NON_ALPHANUMERIC), + trailing_slash = if is_dir { "/" } else { "" }, + name = file_name, + )?; + } + + Ok(Response::success(&gemini_mime()?)?.with_body(listing)) +} + +pub fn guess_mime_from_path>(path: P) -> Mime { + let path = path.as_ref(); + let extension = path.extension().and_then(|s| s.to_str()); + let mime = match extension { + Some(extension) => match extension { + "gemini" => GEMINI_MIME, + "txt" => "text/plain", + "jpeg" | "jpg" | "jpe" => "image/jpeg", + "png" => "image/png", + _ => "application/octet-stream", + }, + None => "application/octet-stream", + }; + + mime.parse::().unwrap_or(mime::APPLICATION_OCTET_STREAM) +}