Merge branch 'devel' into docs

This commit is contained in:
Emi Tatsuo 2020-12-17 17:58:30 -05:00
commit ffc91f2ae2
Signed by: Emi
GPG Key ID: 68FAB2E2E6DFC98B
16 changed files with 549 additions and 147 deletions

View File

@ -10,7 +10,21 @@
```
# kochab
Kochab is an extension & a fork of the Gemini SDK [northstar]. Where northstar creates an efficient and flexible foundation for Gemini projects, kochab seeks to be as ergonomic and intuitive as possible, making it possible to get straight into getting your ideas into geminispace, with no worrying about needing to build the tools to get there.
A hybrid Raw/SCGI gemini server library to make manifesting your best ideas as painless as possible
*(**bold text** added for **readability**)*
Kochab is an extension & a **fork of the Gemini SDK [northstar]**. Where northstar creates an efficient and flexible foundation for Gemini projects, kochab seeks to be as **ergonomic and intuitive** as possible, making it possible to get straight into getting your ideas into geminispace, with no worrying about needing to build the tools to get there.
kochab comes with **several unique features** to make it super easy to make your project happen:
Any kochab project can be **compiled either to serve raw Gemini or SCGI** with a single feature flag. Little to no conversion of the actual code is necessary, meaning you can do all of your development work in raw Gemini mode and switch to SCGI once you're ready for production.
Additionally, kochab optionally comes with a **full user management suite** which takes all of the work out of setting up a login and registration system with client certificates. Kochab can completely handle prompting users for certificates, **storing user data**, allowing users to use passwords to **link new certificates**, and even registering a specific route as an authenticated route.
Kochab might be the only library that can **automatically generate TLS certificates**, without needing so much as a single line of code on your part. But, if you need to customize this behavior, kochab even offers several different modes to handle certificate generation, or you can turn it off completely to make a tiny and compact binary.
Despite offering all these features, if you need your library to be tiny, **kochab can be tiny**. With just the SCGI feature turned on, kochab only has 6 direct dependencies, and a total dependency tree size of just 22 dependencies.
# Usage
@ -20,10 +34,24 @@ It is currently only possible to use kochab through it's git repo, although it m
kochab = { git = "https://gitlab.com/Alch_Emi/kochab.git", branch = "stable" }
```
Once you've got that set up, check out some of our [examples] to jump right into things, or start learning all the amazing features at your disposal by reading our docs [not yet published, please use `cargo doc --features ratelimiting,user_management_advanced,user_management_routes,serve_dir`].
# Generating a key & certificate
By default, kochab enables the `certgen` feature, which will **automatically generate a certificate** for you. All you need to do is run the program once and follow the prompts printed to stdout. You can override this behavior by disabling the feature, or by using the methods in the `Builder`.
If you want to generate a certificate manually, it's recommended that you temporarily enable the `certgen` feature to do it, and then disable it once you're done, although you can also use the `openssl` client tool if you wish
# Credit where credit is due
As this is a fork of [northstar], naturally a fair amount of our code derives from our upstream, and we'd like to grant a generous thank you to panicbit, and all of their work on the original project.
Kochab also depends on the wonderful gemtext parsing/building library written by [Cadey] \[[gemini][cadey-gemini]\] as part of the [maj] ecosystem, which also includes its own gemini server library.
Lastly, we use the :milkyway: emoji from Twemoji as our logo in our docs, which is licensed under CC BY 4.0
[northstar]: https://github.com/panicbit/northstar "Northstar GitHub"
[examples]: ./examples "Code Examples"
[Cadey]: https://christine.website/ "Cadey (Christine Dodrill)'s Blog"
[cadey-gemini]: gemini://cetacean.club/ "Cadey's Capsule on Gemini"
[maj]: https://tulpa.dev/cadey/maj/ "The maj Git Repository"

View File

@ -1,29 +1,42 @@
use anyhow::Result;
use log::LevelFilter;
use std::fmt::Write;
use kochab::{Request, Response, Server};
#[tokio::main]
/// This is a super quick demonstration of how you can check user certificates with kochab
///
/// The goal of this example is just to read the user's certificate and tell them their
/// certificate fingerprint, or if they aren't using a certificate, just tell them that
/// they didn't provide one.
///
/// You can of course require a certificate by sending a [`Response::client_certificate_required()`].
/// But, if you're interested in more advanced user management features, like letting
/// users create an account and persist data, you might want to check out the
/// user_management feature.
async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
Server::new()
.add_route("/", handle_request)
.serve_unix("kochab.sock")
Server::new() // Create a new server
.add_route("/", handle_request) // Bind our handling function to the root path
.serve_ip("localhost:1965") // Start serving content on the default gemini port
.await
}
/// This is the actual handler that does most of the actual work.
/// It'll be called by the server when we receive a request
async fn handle_request(request: Request) -> Result<Response> {
if let Some(fingerprint) = request.certificate() {
let mut message = String::from("You connected with a certificate with a fingerprint of:\n");
for byte in fingerprint {
write!(&mut message, "{:x}", byte).unwrap();
}
if let Some(fingerprint) = request.fingerprint() {
let message = format!(
"You connected with a certificate with a fingerprint of:\n{}",
fingerprint,
);
Ok(Response::success_plain(message))
} else {

View File

@ -40,6 +40,6 @@ async fn main() -> Result<()> {
Server::new()
.add_route("/", response)
.serve_unix("kochab.sock")
.serve_ip("localhost:1965")
.await
}

View File

@ -5,24 +5,41 @@ use log::LevelFilter;
use kochab::{Server, Request, Response, Document};
#[tokio::main]
/// An ultra-simple ratelimiting example
///
/// We set up two pages:
/// * `/*` which can be accessed as much as the user wants
/// * /limit/*` which can only be accessed twice every 10 seconds
///
/// Once we tell it what needs to be ratelimited, kochab will automatically handle keeping
/// track of what users have and have not visited that page and how often. A very small
/// concurrent background task is in charge of cleaning the in-memory database every so
/// often so that a memory leak doesn't form.
async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
Server::new()
.add_route("/", handle_request)
.ratelimit("/limit", 2, Duration::from_secs(60))
.serve_unix("kochab.sock")
Server::new() // Create a server
.add_route("/", handle_request) // Create a page, content doesn't matter
.ratelimit("/limit", 2, Duration::from_secs(10)) // Set the ratelimit to 2 / 10s
.serve_ip("localhost:1965") // Start the server
.await
}
/// Render a simple page based on the current URL
///
/// The actual content of the response, and really anything in this section, doesn't
/// actually affect the ratelimit, but it's nice to have a usable demo, so we set up a
/// couple nice pages
async fn handle_request(request: Request) -> Result<Response> {
let mut document = Document::new();
if let Some("limit") = request.trailing_segments().get(0).map(String::as_str) {
document.add_text("You're on a rate limited page!")
.add_text("You can only access this page twice per minute");
.add_text("You can only access this page twice every 10 seconds");
} else {
document.add_text("You're on a normal page!")
.add_text("You can access this page as much as you like.");

View File

@ -3,16 +3,32 @@ use log::LevelFilter;
use kochab::{Document, document::HeadingLevel, Request, Response};
#[tokio::main]
/// A quick demo to show off how an app can have multiple handlers on diffrent routes
///
/// We set up three different routes:
/// * `/route/long/*`
/// * `/route/*`
/// * `/*` which matches any other route
///
/// Each route generates a slightly different page, although they all use the same layout
/// through the [`generate_doc()`] method. Each page states which route was matched, and
/// all the trailing path segments.
///
/// For example, a request to `/route/trail` would be matched by the short route
/// (`/route/*`) with the trailing path segment `["trail"]`
async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
kochab::Server::new()
.add_route("/", handle_base)
.add_route("/route", handle_short)
.add_route("/route/long", handle_long)
.serve_unix("kochab.sock")
kochab::Server::new() // Create a new server
.add_route("/", handle_base) // Register the base route (order irrelevant)
.add_route("/route", handle_short) // Reigster the short route
.add_route("/route/long", handle_long) // Register the long route
.serve_ip("localhost:1965") // Start the server
.await
}
@ -32,7 +48,11 @@ async fn handle_long(req: Request) -> Result<Response> {
}
fn generate_doc(route_name: &str, req: &Request) -> Document {
// Trailing segments comes in as a Vec of segments, so we join them together for
// display purposes
let trailing = req.trailing_segments().join("/");
let mut doc = Document::new();
doc.add_heading(HeadingLevel::H1, "Routing Demo")
.add_text(&format!("You're currently on the {} route", route_name))

View File

@ -5,7 +5,18 @@ use log::LevelFilter;
use kochab::Server;
#[tokio::main]
/// Serving some static content from the filesystem is easy with Kochab
///
/// This example serves from the `./public` directory on the base route, and adds a
/// special one-page bind to `/about` that always serves `README.md`
///
/// Note, use this module with a little bit of caution. The directory serving feature is
/// currently unfinished, and the API is subject to change dramatically in future updates.
/// It should be secure, but you may need to do some refactoring in coming updates.
async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
@ -13,6 +24,6 @@ async fn main() -> Result<()> {
Server::new()
.add_route("/", PathBuf::from("public")) // Serve directory listings & file contents
.add_route("/about", PathBuf::from("README.md")) // Serve a single file
.serve_unix("kochab.sock")
.serve_ip("localhost:1965")
.await
}

View File

@ -35,7 +35,7 @@ async fn main() -> Result<()> {
.add_um_routes::<String>()
// Start the server
.serve_unix("kochab.sock")
.serve_ip("localhost:1965")
.await
}

View File

@ -1,20 +0,0 @@
# This is a super simple molly brown config file for the purpose of testing SCGI
# applications. Although you are welcome to use this as a base for an actual webserver,
# please find somewhere better for your production sockets.
#
# You can get a copy of molly brown and more information about configuring it from:
# https://tildegit.org/solderpunk/molly-brown
#
# Once installed, run the test server using the command
# molly-brown -c molly-brown.conf
Port = 1965
Hostname = "localhost"
CertPath = "cert/cert.pem"
KeyPath = "cert/key.pem"
AccessLog = "/dev/stdout"
ErrorLog = "/dev/stderr"
[SCGIPaths]
"/" = "kochab.sock"

View File

@ -1,5 +1,5 @@
#![warn(missing_docs)]
#![feature(doc_cfg)]
#![doc(html_logo_url = "https://raw.githubusercontent.com/twitter/twemoji/master/assets/svg/1f30c.svg")]
//! Kochab is an ergonomic and intuitive library for quickly building highly functional
//! and advanced Gemini applications on either SCGI or raw Gemini.
//!
@ -137,15 +137,19 @@
//! To give your code a run, you'll need a server to handle Gemini requests and pass them
//! off to your SCGI server. There's a few Gemini servers out there with SCGI support,
//! but if you're just interested in giving your code a quick run, I'd recommend
//! mollybrown, which has very good SCGI support and is super easy to set up
//! stargazer, which has very good SCGI support and is super easy to set up if you are
//! already using cargo.
//!
//! You can grab a copy of molly brown from [tildegit.org/solderpunk/molly-brown][1].
//! You can install stargazer by running.
//! ```sh
//! cargo install stargazer
//! ```
//!
//! Once you have it, you can find a super simple configuration file [here][2], and then
//! just run
//!
//! ```sh
//! molly-brown -c molly-brown.conf
//! stargazer -C stargazer.ini
//! ```
//!
//! Now, when you run your code, you can connect to `localhost`, and molly brown will
@ -173,7 +177,7 @@
//! For more information, see [`Server::set_autorewrite()`].
//!
//! [1]: https://tildegit.org/solderpunk/molly-brown
//! [2]: https://gitlab.com/Alch_Emi/kochab/-/raw/244fd251/molly-brown.conf
//! [2]: https://gitlab.com/Alch_Emi/kochab/-/raw/devel/stargazer.ini
//! [blobcat-pout]: https://the-apothecary.club/_matrix/media/r0/thumbnail/the-apothecary.club/10a406405a5bcd699a5328259133bfd9260320a6?height=99&width=20 ":blobcat-pout:"
//! <style>
//! img[alt=blobcat-pout] { width: 20px; vertical-align: top; }
@ -188,7 +192,6 @@ compile_error!("Please enable only one of either the `gemini_srv` or `scgi_srv`
compile_error!("Please enable at least one of either the `gemini_srv` or `scgi_srv` features on the kochab crate");
use std::{
sync::Arc,
time::Duration,
future::Future,
};
@ -258,32 +261,33 @@ pub const REQUEST_URI_MAX_LEN: usize = 1024;
/// The default port for the gemini protocol
pub const GEMINI_PORT: u16 = 1965;
#[derive(Clone)]
struct ServerInner {
#[cfg(feature = "gemini_srv")]
tls_acceptor: TlsAcceptor,
routes: Arc<RoutingNode<Handler>>,
routes: RoutingNode<Handler>,
timeout: Duration,
complex_timeout: Option<Duration>,
#[cfg(feature = "scgi_srv")]
autorewrite: bool,
#[cfg(feature="ratelimiting")]
rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
rate_limits: RoutingNode<RateLimiter<IpAddr>>,
#[cfg(feature="user_management")]
manager: UserManager,
}
impl ServerInner {
async fn serve_ip(self, listener: TcpListener) -> Result<()> {
let static_self: &'static Self = Box::leak(Box::new(self));
#[cfg(feature = "ratelimiting")]
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
tokio::spawn(prune_ratelimit_log(&static_self.rate_limits));
loop {
let (stream, _addr) = listener.accept().await
.context("Failed to accept client")?;
let this = self.clone();
tokio::spawn(async move {
if let Err(err) = this.serve_client(stream).await {
if let Err(err) = static_self.serve_client(stream).await {
error!("{:?}", err);
}
});
@ -294,16 +298,17 @@ impl ServerInner {
// Yeah it's code duplication, but I can't find a way around it, so this is what we're
// getting for now
async fn serve_unix(self, listener: UnixListener) -> Result<()> {
let static_self: &'static Self = Box::leak(Box::new(self));
#[cfg(feature = "ratelimiting")]
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
tokio::spawn(prune_ratelimit_log(&static_self.rate_limits));
loop {
let (stream, _addr) = listener.accept().await
.context("Failed to accept client")?;
let this = self.clone();
tokio::spawn(async move {
if let Err(err) = this.serve_client(stream).await {
if let Err(err) = static_self.serve_client(stream).await {
error!("{:?}", err);
}
});
@ -311,7 +316,7 @@ impl ServerInner {
}
async fn serve_client(
&self,
&'static self,
#[cfg(feature = "gemini_srv")]
stream: TcpStream,
#[cfg(all(feature = "scgi_srv", not(feature = "gemini_srv")))]
@ -432,6 +437,7 @@ impl ServerInner {
Ok(())
}
#[allow(clippy::useless_let_if_seq)]
async fn send_response(&self, response: Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
let use_complex_timeout =
response.body.is_some() &&
@ -495,7 +501,7 @@ impl ServerInner {
#[cfg(feature = "gemini_srv")]
async fn receive_request(
&self,
&'static self,
stream: &mut (impl AsyncBufRead + Unpin + Send),
) -> Result<Request> {
const HEADER_LIMIT: usize = REQUEST_URI_MAX_LEN + "\r\n".len();
@ -523,13 +529,13 @@ impl ServerInner {
Request::new(
uri,
#[cfg(feature="user_management")]
self.manager.clone(),
&self.manager,
).context("Failed to create request from URI")
}
#[cfg(feature = "scgi_srv")]
async fn receive_request(
&self,
&'static self,
stream: &mut (impl AsyncBufRead + Unpin),
) -> Result<Request> {
let mut buff = Vec::with_capacity(4);
@ -597,7 +603,7 @@ impl ServerInner {
Request::new(
headers,
#[cfg(feature = "user_management")]
self.manager.clone(),
&self.manager,
)?
)
}
@ -671,6 +677,7 @@ pub struct Server {
timeout: Duration,
complex_body_timeout_override: Option<Duration>,
routes: RoutingNode<Handler>,
#[cfg(feature = "scgi_srv")]
autorewrite: bool,
#[cfg(feature = "gemini_srv")]
cert_path: PathBuf,
@ -693,6 +700,7 @@ impl Server {
timeout: Duration::from_secs(1),
complex_body_timeout_override: Some(Duration::from_secs(30)),
routes: RoutingNode::default(),
#[cfg(feature = "scgi_srv")]
autorewrite: false,
#[cfg(feature = "gemini_srv")]
cert_path: PathBuf::from("cert/cert.pem"),
@ -885,6 +893,7 @@ impl Server {
self
}
#[cfg_attr(feature = "gemini_srv", allow(unused_mut), allow(unused_variables))]
/// Enable or disable autorewrite
///
/// Many times, an app will served alongside other apps all on one domain. For
@ -947,7 +956,9 @@ impl Server {
/// For more information about what responses are rewritten,
/// see [`Response::rewrite_all()`].
pub fn set_autorewrite(mut self, autorewrite: bool) -> Self {
self.autorewrite = autorewrite;
#[cfg(feature = "scgi_srv")] {
self.autorewrite = autorewrite;
}
self
}
@ -966,14 +977,15 @@ impl Server {
let data_dir = self.data_dir;
Ok(ServerInner {
routes: Arc::new(self.routes),
routes: self.routes,
timeout: self.timeout,
complex_timeout: self.complex_body_timeout_override,
#[cfg(feature = "scgi_srv")]
autorewrite: self.autorewrite,
#[cfg(feature = "gemini_srv")]
tls_acceptor: TlsAcceptor::from(config),
#[cfg(feature="ratelimiting")]
rate_limits: Arc::new(self.rate_limits),
rate_limits: self.rate_limits,
#[cfg(feature="user_management")]
manager: UserManager::new(
self.database.unwrap_or_else(move|| sled::open(data_dir).unwrap())
@ -985,6 +997,11 @@ impl Server {
///
/// `addr` can be anything `tokio` can parse, including just a string like
/// "localhost:1965"
///
/// This will only ever exit with an error. It's important to note that even if the
/// function exits, the server will NOT be deallocated, since references to it in
/// concurrently running futures may still exist. As such, a loop that handles an
/// error by re-serving a new server may trigger a memory leak.
pub async fn serve_ip(self, addr: impl ToSocketAddrs + Send) -> Result<()> {
let server = self.build()?;
let socket = TcpListener::bind(addr).await?;
@ -997,6 +1014,8 @@ impl Server {
///
/// Requires an address in the form of a path to bind to. This is only available when
/// in `scgi_srv` mode.
///
/// Please read the details and warnings of [`serve_ip()`] for more information
pub async fn serve_unix(self, addr: impl AsRef<std::path::Path>) -> Result<()> {
let server = self.build()?;
let socket = UnixListener::bind(addr)?;
@ -1047,12 +1066,11 @@ async fn send_response_body(mut body: Option<Body>, stream: &mut (impl AsyncWrit
#[cfg(feature="ratelimiting")]
/// Every 5 minutes, remove excess keys from all ratelimiters
async fn prune_ratelimit_log(rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>) -> Never {
async fn prune_ratelimit_log(rate_limits: &'static RoutingNode<RateLimiter<IpAddr>>) -> Never {
let mut interval = interval(tokio::time::Duration::from_secs(10));
let log = rate_limits.as_ref();
loop {
interval.tick().await;
log.iter().for_each(RateLimiter::trim_keys_verbose);
rate_limits.iter().for_each(RateLimiter::trim_keys_verbose);
}
}

View File

@ -1,4 +1,7 @@
use std::ops;
use std::{
fmt::Write,
ops,
};
#[cfg(feature = "gemini_srv")]
use std::convert::TryInto;
#[cfg(feature = "scgi_srv")]
@ -39,7 +42,7 @@ pub struct Request {
certificate: Option<[u8; 32]>,
trailing_segments: Option<Vec<String>>,
#[cfg(feature="user_management")]
manager: UserManager,
manager: &'static UserManager,
#[cfg(feature = "scgi_srv")]
headers: HashMap<String, String>,
#[cfg(feature = "scgi_srv")]
@ -80,7 +83,7 @@ impl Request {
#[cfg(feature = "scgi_srv")]
headers: HashMap<String, String>,
#[cfg(feature="user_management")]
manager: UserManager,
manager: &'static UserManager,
) -> Result<Self> {
#[cfg(feature = "scgi_srv")]
#[allow(clippy::or_fun_call)] // Lay off it's a macro
@ -248,10 +251,43 @@ impl Request {
#[allow(clippy::missing_const_for_fn)]
/// Get the fingerprint of the certificate the user is connecting with
///
/// Please not that this is **not** the full certificate, just it's fingerprint
/// represented as bytes. The full certificate is not currently exposed, since some
/// SCGI servers may not receive it.
///
/// If you are planning on displaying the certificate to the user, you may want to
/// consider using [`fingerprint()`], which stringifies the output of this method.
///
/// [`fingerprint()`]: Request::fingerprint
pub fn certificate(&self) -> Option<&[u8; 32]> {
self.certificate.as_ref()
}
/// Get the user's certificate as a [`String`] contain the hex fingerprint
///
/// This is a convenience method for stringiying the certificate fingerprint from the
/// [`certificate()`] method. If you're using this fingerprint as a key for some user
/// data, you may want to perfer the former method. This method should be used when
/// the fingerprint is being displayed to the user.
///
/// The returned fingerprint will always be a 64 character string containing lowercase
/// hex digits, such as
/// `5e7097dc25dc62867ee4e0d3214a74b83156e613fdf92ca05e08c79efb14b90e`
///
/// [`certificate()`]: Request::certificate
pub fn fingerprint(&self) -> Option<String> {
self.certificate.as_ref().map(|c| {
let mut message = String::with_capacity(64);
for byte in c {
write!(&mut message, "{:x}", byte).unwrap();
}
message
})
}
#[cfg(feature="user_management")]
#[doc(cfg(feature = "user_management"))]
/// Attempt to determine the user who sent this request
@ -262,7 +298,7 @@ impl Request {
where
UserData: Serialize + DeserializeOwned
{
Ok(self.manager.get_user(self.certificate())?)
Ok(self.manager.get_user_by_cert(self.certificate())?)
}
#[cfg(feature="user_management")]
@ -270,8 +306,8 @@ impl Request {
/// Expose the server's UserManager
///
/// Can be used to query users, or directly access the database
pub fn user_manager(&self) -> &UserManager {
&self.manager
pub fn user_manager(&self) -> &'static UserManager {
self.manager
}
/// Attempt to rewrite an absolute URL against the base path of the SCGI script

View File

@ -180,8 +180,16 @@ impl Response {
}
/// True if the response is a SUCCESS (10) response
///
/// ```
/// # use kochab::Response;
/// let redir = Response::redirect_permanent("/");
/// assert!(!redir.is_success());
/// let success = Response::success_plain("Hello gemini!");
/// assert!(success.is_success());
/// ```
pub const fn is_success(&self) -> bool {
self.status == 10
self.status == 20
}
#[cfg_attr(feature="gemini_srv",allow(unused_variables))]

View File

@ -1,5 +1,7 @@
use serde::{Serialize, de::DeserializeOwned};
use std::convert::TryInto;
use crate::user_management::{User, Result};
use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser};
@ -21,8 +23,9 @@ pub struct UserManager {
/// [`lookup_user()`]: Self::lookup_user
/// [`lookup_certificate()`]: Self::lookup_certificate
pub 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
pub (crate) users: sled::Tree, // user_id:u64 maps to data:PartialUser
pub (crate) certificates: sled::Tree, // fingerprint:[u8; 32] maps to uid:u64
pub (crate) usernames: sled::Tree, // username:String maps to uid:u64
}
impl UserManager {
@ -35,6 +38,7 @@ impl UserManager {
Ok(Self {
users: db.open_tree("gay.emii.kochab.users")?,
certificates: db.open_tree("gay.emii.kochab.certificates")?,
usernames: db.open_tree("gay.emii.kochab.usernames")?,
db,
})
}
@ -44,29 +48,60 @@ impl UserManager {
/// # 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: [u8; 32]) -> Result<Option<String>> {
///
/// [`None`] can be returned if their is no user with this certificate.
pub fn lookup_certificate<UserData: Serialize + DeserializeOwned>(
&self,
cert: [u8; 32]
) -> Result<Option<RegisteredUser<UserData>>> {
if let Some(bytes) = self.certificates.get(cert)? {
Ok(Some(std::str::from_utf8(bytes.as_ref())?.to_string()))
let id = u64::from_le_bytes(bytes.as_ref().try_into()?);
Ok(Some(
self.lookup_user(id)?
.ok_or(super::DeserializeError::InvalidReference(id))?
))
} else {
Ok(None)
}
}
/// Lookup information about a user by username
/// Get the user with the specified username
///
/// # Errors
/// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt
///
/// [`None`] can be returned if their is no user with this username.
pub fn lookup_username<UserData: Serialize + DeserializeOwned>(
&self,
username: impl AsRef<str>
) -> Result<Option<RegisteredUser<UserData>>> {
if let Some(bytes) = self.usernames.get(username.as_ref())? {
let id = u64::from_le_bytes(bytes.as_ref().try_into()?);
Ok(Some(
self.lookup_user(id)?
.ok_or(super::DeserializeError::InvalidReference(id))?
))
} else {
Ok(None)
}
}
/// Lookup a user by uid
///
/// # 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>
uid: u64,
) -> Result<Option<RegisteredUser<UserData>>>
where
UserData: Serialize + DeserializeOwned
{
if let Some(bytes) = self.users.get(username.as_ref())? {
if let Some(bytes) = self.users.get(uid.to_le_bytes())? {
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
Ok(Some(RegisteredUser::new(username.as_ref().to_owned(), None, self.clone(), inner)))
Ok(Some(RegisteredUser::new(uid, None, self.clone(), inner)))
} else {
Ok(None)
}
@ -79,20 +114,18 @@ impl UserManager {
/// the database is corrupt
pub fn all_users<UserData>(
&self,
) -> Vec<RegisteredUser<UserData>>
) -> impl Iterator<Item = Result<RegisteredUser<UserData>>>
where
UserData: Serialize + DeserializeOwned
{
let this = self.clone();
self.users.iter()
.map(|result| {
let (username, bytes) = result.expect("Failed to connect to database");
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())
.expect("Received malformed data from database");
let username = String::from_utf8(username.to_vec())
.expect("Malformed username in database");
RegisteredUser::new(username, None, self.clone(), inner)
.map(move|result| {
let (uid, bytes) = result?;
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
let uid = u64::from_le_bytes(uid.as_ref().try_into()?);
Ok(RegisteredUser::new(uid, None, this.clone(), inner))
})
.collect()
}
/// Attempt to determine the user who sent a request based on the certificate.
@ -103,7 +136,7 @@ impl UserManager {
///
/// # Panics
/// Pancis if the database is corrupt
pub fn get_user<UserData>(
pub fn get_user_by_cert<UserData>(
&self,
cert: Option<&[u8; 32]>
) -> Result<User<UserData>>
@ -111,9 +144,7 @@ impl UserManager {
UserData: Serialize + DeserializeOwned
{
if let Some(certificate) = cert {
if let Some(username) = self.lookup_certificate(*certificate)? {
let user_inner = self.lookup_user(&username)?
.expect("Database corruption: Certificate data refers to non-existant user");
if let Some(user_inner) = self.lookup_certificate(*certificate)? {
Ok(User::SignedIn(user_inner.with_cert(*certificate)))
} else {
Ok(User::NotSignedIn(NotSignedInUser {

View File

@ -60,16 +60,13 @@ pub enum UserManagerError {
/// There was an error deserializing from the database
///
/// This likely indicates that the database was generated with
/// a different version of the software than is curretly being used, either because
/// the UserData struct has changed, or because kochab itself has updated it's schema.
/// This likely indicates that the database was generated with a different version of
/// the software than is curretly being used, either because the UserData struct has
/// changed, or because kochab itself has updated it's schema.
///
/// Recommended handling: Log a visible error and exit. Recommend seeking a database
/// migration script or deleting the database
DeserializeBincodeError(bincode::Error),
/// A different version of [`UserManagerError::DeserializeBincodeError`]
DeserializeUtf8Error(std::str::Utf8Error),
DeserializeError(DeserializeError),
#[cfg(feature = "user_management_advanced")]
#[doc(cfg(feature = "user_management_advanced"))]
@ -94,13 +91,25 @@ impl From<sled::transaction::TransactionError> for UserManagerError {
impl From<bincode::Error> for UserManagerError {
fn from(error: bincode::Error) -> Self {
Self::DeserializeBincodeError(error)
Self::DeserializeError(error.into())
}
}
impl From<std::array::TryFromSliceError> for UserManagerError {
fn from(error: std::array::TryFromSliceError) -> Self {
Self::DeserializeError(error.into())
}
}
impl From<std::str::Utf8Error> for UserManagerError {
fn from(error: std::str::Utf8Error) -> Self {
Self::DeserializeUtf8Error(error)
Self::DeserializeError(error.into())
}
}
impl From<DeserializeError> for UserManagerError {
fn from(error: DeserializeError) -> Self {
Self::DeserializeError(error)
}
}
@ -117,8 +126,7 @@ impl std::error::Error for UserManagerError {
match self {
Self::DatabaseError(e) => Some(e),
Self::DatabaseTransactionError(e) => Some(e),
Self::DeserializeBincodeError(e) => Some(e),
Self::DeserializeUtf8Error(e) => Some(e),
Self::DeserializeError(e) => Some(e),
#[cfg(feature = "user_management_advanced")]
#[doc(cfg(feature = "user_management_advanced"))]
Self::Argon2Error(e) => Some(e),
@ -138,10 +146,8 @@ impl std::fmt::Display for UserManagerError {
write!(f, "Error accessing the user database: {}", e),
Self::DatabaseTransactionError(e) =>
write!(f, "Error accessing the user database: {}", e),
Self::DeserializeBincodeError(e) =>
Self::DeserializeError(e) =>
write!(f, "Recieved messy data from database, possible corruption: {}", e),
Self::DeserializeUtf8Error(e) =>
write!(f, "Recieved invalid UTF-8 from database, possible corruption: {}", e),
#[cfg(feature = "user_management_advanced")]
#[doc(cfg(feature = "user_management_advanced"))]
Self::Argon2Error(e) =>
@ -150,5 +156,83 @@ impl std::fmt::Display for UserManagerError {
}
}
#[derive(Debug)]
/// Indicates an error deserializing from the database
///
/// This likely indicates that the database was generated with
/// a different version of the software than is curretly being used, either because
/// the UserData struct has changed, or because kochab itself has updated it's schema.
pub enum DeserializeError {
/// There was an error deserializing an entire struct
///
/// Most likely indicates that the struct itself changed, i.e. between versions.
StructError(bincode::Error),
/// The was an error deserializing a userid
///
/// Likely because too many bytes were received. If you are getting this, you are
/// likely using a pre-0.1.0 version of kochab, and should update to the latest
/// version, or you're using a database that was created by a version of kochab that
/// was released after the one you're currently using. However, as of right now, no
/// such release exists or is planned.
UidError(std::array::TryFromSliceError),
/// There was an error deserializing a username
///
/// Indicates data corruption or a misaligned database version
UsernameError(std::str::Utf8Error),
/// A certificate or username was linked to a non-existant user id
///
/// This error should not occur, except in very unlikely scenarios, or if there's a
/// bug with kochab
InvalidReference(u64),
}
impl From<bincode::Error> for DeserializeError {
fn from(error: bincode::Error) -> Self {
Self::StructError(error)
}
}
impl From<std::array::TryFromSliceError> for DeserializeError {
fn from(error: std::array::TryFromSliceError) -> Self {
Self::UidError(error)
}
}
impl From<std::str::Utf8Error> for DeserializeError {
fn from(error: std::str::Utf8Error) -> Self {
Self::UsernameError(error)
}
}
impl std::error::Error for DeserializeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::StructError(e) => Some(e),
Self::UidError(e) => Some(e),
Self::UsernameError(e) => Some(e),
Self::InvalidReference(_) => None,
}
}
}
impl std::fmt::Display for DeserializeError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
match self {
Self::StructError(e) =>
write!(f, "Failed to deserialize struct: {}", e),
Self::UidError(e) =>
write!(f, "Got wrong number of bytes while deserializing user ID: {}", e),
Self::UsernameError(e) =>
write!(f, "Got invalid UTF-8 instead of username: {}", e),
Self::InvalidReference(uid) =>
write!(f, "Database refers to nonexistant user with userid {}", uid),
}
}
}
/// A result type returned by many methods within the [`user_management`] module
pub type Result<T> = std::result::Result<T, UserManagerError>;

View File

@ -29,6 +29,7 @@ pub trait UserManagementRoutes: private::Sealed {
/// * `/account/register`, for users to register a new account
/// * `/account/login`, for users to link their certificate to an existing account
/// * `/account/password`, to change the user's password
/// * `/account/delete`, to delete an account
///
/// If this method is used, no more routes should be added under `/account`. If you
/// would like to direct a user to login from your application, you should send them
@ -36,7 +37,7 @@ pub trait UserManagementRoutes: private::Sealed {
///
/// The `redir` argument allows you to specify the point that users will be directed
/// to return to once their account has been created.
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + 'static>(self) -> Self;
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + Send + Sync + 'static>(self) -> Self;
/// Add a special route that requires users to be logged in
///
@ -93,14 +94,15 @@ impl UserManagementRoutes for crate::Server {
/// Add pre-configured routes to the serve to handle authentication
///
/// See [`UserManagementRoutes::add_um_routes()`]
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + 'static>(self) -> Self {
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + Send + Sync + 'static>(self) -> Self {
let clients_page = Response::success_gemini(include_str!("pages/clients.gmi"));
#[allow(unused_mut)]
let mut modified_self = self.add_route("/account", handle_base::<UserData>)
.add_route("/account/askcert", handle_ask_cert::<UserData>)
.add_route("/account/register", handle_register::<UserData>)
.add_route("/account/clients", clients_page);
.add_route("/account/clients", clients_page)
.add_authenticated_route("/account/delete", handle_delete::<UserData>);
#[cfg(feature = "user_management_advanced")] {
modified_self = modified_self
@ -279,6 +281,31 @@ async fn handle_register<UserData: Serialize + DeserializeOwned + Default>(reque
})
}
async fn handle_delete<UserData: Serialize + DeserializeOwned + Sync + Send>(
request: Request,
user: RegisteredUser<UserData>,
) -> Result<Response> {
const DELETE_PHRASE: &str = "I would like to delete my account";
Ok(match request.input() {
Some(DELETE_PHRASE) => {
user.delete()?;
Response::success_gemini(include_str!("pages/deleted.gmi"))
},
Some(_) => {
Response::bad_request("Phrase did not match. Your account has not been deleted.")
},
None => {
Response::input(
format!(
"Are you sure you'd like to delete your account? Please type \"{}\" to continue.",
DELETE_PHRASE,
)
)
},
})
}
#[cfg(feature = "user_management_advanced")]
async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
Ok(match request.user::<UserData>()? {
@ -382,7 +409,9 @@ fn render_settings_menu<UserData: Serialize + DeserializeOwned>(
.add_blank_line()
.add_link("/account/password", if user.has_password() { "Change password" } else { "Set password" });
document.into()
document
.add_link("/account/delete", "Delete your account")
.into()
}
fn render_unauth_page<'a>(
@ -422,7 +451,7 @@ fn get_redirect<T: Serialize + DeserializeOwned>(user: &RegisteredUser<T>) -> St
let maybe_redir = ref_to_map.get(cert).cloned();
let redirect = maybe_redir.unwrap_or_else(||"/".to_string());
debug!("Accessed redirect to \"{}\" for cert {:x?}", redirect, cert);
trace!("Accessed redirect to \"{}\" for cert {:x?}", redirect, cert);
redirect
}

View File

@ -48,6 +48,7 @@ lazy_static::lazy_static! {
pub (crate) struct PartialUser<UserData> {
pub data: UserData,
pub certificates: Vec<[u8; 32]>,
pub username: String,
#[cfg(feature = "user_management_advanced")]
pub pass_hash: Option<(Vec<u8>, [u8; 32])>,
}
@ -58,12 +59,12 @@ impl<UserData> PartialUser<UserData> {
///
/// This MUST be called if the user data is modified using the AsMut trait, or else
/// changes will not be written to the database
fn store(&self, tree: &sled::Tree, username: impl AsRef<[u8]>) -> Result<()>
fn store(&self, tree: &sled::Tree, uid: u64) -> Result<()>
where
UserData: Serialize
{
tree.insert(
&username,
uid.to_le_bytes(),
bincode::serialize(&self)?,
)?;
Ok(())
@ -117,26 +118,48 @@ impl NotSignedInUser {
if self.manager.users.contains_key(username.as_str())? {
Err(super::UserManagerError::UsernameNotUnique)
} else {
info!("User {} registered!", username);
let mut newser = RegisteredUser::new(
// Create the partial user that will go into the database. We can't create
// the full user yet, since the ID won't be generated until we perform the
// insert.
let partial = PartialUser {
username,
data: UserData::default(),
certificates: vec![self.certificate],
#[cfg(feature = "user_management_advanced")]
pass_hash: None,
};
let serialized = bincode::serialize(&partial)?;
// Insert the user into the three relevant tables, thus finalizing their
// creation. This also produces the user id.
let id = (&self.manager.users, &self.manager.certificates, &self.manager.usernames)
.transaction(|(tx_usr, tx_crt, tx_nam)| {
let id = tx_usr.generate_id()?;
let id_bytes = id.to_le_bytes();
tx_usr.insert(
&id_bytes,
serialized.as_slice(),
)?;
tx_crt.insert(
&partial.certificates[0],
&id_bytes,
)?;
tx_nam.insert(
partial.username.as_bytes(),
&id_bytes,
)?;
Ok(id)
})?;
info!("User {}#{:08X} registered!", partial.username, id);
Ok(RegisteredUser::new(
id,
Some(self.certificate),
self.manager,
PartialUser {
data: UserData::default(),
certificates: Vec::with_capacity(1),
#[cfg(feature = "user_management_advanced")]
pass_hash: None,
},
);
// As a nice bonus, calling add_certificate with a user not yet in the
// database creates the user and adds the certificate in a single transaction.
// Because of this, we can delegate here ^^
newser.add_certificate(self.certificate)?;
Ok(newser)
partial,
))
}
}
@ -173,7 +196,7 @@ impl NotSignedInUser {
username: &str,
password: Option<&[u8]>,
) -> Result<Option<RegisteredUser<UserData>>> {
if let Some(mut user) = self.manager.lookup_user(username)? {
if let Some(mut user) = self.manager.lookup_username(username)? {
// Perform password check, if caller wants
if let Some(password) = password {
if !user.check_password(password)? {
@ -181,7 +204,6 @@ impl NotSignedInUser {
}
}
info!("User {} attached certificate with fingerprint {:x?}", username, &self.certificate[..]);
user.add_certificate(self.certificate)?;
user.active_certificate = Some(self.certificate);
Ok(Some(user))
@ -196,7 +218,7 @@ impl NotSignedInUser {
///
/// For more information about the user lifecycle and sign-in stages, see [`User`]
pub struct RegisteredUser<UserData: Serialize + DeserializeOwned> {
username: String,
uid: u64,
active_certificate: Option<[u8; 32]>,
manager: UserManager,
inner: PartialUser<UserData>,
@ -208,13 +230,13 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
/// Create a new user from parts
pub (crate) fn new(
username: String,
uid: u64,
active_certificate: Option<[u8; 32]>,
manager: UserManager,
inner: PartialUser<UserData>
) -> Self {
Self {
username,
uid,
active_certificate,
manager,
inner,
@ -247,9 +269,19 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
/// Get the user's current username.
///
/// NOTE: This is not guaranteed not to change.
/// NOTE: This is not guaranteed not to change. If you need an immutable reference to
/// this user, prefer their [UID], which is guaranteed static.
///
/// [UID]: Self::uid()
pub fn username(&self) -> &String {
&self.username
&self.inner.username
}
/// Get the user's id.
///
/// This is not guaranteed not to change.
pub fn uid(&self) -> u64 {
self.uid
}
#[cfg(feature = "user_management_advanced")]
@ -264,13 +296,18 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
try_password: impl AsRef<[u8]>
) -> Result<bool> {
if let Some((hash, salt)) = &self.inner.pass_hash {
Ok(argon2::verify_raw(
let result = argon2::verify_raw(
try_password.as_ref(),
salt,
hash.as_ref(),
&ARGON2_CONFIG,
)?)
)?;
if !result {
info!("Someone failed to log in to the account of {} (wrong)", self);
}
Ok(result)
} else {
info!("Someone failed to log in to the account of {} (not set)", self);
Err(super::UserManagerError::PasswordNotSet)
}
}
@ -320,6 +357,8 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
salt,
));
self.has_changed = true;
info!("Updated password for user {}", self);
Ok(())
}
@ -331,8 +370,9 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
where
UserData: Serialize
{
self.inner.store(&self.manager.users, &self.username)?;
self.inner.store(&self.manager.users, self.uid)?;
self.has_changed = false;
debug!("Changes to user {} saved", self);
Ok(())
}
@ -349,20 +389,67 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
self.inner.certificates.push(certificate);
let inner_serialized = bincode::serialize(&self.inner)?;
let uid_bytes = self.uid.to_le_bytes();
(&self.manager.users, &self.manager.certificates)
.transaction(|(tx_usr, tx_crt)| {
tx_usr.insert(
self.username.as_str(),
&uid_bytes,
inner_serialized.clone(),
)?;
tx_crt.insert(
&certificate,
self.username.as_bytes(),
&uid_bytes,
)?;
Ok(())
})?;
info!("User {} added certificate with fingerprint {:X?}", self, certificate);
Ok(())
}
/// Permanently delete this user and all their data
///
/// Permanently remove all traces of this user from the database, including:
/// * User data associated with their account
/// * Any certificates linked to their account
/// * Their username (which is freed for other users to take)
/// * Their password hash
/// * ~~Any happy memories you have with them~~
///
/// If you're not using [`UserManagementRoutes`], it's strongly recommended that you
/// expose some way for users to delete their accounts, in order to appropriately
/// respect their privacy and their right to their data.
///
/// If you *are* using [`UserManagementRoutes`], your users already have a way of
/// deleting their accounts! Just direct them to `/account`.
///
/// # Errors
/// Can error if the a database error occurs
pub fn delete(mut self) -> Result<()> {
// Prevent re-saving on drop
self.has_changed = false;
let certificates = self.all_certificates();
(&self.manager.users, &self.manager.certificates, &self.manager.usernames).transaction(|(tx_usr, tx_crt, tx_nam)| {
tx_nam.remove(
self.inner.username.as_str(),
)?;
tx_usr.remove(
&self.uid.to_le_bytes(),
)?;
for cert in certificates {
tx_crt.remove(
cert,
)?;
}
Ok(())
})?;
info!("Deleted user {}", self);
Ok(())
}
@ -397,6 +484,19 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
}
}
impl <UD: Serialize + DeserializeOwned> std::fmt::Display for RegisteredUser<UD> {
/// Synthesize a unique identifier for the user including their username
///
/// This is literally just the user's username postfixed with `#` and eight characters
/// representing the hex encoding of the users id. This is not guaranteed not to
/// change, but is great for logging, because it is simultaniously human-readable but
/// at the same time the last 8 characters offer a way to look up a user even with
/// username changes
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{:08X}", self.username(), self.uid)
}
}
impl<UserData: Serialize + DeserializeOwned> std::ops::Drop for RegisteredUser<UserData> {
fn drop(&mut self) {
if self.has_changed {
@ -419,6 +519,7 @@ impl<UserData: Serialize + DeserializeOwned> AsMut<UserData> for RegisteredUser<
}
}
#[cfg(all(feature = "user_management_advanced", not(feature = "ring")))]
/// Inexpensive but low quality random
fn pcg8(state: &mut u16) -> u8 {

26
stargazer.ini Normal file
View File

@ -0,0 +1,26 @@
; This is a super simple configuration file for testing out SCGI apps with stargazer.
;
; To spin a demo server, first install stargazer with
; cargo install stargazer
;
; and then run this configuration with
; stargazer -c stargazer.ini
;
; Now, when you run your application bound to localhost:1312, any gemini connections to
; gemini://localhost should automatically be forwarded to your application. If you'd like
; to test out path rewriting, change the [localhost:/] header to [localhost:/app], restart
; stargazer, and connect to gemini://localhost/path.
;
; This configuration should be sufficient for any testing, but if you're interested in
; more in-depth configuration, or for using stargazer in production, please see the
; stargazer repository at https://git.sr.ht/~zethra/stargazer/
listen = 0.0.0.0:1965
[:tls]
store = cert
organization = Kochab Test Server
[localhost:/]
scgi = on
scgi-address = localhost:1312