mirror of
https://github.com/YGGverse/btracker-gemini.git
synced 2026-03-31 17:15:30 +00:00
initial commit
This commit is contained in:
parent
fc65ac65d5
commit
1f2eb60318
8 changed files with 442 additions and 0 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
custom: https://yggverse.github.io/#donate
|
||||||
27
.github/workflows/build.yml
vendored
Normal file
27
.github/workflows/build.yml
vendored
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
*.crt
|
||||||
|
*.csr
|
||||||
|
*.pem
|
||||||
|
*.pfx
|
||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
storage
|
||||||
26
Cargo.toml
Normal file
26
Cargo.toml
Normal 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
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# btracker-gemini
|
||||||
|
|
||||||
|

|
||||||
|
[](https://deps.rs/repo/github/YGGverse/btracker-gemini)
|
||||||
|
[](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
30
src/argument.rs
Normal 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
17
src/format.rs
Normal 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
294
src/main.rs
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue