initial commit

This commit is contained in:
yggverse 2025-09-08 13:55:50 +03:00
parent fc65ac65d5
commit 1f2eb60318
8 changed files with 442 additions and 0 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
custom: https://yggverse.github.io/#donate

27
.github/workflows/build.yml vendored Normal file
View file

@ -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

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
*.crt
*.csr
*.pem
*.pfx
/target
Cargo.lock
storage

26
Cargo.toml Normal file
View file

@ -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" }

40
README.md Normal file
View file

@ -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
<details>
<summary>Generate PKCS (PFX)</summary>
<pre>
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</pre>
</details>
## 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

30
src/argument.rs Normal file
View file

@ -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,
}

17
src/format.rs Normal file
View file

@ -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)
}
}

294
src/main.rs Normal file
View file

@ -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<Public>,
peer: SocketAddr,
connection: Result<TlsStream<TcpStream>, HandshakeError<TcpStream>>,
) {
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<TcpStream>,
) {
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::<usize>().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<TcpStream>) -> 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<TcpStream>, callback: impl FnOnce(Result<()>)) {
callback((|| {
stream.write_all(data)?;
close(stream)?;
Ok(())
})());
}
fn index(public: &Public, page: Option<usize>) -> Result<String> {
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::<u64>())
.unwrap_or_default()
+ meta.info.length.unwrap_or_default(),
)
}