This site is hosted with Maj


Well, I've done a lot of work behind the scenes and now I can proudly announce that cetacean.club is now hosted using my own server code! I'll go into more detail below, but first I want to thank a few people and projects that helped make this happen:

  • Rustls for being such a flexible and decent TLS client/server framework for doing TLS operations in Rust
  • The team behind async-std, which powers every single client socket connected to this site
  • Matt Brubeck for writing agate, which has been a huge inspiration (and was previously what this site was hosted with)
  • The people of #gemini for inspiration and moral support

  • agate - a Gemini server using Rust

    What is Maj?


    Maj is a set of three opinionated frameworks in one small package. Maj includes a text/gemini parser, a Gemini response parser, a simple client framework and a simple server framework.

    Much of this is still in the earlier phases of development, and this site is going to be one of the biggest tests of Maj in production style workloads. With this post, I am happy to announce that Maj version 0.5.0 is mostly feature-complete and on the road to a stable release I'll call version 1.0.0.

    The Name Maj


    The name Maj (sounds like the month of May) is a nonbinary name commonly used in Sweden and Nordic countries. It means "a pearl", but the name has no significance to the project or myself.

    The text/gemini Parser


    The text/gemini parser takes a string of UTF-8 formatted gemtext and emits a list of Gemini document nodes. It is currently known to work on the biggest gemini sites, however if you find any issues with this, please let me know at cadey@firemail.cc. Essentially it takes a document that looks like this:

    # Hello World!
    Hello, world! This is a test.

    into a list of nodes that looks like this:

    vec![
      Node::Heading { level: 1, body: "Hello, World!" },
      Node::Text("Hello, world! This is a test.")
    ]

    This kind of layout makes it easier to iterate over gemtext nodes and translate gemtext into any other arbitrary format, such as Markdown:

    fn gem_to_md(nodes: Vec, out: &mut impl Write) -> io::Result<()> {
        use Node::*;
    
        for node in nodes {
            match node {
                Text(body) => write!(out, "{}\n", body)?,
                Link { to, name } => match name {
                    Some(name) => write!(out, "[{}]({})\n\n", name, to)?,
                    None => write!(out, "[{0}]({0})", to)?,
                },
                Preformatted(body) => write!(out, "```\n{}\n```\n\n", body)?,
                Heading { level, body } => {
                    write!(out, "##{} {}\n\n", "#".repeat(level as usize), body)?
                }
                ListItem(body) => write!(out, "* {}\n", body)?,
                Quote(body) => write!(out, "> {}\n\n", body)?,
            }
        }
    
        Ok(())
    }

    The Client


    Maj's client feature can be enabled in Cargo.toml using the client feature:

    [dependencies]
    maj = { version = "0.5", default-features = false, features = ["client"] }
    rustls = { version = "0.18", features = ["dangerous_configuration"] }

    The client is made a feature so that users can pick and choose what parts of Maj they need for their application. This also allows users to avoid having to link in the entire server framework (and its dependencies) without having to chop up this crate into other crates.

    The client is exposed in a single function:

    let resp = maj::get("gemini://cetacean.club/", cfg).await?;

    The cfg argument is a rustls::ClientConfig instance, which allows users to specify any custom TLS configuration they want. The most common configuration will probably look something like this:

    use std::sync::Arc;
    
    pub fn config() -> rustls::ClientConfig {
        let mut config = rustls::ClientConfig::new();
        config
            .dangerous()
            .set_certificate_verifier(Arc::new(NoCertificateVerification {}));
    
        config
    }
    
    struct NoCertificateVerification {}
    
    impl rustls::ServerCertVerifier for NoCertificateVerification {
        fn verify_server_cert(
            &self,
            _roots: &rustls::RootCertStore,
            _presented_certs: &[rustls::Certificate],
            _dns_name: webpki::DNSNameRef<'_>,
            _ocsp: &[u8],
        ) -> Result {
            Ok(rustls::ServerCertVerified::assertion())
        }
    }

    This is a bit awkward because rustls really does not like people disabling TLS certificate verification. However, this also gives implementors the freedom to implement a Trust On First Use (TOFU) model like the Gemini spec suggests.

    The Server


    Maj also supplies a server framework behind the server feature:

    [dependencies]
    maj = { version = "0.5", features = ["server"], default-features = false }
    rustls = { version = "0.18", features = ["dangerous_configuration"] }

    The core of this framework is the Handler, which is similar to Go's net/http#Handler. Users pass their own Handler implementation to the maj::server::serve function and then wait for that to happen. There are also a few routing macros exposed by maj when the server feature is enabled, and I will go into detail about them below.

    net/http#Handler

    Here is a minimal implementation that uses the built-in fileserver handler:

    use async_std::task;
    use maj::{route, seg, split, server::*};
    use rustls::{
        internal::pemfile::{certs, pkcs8_private_keys},
        AllowAnyAnonymousOrAuthenticatedClient, Certificate, PrivateKey, RootCertStore, ServerConfig,
    };
    use std::{
        fs::File,
        io::{self, BufReader},
        path::Path,
        sync::Arc,
    };
    
    fn load_certs(path: &Path) -> io::Result> {
        certs(&mut BufReader::new(File::open(path)?))
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid cert"))
    }
    
    fn load_keys(path: &Path) -> io::Result> {
        pkcs8_private_keys(&mut BufReader::new(File::open(path)?))
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid key"))
    }
    
    fn main() -> Result<(), maj::server::Error> {
        let certs = load_certs("./certs/cert.pem")?;
        let mut keys = load_keys("./certs/key.pem")?;
    
        let mut config =
            ServerConfig::new(AllowAnyAnonymousOrAuthenticatedClient::new(
                RootCertStore::empty(),
            ));
        config
            .set_single_cert(certs, keys.remove(0))
            .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
    
        task::block_on(maj::server::serve(
            Arc::new(files::Handler::new("./static")),
            config,
            "0.0.0.0",
            1965,
        ))?;
    }

    If you need to do something more fancy, please replace these hard-coded constants with something like structopt. By default this will serve all files in ./static over Gemini with the cert in ./certs/cert.pem and the key in ./certs/key.pem.

    structopt

    For an example of a dynamic route on this site, see /dice:

    Dice rolling tool

    Known Issues


  • For some reason, client certificates are NOT accepted by maj's server framework at this moment. This should be fixed in the future, but currently this is a known problem.

  • Next Steps


    Next I want to make a few applications on top of Maj in order to test where the boundaries are and give fundamental improvements to user experience with it. majc also needs to get a sqlite based history implementation and client certificate support.

    Either way, I hope I can make a valuable tool for creating Gemini sites with Maj. I am also starting to work on majd, which will be a vhost-aware Gemini server that should be safe for use on tilde servers (complete with ~user paths expanding to the given user's public_gemini folders). I also am working on ideas to have cetacean.club automatically served over HTTP as well as Gemini.

    ---

    Go back