From 1f2eb603187866958e9e1c0a4786571034de9ffa Mon Sep 17 00:00:00 2001 From: yggverse Date: Mon, 8 Sep 2025 13:55:50 +0300 Subject: [PATCH] initial commit --- .github/FUNDING.yml | 1 + .github/workflows/build.yml | 27 ++++ .gitignore | 7 + Cargo.toml | 26 ++++ README.md | 40 +++++ src/argument.rs | 30 ++++ src/format.rs | 17 +++ src/main.rs | 294 ++++++++++++++++++++++++++++++++++++ 8 files changed, 442 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/argument.rs create mode 100644 src/format.rs create mode 100644 src/main.rs diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ada8a24 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://yggverse.github.io/#donate \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..324178e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Run rustfmt + run: cargo fmt --all -- --check + - name: Run clippy + run: cargo clippy --all-targets + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ca5087 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.crt +*.csr +*.pem +*.pfx +/target +Cargo.lock +storage \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d699726 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "btracker-gemini" +version = "0.1.0" +edition = "2024" +license = "MIT" +readme = "README.md" +description = "βtracker server implementation for the Gemini protocol" +keywords = ["btracker", "bittorrent", "gemini", "gemini-protocol", "server"] +categories = ["database-implementations", "network-programming", "filesystem"] +repository = "https://github.com/YGGverse/titanit" + +[dependencies] +anyhow = "1.0.95" +btracker-fs = { version = "0.2.0", features = ["public"] } +chrono = "^0.4.20" +clap = { version = "4.5.30", features = ["derive"] } +log = "0.4.28" +native-tls = "0.2.14" +titanite = "0.3.2" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +librqbit-core = "5.0.0" +plurify = "0.2.0" + +# development +[patch.crates-io] +btracker-fs = { git = "https://github.com/YGGverse/btracker-fs.git" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..22da2e4 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# btracker-gemini + +![Build](https://github.com/YGGverse/btracker-gemini/actions/workflows/build.yml/badge.svg) +[![Dependencies](https://deps.rs/repo/github/YGGverse/btracker-gemini/status.svg)](https://deps.rs/repo/github/YGGverse/btracker-gemini) +[![crates.io](https://img.shields.io/crates/v/btracker-gemini.svg)](https://crates.io/crates/btracker-gemini) + +βtracker server implementation for the Gemini protocol + +> [!NOTE] +> In development! + +## Install + +``` bash +git clone https://github.com/YGGverse/btracker-gemini.git && cd btracker-gemini +cargo build --release +sudo install target/release/btracker-gemini /usr/local/bin/btracker-gemini +``` +* to setup Rust environment see [rustup](https://rustup.rs) + +## Setup + +
+Generate PKCS (PFX) +
+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 + +``` bash +btracker-gemini -i /path/to/server.pfx\ + -s /path/to/btracker-fs +``` +* prepend `RUST_LOG=trace` or `RUST_LOG=btracker_gemini=trace` to debug +* use `-b` to bind server on specified `host:port` +* use `-h` to print all available options \ No newline at end of file diff --git a/src/argument.rs b/src/argument.rs new file mode 100644 index 0000000..9301f1e --- /dev/null +++ b/src/argument.rs @@ -0,0 +1,30 @@ +use clap::Parser; +use std::path::PathBuf; + +#[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, default_value_t = String::from("127.0.0.1:1965"))] + pub bind: String, + + /// Filepath to server identity in PKCS (PFX) format + #[arg(short, long)] + pub identity: PathBuf, + + /// Passphrase to unlock encrypted identity + #[arg(short, long, default_value_t = String::new())] + pub password: String, + + /// btracker-fs directory + #[arg(short, long)] + pub storage: PathBuf, + + /// Listing items limit + #[arg(short, long, default_value_t = 20)] + pub limit: usize, + + /// Default index capacity + #[arg(short, long, default_value_t = 1000)] + pub capacity: usize, +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..1170454 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,17 @@ +pub fn size(value: u64) -> String { + const KB: f32 = 1024.0; + const MB: f32 = KB * KB; + const GB: f32 = MB * KB; + + let f = value as f32; + + if f < KB { + format!("{value} B") + } else if f < MB { + format!("{:.2} KB", f / KB) + } else if f < GB { + format!("{:.2} MB", f / MB) + } else { + format!("{:.2} GB", f / GB) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..acb4949 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,294 @@ +mod argument; +mod format; + +use anyhow::Result; +use argument::Argument; +use btracker_fs::public::{Order, Public, Sort}; +use chrono::Local; +use clap::Parser; +use librqbit_core::torrent_metainfo::{TorrentMetaV1Owned, torrent_from_bytes}; +use log::*; +use native_tls::{HandshakeError, Identity, TlsAcceptor, TlsStream}; +use plurify::Plurify; +use std::{ + fs::File, + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + sync::Arc, + thread, +}; +use titanite::*; + +fn main() -> Result<()> { + if std::env::var("RUST_LOG").is_ok() { + use tracing_subscriber::{EnvFilter, fmt::*}; + struct T; + impl time::FormatTime for T { + fn format_time(&self, w: &mut format::Writer<'_>) -> std::fmt::Result { + write!(w, "{}", Local::now()) + } + } + fmt() + .with_timer(T) + .with_env_filter(EnvFilter::from_default_env()) + .init() + } + + let argument = Arc::new(Argument::parse()); + + let public = + Arc::new(Public::init(&argument.storage, argument.limit, argument.capacity).unwrap()); + + // 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 = TcpListener::bind(&argument.bind)?; + + info!("Server started on `{}`", argument.bind); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + thread::spawn({ + let public = public.clone(); + let peer = stream.peer_addr()?; + let connection = acceptor.accept(stream); + move || handle(public, peer, connection) + }); + } + Err(e) => error!("{e}"), + } + } + Ok(()) +} + +fn handle( + public: Arc, + peer: SocketAddr, + connection: Result, HandshakeError>, +) { + debug!("New peer connected: `{peer}`"); + match connection { + Ok(mut stream) => { + // server should work with large files without memory overload, + // because of that, incoming data read partially, using chunks; + // collect header bytes first to route the request by its type. + let mut header_buffer = Vec::with_capacity(HEADER_MAX_LEN); + loop { + let mut header_chunk = vec![0]; + match stream.read(&mut header_chunk) { + Ok(0) => warn!("Peer `{peer}` closed connection."), + Ok(l) => { + // validate header buffer, break on its length reached protocol limits + if header_buffer.len() + l > HEADER_MAX_LEN { + return send( + &response::failure::permanent::BadRequest { + message: Some("Bad request".to_string()), + } + .into_bytes(), + &mut stream, + |result| match result { + Ok(()) => warn!("Bad request from peer `{peer}`"), + Err(e) => error!("Send packet to peer `{peer}` failed: {e}"), + }, + ); + } + + // take chunk bytes at this point + header_buffer.extend(header_chunk); + + // ending header byte received + if header_buffer.last().is_some_and(|&b| b == b'\n') { + // header bytes contain valid Gemini **request** + if let Ok(request) = request::Gemini::from_bytes(&header_buffer) { + return response(request, &public, &peer, &mut stream); + } + + // header bytes received but yet could not be parsed, + // complete with request failure + return send( + &response::failure::permanent::BadRequest { + message: Some("Bad request".to_string()), + } + .into_bytes(), + &mut stream, + |result| match result { + Ok(()) => warn!("Bad request from peer `{peer}`"), + Err(e) => error!("Send packet to peer `{peer}` failed: {e}"), + }, + ); + } + } + Err(e) => { + return send( + &response::failure::permanent::BadRequest { + message: Some("Bad request".to_string()), + } + .into_bytes(), + &mut stream, + |result| match result { + Ok(()) => warn!("Send failure response to peer `{peer}`: {e}"), + Err(e) => error!("Send packet to peer `{peer}` failed: {e}"), + }, + ); + } + } + } + } + Err(e) => warn!("Handshake issue for peer `{peer}`: {e}"), + } +} + +fn response( + request: titanite::request::Gemini, + public: &Public, + peer: &SocketAddr, + stream: &mut TlsStream, +) { + debug!("Incoming request from `{peer}` to `{}`", request.url.path()); + + // try index page + if request.url.path().trim_end_matches("/").is_empty() { + return send( + &match index( + public, + request.url.query_pairs().find_map(|a| { + if a.0 == "page" { + a.1.parse::().ok() + } else { + None + } + }), + ) { + Ok(data) => response::success::Default { + data: data.as_bytes(), + meta: response::success::default::Meta { + mime: "text/gemini".to_string(), + }, + } + .into_bytes(), + Err(e) => { + error!("Internal server error on handle peer `{peer}` request: `{e}`"); + response::failure::temporary::General { + message: Some("Internal server error".to_string()), + } + .into_bytes() + } + }, + stream, + |result| match result { + Ok(()) => debug!("Home page request from peer `{peer}`"), + Err(e) => error!("Internal server error on handle peer `{peer}` request: `{e}`"), + }, + ); + } + // try info page @TODO +} + +fn close(stream: &mut TlsStream) -> Result<()> { + stream.flush()?; + // close connection gracefully + // https://geminiprotocol.net/docs/protocol-specification.gmi#closing-connections + stream.shutdown()?; + Ok(()) +} + +fn send(data: &[u8], stream: &mut TlsStream, callback: impl FnOnce(Result<()>)) { + callback((|| { + stream.write_all(data)?; + close(stream)?; + Ok(()) + })()); +} + +fn index(public: &Public, page: Option) -> Result { + let (total, torrents) = public.torrents( + None, // @TODO + Some((Sort::Modified, Order::Desc)), + page.map(|p| if p > 0 { p - 1 } else { p } * public.default_limit), + Some(public.default_limit), + )?; + + let mut b = Vec::with_capacity(torrents.len()); + + for torrent in torrents { + let i: TorrentMetaV1Owned = torrent_from_bytes(&torrent.bytes)?; + b.push(format!( + "### {}", + i.info + .name + .as_ref() + .map(|n| n.to_string()) + .unwrap_or_default() + )); + b.push(format!( + "=> {} {} • {} • {}\n", + i.info_hash.as_string(), + torrent.time.format("%Y/%m/%d"), // @TODO optional + size(&i), + files(&i), + )); + } + + b.push(format!( + "Page {} / {} ({total} {} total)", + page.unwrap_or(1), + (total as f64 / public.default_limit as f64).ceil(), + total.plurify(&["torrent", "torrents", "torrents"]) + )); + + if let Some(p) = page { + b.push(format!( + "=> /{} Back", + if p > 2 { + Some(format!("?page={}", p - 1)) + } else { + None + } + .unwrap_or_default() + )) + } + + /*if page.unwrap_or(1) * public.default_limit < total { + None + } else { + Some(uri!(index(search, Some(page.map_or(2, |p| p + 1))))) + }*/ + + if let Some(p) = page { + b.push(format!( + "=> /{} Next", + if p > 2 { + Some(format!("?page={}", p + 1)) + } else { + None + } + .unwrap_or_default() + )) + } + + Ok(b.join("\n")) +} + +fn files(meta: &TorrentMetaV1Owned) -> String { + let total = meta.info.files.as_ref().map(|f| f.len()).unwrap_or(1); + format!("{total} {}", total.plurify(&["file", "files", "files"])) +} + +fn size(meta: &TorrentMetaV1Owned) -> String { + format::size( + meta.info + .files + .as_ref() + .map(|files| files.iter().map(|file| file.length).sum::()) + .unwrap_or_default() + + meta.info.length.unwrap_or_default(), + ) +}