mirror of
https://github.com/YGGverse/titanit.git
synced 2026-03-31 09:05:45 +00:00
initial commit
This commit is contained in:
parent
c6f6ec7ba4
commit
86e07f08c7
7 changed files with 408 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/target
|
||||
/public
|
||||
Cargo.lock
|
||||
*.pem
|
||||
*.csr
|
||||
*.pfx
|
||||
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
|
|
@ -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"
|
||||
49
README.md
49
README.md
|
|
@ -1,2 +1,51 @@
|
|||
# titanit
|
||||
|
||||

|
||||
[](https://deps.rs/repo/github/YGGverse/titanit)
|
||||
[](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`
|
||||
34
src/argument.rs
Normal file
34
src/argument.rs
Normal file
|
|
@ -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<usize>,
|
||||
|
||||
/// Uploads MIME type allowed (comma separated, all by default)
|
||||
/// * based on headers
|
||||
#[arg(short, long)]
|
||||
pub mime: Option<String>,
|
||||
|
||||
/// Redirection URL on request handle complete (e.g. `gemini://localhost`)
|
||||
#[arg(short, long)]
|
||||
pub redirect: Option<String>,
|
||||
}
|
||||
56
src/header.rs
Normal file
56
src/header.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
use anyhow::{bail, Result};
|
||||
use native_tls::TlsStream;
|
||||
use std::{io::Read, net::TcpStream};
|
||||
|
||||
pub struct Header {
|
||||
pub mime: Option<String>,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn for_stream(stream: &mut TlsStream<TcpStream>) -> Result<Self> {
|
||||
let mut header: Vec<u8> = 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<u8>) -> Result<Self> {
|
||||
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::<usize>() {
|
||||
Ok(s) => s,
|
||||
Err(e) => bail!(e),
|
||||
},
|
||||
None => bail!("Size required"),
|
||||
},
|
||||
None => bail!("Size required"),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
162
src/main.rs
Normal file
162
src/main.rs
Normal file
|
|
@ -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<Argument>, peer: SocketAddr, mut stream: TlsStream<TcpStream>) {
|
||||
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()
|
||||
}
|
||||
85
src/storage.rs
Normal file
85
src/storage.rs
Normal file
|
|
@ -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<Self> {
|
||||
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 {} */
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue