From df8687fe6e8ac2a595ee3aa87b8eb500508a82e6 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sun, 6 Sep 2026 22:09:06 +0200 Subject: [PATCH] chore: improve pumpkin-command does not depend on pumpkin-world anymore --- Cargo.lock | 1 - crates/pumpkin-command/Cargo.toml | 1 - .../pumpkin-command/src/argument_types/mod.rs | 2 - .../src/argument_types/placed_feature.rs | 2 +- .../src/argument_types/structure.rs | 2 +- .../pumpkin/src/command/argument_types/mod.rs | 2 + .../src/command}/argument_types/pool.rs | 15 +- .../src/command}/argument_types/template.rs | 15 +- crates/pumpkin/src/telemetry.rs | 680 +++++++++++++++--- 9 files changed, 615 insertions(+), 105 deletions(-) rename crates/{pumpkin-command/src => pumpkin/src/command}/argument_types/pool.rs (66%) rename crates/{pumpkin-command/src => pumpkin/src/command}/argument_types/template.rs (66%) diff --git a/Cargo.lock b/Cargo.lock index ea3b66478..41a5d1a5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3446,7 +3446,6 @@ dependencies = [ "pumpkin-nbt", "pumpkin-protocol", "pumpkin-util", - "pumpkin-world", "rustc-hash", "serde", "serde_json", diff --git a/crates/pumpkin-command/Cargo.toml b/crates/pumpkin-command/Cargo.toml index a8dc46258..6ef009785 100644 --- a/crates/pumpkin-command/Cargo.toml +++ b/crates/pumpkin-command/Cargo.toml @@ -10,7 +10,6 @@ pumpkin-util.workspace = true pumpkin-protocol = { workspace = true, features = ["default"] } pumpkin-data = { workspace = true, features = ["default"] } pumpkin-nbt.workspace = true -pumpkin-world.workspace = true pumpkin-codecs.workspace = true uuid.workspace = true diff --git a/crates/pumpkin-command/src/argument_types/mod.rs b/crates/pumpkin-command/src/argument_types/mod.rs index 0812b136e..af5d53d42 100644 --- a/crates/pumpkin-command/src/argument_types/mod.rs +++ b/crates/pumpkin-command/src/argument_types/mod.rs @@ -250,7 +250,6 @@ pub mod nbt; pub mod nbt_path; pub mod particle; pub mod placed_feature; -pub mod pool; pub mod range; pub mod resource; pub mod resource_or_tag; @@ -258,6 +257,5 @@ pub mod slot; pub mod sound_category; pub mod structure; pub mod team_color; -pub mod template; pub mod time; pub mod uuid; diff --git a/crates/pumpkin-command/src/argument_types/placed_feature.rs b/crates/pumpkin-command/src/argument_types/placed_feature.rs index 13fae8546..92f89c851 100644 --- a/crates/pumpkin-command/src/argument_types/placed_feature.rs +++ b/crates/pumpkin-command/src/argument_types/placed_feature.rs @@ -22,7 +22,7 @@ impl ArgumentType for PlacedFeatureNameArgum _context: &CommandContext, builder: SuggestionsBuilder, ) -> Suggestions { - let names = pumpkin_world::generation::feature::placed_features::all_placed_feature_names(); + let names = pumpkin_data::placed_feature::PlacedFeature::all_names(); builder .filter_and_suggest_iter(names.iter().copied()) .build() diff --git a/crates/pumpkin-command/src/argument_types/structure.rs b/crates/pumpkin-command/src/argument_types/structure.rs index 8079a527c..de1f7b8b9 100644 --- a/crates/pumpkin-command/src/argument_types/structure.rs +++ b/crates/pumpkin-command/src/argument_types/structure.rs @@ -21,7 +21,7 @@ impl ArgumentType for StructureNameArgumentT _context: &CommandContext, builder: SuggestionsBuilder, ) -> Suggestions { - let names = pumpkin_world::generation::structure::template::all_structure_names(); + let names = pumpkin_data::structures::StructureKeys::all_names(); builder .filter_and_suggest_iter(names.iter().copied()) .build() diff --git a/crates/pumpkin/src/command/argument_types/mod.rs b/crates/pumpkin/src/command/argument_types/mod.rs index 97bb864d8..0ec65c850 100644 --- a/crates/pumpkin/src/command/argument_types/mod.rs +++ b/crates/pumpkin/src/command/argument_types/mod.rs @@ -16,5 +16,7 @@ pub mod entity_anchor; pub mod entity_selector; pub mod game_profile; pub mod objective; +pub mod pool; pub mod resource_key; pub mod team; +pub mod template; diff --git a/crates/pumpkin-command/src/argument_types/pool.rs b/crates/pumpkin/src/command/argument_types/pool.rs similarity index 66% rename from crates/pumpkin-command/src/argument_types/pool.rs rename to crates/pumpkin/src/command/argument_types/pool.rs index 52062a2bf..c290f9bb5 100644 --- a/crates/pumpkin-command/src/argument_types/pool.rs +++ b/crates/pumpkin/src/command/argument_types/pool.rs @@ -1,15 +1,16 @@ -use crate::argument_types::FromStringReader; -use crate::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; -use crate::context::command_context::CommandContext; -use crate::errors::command_syntax_error::CommandSyntaxError; -use crate::string_reader::StringReader; -use crate::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; +use pumpkin_command::argument_types::FromStringReader; +use pumpkin_command::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; +use pumpkin_command::context::command_context::CommandContext; +use pumpkin_command::errors::command_syntax_error::CommandSyntaxError; +use pumpkin_command::source::CommandSource; +use pumpkin_command::string_reader::StringReader; +use pumpkin_command::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; use pumpkin_protocol::java::client::play::SuggestionProviders; use pumpkin_util::identifier::Identifier; pub struct PoolNameArgumentType; -impl ArgumentType for PoolNameArgumentType { +impl ArgumentType for PoolNameArgumentType { type Item = Identifier; fn parse(&self, reader: &mut StringReader) -> Result { diff --git a/crates/pumpkin-command/src/argument_types/template.rs b/crates/pumpkin/src/command/argument_types/template.rs similarity index 66% rename from crates/pumpkin-command/src/argument_types/template.rs rename to crates/pumpkin/src/command/argument_types/template.rs index 089f1bebb..61b2222ac 100644 --- a/crates/pumpkin-command/src/argument_types/template.rs +++ b/crates/pumpkin/src/command/argument_types/template.rs @@ -1,15 +1,16 @@ -use crate::argument_types::FromStringReader; -use crate::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; -use crate::context::command_context::CommandContext; -use crate::errors::command_syntax_error::CommandSyntaxError; -use crate::string_reader::StringReader; -use crate::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; +use pumpkin_command::argument_types::FromStringReader; +use pumpkin_command::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; +use pumpkin_command::context::command_context::CommandContext; +use pumpkin_command::errors::command_syntax_error::CommandSyntaxError; +use pumpkin_command::source::CommandSource; +use pumpkin_command::string_reader::StringReader; +use pumpkin_command::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; use pumpkin_protocol::java::client::play::SuggestionProviders; use pumpkin_util::identifier::Identifier; pub struct TemplateNameArgumentType; -impl ArgumentType for TemplateNameArgumentType { +impl ArgumentType for TemplateNameArgumentType { type Item = String; fn parse(&self, reader: &mut StringReader) -> Result { diff --git a/crates/pumpkin/src/telemetry.rs b/crates/pumpkin/src/telemetry.rs index d5f61af15..872a9a737 100644 --- a/crates/pumpkin/src/telemetry.rs +++ b/crates/pumpkin/src/telemetry.rs @@ -1,16 +1,31 @@ use std::path::Path; use std::sync::Arc; use std::sync::atomic::Ordering; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use pumpkin_config::TelemetryConfig; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::server::Server; -/// Filename used to persist the server ID across restarts. -pub const SERVER_ID_FILENAME: &str = ".pumpkin-server-id"; +/// Filename used to persist the telemetry identity key across restarts. +pub const IDENTITY_KEY_PATH: &str = ".pumpkin/identity.key"; + +/// Fallback filename used to persist the telemetry identity key across restarts. +pub const FALLBACK_IDENTITY_KEY_PATH: &str = "telemetry_key.bin"; + +/// HTTP header name for the Ed25519 public key in lowercase hex format. +pub const HEADER_PUBLIC_KEY: &str = "X-Telemetry-Public-Key"; + +/// HTTP header name for the Ed25519 signature in lowercase hex format. +pub const HEADER_SIGNATURE: &str = "X-Telemetry-Signature"; + +/// HTTP header name for the request timestamp in Unix seconds. +pub const HEADER_TIMESTAMP: &str = "X-Telemetry-Timestamp"; + +/// Maximum allowable clock drift between telemetry client and server (300 seconds / 5 minutes). +pub const MAX_CLOCK_DRIFT_SECS: u64 = 300; /// Information about an active plugin on the server. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -21,11 +36,12 @@ pub struct PluginInfo { pub version: String, } +/// Type alias for plugin telemetry information. +pub type PluginTelemetryInfo = PluginInfo; + /// The telemetry heartbeat payload transmitted to the backend. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct HeartbeatPayload { - /// Pseudonymous UUID identifying this server installation. - pub server_id: Uuid, /// The server software implementation type (e.g. "pumpkin"). pub server_type: String, /// The Pumpkin server software version. @@ -66,56 +82,298 @@ pub struct HeartbeatResponse { pub status: String, /// Status or error message from the backend. #[serde(default)] - pub message: Option, - /// Confirmed server ID acknowledged by the backend. + pub message: String, + /// Confirmed server public key acknowledged by the backend. #[serde(default)] - pub server_id: Option, + pub server_public_key: String, /// Recommended seconds to wait before the next heartbeat. #[serde(default)] - pub next_ping_seconds: Option, + pub next_ping_seconds: u32, } -/// Loads an existing server UUID from the given path or generates and persists a new one. -pub fn get_or_create_server_id(path: &Path) -> Uuid { - if path.exists() - && let Ok(content) = std::fs::read_to_string(path) - { - let trimmed = content.trim(); - if let Ok(id) = Uuid::parse_str(trimmed) { - return id; +/// Computes the signed data byte layout: `[timestamp_ascii_bytes] + [b'.'] + [raw_json_body_bytes]`. +#[must_use] +pub fn compute_signed_data(timestamp_str: &str, body_bytes: &[u8]) -> Vec { + let mut signed_data = Vec::with_capacity(timestamp_str.len() + 1 + body_bytes.len()); + signed_data.extend_from_slice(timestamp_str.as_bytes()); + signed_data.push(b'.'); + signed_data.extend_from_slice(body_bytes); + signed_data +} + +/// Signs telemetry message data using an Ed25519 signing key and returns `(public_key_hex, signature_hex)`. +#[must_use] +pub fn sign_telemetry_payload( + signing_key: &SigningKey, + timestamp_str: &str, + body_bytes: &[u8], +) -> (String, String) { + let signed_data = compute_signed_data(timestamp_str, body_bytes); + let signature = signing_key.sign(&signed_data); + let pubkey_hex = hex::encode(signing_key.verifying_key().to_bytes()); + let sig_hex = hex::encode(signature.to_bytes()); + (pubkey_hex, sig_hex) +} + +/// Errors that can occur during telemetry request signature verification. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum TelemetryVerificationError { + #[error("Invalid timestamp string format")] + InvalidTimestamp, + #[error( + "Timestamp drift exceeded: drift was {drift}s (max allowed is {MAX_CLOCK_DRIFT_SECS}s)" + )] + ClockDriftExceeded { drift: u64 }, + #[error("Invalid public key hex encoding")] + InvalidPublicKeyHex, + #[error("Invalid public key bytes: {0}")] + InvalidPublicKey(String), + #[error("Invalid signature hex encoding")] + InvalidSignatureHex, + #[error("Invalid signature bytes: {0}")] + InvalidSignature(String), + #[error("Signature verification failed: {0}")] + VerificationFailed(String), +} + +/// Verifies a signed telemetry request against an Ed25519 public key and timestamp. +/// +/// Ensures clock drift between `current_time_secs` and `timestamp_str` does not exceed `±300` seconds. +pub fn verify_telemetry_request( + pubkey_hex: &str, + sig_hex: &str, + timestamp_str: &str, + body_bytes: &[u8], + current_time_secs: u64, +) -> Result<(), TelemetryVerificationError> { + let ts: u64 = timestamp_str + .parse() + .map_err(|_| TelemetryVerificationError::InvalidTimestamp)?; + + let drift = current_time_secs.abs_diff(ts); + if drift > MAX_CLOCK_DRIFT_SECS { + return Err(TelemetryVerificationError::ClockDriftExceeded { drift }); + } + + let pubkey_bytes = + hex::decode(pubkey_hex).map_err(|_| TelemetryVerificationError::InvalidPublicKeyHex)?; + let vk = VerifyingKey::try_from(pubkey_bytes.as_slice()) + .map_err(|e| TelemetryVerificationError::InvalidPublicKey(e.to_string()))?; + + let sig_bytes = + hex::decode(sig_hex).map_err(|_| TelemetryVerificationError::InvalidSignatureHex)?; + let sig = Signature::from_slice(&sig_bytes) + .map_err(|e| TelemetryVerificationError::InvalidSignature(e.to_string()))?; + + let signed_data = compute_signed_data(timestamp_str, body_bytes); + vk.verify(&signed_data, &sig) + .map_err(|e| TelemetryVerificationError::VerificationFailed(e.to_string()))?; + + Ok(()) +} + +/// Client for communicating with the Pumpkin Marketplace telemetry backend. +pub struct TelemetryClient { + pub signing_key: SigningKey, + pub http_client: reqwest::Client, + pub endpoint: String, + pub api_base_url: String, +} + +impl TelemetryClient { + /// Creates a new `TelemetryClient` instance. + #[must_use] + pub fn new(signing_key: SigningKey, http_client: reqwest::Client, endpoint: String) -> Self { + let api_base_url = endpoint + .strip_suffix("/api/v1/rest/telemetry/heartbeat") + .unwrap_or_else(|| { + endpoint + .strip_suffix("/heartbeat") + .unwrap_or_else(|| endpoint.trim_end_matches('/')) + }) + .to_string(); + + Self { + signing_key, + http_client, + endpoint, + api_base_url, } + } + + /// Returns the 64-character lowercase hexadecimal representation of the client's Ed25519 public key. + #[must_use] + pub fn public_key_hex(&self) -> String { + hex::encode(self.signing_key.verifying_key().to_bytes()) + } + + /// Loads an existing Ed25519 private key/seed from the given path or generates and persists a new one. + pub fn load_or_generate_key(key_path: &Path) -> Result { + if key_path.exists() { + let bytes = std::fs::read(key_path)?; + if bytes.len() == 32 { + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes); + return Ok(SigningKey::from_bytes(&seed)); + } + tracing::warn!( + "Failed to read valid 32-byte identity key from {}, generating a new one", + key_path.display() + ); + } + + // Generate new key + let seed: [u8; 32] = rand::random(); + let key = SigningKey::from_bytes(&seed); + + if let Some(parent) = key_path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + std::fs::write(key_path, seed)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)); + } + + Ok(key) + } + + /// Sends a signed heartbeat request to the telemetry backend. + pub async fn send_heartbeat( + &self, + payload: &HeartbeatPayload, + ) -> Result { + #[allow(clippy::expect_used)] + let body_bytes = serde_json::to_vec(payload).expect("Serialization failed"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let timestamp_str = timestamp.to_string(); + + let (pubkey_hex, sig_hex) = + sign_telemetry_payload(&self.signing_key, ×tamp_str, &body_bytes); + + let response = self + .http_client + .post(&self.endpoint) + .header("Content-Type", "application/json") + .header(HEADER_PUBLIC_KEY, pubkey_hex) + .header(HEADER_SIGNATURE, sig_hex) + .header(HEADER_TIMESTAMP, timestamp_str) + .body(body_bytes) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(response) + } + + /// Sends a signed server shutdown notice to the telemetry backend. + pub async fn send_shutdown(&self, reason: Option<&str>) -> Result<(), reqwest::Error> { + let payload = serde_json::json!({ + "reason": reason.unwrap_or("server shutdown") + }); + #[allow(clippy::expect_used)] + let body_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let timestamp_str = timestamp.to_string(); + + let (pubkey_hex, sig_hex) = + sign_telemetry_payload(&self.signing_key, ×tamp_str, &body_bytes); + + let _ = self + .http_client + .post(format!( + "{}/api/v1/rest/telemetry/shutdown", + self.api_base_url + )) + .header("Content-Type", "application/json") + .header(HEADER_PUBLIC_KEY, pubkey_hex) + .header(HEADER_SIGNATURE, sig_hex) + .header(HEADER_TIMESTAMP, timestamp_str) + .body(body_bytes) + .send() + .await; + + Ok(()) + } +} + +/// Resolves the server identity key by checking default locations or generating a new one. +#[must_use] +pub fn resolve_identity_key() -> SigningKey { + let primary = Path::new(IDENTITY_KEY_PATH); + if primary.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(primary) + { + return key; + } + + let fallback = Path::new(FALLBACK_IDENTITY_KEY_PATH); + if fallback.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(fallback) + { + return key; + } + + let data_primary = Path::new("data").join(IDENTITY_KEY_PATH); + if data_primary.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(&data_primary) + { + return key; + } + + let data_fallback = Path::new("data").join(FALLBACK_IDENTITY_KEY_PATH); + if data_fallback.exists() + && let Ok(key) = TelemetryClient::load_or_generate_key(&data_fallback) + { + return key; + } + + let data_dir_key = Path::new("data").join(IDENTITY_KEY_PATH); + if Path::new("data").is_dir() + && let Ok(key) = TelemetryClient::load_or_generate_key(&data_dir_key) + { + return key; + } + + TelemetryClient::load_or_generate_key(primary).unwrap_or_else(|err| { tracing::warn!( - "Failed to parse server ID from {}, generating a new one", + "Failed to persist telemetry identity key at {}: {err}, using ephemeral key", + primary.display() + ); + let seed: [u8; 32] = rand::random(); + SigningKey::from_bytes(&seed) + }) +} + +/// Helper function to load or create an identity key at a specified path. +#[must_use] +pub fn get_or_create_identity_key(path: &Path) -> SigningKey { + TelemetryClient::load_or_generate_key(path).unwrap_or_else(|err| { + tracing::warn!( + "Failed to save identity key to {}: {err}, using ephemeral key", path.display() ); - } - - let id = Uuid::new_v4(); - if let Err(err) = std::fs::write(path, id.to_string()) { - tracing::warn!("Failed to save server ID to {}: {err}", path.display()); - } - id -} - -/// Resolves the server ID by checking default locations or generating a new one. -#[must_use] -pub fn resolve_server_id() -> Uuid { - let root_path = Path::new(SERVER_ID_FILENAME); - if root_path.exists() { - return get_or_create_server_id(root_path); - } - let data_path = Path::new("data").join(SERVER_ID_FILENAME); - if data_path.exists() { - return get_or_create_server_id(&data_path); - } - get_or_create_server_id(root_path) + let seed: [u8; 32] = rand::random(); + SigningKey::from_bytes(&seed) + }) } /// Builds the telemetry heartbeat payload from current server and runtime state. #[must_use] pub fn build_heartbeat_payload( server: &Server, - server_id: Uuid, telemetry_config: &TelemetryConfig, ) -> HeartbeatPayload { let os = sysinfo::System::long_os_version().unwrap_or_else(|| { @@ -164,7 +422,6 @@ pub fn build_heartbeat_payload( }; HeartbeatPayload { - server_id, server_type: "pumpkin".to_string(), server_version: env!("CARGO_PKG_VERSION").to_string(), minecraft_version: pumpkin_data::packet::CURRENT_MC_VERSION.to_string(), @@ -190,6 +447,7 @@ pub fn start_telemetry(server: Arc) { } /// Starts the background telemetry reporting task with a specific configuration. +#[allow(clippy::needless_pass_by_value)] pub fn start_telemetry_with_config(server: Arc, config: &TelemetryConfig) { if !config.enabled { tracing::trace!("Telemetry is disabled in configuration."); @@ -202,7 +460,7 @@ pub fn start_telemetry_with_config(server: Arc, config: &TelemetryConfig ); let user_agent = format!("Pumpkin-Server/{}", env!("CARGO_PKG_VERSION")); - let client = match reqwest::Client::builder() + let http_client = match pumpkin_util::client_builder() .timeout(Duration::from_secs(10)) .user_agent(&user_agent) .build() @@ -214,74 +472,107 @@ pub fn start_telemetry_with_config(server: Arc, config: &TelemetryConfig } }; - let server_id = resolve_server_id(); - let interval_secs = config.interval_secs.max(60); - let endpoint = config.endpoint.clone(); - let config = config.clone(); + let signing_key = resolve_identity_key(); + let telemetry_client = Arc::new(TelemetryClient::new( + signing_key, + http_client, + config.endpoint.clone(), + )); - tokio::spawn(async move { + let interval_secs = config.interval_secs.max(60); + let config = config.clone(); + let server_task = server.clone(); + + server.spawn_task(async move { let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); while !crate::SHOULD_STOP.load(Ordering::Relaxed) { - interval.tick().await; + tokio::select! { + _ = interval.tick() => {} + () = crate::STOP_INTERRUPT.cancelled() => { + break; + } + } if crate::SHOULD_STOP.load(Ordering::Relaxed) { break; } - let payload = build_heartbeat_payload(&server, server_id, &config); + let payload = build_heartbeat_payload(&server_task, &config); - let res = client - .post(&endpoint) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header(reqwest::header::USER_AGENT, &user_agent) - .json(&payload) - .send() - .await; - - match res { + match telemetry_client.send_heartbeat(&payload).await { Ok(resp) => { - if resp.status().is_success() { - tracing::trace!("Telemetry heartbeat successfully transmitted"); - } else { - tracing::debug!( - "Telemetry heartbeat received non-success HTTP status: {}", - resp.status() - ); - } + tracing::trace!( + "Telemetry heartbeat successfully transmitted (status: {}, public_key: {})", + resp.status, + resp.server_public_key + ); } Err(err) => { tracing::debug!("Telemetry heartbeat failed to send: {err}"); } } } + + // Trigger shutdown telemetry on server termination / SIGTERM + if let Err(err) = telemetry_client + .send_shutdown(Some("server shutdown")) + .await + { + tracing::debug!("Telemetry shutdown message failed to send: {err}"); + } }); } #[cfg(test)] mod tests { use super::*; + use axum::Router; + use axum::extract::Request; + use axum::http::StatusCode; + use axum::response::IntoResponse; + use axum::routing::post; #[test] - fn server_id_generation_and_persistence() { + fn identity_key_generation_and_persistence() { let dir = tempfile::tempdir().unwrap(); - let id_path = dir.path().join(".pumpkin-server-id"); + let key_path = dir.path().join(".pumpkin/identity.key"); - assert!(!id_path.exists()); - let id1 = get_or_create_server_id(&id_path); - assert!(id_path.exists()); + assert!(!key_path.exists()); + let key1 = TelemetryClient::load_or_generate_key(&key_path).unwrap(); + assert!(key_path.exists()); - let id2 = get_or_create_server_id(&id_path); - assert_eq!(id1, id2); + // Check key file has 32 bytes + let file_bytes = std::fs::read(&key_path).unwrap(); + assert_eq!(file_bytes.len(), 32); - // Corrupted file test - std::fs::write(&id_path, "not-a-uuid").unwrap(); - let id3 = get_or_create_server_id(&id_path); - assert_ne!(id1, id3); + #[cfg(unix)] + assert_eq!( + std::os::unix::fs::PermissionsExt::mode( + &std::fs::metadata(&key_path).unwrap().permissions() + ) & 0o777, + 0o600 + ); + + // Reload existing key + let key2 = TelemetryClient::load_or_generate_key(&key_path).unwrap(); + assert_eq!( + key1.verifying_key().to_bytes(), + key2.verifying_key().to_bytes() + ); + + // Corrupted file test (less than 32 bytes) + std::fs::write(&key_path, b"corrupted-key-data").unwrap(); + let key3 = TelemetryClient::load_or_generate_key(&key_path).unwrap(); + assert_ne!( + key1.verifying_key().to_bytes(), + key3.verifying_key().to_bytes() + ); + let new_file_bytes = std::fs::read(&key_path).unwrap(); + assert_eq!(new_file_bytes.len(), 32); } #[test] fn heartbeat_payload_serialization() { let payload = HeartbeatPayload { - server_id: Uuid::parse_str("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d").unwrap(), server_type: "pumpkin".to_string(), server_version: "0.3.2".to_string(), minecraft_version: "1.21.4".to_string(), @@ -311,7 +602,9 @@ mod tests { let json_str = serde_json::to_string_pretty(&payload).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); - assert_eq!(parsed["server_id"], "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"); + // Ensure server_id is completely removed + assert!(parsed.get("server_id").is_none()); + assert_eq!(parsed["server_type"], "pumpkin"); assert_eq!(parsed["server_version"], "0.3.2"); assert_eq!(parsed["minecraft_version"], "1.21.4"); @@ -334,17 +627,234 @@ mod tests { let json_data = r#"{ "status": "ok", "message": "Heartbeat accepted", - "server_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "server_public_key": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "next_ping_seconds": 300 }"#; let resp: HeartbeatResponse = serde_json::from_str(json_data).unwrap(); assert_eq!(resp.status, "ok"); - assert_eq!(resp.message.as_deref(), Some("Heartbeat accepted")); + assert_eq!(resp.message, "Heartbeat accepted"); assert_eq!( - resp.server_id.as_deref(), - Some("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d") + resp.server_public_key, + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" ); - assert_eq!(resp.next_ping_seconds, Some(300)); + assert_eq!(resp.next_ping_seconds, 300); + } + + #[test] + fn message_signing_and_verification() { + let seed = [42u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let timestamp_str = "1757185000"; + let body = br#"{"server_type":"pumpkin","online_players":5}"#; + + // Verify signed data format: "{timestamp}.{body}" + let signed_data = compute_signed_data(timestamp_str, body); + let expected_prefix = format!("{timestamp_str}."); + assert!(signed_data.starts_with(expected_prefix.as_bytes())); + assert_eq!(&signed_data[expected_prefix.len()..], body); + + // Sign payload + let (pubkey_hex, sig_hex) = sign_telemetry_payload(&signing_key, timestamp_str, body); + assert_eq!(pubkey_hex.len(), 64); + assert_eq!(sig_hex.len(), 128); + + // All hex chars must be lowercase + assert_eq!(pubkey_hex, pubkey_hex.to_lowercase()); + assert_eq!(sig_hex, sig_hex.to_lowercase()); + + // Verify with matching timestamp + let current_time: u64 = 1757185100; // 100s drift (within 300s) + let verify_result = + verify_telemetry_request(&pubkey_hex, &sig_hex, timestamp_str, body, current_time); + assert!(verify_result.is_ok()); + + // Drift exceeded (> 300s) + let expired_time: u64 = 1757185000 + 301; + let expired_result = + verify_telemetry_request(&pubkey_hex, &sig_hex, timestamp_str, body, expired_time); + assert!(matches!( + expired_result, + Err(TelemetryVerificationError::ClockDriftExceeded { .. }) + )); + + // Corrupted signature + let mut corrupted_sig = sig_hex.clone(); + let flipped_char = if corrupted_sig.starts_with('0') { + '1' + } else { + '0' + }; + corrupted_sig.replace_range(0..1, &flipped_char.to_string()); + let corrupted_result = verify_telemetry_request( + &pubkey_hex, + &corrupted_sig, + timestamp_str, + body, + current_time, + ); + assert!(matches!( + corrupted_result, + Err(TelemetryVerificationError::VerificationFailed(_)) + )); + + // Corrupted body + let tampered_body = br#"{"server_type":"pumpkin","online_players":6}"#; + let tampered_result = verify_telemetry_request( + &pubkey_hex, + &sig_hex, + timestamp_str, + tampered_body, + current_time, + ); + assert!(matches!( + tampered_result, + Err(TelemetryVerificationError::VerificationFailed(_)) + )); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn telemetry_client_send_heartbeat_and_shutdown() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let seed = [7u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let expected_pubkey = hex::encode(signing_key.verifying_key().to_bytes()); + + // Set up mock axum server + let app = Router::new() + .route( + "/api/v1/rest/telemetry/heartbeat", + post(move |req: Request| async move { + let headers = req.headers().clone(); + let pubkey = headers.get(HEADER_PUBLIC_KEY).and_then(|v| v.to_str().ok()); + let signature = headers.get(HEADER_SIGNATURE).and_then(|v| v.to_str().ok()); + let timestamp = headers.get(HEADER_TIMESTAMP).and_then(|v| v.to_str().ok()); + + let (Some(pubkey), Some(sig), Some(ts)) = (pubkey, signature, timestamp) else { + return (StatusCode::UNAUTHORIZED, "Missing required headers") + .into_response(); + }; + + let body = axum::body::to_bytes(req.into_body(), 1024 * 1024) + .await + .unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if verify_telemetry_request(pubkey, sig, ts, &body, now).is_err() { + return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response(); + } + + let response = HeartbeatResponse { + status: "ok".to_string(), + message: "Heartbeat accepted".to_string(), + server_public_key: pubkey.to_string(), + next_ping_seconds: 300, + }; + let json_bytes = serde_json::to_vec(&response).unwrap(); + ( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/json")], + json_bytes, + ) + .into_response() + }), + ) + .route( + "/api/v1/rest/telemetry/shutdown", + post(move |req: Request| async move { + let headers = req.headers().clone(); + let pubkey = headers.get(HEADER_PUBLIC_KEY).and_then(|v| v.to_str().ok()); + let signature = headers.get(HEADER_SIGNATURE).and_then(|v| v.to_str().ok()); + let timestamp = headers.get(HEADER_TIMESTAMP).and_then(|v| v.to_str().ok()); + + let (Some(pubkey), Some(sig), Some(ts)) = (pubkey, signature, timestamp) else { + return (StatusCode::UNAUTHORIZED, "Missing headers").into_response(); + }; + + let body = axum::body::to_bytes(req.into_body(), 1024 * 1024) + .await + .unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if verify_telemetry_request(pubkey, sig, ts, &body, now).is_err() { + return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response(); + } + + (StatusCode::OK, "Shutdown received").into_response() + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let endpoint = format!("http://127.0.0.1:{port}/api/v1/rest/telemetry/heartbeat"); + let http_client = pumpkin_util::client_builder().build().unwrap(); + let client = TelemetryClient::new(signing_key, http_client, endpoint); + + let payload = HeartbeatPayload { + server_type: "pumpkin".to_string(), + server_version: "0.1.0".to_string(), + minecraft_version: "1.21.4".to_string(), + protocol_version: 769, + online_players: 1, + max_players: 20, + os: "Linux".to_string(), + arch: "x86_64".to_string(), + cpu_cores: 4, + cpu_model: "Test CPU".to_string(), + ram_allocated_mb: 1024, + total_ram_mb: 2048, + plugins: vec![], + is_public: false, + public_name: None, + }; + + // Send valid heartbeat -> 200 OK + let resp = client.send_heartbeat(&payload).await.unwrap(); + assert_eq!(resp.status, "ok"); + assert_eq!(resp.message, "Heartbeat accepted"); + assert_eq!(resp.server_public_key, expected_pubkey); + assert_eq!(resp.next_ping_seconds, 300); + + // Send valid shutdown -> 200 OK + let shutdown_res = client.send_shutdown(Some("maintenance")).await; + assert!(shutdown_res.is_ok()); + + // Test invalid signature / unauthorized + let body_bytes = serde_json::to_vec(&payload).unwrap(); + let timestamp_str = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + let response = pumpkin_util::client_builder() + .build() + .unwrap() + .post(format!( + "http://127.0.0.1:{port}/api/v1/rest/telemetry/heartbeat" + )) + .header("Content-Type", "application/json") + .header(HEADER_PUBLIC_KEY, &expected_pubkey) + .header(HEADER_SIGNATURE, "00".repeat(64)) // invalid signature + .header(HEADER_TIMESTAMP, timestamp_str) + .body(body_bytes) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } }