From 86e07f08c72e6b45c5093587d443c830db688e84 Mon Sep 17 00:00:00 2001 From: yggverse Date: Thu, 20 Feb 2025 05:45:19 +0200 Subject: [PATCH] initial commit --- .gitignore | 6 ++ Cargo.toml | 16 +++++ README.md | 49 +++++++++++++++ src/argument.rs | 34 ++++++++++ src/header.rs | 56 +++++++++++++++++ src/main.rs | 162 ++++++++++++++++++++++++++++++++++++++++++++++++ src/storage.rs | 85 +++++++++++++++++++++++++ 7 files changed, 408 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/argument.rs create mode 100644 src/header.rs create mode 100644 src/main.rs create mode 100644 src/storage.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78fc99b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target +/public +Cargo.lock +*.pem +*.csr +*.pfx \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1785221 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "titanit" +version = "0.1.0" +edition = "2021" +license = "MIT" +readme = "README.md" +description = "File share server for Gemini & Titan protocols" +keywords = ["gemini", "titan", "gemini-protocol", "titan-protocol", "server"] +categories = ["database-implementations", "network-programming", "filesystem"] +repository = "https://github.com/YGGverse/titanit" + +[dependencies] +anyhow = "1.0.95" +clap = { version = "4.5.30", features = ["derive"] } +native-tls = "0.2.13" +regex = "1.11.1" diff --git a/README.md b/README.md index 3546414..68a0631 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ # titanit + +![Build](https://github.com/YGGverse/titanit/actions/workflows/build.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/YGGverse/titanit/status.svg)](https://deps.rs/repo/github/YGGverse/titanit) +[![crates.io](https://img.shields.io/crates/v/titanit.svg)](https://crates.io/crates/titanit) + File share server for Gemini & Titan protocols + +> [!NOTE] +> +> Project in development! + +## Install + +``` bash +cargo install titanit +``` + +## Setup + +### Generate PKCS (PFX) + +``` bash +openssl genpkey -algorithm RSA -out server.pem -pkeyopt rsa_keygen_bits:2048 +openssl req -new -key server.pem -out request.csr +openssl x509 -req -in request.csr -signkey server.pem -out server.crt -days 365 +openssl pkcs12 -export -out server.pfx -inkey server.pem -in server.crt +``` + +## Launch + +### Arguments + +* `--bind`, `-b` required, server `host:port` to listen incoming connections +* `--identity`, `-i` required, filepath to server identity in PKCS (PFX) format +* `--password`, `-p` optional, unlock encrypted `identity` by passphrase +* `--size`, `-s` optional, max size limit in bytes (unlimited by default) +* `--mime`, `-m` optional, uploads MIME type whitelist (comma separated, all by default) +* `--directory`, `-d` optional, uploads target directory (`public` by default) +* `--redirect`, `-r` optional, redirection URL on request handle complete (e.g. `gemini://localhost`) + +### Start + +``` bash +titanit --bind 127.0.0.1:1965 \ + --identity path/to/server.pfx +``` + +### titan it! + +* `titan://127.0.0.1` \ No newline at end of file diff --git a/src/argument.rs b/src/argument.rs new file mode 100644 index 0000000..adca508 --- /dev/null +++ b/src/argument.rs @@ -0,0 +1,34 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Argument { + /// Bind server `host:port` to listen incoming connections on it + #[arg(short, long)] + pub bind: String, + + /// Filepath to server identity in PKCS (PFX) format + #[arg(short, long)] + pub identity: String, + + /// Passphrase to unlock encrypted identity + #[arg(short, long, default_value_t = String::new())] + pub password: String, + + /// Uploads directory (e.g. `public` directory) + #[arg(short, long, default_value_t = String::from("public"))] + pub directory: String, + + /// Uploads max size limit in bytes (unlimited by default) + #[arg(short, long)] + pub size: Option, + + /// Uploads MIME type allowed (comma separated, all by default) + /// * based on headers + #[arg(short, long)] + pub mime: Option, + + /// Redirection URL on request handle complete (e.g. `gemini://localhost`) + #[arg(short, long)] + pub redirect: Option, +} diff --git a/src/header.rs b/src/header.rs new file mode 100644 index 0000000..71fb30e --- /dev/null +++ b/src/header.rs @@ -0,0 +1,56 @@ +use anyhow::{bail, Result}; +use native_tls::TlsStream; +use std::{io::Read, net::TcpStream}; + +pub struct Header { + pub mime: Option, + pub size: usize, +} + +impl Header { + pub fn for_stream(stream: &mut TlsStream) -> Result { + let mut header: Vec = Vec::new(); + let mut buffer = vec![0]; + loop { + match stream.read(&mut buffer) { + Ok(0) => bail!("Invalid protocol"), + Ok(_) => { + if header.len() > 1024 { + bail!("Invalid length") + } + if buffer[0] == b'\r' { + continue; + } + if buffer[0] == b'\n' { + break; + } + header.push(buffer[0]) + } + Err(e) => bail!(e), + } + } + Self::from_bytes(header) + } + + fn from_bytes(buffer: Vec) -> Result { + use anyhow::bail; + use regex::Regex; + let header = String::from_utf8(buffer)?; + Ok(Self { + mime: match Regex::new(r"mime=([^\/]+\/[^\s;]+)")?.captures(&header) { + Some(c) => c.get(1).map(|v| v.as_str().to_string()), + None => None, + }, + size: match Regex::new(r"size=(\d+)")?.captures(&header) { + Some(c) => match c.get(1) { + Some(v) => match v.as_str().parse::() { + Ok(s) => s, + Err(e) => bail!(e), + }, + None => bail!("Size required"), + }, + None => bail!("Size required"), + }, + }) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a104e09 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,162 @@ +mod argument; +mod header; +mod storage; + +use anyhow::Result; +use argument::Argument; +use clap::Parser; +use header::Header; +use native_tls::{Identity, TlsAcceptor, TlsStream}; +use std::{ + fs::File, + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + sync::Arc, + thread, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn main() -> Result<()> { + let argument = Arc::new(Argument::parse()); + + // https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls + let acceptor = TlsAcceptor::new(Identity::from_pkcs12( + &{ + let mut buffer = vec![]; + File::open(&argument.identity)?.read_to_end(&mut buffer)?; + buffer + }, + &argument.password, + )?)?; + + let listener = { + println!("[{}] [info] Server started on {}", now(), argument.bind); + TcpListener::bind(&argument.bind)? + }; + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + thread::spawn({ + let argument = argument.clone(); + let peer = stream.peer_addr()?; + let stream = acceptor.accept(stream)?; + move || handle(argument, peer, stream) + }); + } + Err(e) => println!("[{}] [error] Failed to accept connection: {e}", now()), + } + } + Ok(()) +} + +fn handle(argument: Arc, peer: SocketAddr, mut stream: TlsStream) { + println!("[{}] [info] [{peer}] New connection", now()); + match Header::for_stream(&mut stream) { + Ok(header) => { + // do not trust header values, but check it to continue + if argument.size.is_some_and(|s| header.size > s) { + println!("[{}] [error] [{peer}] Max size limit reached", now()); + return; + } + // make sure mime type whitelisted + if argument + .mime + .as_ref() + .is_some_and(|m| header.mime.is_some_and(|h| m.contains(&h))) + { + println!("[{}] [error] [{peer}] MIME type not allowed", now()); + return; + } + // begin data handle + let mut total = 0; + let mut buffer = vec![0]; // @TODO optional chunk size + match storage::Item::create(&argument.directory) { + Ok(mut item) => { + loop { + match stream.read(&mut buffer) { + Ok(0) => { + item.delete().unwrap(); + println!( + "[{}] [warning] [{peer}] Connection closed with rollback", + now() + ); + break; + } + Ok(n) => { + total += n; // validate real data size + if argument.size.is_some_and(|s| s > total) { + item.delete().unwrap(); + println!("[{}] [error] [{peer}] Max size limit reached", now()); + break; + } + if total > header.size { + item.delete().unwrap(); + println!( + "[{}] [error] [{peer}] Content size larger than declared in headers", now() + ); + break; + } + if let Err(e) = item.file.write(&buffer) { + item.delete().unwrap(); + println!("[{}] [error] [{peer}] Failed to write file from stream: {e}", now()); + break; + } + // transfer completed + if total == header.size { + match &stream.write_all( + // https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection + format!( + "31 {}\r\n", + argument + .redirect + .clone() + .unwrap_or(format!("gemini://{}", argument.bind)) + ) + .as_bytes(), + ) { + Ok(_) => { + item.commit().unwrap(); // @TODO detect/cache mime based on content type received + println!("[{}] [info] [{peer}] Success", now()); + // @TODO close connection gracefully + // https://geminiprotocol.net/docs/protocol-specification.gmi#closing-connections + match stream.flush() { + Ok(_) => println!("[{}] [info] [{peer}] Connection closed by server.", now()), + Err(e) => println!("[{}] [error] [{peer}] {e}", now()) + }; + } + Err(e) => { + item.delete().unwrap(); + println!( + "[{}] [error] [{peer}] Failed write to stream: {e}", + now() + ) + } + } + break; + } + } + Err(e) => { + item.delete().unwrap(); + println!( + "[{}] [error] [{peer}] Failed read from stream: {e}", + now() + ); + break; + } + } + } + } + Err(e) => println!("[{}] [error] [{peer}] Could not create Item: {e}", now()), + } + } + Err(e) => println!("[{}] [error] [{peer}] Header error: {e}", now()), + } +} + +fn now() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..2371de5 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,85 @@ +use anyhow::{bail, Result}; +use std::{ + fs::{create_dir_all, remove_file, rename, File}, + path::{PathBuf, MAIN_SEPARATOR}, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +const TMP_SUFFIX: &str = ".tmp"; + +pub struct Item { + pub file: File, + pub path: PathBuf, + // pub id: u64, +} + +impl Item { + // Constructors + + pub fn create(directory: &str) -> Result { + loop { + // generate file id from current unix time + let id = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + // build optimized fs path: + // add directory separator after every digit to keep up to 10 files per directory + let path = PathBuf::from(format!( + "{}{MAIN_SEPARATOR}{}{TMP_SUFFIX}", + directory.trim_end_matches(MAIN_SEPARATOR), + id.to_string().chars().fold(String::new(), |mut acc, c| { + if !acc.is_empty() { + acc.push(MAIN_SEPARATOR); + } + acc.push(c); + acc + }) + )); + + // recursively create directories + // * parent directory is expected + create_dir_all(path.parent().unwrap())?; + + // build `Self` + match File::create_new(&path) { + // make sure slot is not taken (by another thread) + Ok(file) => { + return Ok(Self { + file, + path, + // id + }); + } + Err(_) => { + println!("[warning] Could not init location: {:?}", path.to_str()); + // find free slot after some delay.. + thread::sleep(Duration::from_secs(1)); + continue; + } + } + } + } + + // Actions + + /// Take object processed, commit its changes + pub fn commit(self) -> Result<()> { + match self.path.to_str() { + Some(old) => match old.strip_suffix(TMP_SUFFIX) { + Some(new) => Ok(rename(old, new)?), + None => bail!("Invalid TMP suffix"), // | panic + }, + None => bail!("Invalid Item path"), // | panic + } + } + + /// Cleanup object relationships + pub fn delete(self) -> Result<()> { + Ok(remove_file(self.path)?) + } + + // Getters + + /* @TODO implement short links handle without slash + pub fn alias(&self, relative: &str) -> String {} */ +}