about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/conf.rs180
-rw-r--r--src/err.rs51
-rw-r--r--src/file.rs73
-rw-r--r--src/handlers.rs150
-rw-r--r--src/logging.rs49
-rw-r--r--src/main.rs113
-rw-r--r--src/response.rs90
7 files changed, 706 insertions, 0 deletions
diff --git a/src/conf.rs b/src/conf.rs
new file mode 100644
index 0000000..e2158f1
--- /dev/null
+++ b/src/conf.rs
@@ -0,0 +1,180 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use std::error::Error;
+use std::fmt::Debug;
+use std::fs;
+use std::io;
+use std::path;
+use std::sync::Arc;
+
+use argh::FromArgs;
+use serde::{Deserialize, Serialize};
+use tokio::net::TcpListener;
+use tokio_rustls::rustls::{Certificate, PrivateKey};
+use tokio_rustls::{rustls, TlsAcceptor};
+
+use crate::err::Supernova;
+
+/// Configuration options for laika.
+#[derive(FromArgs)]
+struct Args {
+    /// address:port for laika to bind to.
+    #[argh(option, short = 'b', description = "address:port")]
+    bind_address: Option<String>,
+
+    // config file path.
+    #[argh(option, short = 'c', description = "config file path")]
+    config: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct ConfYaml {
+    bind_address: String,
+    tls_key: path::PathBuf,
+    tls_cert: path::PathBuf,
+    index_file_name: String,
+    log_file: path::PathBuf,
+    root_directory: path::PathBuf,
+    debug: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct Conf {
+    addr: String,
+    certs: Vec<Certificate>,
+    key: PrivateKey,
+    index_file_name: String,
+    log_file: path::PathBuf,
+    root_directory: path::PathBuf,
+    debug: bool,
+}
+
+impl Conf {
+    pub fn new() -> Result<Conf, Supernova> {
+        let args: Args = argh::from_env();
+
+        let config_file_path = match args.config {
+            None => path::PathBuf::from("laika.yaml"),
+            Some(a) => path::PathBuf::from(a),
+        };
+
+        let config_fd = match fs::File::open(config_file_path) {
+            Ok(fd) => fd,
+            Err(e) => {
+                let msg = format!("Could not open config file: {}", e);
+                return Err(Supernova::boom(&msg));
+            }
+        };
+        let config_yaml: ConfYaml = match serde_yaml::from_reader(config_fd) {
+            Ok(v) => v,
+            Err(e) => {
+                let msg = format!("Could not parse config file: {}", e);
+                return Err(Supernova::boom(&msg));
+            }
+        };
+
+        let cert_fd = match fs::File::open(config_yaml.tls_cert) {
+            Err(e) => {
+                let msg = format!("Could not open TLS certificate file: {}", e);
+                return Err(Supernova::boom(&msg));
+            }
+            Ok(fd) => fd,
+        };
+
+        let certs: Vec<Certificate> = match rustls_pemfile::certs(&mut io::BufReader::new(cert_fd))
+        {
+            Ok(v) => v.into_iter().map(Certificate).collect(),
+            Err(e) => {
+                let msg = format!("Could not parse TLS certificate file: {}", e);
+                return Err(Supernova::boom(&msg));
+            }
+        };
+
+        let key_fd = match fs::File::open(config_yaml.tls_key) {
+            Err(e) => {
+                let msg = format!("Could not open TLS key file: {}", e);
+                return Err(Supernova::boom(&msg));
+            }
+            Ok(key) => key,
+        };
+
+        let key = match rustls_pemfile::pkcs8_private_keys(&mut io::BufReader::new(key_fd)) {
+            Ok(v) => {
+                let keys: Vec<PrivateKey> = v.into_iter().map(PrivateKey).collect();
+                keys[0].clone()
+            }
+            Err(e) => {
+                let msg = format!("Could not parse TLS key file: {}", e);
+                return Err(Supernova::boom(&msg));
+            }
+        };
+
+        let addr = match args.bind_address {
+            Some(v) => v,
+            None => config_yaml.bind_address,
+        };
+
+        let index_file_name = config_yaml.index_file_name;
+        let log_file = config_yaml.log_file;
+        let debug = config_yaml.debug;
+        let root_directory = config_yaml.root_directory;
+
+        Ok(Conf {
+            addr,
+            certs,
+            key,
+            index_file_name,
+            log_file,
+            root_directory,
+            debug,
+        })
+    }
+
+    pub fn bind_address(&self) -> &str {
+        &self.addr
+    }
+    pub fn debug(&self) -> bool {
+        self.debug
+    }
+    pub fn index_file_name(&self) -> String {
+        self.index_file_name.clone()
+    }
+    pub fn log_file(&self) -> path::PathBuf {
+        self.log_file.to_owned()
+    }
+    pub fn root_directory(&self) -> path::PathBuf {
+        self.root_directory.to_owned()
+    }
+    pub fn tls_cert(&self) -> Vec<Certificate> {
+        self.certs.to_owned()
+    }
+    pub fn tls_key(&self) -> PrivateKey {
+        self.key.to_owned()
+    }
+
+    pub async fn get_listener(&self) -> Result<(TcpListener, TlsAcceptor), Box<dyn Error>> {
+        let tls_config = rustls::ServerConfig::builder()
+            .with_safe_defaults()
+            .with_no_client_auth()
+            .with_single_cert(self.tls_cert(), self.tls_key())?;
+
+        let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config));
+        let tcp_listener = TcpListener::bind(self.bind_address()).await?;
+
+        Ok((tcp_listener, tls_acceptor))
+    }
+}
diff --git a/src/err.rs b/src/err.rs
new file mode 100644
index 0000000..6c178bd
--- /dev/null
+++ b/src/err.rs
@@ -0,0 +1,51 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use crate::response;
+use std::error::Error;
+use std::fmt::Formatter;
+
+#[derive(Clone, Debug)]
+pub struct Supernova {
+    message: String,
+    code: response::Code,
+}
+
+impl Error for Supernova {}
+
+impl Supernova {
+    pub fn boom(message: &str) -> Supernova {
+        Supernova {
+            message: message.into(),
+            code: response::Code::Unknown,
+        }
+    }
+
+    pub fn code(&self) -> response::Code {
+        self.code
+    }
+
+    pub fn with_code(&mut self, code: response::Code) -> Supernova {
+        self.code = code;
+        self.clone()
+    }
+}
+
+impl std::fmt::Display for Supernova {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", &self.message)
+    }
+}
diff --git a/src/file.rs b/src/file.rs
new file mode 100644
index 0000000..4500473
--- /dev/null
+++ b/src/file.rs
@@ -0,0 +1,73 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use std::io;
+
+use tokio::fs;
+
+use crate::err::Supernova;
+use crate::response;
+
+pub async fn get(path: &str) -> Result<(fs::File, String), Supernova> {
+    let metadata = match fs::metadata(path).await {
+        Ok(m) => m,
+        Err(e) => {
+            let code = match e.kind() {
+                io::ErrorKind::NotFound => response::Code::NotFound,
+                _ => response::Code::PermanentFailure,
+            };
+
+            let msg = format!("{}", e);
+            let wrapped_err = Supernova::boom(&msg).with_code(code);
+
+            return Err(wrapped_err);
+        }
+    };
+
+    let path = if metadata.is_dir() {
+        format!("{}/index.gmi", path)
+    } else {
+        path.to_string()
+    };
+
+    let fd = match fs::File::open(&path).await {
+        Ok(fd) => fd,
+        Err(e) => {
+            let code = match e.kind() {
+                io::ErrorKind::NotFound => response::Code::NotFound,
+                _ => response::Code::PermanentFailure,
+            };
+
+            let msg = format!("{}", e);
+            let wrapped_err = Supernova::boom(&msg).with_code(code);
+
+            return Err(wrapped_err);
+        }
+    };
+
+    let mime = match tree_magic_mini::from_filepath(path.as_ref()) {
+        Some(m) => {
+            if path.ends_with(".gmi") {
+                response::GEMINI_MIME
+            } else {
+                m
+            }
+        }
+        None => response::GEMINI_MIME,
+    };
+
+    Ok((fd, mime.to_string()))
+}
diff --git a/src/handlers.rs b/src/handlers.rs
new file mode 100644
index 0000000..826523e
--- /dev/null
+++ b/src/handlers.rs
@@ -0,0 +1,150 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use std::net::SocketAddr;
+use std::str;
+
+use tokio::io::AsyncReadExt;
+use tokio::io::AsyncWriteExt;
+use tokio::net::TcpStream;
+use tokio_rustls::server::TlsStream;
+use url::Url;
+
+use crate::conf::Conf;
+use crate::err::Supernova;
+use crate::file;
+use crate::response;
+
+pub async fn flush_and_kill(stream: &mut TlsStream<TcpStream>, remote_address: SocketAddr) {
+    if let Err(e) = stream.flush().await {
+        log::error!("Could not flush writer to {}: {}", remote_address, e);
+    };
+    if let Err(e) = stream.shutdown().await {
+        log::error!(
+            "Could not shut down connection to {}: {}",
+            remote_address,
+            e
+        );
+    };
+}
+
+pub async fn entrance(
+    stream: &mut TlsStream<TcpStream>,
+    remote_address: SocketAddr,
+) -> Result<Url, Supernova> {
+    let mut req_buf: [u8; 1024] = [0; 1024];
+
+    let n = match stream.read(&mut req_buf).await {
+        Ok(n) => n,
+        Err(e) => {
+            let msg = format!("failed to read from socket: {}", e);
+            return Err(Supernova::boom(&msg));
+        }
+    };
+
+    let req_str = match str::from_utf8(&req_buf[..n - 1]) {
+        Ok(v) => v,
+        Err(e) => {
+            let msg = format!("failed to parse request as UTF-8 string: {}", e);
+            return Err(Supernova::boom(&msg).with_code(response::Code::BadRequest));
+        }
+    };
+
+    log::info!("REQ {} :: {}", remote_address, req_str);
+
+    if req_str.contains("../") || req_str.contains("/..") {
+        let msg = format!("directory traversal attempted: {}", req_str);
+        return Err(Supernova::boom(&msg).with_code(response::Code::BadRequest));
+    };
+
+    let url = match Url::parse(req_str) {
+        Ok(v) => v,
+        Err(e) => {
+            let msg = format!("could not parse request as URL: {}", e);
+            return Err(Supernova::boom(&msg).with_code(response::Code::BadRequest));
+        }
+    };
+
+    let url_scheme = url.scheme();
+    if url_scheme != "gemini" {
+        let msg = format!("invalid URL scheme. refusing to proxy to: {}", url_scheme);
+        return Err(Supernova::boom(&msg).with_code(response::Code::ProxyRequestRefused));
+    }
+
+    Ok(url)
+}
+
+pub async fn route(
+    conf: &Conf,
+    stream: &mut TlsStream<TcpStream>,
+    remote_address: SocketAddr,
+    req_url: Url,
+) -> Result<(), Supernova> {
+    let path = req_url.path();
+    let root_directory = conf.root_directory();
+    let root_directory_str = root_directory.display();
+
+    let fixed_path = if path.is_empty() {
+        format!("{}/{}", root_directory_str, conf.index_file_name())
+    } else {
+        format!("{}{}", root_directory_str, path)
+    };
+
+    log::debug!(
+        "REQ {} :: full local request path: {}",
+        remote_address,
+        fixed_path
+    );
+
+    let (mut fd, mime) = file::get(&fixed_path).await?;
+
+    let header = response::Code::Success.get_header(&mime);
+
+    log::debug!(
+        "REQ {} :: file {} has mime {}",
+        remote_address,
+        fixed_path,
+        mime
+    );
+
+    let n = match stream.write(&header).await {
+        Ok(n) => n,
+        Err(e) => {
+            let msg = format!("could not write header to tls socket: {}", e);
+            return Err(Supernova::boom(&msg));
+        }
+    };
+
+    let n = match tokio::io::copy(&mut fd, stream).await {
+        Ok(v) => v as usize + n,
+        Err(e) => {
+            let msg = format!("could not write body to tls socket: {}", e);
+            return Err(Supernova::boom(&msg));
+        }
+    };
+
+    let bytes_written = match stream.write(response::footer_bytes()).await {
+        Ok(v) => v + n,
+        Err(e) => {
+            let msg = format!("could not write footer to tls socket: {}", e);
+            return Err(Supernova::boom(&msg));
+        }
+    };
+
+    log::info!("REQ {} :: {} bytes written", remote_address, bytes_written);
+
+    Ok(())
+}
diff --git a/src/logging.rs b/src/logging.rs
new file mode 100644
index 0000000..e89f4e0
--- /dev/null
+++ b/src/logging.rs
@@ -0,0 +1,49 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use std::error::Error;
+use std::fs;
+use std::path::PathBuf;
+
+use simplelog::*;
+
+use crate::conf;
+
+pub fn init(conf: &conf::Conf) -> Result<(), Box<dyn Error>> {
+    let log_fd = fs::OpenOptions::new()
+        .write(true)
+        .create(true)
+        .open(conf.log_file())?;
+
+    let log_level = if conf.debug() {
+        LevelFilter::Debug
+    } else {
+        LevelFilter::Info
+    };
+
+    if conf.log_file() == PathBuf::from("stderr") {
+        TermLogger::init(
+            log_level,
+            Config::default(),
+            TerminalMode::Stderr,
+            ColorChoice::Auto,
+        )?;
+    } else {
+        WriteLogger::init(log_level, Config::default(), log_fd)?;
+    }
+
+    Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..8692668
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,113 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use std::process;
+
+use tokio::io::AsyncWriteExt;
+
+mod conf;
+mod err;
+mod file;
+mod handlers;
+mod logging;
+mod response;
+
+static LAIKA_VERSION: &str = "0.1";
+
+#[tokio::main]
+async fn main() {
+    let conf = match conf::Conf::new() {
+        Ok(v) => v,
+        Err(e) => {
+            eprintln!("{}", e);
+            process::exit(1);
+        }
+    };
+
+    if let Err(e) = logging::init(&conf) {
+        eprintln!("Failed to initialize logger: {}", e);
+        process::exit(1);
+    };
+
+    log::info!("laika {} starting", LAIKA_VERSION);
+    log::info!("Binding to {}", conf.bind_address());
+
+    log::debug!("laika config:\n{:?}", conf);
+
+    let (tcp_listener, tls_acceptor) = match conf.get_listener().await {
+        Ok((tcp, tls)) => (tcp, tls),
+        Err(e) => {
+            log::error!("Could not get TCP listener or TLS acceptor: {}", e);
+            process::exit(1);
+        }
+    };
+
+    loop {
+        let (socket, remote_address) = match tcp_listener.accept().await {
+            Ok(v) => v,
+            Err(e) => {
+                log::error!("Could not accept connection: {}", e);
+                continue;
+            }
+        };
+        let tls_acceptor = tls_acceptor.clone();
+        let conf = conf.clone();
+
+        tokio::spawn(async move {
+            let mut stream = match tls_acceptor.accept(socket).await {
+                Ok(s) => s,
+                Err(e) => {
+                    log::error!("could not negotiate TLS: {}", e);
+                    return;
+                }
+            };
+
+            log::info!("REQ {} :: Connected", remote_address);
+
+            let req_url = match handlers::entrance(&mut stream, remote_address).await {
+                Ok(v) => v,
+                Err(e) => {
+                    log::error!("REQ {} :: {}", remote_address, e);
+                    if e.code() != response::Code::Unknown {
+                        let header = e.code().get_header("");
+                        match stream.write(&header).await {
+                            Ok(_) => (),
+                            Err(e) => {
+                                log::error!("REQ {} :: {}", remote_address, e);
+                            }
+                        };
+                    }
+                    handlers::flush_and_kill(&mut stream, remote_address).await;
+                    log::info!("REQ {} :: Terminated", remote_address);
+                    return;
+                }
+            };
+
+            if let Err(e) = handlers::route(&conf, &mut stream, remote_address, req_url).await {
+                log::error!("REQ {} :: {}", remote_address, e);
+                if e.code() != response::Code::Unknown {
+                    let header = e.code().get_header("");
+                    if let Err(e) = stream.write_all(&header).await {
+                        log::error!("REQ {} :: {}", remote_address, e);
+                    }
+                }
+            }
+
+            handlers::flush_and_kill(&mut stream, remote_address).await;
+            log::info!("REQ {} :: Terminated", remote_address);
+        });
+    }
+}
diff --git a/src/response.rs b/src/response.rs
new file mode 100644
index 0000000..5711907
--- /dev/null
+++ b/src/response.rs
@@ -0,0 +1,90 @@
+/* Copyright (C) 2023  Ben Morrison <ben@gbmor.org>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https: *www.gnu.org/licenses/>.
+ */
+
+use std::fmt;
+
+pub const GEMINI_MIME: &str = "text/gemini";
+
+// Response codes
+#[allow(dead_code)]
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Code {
+    Unknown = 00,
+    Input = 10,
+    SensitiveInput = 11,
+    Success = 20,
+    RedirectTemporary = 30,
+    RedirectPermanent = 31,
+    TemporaryFailure = 40,
+    ServerUnavailable = 41,
+    CgiError = 42,
+    ProxyError = 43,
+    SlowDown = 44,
+    PermanentFailure = 50,
+    NotFound = 51,
+    Gone = 52,
+    ProxyRequestRefused = 53,
+    BadRequest = 59,
+    ClientCertificateRequired = 60,
+    CertificateNotAuthorised = 61,
+    CertificateNotValid = 62,
+}
+
+impl Code {
+    pub fn get_header(&self, mime: &str) -> Vec<u8> {
+        let msg = if *self == Code::Success {
+            format!("{} {}\r\n", self, mime)
+        } else {
+            format!("{}\r\n", self)
+        };
+
+        msg.into_bytes()
+    }
+}
+
+impl fmt::Display for Code {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let code_str = format!("{}", *self as u8);
+        let msg = match self {
+            Code::Unknown => format!("{} UNKNOWN", code_str),
+            Code::Input => format!("{} INPUT", code_str),
+            Code::SensitiveInput => format!("{} SENSITIVE INPUT", code_str),
+            Code::Success => format!("{}", code_str),
+            Code::RedirectTemporary => format!("{} REDIRECT - TEMPORARY", code_str),
+            Code::RedirectPermanent => format!("{} REDIRECT - PERMANENT", code_str),
+            Code::TemporaryFailure => format!("{} TEMPORARY FAILURE", code_str),
+            Code::ServerUnavailable => format!("{} SERVER UNAVAILABLE", code_str),
+            Code::CgiError => format!("{} CGI ERROR", code_str),
+            Code::ProxyError => format!("{} PROXY ERROR", code_str),
+            Code::SlowDown => format!("{} SLOW DOWN", code_str),
+            Code::PermanentFailure => format!("{} PERMANENT FAILURE", code_str),
+            Code::NotFound => format!("{} NOT FOUND", code_str),
+            Code::Gone => format!("{} GONE", code_str),
+            Code::ProxyRequestRefused => format!("{} PROXY REQUEST REFUSED", code_str),
+            Code::BadRequest => format!("{} BAD REQUEST", code_str),
+            Code::ClientCertificateRequired => format!("{} CLIENT CERTIFICATE REQUIRED", code_str),
+            Code::CertificateNotAuthorised => format!("{} CERTIFICATE NOT AUTHORISED", code_str),
+            Code::CertificateNotValid => format!("{} CERTIFICATE NOT VALID", code_str),
+        };
+
+        write!(f, "{}", msg)
+    }
+}
+
+// Appended to the bottom of .gmi files
+pub fn footer_bytes<'a>() -> &'a [u8] {
+    "\n\n~~~~ served by laika ~~~~~~~~~\nhttps://sr.ht/~gbmor/laika\n\n".as_bytes()
+}