diff options
author | Benjamin Morrison <ben@gbmor.org> | 2023-06-20 23:29:12 -0400 |
---|---|---|
committer | Benjamin Morrison <ben@gbmor.org> | 2023-06-20 23:30:17 -0400 |
commit | dc5cce23e9b0869455f546d968052bc200dd0011 (patch) | |
tree | bb3754de4483b4a50edc8a6a3d4585f305e4d1e2 /src | |
download | laika-dc5cce23e9b0869455f546d968052bc200dd0011.tar.gz |
init
Diffstat (limited to 'src')
-rw-r--r-- | src/conf.rs | 180 | ||||
-rw-r--r-- | src/err.rs | 51 | ||||
-rw-r--r-- | src/file.rs | 73 | ||||
-rw-r--r-- | src/handlers.rs | 150 | ||||
-rw-r--r-- | src/logging.rs | 49 | ||||
-rw-r--r-- | src/main.rs | 113 | ||||
-rw-r--r-- | src/response.rs | 90 |
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() +} |