mirror of
https://github.com/YGGverse/titanit.git
synced 2026-03-31 17:15:30 +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
|
# titanit
|
||||||
|
|
||||||
|

|
||||||
|
[](https://deps.rs/repo/github/YGGverse/titanit)
|
||||||
|
[](https://crates.io/crates/titanit)
|
||||||
|
|
||||||
File share server for Gemini & Titan protocols
|
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