Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,8 @@ a watched file) or needs a **restart**.
|---|---|---|
| `tcp_port` | eD2k TCP port (default 4661). All UDP ports are derived from it (see below) | **restart** |
| `listen_ip` | Bind address for the listeners | restart |
| `listen_backlog`, `max_frame_size` | Socket / frame tuning | restart |
| `listen_backlog`, `max_frame_size` | Socket / wire-frame tuning | restart |
| `max_decompressed_frame_size` | Plaintext ceiling for one packed `0xD4` frame; default 8,000,000 bytes | restart |
| `login_timeout_ms` | Login handshake timeout | restart |
| `support_crypt` | Advertise protocol obfuscation support | restart |
| `hairpin_lan_clients` | Let a client on the server's own network reach HighID (see below). Off by default | live |
Expand Down
1 change: 1 addition & 0 deletions config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ tcp_port = 4661
listen_ip = "0.0.0.0"
listen_backlog = 256
max_frame_size = 1_000_000
max_decompressed_frame_size = 8_000_000
login_timeout_ms = 2000
support_crypt = true

Expand Down
1 change: 1 addition & 0 deletions config/config.vps.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ tcp_port = 4661
listen_ip = "0.0.0.0" # DO NOT change to your VPS IP — 0.0.0.0 is correct
listen_backlog = 1024
max_frame_size = 1_000_000
max_decompressed_frame_size = 8_000_000
login_timeout_ms = 3000 # Slightly longer for VPS latency
support_crypt = true

Expand Down
29 changes: 29 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ pub struct NetworkConfig {
pub listen_backlog: u32,
#[serde(default = "default_max_frame")]
pub max_frame_size: u32,
/// Maximum plaintext bytes produced by one packed `0xD4` frame.
#[serde(default = "default_max_decompressed_frame")]
pub max_decompressed_frame_size: u32,
/// Server key embedded in GLOBSERVSTATRES
#[serde(default = "default_udp_server_key")]
pub udp_server_key: u32,
Expand Down Expand Up @@ -635,6 +638,9 @@ fn default_backlog() -> u32 {
fn default_max_frame() -> u32 {
1_000_000
}
fn default_max_decompressed_frame() -> u32 {
crate::proto::frame::DEFAULT_MAX_DECOMPRESSED_FRAME_SIZE
}
fn default_udp_server_key() -> u32 {
0x1234_5678
}
Expand Down Expand Up @@ -801,6 +807,9 @@ whitelist_hashes = ""
/// Enforce the SPEC.md §1.2 rule: refuse public deployment without
/// a hash blocklist configured.
pub fn validate(&self) -> Result<()> {
if self.network.max_decompressed_frame_size == 0 {
bail!("network.max_decompressed_frame_size must be at least 1");
}
if self.server.public && self.content_filter.hash_banlist.is_empty() {
bail!(
"server.public = true requires content_filter.hash_banlist \
Expand Down Expand Up @@ -836,6 +845,26 @@ whitelist_hashes = ""
mod tests {
use super::*;

#[test]
fn decompressed_frame_limit_defaults_to_eight_megabytes() {
let cfg: NetworkConfig = toml::from_str("tcp_port = 4661").unwrap();
assert_eq!(cfg.max_decompressed_frame_size, 8_000_000);
}

#[test]
fn decompressed_frame_limit_accepts_an_explicit_override() {
let cfg: NetworkConfig =
toml::from_str("tcp_port = 4661\nmax_decompressed_frame_size = 2_000_000").unwrap();
assert_eq!(cfg.max_decompressed_frame_size, 2_000_000);
}

#[test]
fn zero_decompressed_frame_limit_is_invalid() {
let mut cfg = Config::minimal_test_config();
cfg.network.max_decompressed_frame_size = 0;
assert!(cfg.validate().is_err());
}

#[test]
fn udp_port_is_derived_from_tcp_port() {
let cfg = Config::minimal_test_config();
Expand Down
134 changes: 116 additions & 18 deletions src/proto/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ use super::opcodes::{PROTO_EDONKEY, PROTO_PACKED};

const HEADER_LEN: usize = 6;

/// Default ceiling for plaintext produced from one packed `0xD4` frame.
pub const DEFAULT_MAX_DECOMPRESSED_FRAME_SIZE: u32 = 8_000_000;

#[derive(Debug, Error)]
pub enum FrameError {
#[error("io: {0}")]
Expand All @@ -37,6 +40,9 @@ pub enum FrameError {
#[error("zlib decompression failed: {0}")]
Decompress(String),

#[error("decompressed frame exceeds limit of {limit} bytes")]
DecompressedTooLarge { limit: u32 },

#[error("zero-length frame")]
EmptyFrame,
}
Expand All @@ -56,11 +62,19 @@ impl Frame {

pub struct Ed2kCodec {
pub max_frame_size: u32,
pub max_decompressed_frame_size: u32,
}

impl Ed2kCodec {
pub fn new(max_frame_size: u32) -> Self {
Self { max_frame_size }
Self::with_limits(max_frame_size, DEFAULT_MAX_DECOMPRESSED_FRAME_SIZE)
}

pub fn with_limits(max_frame_size: u32, max_decompressed_frame_size: u32) -> Self {
Self {
max_frame_size,
max_decompressed_frame_size,
}
}
}

Expand Down Expand Up @@ -113,12 +127,23 @@ impl Decoder for Ed2kCodec {
let raw_payload = src.split_to(payload_len);

let payload = if proto == PROTO_PACKED {
// zlib-decompress
let mut decoder = flate2::read::ZlibDecoder::new(raw_payload.as_ref());
let mut out = Vec::with_capacity(payload_len * 2);
// WHY: max_frame_size bounds only compressed wire bytes. A small zlib
// payload can otherwise expand until it exhausts process memory, so
// stop after one byte beyond the configured plaintext ceiling.
let decoder = flate2::read::ZlibDecoder::new(raw_payload.as_ref());
let initial_capacity = payload_len
.saturating_mul(2)
.min(self.max_decompressed_frame_size as usize);
let mut out = Vec::with_capacity(initial_capacity);
decoder
.take(u64::from(self.max_decompressed_frame_size) + 1)
.read_to_end(&mut out)
.map_err(|e| FrameError::Decompress(e.to_string()))?;
if out.len() > self.max_decompressed_frame_size as usize {
return Err(FrameError::DecompressedTooLarge {
limit: self.max_decompressed_frame_size,
});
}
out
} else {
raw_payload.to_vec()
Expand Down Expand Up @@ -222,6 +247,22 @@ impl Encoder<Frame> for Ed2kCodec {
#[cfg(test)]
mod tests {
use super::*;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;

fn packed_frame(plaintext: &[u8]) -> BytesMut {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(plaintext).unwrap();
let compressed = encoder.finish().unwrap();

let mut buf = BytesMut::new();
buf.put_u8(PROTO_PACKED);
buf.put_u32_le((compressed.len() + 1) as u32);
buf.put_u8(0x33);
buf.put_slice(&compressed);
buf
}

#[test]
fn round_trip_plain() {
Expand Down Expand Up @@ -342,25 +383,82 @@ mod tests {

#[test]
fn decompresses_d4_frame() {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;

let plaintext = b"compressible compressible compressible";
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(plaintext).unwrap();
let compressed = encoder.finish().unwrap();

// Build a D4 frame: marker=D4, length = 1+compressed.len(), opcode=0x33
let mut buf = BytesMut::new();
buf.put_u8(PROTO_PACKED);
buf.put_u32_le((compressed.len() + 1) as u32);
buf.put_u8(0x33);
buf.put_slice(&compressed);
let mut buf = packed_frame(plaintext);

let mut codec = Ed2kCodec::new(1_000_000);
let frame = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(frame.opcode, 0x33);
assert_eq!(frame.payload, plaintext);
}

#[test]
fn accepts_decompressed_payload_at_limit() {
let plaintext = vec![b'A'; 64];
let mut buf = packed_frame(&plaintext);
let mut codec = Ed2kCodec::with_limits(1_000_000, plaintext.len() as u32);

let frame = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(frame.payload, plaintext);
}

#[test]
fn rejects_decompressed_payload_one_byte_over_limit() {
let plaintext = vec![b'A'; 65];
let mut buf = packed_frame(&plaintext);
let mut codec = Ed2kCodec::with_limits(1_000_000, 64);

assert!(matches!(
codec.decode(&mut buf),
Err(FrameError::DecompressedTooLarge { limit: 64 })
));
}

#[test]
fn rejects_high_ratio_packed_frame_at_output_limit() {
let plaintext = vec![0u8; 1_000_000];
let mut buf = packed_frame(&plaintext);
assert!(buf.len() < 2_000, "fixture must exercise high expansion");
let mut codec = Ed2kCodec::with_limits(1_000_000, 64 * 1024);

assert!(matches!(
codec.decode(&mut buf),
Err(FrameError::DecompressedTooLarge { limit: 65_536 })
));
}

#[test]
fn rejects_truncated_zlib_stream() {
let mut buf = packed_frame(b"truncated packed frame payload");
buf.truncate(buf.len() - 6);
let compressed_len = buf.len() - HEADER_LEN;
buf[1..5].copy_from_slice(&((compressed_len + 1) as u32).to_le_bytes());
let mut codec = Ed2kCodec::with_limits(1_000_000, 1_000_000);

assert!(matches!(
codec.decode(&mut buf),
Err(FrameError::Decompress(_))
));
}

#[test]
fn compressed_wire_limit_is_checked_before_decompression() {
let mut buf = packed_frame(&vec![b'A'; 1_000]);
let wire_length = u32::from_le_bytes(buf[1..5].try_into().unwrap());
let mut codec = Ed2kCodec::with_limits(wire_length - 1, 10_000);

assert!(matches!(
codec.decode(&mut buf),
Err(FrameError::TooLarge { .. })
));
}

#[test]
fn plain_frame_is_not_subject_to_decompressed_limit() {
let mut buf = BytesMut::from(&b"\xE3\x06\x00\x00\x00\x38hello"[..]);
let mut codec = Ed2kCodec::with_limits(1_000_000, 4);

let frame = codec.decode(&mut buf).unwrap().unwrap();
assert_eq!(frame.payload, b"hello");
}
}
5 changes: 4 additions & 1 deletion src/server/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ pub async fn handle_connection(
// tokio may move between worker threads. load_full() hands back a plain Arc,
// which is safe to keep and costs one refcount bump per connection.
let live = state.live_cfg.load_full();
let codec = Ed2kCodec::new(live.network.max_frame_size);
let codec = Ed2kCodec::with_limits(
live.network.max_frame_size,
live.network.max_decompressed_frame_size,
);
// Framed::new would allocate 8 KiB for the read buffer AND 8 KiB for the write
// buffer — 16 KiB of heap per connection before a single byte arrives. eD2k
// control frames are small (a login, a search, a keepalive: tens to hundreds of
Expand Down
Loading