Merge several pending changes into a virtual master

Octopus merges are just a cheap trick to make weak branches stronger
This commit is contained in:
Emi Tatsuo 2020-11-24 23:04:26 -05:00
Signed by: Emi
GPG Key ID: 68FAB2E2E6DFC98B
29 changed files with 1658 additions and 225 deletions

View File

@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `document` API for creating Gemini documents
- preliminary timeout API, incl a special case for complex MIMEs by [@Alch-Emi]
- `Response::success_with_body` by [@Alch-Emi]
- `Response::success_*` variants by [@Alch-Emi]
- `redirect_temporary_lossy` for `Response` and `ResponseHeader`
- `bad_request_lossy` for `Response` and `ResponseHeader`
- support for a lot more mime-types in `guess_mime_from_path`, backed by the `mime_guess` crate
@ -17,10 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Docments can be converted into responses with std::convert::Into [@Alch-Emi]
- Added ratelimiting API [@Alch-Emi]
### Improved
- build time and size by [@Alch-Emi](https://github.com/Alch-Emi)
- build time and size by [@Alch-Emi]
- Improved error handling in serve_dir [@Alch-Emi]
### Changed
- Added route API [@Alch-Emi](https://github.com/Alch-Emi)
- API for adding handlers now accepts async handlers [@Alch-Emi](https://github.com/Alch-Emi)
- `Response::success` now takes a request body [@Alch-Emi]
## [0.3.0] - 2020-11-14
### Added

View File

@ -9,6 +9,9 @@ repository = "https://github.com/panicbit/northstar"
documentation = "https://docs.rs/northstar"
[features]
user_management = ["sled", "bincode", "serde/derive", "crc32fast"]
user_management_advanced = ["rust-argon2", "ring", "user_management"]
user_management_routes = ["user_management"]
default = ["serve_dir"]
serve_dir = ["mime_guess", "tokio/fs"]
ratelimiting = ["dashmap"]
@ -21,18 +24,30 @@ tokio = { version = "0.3.1", features = ["io-util","net","time", "rt"] }
mime = "0.3.16"
uriparse = "0.6.3"
percent-encoding = "2.1.0"
futures-core = "0.3.7"
log = "0.4.11"
webpki = "0.21.0"
lazy_static = "1.4.0"
mime_guess = { version = "2.0.3", optional = true }
dashmap = { version = "3.11.10", optional = true }
sled = { version = "0.34.6", optional = true }
bincode = { version = "1.3.1", optional = true }
serde = { version = "1.0", optional = true }
rust-argon2 = { version = "0.8.2", optional = true }
crc32fast = { version = "1.2.1", optional = true }
ring = { version = "0.16.15", optional = true }
[dev-dependencies]
env_logger = "0.8.1"
futures-util = "0.3.7"
tokio = { version = "0.3.1", features = ["macros", "rt-multi-thread", "sync"] }
[[example]]
name = "user_management"
required-features = ["user_management_routes"]
[[example]]
name = "serve_dir"
required-features = ["serve_dir"]
[[example]]
name = "ratelimiting"
required-features = ["ratelimiting"]

View File

@ -1,9 +1,7 @@
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use log::LevelFilter;
use tokio::sync::RwLock;
use northstar::{Certificate, GEMINI_MIME, GEMINI_PORT, Request, Response, Server};
use northstar::{Certificate, GEMINI_PORT, Request, Response, Server};
use std::collections::HashMap;
use std::sync::Arc;
@ -31,44 +29,38 @@ async fn main() -> Result<()> {
/// selecting a username. They'll then get a message confirming their account creation.
/// Any time this user visits the site in the future, they'll get a personalized welcome
/// message.
fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
if let Some(Certificate(cert_bytes)) = request.certificate() {
// The user provided a certificate
let users_read = users.read().await;
if let Some(user) = users_read.get(cert_bytes) {
// The user has already registered
async fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> Result<Response> {
if let Some(Certificate(cert_bytes)) = request.certificate() {
// The user provided a certificate
let users_read = users.read().await;
if let Some(user) = users_read.get(cert_bytes) {
// The user has already registered
Ok(
Response::success_gemini(format!("Welcome {}!", user))
)
} else {
// The user still needs to register
drop(users_read);
if let Some(query_part) = request.uri().query() {
// The user provided some input (a username request)
let username = query_part.as_str();
let mut users_write = users.write().await;
users_write.insert(cert_bytes.clone(), username.to_owned());
Ok(
Response::success_with_body(
&GEMINI_MIME,
format!("Welcome {}!", user)
Response::success_gemini(
format!(
"Your account has been created {}! Welcome!",
username
)
)
)
} else {
// The user still needs to register
drop(users_read);
if let Some(query_part) = request.uri().query() {
// The user provided some input (a username request)
let username = query_part.as_str();
let mut users_write = users.write().await;
users_write.insert(cert_bytes.clone(), username.to_owned());
Ok(
Response::success_with_body(
&GEMINI_MIME,
format!(
"Your account has been created {}! Welcome!",
username
)
)
)
} else {
// The user didn't provide input, and should be prompted
Response::input("What username would you like?")
}
// The user didn't provide input, and should be prompted
Response::input("What username would you like?")
}
} else {
// The user didn't provide a certificate
Ok(Response::client_certificate_required())
}
}.boxed()
} else {
// The user didn't provide a certificate
Ok(Response::client_certificate_required())
}
}

View File

@ -1,8 +1,6 @@
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use log::LevelFilter;
use northstar::{Server, Request, Response, GEMINI_PORT, Document};
use northstar::{Server, Response, GEMINI_PORT, Document};
use northstar::document::HeadingLevel::*;
#[tokio::main]
@ -11,40 +9,34 @@ async fn main() -> Result<()> {
.filter_module("northstar", LevelFilter::Debug)
.init();
let response: Response = Document::new()
.add_preformatted(include_str!("northstar_logo.txt"))
.add_blank_line()
.add_link("https://docs.rs/northstar", "Documentation")
.add_link("https://github.com/panicbit/northstar", "GitHub")
.add_blank_line()
.add_heading(H1, "Usage")
.add_blank_line()
.add_text("Add the latest version of northstar to your `Cargo.toml`.")
.add_blank_line()
.add_heading(H2, "Manually")
.add_blank_line()
.add_preformatted_with_alt("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#)
.add_blank_line()
.add_heading(H2, "Automatically")
.add_blank_line()
.add_preformatted_with_alt("sh", "cargo add northstar")
.add_blank_line()
.add_heading(H1, "Generating a key & certificate")
.add_blank_line()
.add_preformatted_with_alt("sh", concat!(
"mkdir cert && cd cert\n",
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
))
.into();
Server::bind(("localhost", GEMINI_PORT))
.add_route("/",handle_request)
.add_route("/", response)
.serve()
.await
}
fn handle_request(_request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
let response = Document::new()
.add_preformatted(include_str!("northstar_logo.txt"))
.add_blank_line()
.add_link("https://docs.rs/northstar", "Documentation")
.add_link("https://github.com/panicbit/northstar", "GitHub")
.add_blank_line()
.add_heading(H1, "Usage")
.add_blank_line()
.add_text("Add the latest version of northstar to your `Cargo.toml`.")
.add_blank_line()
.add_heading(H2, "Manually")
.add_blank_line()
.add_preformatted_with_alt("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#)
.add_blank_line()
.add_heading(H2, "Automatically")
.add_blank_line()
.add_preformatted_with_alt("sh", "cargo add northstar")
.add_blank_line()
.add_heading(H1, "Generating a key & certificate")
.add_blank_line()
.add_preformatted_with_alt("sh", concat!(
"mkdir cert && cd cert\n",
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
))
.into();
Ok(response)
}
.boxed()
}

View File

@ -1,6 +1,4 @@
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use log::LevelFilter;
use northstar::{Document, document::HeadingLevel, Request, Response, GEMINI_PORT};
@ -18,25 +16,19 @@ async fn main() -> Result<()> {
.await
}
fn handle_base(req: Request) -> BoxFuture<'static, Result<Response>> {
async fn handle_base(req: Request) -> Result<Response> {
let doc = generate_doc("base", &req);
async move {
Ok(Response::document(doc))
}.boxed()
Ok(doc.into())
}
fn handle_short(req: Request) -> BoxFuture<'static, Result<Response>> {
async fn handle_short(req: Request) -> Result<Response> {
let doc = generate_doc("short", &req);
async move {
Ok(Response::document(doc))
}.boxed()
Ok(doc.into())
}
fn handle_long(req: Request) -> BoxFuture<'static, Result<Response>> {
async fn handle_long(req: Request) -> Result<Response> {
let doc = generate_doc("long", &req);
async move {
Ok(Response::document(doc))
}.boxed()
Ok(doc.into())
}
fn generate_doc(route_name: &str, req: &Request) -> Document {

View File

@ -1,8 +1,8 @@
use std::path::PathBuf;
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use log::LevelFilter;
use northstar::{Server, Request, Response, GEMINI_PORT};
use northstar::{Server, GEMINI_PORT};
#[tokio::main]
async fn main() -> Result<()> {
@ -11,17 +11,8 @@ async fn main() -> Result<()> {
.init();
Server::bind(("localhost", GEMINI_PORT))
.add_route("/", handle_request)
.add_route("/", PathBuf::from("public")) // Serve directory listings & file contents
.add_route("/about", PathBuf::from("README.md")) // Serve a single file
.serve()
.await
}
fn handle_request(request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
let path = request.path_segments();
let response = northstar::util::serve_dir("public", &path).await?;
Ok(response)
}
.boxed()
}

View File

@ -0,0 +1,83 @@
use anyhow::*;
use log::LevelFilter;
use northstar::{
GEMINI_PORT,
Document,
Request,
Response,
Server,
user_management::{
user::RegisteredUser,
UserManagementRoutes,
},
};
#[tokio::main]
/// An ultra-simple demonstration of authentication.
///
/// The user should be able to set a secret string that only they can see. They should be
/// able to change this at any time to any thing. Both the string and the user account
/// will persist across restarts.
///
/// This method sets up and starts the server
async fn main() -> Result<()> {
// Turn on logging
env_logger::builder()
.filter_module("northstar", LevelFilter::Debug)
.init();
Server::bind(("0.0.0.0", GEMINI_PORT))
// Add our main routes
.add_authenticated_route("/", handle_main)
.add_authenticated_input_route("/update", "Enter your new string:", handle_update)
// Add routes for handling user authentication
.add_um_routes::<String>("/")
// Start the server
.serve()
.await
}
/// The landing page
///
/// Displays the user's current secret string, or prompts the user to sign in if they
/// haven't. Includes links to update your string (`/update`) or your account
/// (`/account`). Even though we haven't added an explicit handler for `/account`, this
/// route is managed by northstar.
///
/// Because this route is registered as an authenticated route, any connections without a
/// certificate will be prompted to add a certificate and register.
async fn handle_main(_req: Request, user: RegisteredUser<String>) -> Result<Response> {
// If the user is signed in, render and return their page
let response = Document::new()
.add_text("Your personal secret string:")
.add_text(user.as_ref())
.add_blank_line()
.add_link("/update", "Change your string")
.add_link("/account", "Update your account")
.into();
Ok(response)
}
/// The update endpoint
///
/// Users can update their secret string here.
async fn handle_update(_request: Request, mut user: RegisteredUser<String>, input: String) -> Result<Response> {
// The user has already been prompted to log in if they weren't and asked to give an
// input string, so all we need to do is...
// Update the users data
*user.as_mut() = input;
// Render a response
let response = Document::new()
.add_text("String updated!")
.add_blank_line()
.add_link("/", "Back")
.into();
Ok(response)
}

211
src/handling.rs Normal file
View File

@ -0,0 +1,211 @@
//! Types for handling requests
//!
//! The main type is the [`Handler`], which wraps a more specific type of handler and
//! manages delegating responses to it.
//!
//! For most purposes, you should never have to manually create any of these structs
//! yourself, though it may be useful to look at the implementations of [`From`] on
//! [`Handler`], as these are the things that can be used as handlers for routes.
use anyhow::Result;
use std::{
pin::Pin,
future::Future,
task::Poll,
panic::{catch_unwind, AssertUnwindSafe},
};
#[cfg(feature = "serve_dir")]
use std::path::PathBuf;
use crate::{Document, types::{Body, Response, Request}};
/// A struct representing something capable of handling a request.
///
/// In the future, this may have multiple varieties, but at the minute, it just wraps an
/// [`Fn`](std::ops::Fn).
///
/// The most useful part of the documentation for this is the implementations of [`From`]
/// on it, as this is what can be passed to
/// [`Builder::add_route`](crate::Builder::add_route) in order to create a new route.
/// Each implementation has bespoke docs that describe how the type is used, and what
/// response is produced.
pub enum Handler {
FnHandler(HandlerInner),
StaticHandler(Response),
#[cfg(feature = "serve_dir")]
FilesHandler(PathBuf),
}
/// Since we can't store train objects, we need to wrap fn handlers in a box
type HandlerInner = Box<dyn Fn(Request) -> HandlerResponse + Send + Sync>;
/// Same with dyn Futures
type HandlerResponse = Pin<Box<dyn Future<Output = Result<Response>> + Send>>;
impl Handler {
/// Handle an incoming request
///
/// This delegates to the request to the appropriate method of handling it, whether
/// that's fetching a file or directory listing, cloning a static response, or handing
/// the request to a wrapped handler function.
///
/// Any unexpected errors that occur will be printed to the log and potentially
/// reported to the user, depending on the handler type.
pub async fn handle(&self, request: Request) -> Response {
match self {
Self::FnHandler(inner) => {
let fut_handle = (inner)(request);
let fut_handle = AssertUnwindSafe(fut_handle);
HandlerCatchUnwind::new(fut_handle).await
.unwrap_or_else(|err| {
error!("Handler failed: {:?}", err);
Response::server_error("").unwrap()
})
},
Self::StaticHandler(response) => {
let body = response.as_ref();
match body {
None => Response::new(response.header().clone()),
Some(Body::Bytes(bytes)) => {
Response::new(response.header().clone())
.with_body(bytes.clone())
},
_ => {
error!(concat!(
"Cannot construct a static handler with a reader-based body! ",
" We're sending a response so that the client doesn't crash, but",
" given that this is a release build you should really fix this."
));
Response::server_error(
"Very bad server error, go tell the sysadmin to look at the logs."
).unwrap()
}
}
},
#[cfg(feature = "serve_dir")]
Self::FilesHandler(path) => {
let resp = if path.is_dir() {
crate::util::serve_dir(path, request.trailing_segments()).await
} else {
let mime = crate::util::guess_mime_from_path(&path);
crate::util::serve_file(path, &mime).await
};
resp.unwrap_or_else(|e| {
warn!("Unexpected error serving from {}: {:?}", path.display(), e);
Response::server_error("").unwrap()
})
},
}
}
}
impl<H, R> From<H> for Handler
where
H: 'static + Fn(Request) -> R + Send + Sync,
R: 'static + Future<Output = Result<Response>> + Send,
{
/// Wrap an [`Fn`] in a [`Handler`] struct
///
/// This automatically boxes both the [`Fn`] and the [`Fn`]'s response.
///
/// Any requests passed to the handler will be directly handed down to the handler,
/// with the request as the first argument. The response provided will be sent to the
/// requester. If the handler panics or returns an [`Err`], this will be logged, and
/// the requester will be sent a [`SERVER_ERROR`](Response::server_error()).
fn from(handler: H) -> Self {
Self::FnHandler(
Box::new(move|req| Box::pin((handler)(req)) as HandlerResponse)
)
}
}
// We tolerate a fallible `impl From` because this is *really* not the kind of thing the
// user should be catching in runtime.
#[allow(clippy::fallible_impl_from)]
impl From<Response> for Handler {
/// Serve an unchanging response
///
/// Any and all requests to this handler will be responded to with the same response,
/// no matter what. This is good for static content that is provided by your app.
/// For serving files & directories, try looking at creating a handler from a path
///
/// ## Panics
/// This response type **CANNOT** be created using Responses with [`Reader`] bodies.
/// Attempting to do this will cause a panic. Don't.
///
/// [`Reader`]: Body::Reader
fn from(response: Response) -> Self {
#[cfg(debug_assertions)] {
// We have another check once the handler is actually called that is not
// disabled for release builds
if let Some(Body::Reader(_)) = response.as_ref() {
panic!("Cannot construct a static handler with a reader-based body");
}
}
Self::StaticHandler(response)
}
}
impl From<&Document> for Handler {
/// Serve an unchanging response, shorthand for From<Response>
///
/// This document will be sent in response to any requests that arrive at this
/// handler. As with all documents, this will be a successful response with a
/// `text/gemini` MIME.
fn from(doc: &Document) -> Self {
Self::StaticHandler(doc.into())
}
}
#[cfg(feature = "serve_dir")]
impl From<PathBuf> for Handler {
/// Serve files from a directory
///
/// Any requests directed to this handler will be served from this path. For example,
/// if a handler serving files from the path `./public/` and bound to `/serve`
/// receives a request for `/serve/file.txt`, it will respond with the contents of the
/// file at `./public/file.txt`.
///
/// This is equivilent to serving files using [`util::serve_dir()`], and as such will
/// include directory listings.
///
/// The path to a single file can be passed in order to serve only a single file for
/// any and all requests.
///
/// [`util::serve_dir()`]: crate::util::serve_dir()
fn from(path: PathBuf) -> Self {
Self::FilesHandler(path)
}
}
/// A utility for catching unwinds on Futures.
///
/// This is adapted from the futures-rs CatchUnwind, in an effort to reduce the large
/// amount of dependencies tied into the feature that provides this simple struct.
#[must_use = "futures do nothing unless polled"]
struct HandlerCatchUnwind {
future: AssertUnwindSafe<HandlerResponse>,
}
impl HandlerCatchUnwind {
fn new(future: AssertUnwindSafe<HandlerResponse>) -> Self {
Self { future }
}
}
impl Future for HandlerCatchUnwind {
type Output = Result<Response>;
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context
) -> Poll<Self::Output> {
match catch_unwind(AssertUnwindSafe(|| self.future.as_mut().poll(cx))) {
Ok(res) => res,
Err(e) => {
error!("Handler panic! {:?}", e);
Poll::Ready(Response::server_error(""))
}
}
}
}

View File

@ -1,7 +1,6 @@
#[macro_use] extern crate log;
use std::{
panic::AssertUnwindSafe,
convert::TryFrom,
io::BufReader,
sync::Arc,
@ -10,7 +9,6 @@ use std::{
};
#[cfg(feature = "ratelimiting")]
use std::net::IpAddr;
use futures_core::future::BoxFuture;
use tokio::{
prelude::*,
io::{self, BufStream},
@ -34,8 +32,14 @@ use ratelimiting::RateLimiter;
pub mod types;
pub mod util;
pub mod routing;
pub mod handling;
#[cfg(feature = "ratelimiting")]
pub mod ratelimiting;
#[cfg(feature = "user_management")]
pub mod user_management;
#[cfg(feature="user_management")]
use user_management::UserManager;
pub use mime;
pub use uriparse as uri;
@ -44,18 +48,18 @@ pub use types::*;
pub const REQUEST_URI_MAX_LEN: usize = 1024;
pub const GEMINI_PORT: u16 = 1965;
type Handler = Arc<dyn Fn(Request) -> HandlerResponse + Send + Sync>;
pub (crate) type HandlerResponse = BoxFuture<'static, Result<Response>>;
use handling::Handler;
#[derive(Clone)]
pub struct Server {
tls_acceptor: TlsAcceptor,
listener: Arc<TcpListener>,
routes: Arc<RoutingNode<Handler>>,
timeout: Duration,
complex_timeout: Option<Duration>,
#[cfg(feature="ratelimiting")]
rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
#[cfg(feature="user_management")]
manager: UserManager,
}
impl Server {
@ -63,12 +67,12 @@ impl Server {
Builder::bind(addr)
}
async fn serve(self) -> Result<()> {
async fn serve(self, listener: TcpListener) -> Result<()> {
#[cfg(feature = "ratelimiting")]
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
loop {
let (stream, _addr) = self.listener.accept().await
let (stream, _addr) = listener.accept().await
.context("Failed to accept client")?;
let this = self.clone();
@ -89,7 +93,11 @@ impl Server {
.context("Failed to establish TLS session")?;
let mut stream = BufStream::new(stream);
let request = receive_request(&mut stream).await
#[cfg(feature="user_management")]
let request = self.receive_request(&mut stream).await
.context("Failed to receive request")?;
#[cfg(not(feature="user_management"))]
let request = Self::receive_request(&mut stream).await
.context("Failed to receive request")?;
Result::<_, anyhow::Error>::Ok((request, stream))
@ -120,19 +128,8 @@ impl Server {
request.set_cert(client_cert);
let response = if let Some((trailing, handler)) = self.routes.match_request(&request) {
request.set_trailing(trailing);
let handler = (handler)(request);
let handler = AssertUnwindSafe(handler);
util::HandlerCatchUnwind::new(handler).await
.unwrap_or_else(|_| Response::server_error(""))
.or_else(|err| {
error!("Handler failed: {:?}", err);
Response::server_error("")
})
.context("Request handler failed")?
handler.handle(request).await
} else {
Response::not_found()
};
@ -201,6 +198,41 @@ impl Server {
}
None
}
async fn receive_request(
#[cfg(feature="user_management")]
&self,
stream: &mut (impl AsyncBufRead + Unpin)
) -> Result<Request> {
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)
.context("Request URI is invalid")?
.into_owned();
let request = Request::from_uri(
uri,
#[cfg(feature="user_management")]
self.manager.clone(),
) .context("Failed to create request from URI")?;
Ok(request)
}
}
pub struct Builder<A> {
@ -212,6 +244,8 @@ pub struct Builder<A> {
routes: RoutingNode<Handler>,
#[cfg(feature="ratelimiting")]
rate_limits: RoutingNode<RateLimiter<IpAddr>>,
#[cfg(feature="user_management")]
data_dir: PathBuf,
}
impl<A: ToSocketAddrs> Builder<A> {
@ -225,9 +259,20 @@ impl<A: ToSocketAddrs> Builder<A> {
routes: RoutingNode::default(),
#[cfg(feature="ratelimiting")]
rate_limits: RoutingNode::default(),
#[cfg(feature="user_management")]
data_dir: "data".into(),
}
}
#[cfg(feature="user_management")]
/// Sets the directory to store user data in
///
/// Defaults to `./data` if not specified
pub fn set_database_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.data_dir = path.into();
self
}
/// Sets the directory that northstar should look for TLS certs and keys into
///
/// Northstar will look for files called `cert.pem` and `key.pem` in the provided
@ -333,11 +378,8 @@ impl<A: ToSocketAddrs> Builder<A> {
/// "endpoint". Entering a relative or malformed path will result in a panic.
///
/// For more information about routing mechanics, see the docs for [`RoutingNode`].
pub fn add_route<H>(mut self, path: &'static str, handler: H) -> Self
where
H: Fn(Request) -> HandlerResponse + Send + Sync + 'static,
{
self.routes.add_route(path, Arc::new(handler));
pub fn add_route(mut self, path: &'static str, handler: impl Into<Handler>) -> Self {
self.routes.add_route(path, handler.into());
self
}
@ -369,46 +411,19 @@ impl<A: ToSocketAddrs> Builder<A> {
let server = Server {
tls_acceptor: TlsAcceptor::from(config),
listener: Arc::new(listener),
routes: Arc::new(self.routes),
timeout: self.timeout,
complex_timeout: self.complex_body_timeout_override,
#[cfg(feature="ratelimiting")]
rate_limits: Arc::new(self.rate_limits),
#[cfg(feature="user_management")]
manager: UserManager::new(self.data_dir)?,
};
server.serve().await
server.serve(listener).await
}
}
async fn receive_request(stream: &mut (impl AsyncBufRead + Unpin)) -> Result<Request> {
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)
.context("Request URI is invalid")?
.into_owned();
let request = Request::from_uri(uri)
.context("Failed to create request from URI")?;
Ok(request)
}
async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> {
let header = format!(
"{status} {meta}\r\n",

View File

@ -3,22 +3,40 @@ use anyhow::*;
use percent_encoding::percent_decode_str;
use uriparse::URIReference;
use rustls::Certificate;
#[cfg(feature="user_management")]
use serde::{Serialize, de::DeserializeOwned};
#[cfg(feature="user_management")]
use crate::user_management::{UserManager, User};
pub struct Request {
uri: URIReference<'static>,
input: Option<String>,
certificate: Option<Certificate>,
trailing_segments: Option<Vec<String>>,
#[cfg(feature="user_management")]
manager: UserManager,
}
impl Request {
pub fn from_uri(uri: URIReference<'static>) -> Result<Self> {
Self::with_certificate(uri, None)
pub fn from_uri(
uri: URIReference<'static>,
#[cfg(feature="user_management")]
manager: UserManager,
) -> Result<Self> {
Self::with_certificate(
uri,
None,
#[cfg(feature="user_management")]
manager
)
}
pub fn with_certificate(
mut uri: URIReference<'static>,
certificate: Option<Certificate>
certificate: Option<Certificate>,
#[cfg(feature="user_management")]
manager: UserManager,
) -> Result<Self> {
uri.normalize();
@ -38,6 +56,8 @@ impl Request {
input,
certificate,
trailing_segments: None,
#[cfg(feature="user_management")]
manager,
})
}
@ -94,6 +114,18 @@ impl Request {
pub const fn certificate(&self) -> Option<&Certificate> {
self.certificate.as_ref()
}
#[cfg(feature="user_management")]
/// Attempt to determine the user who sent this request
///
/// May return a variant depending on if the client used a client certificate, and if
/// they've registered as a user yet.
pub fn user<UserData>(&self) -> Result<User<UserData>>
where
UserData: Serialize + DeserializeOwned
{
Ok(self.manager.get_user(self.certificate())?)
}
}
impl ops::Deref for Request {

View File

@ -20,8 +20,12 @@ impl Response {
}
}
#[deprecated(
since = "0.4.0",
note = "Deprecated in favor of Response::success_gemini() or Document::into()"
)]
pub fn document(document: impl Borrow<Document>) -> Self {
Self::success_with_body(&GEMINI_MIME, document)
Self::success_gemini(document)
}
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
@ -34,27 +38,27 @@ impl Response {
Self::new(header)
}
pub fn success(mime: &Mime) -> Self {
let header = ResponseHeader::success(mime);
Self::new(header)
}
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> Self {
let header = ResponseHeader::redirect_temporary_lossy(location);
Self::new(header)
}
/// Create a successful response with a preconfigured body
///
/// This is equivilent to:
///
/// ```ignore
/// Response::success(mime)
/// .with_body(body)
/// ```
pub fn success_with_body(mime: &Mime, body: impl Into<Body>) -> Self {
Self::success(mime)
.with_body(body)
/// Create a successful response with a given body and MIME
pub fn success(mime: &Mime, body: impl Into<Body>) -> Self {
Self {
header: ResponseHeader::success(mime),
body: Some(body.into()),
}
}
/// Create a successful response with a `text/gemini` MIME
pub fn success_gemini(body: impl Into<Body>) -> Self {
Self::success(&GEMINI_MIME, body)
}
/// Create a successful response with a `text/plain` MIME
pub fn success_plain(body: impl Into<Body>) -> Self {
Self::success(&mime::TEXT_PLAIN, body)
}
pub fn server_error(reason: impl Cowy<str>) -> Result<Self> {
@ -96,8 +100,20 @@ impl Response {
}
}
impl<D: Borrow<Document>> From<D> for Response {
fn from(doc: D) -> Self {
Self::document(doc)
impl AsRef<Option<Body>> for Response {
fn as_ref(&self) -> &Option<Body> {
&self.body
}
}
impl AsMut<Option<Body>> for Response {
fn as_mut(&mut self) -> &mut Option<Body> {
&mut self.body
}
}
impl<D: Borrow<Document>> From<D> for Response {
fn from(doc: D) -> Self {
Self::success_gemini(doc)
}
}

View File

@ -0,0 +1,120 @@
use rustls::Certificate;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::path::Path;
use crate::user_management::{User, Result};
use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser};
#[derive(Debug, Clone, Deserialize, Serialize)]
/// Data stored in the certificate tree about a certain certificate
pub struct CertificateData {
#[serde(with = "CertificateDef")]
/// The certificate in question
pub certificate: Certificate,
/// The username of the user to which this certificate is registered
pub owner_username: String,
}
#[derive(Serialize, Deserialize)]
#[serde(remote = "Certificate")]
struct CertificateDef(Vec<u8>);
#[derive(Debug, Clone)]
/// A struct containing information for managing users.
///
/// Wraps a [`sled::Db`]
pub struct UserManager {
db: sled::Db,
pub (crate) users: sled::Tree, // user_id:String maps to data:UserData
pub (crate) certificates: sled::Tree, // certificate:u64 maps to data:CertificateData
}
impl UserManager {
/// Create or open a new UserManager
///
/// The `dir` argument is the path to a data directory, to be populated using sled.
/// This will be created if it does not exist.
pub fn new(dir: impl AsRef<Path>) -> Result<Self> {
let db = sled::open(dir)?;
Ok(Self {
users: db.open_tree("users")?,
certificates: db.open_tree("certificates")?,
db,
})
}
/// Produce a u32 hash from a certificate, used for [`lookup_certificate()`](Self::lookup_certificate())
pub fn hash_certificate(cert: &Certificate) -> u32 {
let mut hasher = crc32fast::Hasher::new();
hasher.update(cert.0.as_ref());
hasher.finalize()
}
/// Lookup information about a certificate based on it's u32 hash
///
/// # Errors
/// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt
pub fn lookup_certificate(&self, cert: u32) -> Result<Option<CertificateData>> {
if let Some(bytes) = self.certificates.get(cert.to_le_bytes())? {
Ok(Some(bincode::deserialize(&bytes)?))
} else {
Ok(None)
}
}
/// Lookup information about a user by username
///
/// # Errors
/// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt
pub fn lookup_user<UserData>(
&self,
username: impl AsRef<str>
) -> Result<Option<RegisteredUser<UserData>>>
where
UserData: Serialize + DeserializeOwned
{
if let Some(bytes) = self.users.get(username.as_ref())? {
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
Ok(Some(RegisteredUser::new(username.as_ref().to_owned(), None, self.clone(), inner)))
} else {
Ok(None)
}
}
/// Attempt to determine the user who sent a request based on the certificate.
///
/// # Errors
/// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt
///
/// # Panics
/// Pancis if the database is corrupt
pub fn get_user<UserData>(
&self,
cert: Option<&Certificate>
) -> Result<User<UserData>>
where
UserData: Serialize + DeserializeOwned
{
if let Some(certificate) = cert {
let cert_hash = Self::hash_certificate(certificate);
if let Some(certificate_data) = self.lookup_certificate(cert_hash)? {
let user_inner = self.lookup_user(&certificate_data.owner_username)?
.expect("Database corruption: Certificate data refers to non-existant user");
Ok(User::SignedIn(user_inner.with_cert(certificate_data.certificate)))
} else {
Ok(User::NotSignedIn(NotSignedInUser {
certificate: certificate.clone(),
manager: self.clone(),
}))
}
} else {
Ok(User::Unauthenticated)
}
}
}

105
src/user_management/mod.rs Normal file
View File

@ -0,0 +1,105 @@
//! Tools for registering users & persisting arbitrary user data
//!
//! Many Gemini applications use some form of a login method in order to allow users to
//! persist personal data, authenticate themselves, and login from multiple devices using
//! multiple certificates.
//!
//! This module contains tools to help you build a system like this without stress. A
//! typical workflow looks something like this:
//!
//! * Call [`Request::user()`] to retrieve information about a user
//! * Direct any users without a certificate to create a certificate
//! * Ask users with a certificate not yet linked to an account to create an account using
//! [`NotSignedInUser::register()`] or link their certificate to an existing account
//! with a password using [`NotSignedInUser::attach()`].
//! * You should now have a [`RegisteredUser`] either from registering/attaching a
//! [`NotSignedInUser`] or because the user was already registered
//! * Access and modify user data using [`RegisteredUser::as_mut()`], changes are
//! automatically persisted to the database (on user drop).
//!
//! Use of this module requires the `user_management` feature to be enabled
pub mod user;
mod manager;
#[cfg(feature = "user_management_routes")]
mod routes;
#[cfg(feature = "user_management_routes")]
pub use routes::UserManagementRoutes;
pub use manager::UserManager;
pub use user::User;
pub use manager::CertificateData;
// Imports for docs
#[allow(unused_imports)]
use user::{NotSignedInUser, RegisteredUser};
#[allow(unused_imports)]
use crate::types::Request;
#[derive(Debug)]
pub enum UserManagerError {
UsernameNotUnique,
PasswordNotSet,
DatabaseError(sled::Error),
DatabaseTransactionError(sled::transaction::TransactionError),
DeserializeError(bincode::Error),
#[cfg(feature = "user_management_advanced")]
Argon2Error(argon2::Error),
}
impl From<sled::Error> for UserManagerError {
fn from(error: sled::Error) -> Self {
Self::DatabaseError(error)
}
}
impl From<sled::transaction::TransactionError> for UserManagerError {
fn from(error: sled::transaction::TransactionError) -> Self {
Self::DatabaseTransactionError(error)
}
}
impl From<bincode::Error> for UserManagerError {
fn from(error: bincode::Error) -> Self {
Self::DeserializeError(error)
}
}
#[cfg(feature = "user_management_advanced")]
impl From<argon2::Error> for UserManagerError {
fn from(error: argon2::Error) -> Self {
Self::Argon2Error(error)
}
}
impl std::error::Error for UserManagerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::DatabaseError(e) => Some(e),
Self::DatabaseTransactionError(e) => Some(e),
Self::DeserializeError(e) => Some(e),
#[cfg(feature = "user_management_advanced")]
Self::Argon2Error(e) => Some(e),
_ => None
}
}
}
impl std::fmt::Display for UserManagerError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
match self {
Self::UsernameNotUnique =>
write!(f, "Attempted to create a user using a username that's already been taken"),
Self::PasswordNotSet =>
write!(f, "Attempted to check the password of a user who has not set one yet"),
Self::DatabaseError(e) =>
write!(f, "Error accessing the user database: {}", e),
Self::DatabaseTransactionError(e) =>
write!(f, "Error accessing the user database: {}", e),
Self::DeserializeError(e) =>
write!(f, "Recieved messy data from database, possible corruption: {}", e),
#[cfg(feature = "user_management_advanced")]
Self::Argon2Error(e) =>
write!(f, "Argon2 Error, likely malformed password hash, possible database corruption: {}", e),
}
}
}
pub type Result<T> = std::result::Result<T, UserManagerError>;

View File

@ -0,0 +1,7 @@
# Account Exists
Hi {username}!
It looks like you already have an account all set up, and you're good to go. The link below will take you back to the app.
=> {redirect} Back

View File

@ -0,0 +1,11 @@
# Certificate Found!
Your certificate was found, and you're good to go.
If this is your first time, please create an account to get started!
=> /account/register Sign Up
If you already have an account, and this is a new certificate that you'd like to link, you can login using your password (if you've set it) below.
=> /account/login Log In

View File

@ -0,0 +1,7 @@
# Success!
Welcome {username}!
Your certificate has been linked.
=> {redirect} Back to app

View File

@ -0,0 +1,8 @@
# Wrong username or password
Sorry {username},
It looks like that username and password didn't match.
=> /account/login/{username} Try another password
=> /account/login That's not me!

View File

@ -0,0 +1,5 @@
# Certificate Found!
Your certificate was found, all that's left to do is pick a username!
=> /account/register Sign Up

View File

@ -0,0 +1,5 @@
# Welcome!
To continue, please create an account.
=> /account/register Set up my account

View File

@ -0,0 +1,5 @@
# Username Exists
Unfortunately, it looks like the username {username} is already taken.
=> /account/register Choose a different username

View File

@ -0,0 +1,5 @@
# Account Created!
Welcome {username}! Your account has been created.
=> {redirect} Back to app

View File

@ -0,0 +1,8 @@
# Welcome!
To continue, please create an account, or log in to link this certificate to an existing account.
=> /account/login Log In
=> /account/register Sign Up
Note: You can only link a new certificate if you have set a password using your original certificate. If you haven't already, please log in and set a password.

View File

@ -0,0 +1,5 @@
# Password Updated
To add a certificate, log in using the new certificate and provide your username and password.
=> /account Back to settings

View File

@ -0,0 +1,6 @@
# Username Exists
Unfortunately, it looks like the username {username} is already taken. If this is your account, and you have a password set, you can link this certificate to your account. Otherwise, please choose another username.
=> /account/register Choose a different username
=> /account/login/{username} Link this certificate

View File

@ -0,0 +1,6 @@
# Account Created!
Welcome {username}! Your account has been created. The link below will take you back to the app, or you can take a moment to review your account settings, including adding a password.
=> {redirect} Back
=> /account Settings

View File

@ -0,0 +1,9 @@
# Welcome!
It seems like you don't have a client certificate enabled. In order to log in, you need to connect using a client certificate. If your client supports it, you can use the link below to activate a certificate.
=> /account/askcert Choose a Certificate
If your client can't automatically manage client certificates, check the link below for a list of clients that support client certificates.
=> /account/clients Clients

View File

@ -0,0 +1,361 @@
use anyhow::Result;
use tokio::net::ToSocketAddrs;