chore: improve pumpkin-command

does not depend on pumpkin-world anymore
This commit is contained in:
Alexander Medvedev 2026-09-06 22:09:06 +02:00
parent 4f88d77ab1
commit df8687fe6e
9 changed files with 615 additions and 105 deletions

1
Cargo.lock generated
View File

@ -3446,7 +3446,6 @@ dependencies = [
"pumpkin-nbt", "pumpkin-nbt",
"pumpkin-protocol", "pumpkin-protocol",
"pumpkin-util", "pumpkin-util",
"pumpkin-world",
"rustc-hash", "rustc-hash",
"serde", "serde",
"serde_json", "serde_json",

View File

@ -10,7 +10,6 @@ pumpkin-util.workspace = true
pumpkin-protocol = { workspace = true, features = ["default"] } pumpkin-protocol = { workspace = true, features = ["default"] }
pumpkin-data = { workspace = true, features = ["default"] } pumpkin-data = { workspace = true, features = ["default"] }
pumpkin-nbt.workspace = true pumpkin-nbt.workspace = true
pumpkin-world.workspace = true
pumpkin-codecs.workspace = true pumpkin-codecs.workspace = true
uuid.workspace = true uuid.workspace = true

View File

@ -250,7 +250,6 @@ pub mod nbt;
pub mod nbt_path; pub mod nbt_path;
pub mod particle; pub mod particle;
pub mod placed_feature; pub mod placed_feature;
pub mod pool;
pub mod range; pub mod range;
pub mod resource; pub mod resource;
pub mod resource_or_tag; pub mod resource_or_tag;
@ -258,6 +257,5 @@ pub mod slot;
pub mod sound_category; pub mod sound_category;
pub mod structure; pub mod structure;
pub mod team_color; pub mod team_color;
pub mod template;
pub mod time; pub mod time;
pub mod uuid; pub mod uuid;

View File

@ -22,7 +22,7 @@ impl<S: crate::source::CommandSource> ArgumentType<S> for PlacedFeatureNameArgum
_context: &CommandContext<S>, _context: &CommandContext<S>,
builder: SuggestionsBuilder, builder: SuggestionsBuilder,
) -> Suggestions { ) -> Suggestions {
let names = pumpkin_world::generation::feature::placed_features::all_placed_feature_names(); let names = pumpkin_data::placed_feature::PlacedFeature::all_names();
builder builder
.filter_and_suggest_iter(names.iter().copied()) .filter_and_suggest_iter(names.iter().copied())
.build() .build()

View File

@ -21,7 +21,7 @@ impl<S: crate::source::CommandSource> ArgumentType<S> for StructureNameArgumentT
_context: &CommandContext<S>, _context: &CommandContext<S>,
builder: SuggestionsBuilder, builder: SuggestionsBuilder,
) -> Suggestions { ) -> Suggestions {
let names = pumpkin_world::generation::structure::template::all_structure_names(); let names = pumpkin_data::structures::StructureKeys::all_names();
builder builder
.filter_and_suggest_iter(names.iter().copied()) .filter_and_suggest_iter(names.iter().copied())
.build() .build()

View File

@ -16,5 +16,7 @@ pub mod entity_anchor;
pub mod entity_selector; pub mod entity_selector;
pub mod game_profile; pub mod game_profile;
pub mod objective; pub mod objective;
pub mod pool;
pub mod resource_key; pub mod resource_key;
pub mod team; pub mod team;
pub mod template;

View File

@ -1,15 +1,16 @@
use crate::argument_types::FromStringReader; use pumpkin_command::argument_types::FromStringReader;
use crate::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; use pumpkin_command::argument_types::argument_type::{ArgumentType, JavaClientArgumentType};
use crate::context::command_context::CommandContext; use pumpkin_command::context::command_context::CommandContext;
use crate::errors::command_syntax_error::CommandSyntaxError; use pumpkin_command::errors::command_syntax_error::CommandSyntaxError;
use crate::string_reader::StringReader; use pumpkin_command::source::CommandSource;
use crate::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; use pumpkin_command::string_reader::StringReader;
use pumpkin_command::suggestion::suggestions::{Suggestions, SuggestionsBuilder};
use pumpkin_protocol::java::client::play::SuggestionProviders; use pumpkin_protocol::java::client::play::SuggestionProviders;
use pumpkin_util::identifier::Identifier; use pumpkin_util::identifier::Identifier;
pub struct PoolNameArgumentType; pub struct PoolNameArgumentType;
impl<S: crate::source::CommandSource> ArgumentType<S> for PoolNameArgumentType { impl<S: CommandSource> ArgumentType<S> for PoolNameArgumentType {
type Item = Identifier; type Item = Identifier;
fn parse(&self, reader: &mut StringReader) -> Result<Self::Item, CommandSyntaxError> { fn parse(&self, reader: &mut StringReader) -> Result<Self::Item, CommandSyntaxError> {

View File

@ -1,15 +1,16 @@
use crate::argument_types::FromStringReader; use pumpkin_command::argument_types::FromStringReader;
use crate::argument_types::argument_type::{ArgumentType, JavaClientArgumentType}; use pumpkin_command::argument_types::argument_type::{ArgumentType, JavaClientArgumentType};
use crate::context::command_context::CommandContext; use pumpkin_command::context::command_context::CommandContext;
use crate::errors::command_syntax_error::CommandSyntaxError; use pumpkin_command::errors::command_syntax_error::CommandSyntaxError;
use crate::string_reader::StringReader; use pumpkin_command::source::CommandSource;
use crate::suggestion::suggestions::{Suggestions, SuggestionsBuilder}; use pumpkin_command::string_reader::StringReader;
use pumpkin_command::suggestion::suggestions::{Suggestions, SuggestionsBuilder};
use pumpkin_protocol::java::client::play::SuggestionProviders; use pumpkin_protocol::java::client::play::SuggestionProviders;
use pumpkin_util::identifier::Identifier; use pumpkin_util::identifier::Identifier;
pub struct TemplateNameArgumentType; pub struct TemplateNameArgumentType;
impl<S: crate::source::CommandSource> ArgumentType<S> for TemplateNameArgumentType { impl<S: CommandSource> ArgumentType<S> for TemplateNameArgumentType {
type Item = String; type Item = String;
fn parse(&self, reader: &mut StringReader) -> Result<Self::Item, CommandSyntaxError> { fn parse(&self, reader: &mut StringReader) -> Result<Self::Item, CommandSyntaxError> {

View File

@ -1,16 +1,31 @@
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::Ordering; 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 pumpkin_config::TelemetryConfig;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::server::Server; use crate::server::Server;
/// Filename used to persist the server ID across restarts. /// Filename used to persist the telemetry identity key across restarts.
pub const SERVER_ID_FILENAME: &str = ".pumpkin-server-id"; 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. /// Information about an active plugin on the server.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
@ -21,11 +36,12 @@ pub struct PluginInfo {
pub version: String, pub version: String,
} }
/// Type alias for plugin telemetry information.
pub type PluginTelemetryInfo = PluginInfo;
/// The telemetry heartbeat payload transmitted to the backend. /// The telemetry heartbeat payload transmitted to the backend.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct HeartbeatPayload { pub struct HeartbeatPayload {
/// Pseudonymous UUID identifying this server installation.
pub server_id: Uuid,
/// The server software implementation type (e.g. "pumpkin"). /// The server software implementation type (e.g. "pumpkin").
pub server_type: String, pub server_type: String,
/// The Pumpkin server software version. /// The Pumpkin server software version.
@ -66,56 +82,298 @@ pub struct HeartbeatResponse {
pub status: String, pub status: String,
/// Status or error message from the backend. /// Status or error message from the backend.
#[serde(default)] #[serde(default)]
pub message: Option<String>, pub message: String,
/// Confirmed server ID acknowledged by the backend. /// Confirmed server public key acknowledged by the backend.
#[serde(default)] #[serde(default)]
pub server_id: Option<String>, pub server_public_key: String,
/// Recommended seconds to wait before the next heartbeat. /// Recommended seconds to wait before the next heartbeat.
#[serde(default)] #[serde(default)]
pub next_ping_seconds: Option<u64>, pub next_ping_seconds: u32,
} }
/// Loads an existing server UUID from the given path or generates and persists a new one. /// Computes the signed data byte layout: `[timestamp_ascii_bytes] + [b'.'] + [raw_json_body_bytes]`.
pub fn get_or_create_server_id(path: &Path) -> Uuid { #[must_use]
if path.exists() pub fn compute_signed_data(timestamp_str: &str, body_bytes: &[u8]) -> Vec<u8> {
&& let Ok(content) = std::fs::read_to_string(path) let mut signed_data = Vec::with_capacity(timestamp_str.len() + 1 + body_bytes.len());
{ signed_data.extend_from_slice(timestamp_str.as_bytes());
let trimmed = content.trim(); signed_data.push(b'.');
if let Ok(id) = Uuid::parse_str(trimmed) { signed_data.extend_from_slice(body_bytes);
return id; 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<SigningKey, std::io::Error> {
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!( tracing::warn!(
"Failed to parse server ID from {}, generating a new one", "Failed to read valid 32-byte identity key from {}, generating a new one",
path.display() key_path.display()
); );
} }
let id = Uuid::new_v4(); // Generate new key
if let Err(err) = std::fs::write(path, id.to_string()) { let seed: [u8; 32] = rand::random();
tracing::warn!("Failed to save server ID to {}: {err}", path.display()); 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)?;
} }
id 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));
} }
/// Resolves the server ID by checking default locations or generating a new one. Ok(key)
}
/// Sends a signed heartbeat request to the telemetry backend.
pub async fn send_heartbeat(
&self,
payload: &HeartbeatPayload,
) -> Result<HeartbeatResponse, reqwest::Error> {
#[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, &timestamp_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::<HeartbeatResponse>()
.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, &timestamp_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] #[must_use]
pub fn resolve_server_id() -> Uuid { pub fn resolve_identity_key() -> SigningKey {
let root_path = Path::new(SERVER_ID_FILENAME); let primary = Path::new(IDENTITY_KEY_PATH);
if root_path.exists() { if primary.exists()
return get_or_create_server_id(root_path); && let Ok(key) = TelemetryClient::load_or_generate_key(primary)
{
return key;
} }
let data_path = Path::new("data").join(SERVER_ID_FILENAME);
if data_path.exists() { let fallback = Path::new(FALLBACK_IDENTITY_KEY_PATH);
return get_or_create_server_id(&data_path); if fallback.exists()
&& let Ok(key) = TelemetryClient::load_or_generate_key(fallback)
{
return key;
} }
get_or_create_server_id(root_path)
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 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 seed: [u8; 32] = rand::random();
SigningKey::from_bytes(&seed)
})
} }
/// Builds the telemetry heartbeat payload from current server and runtime state. /// Builds the telemetry heartbeat payload from current server and runtime state.
#[must_use] #[must_use]
pub fn build_heartbeat_payload( pub fn build_heartbeat_payload(
server: &Server, server: &Server,
server_id: Uuid,
telemetry_config: &TelemetryConfig, telemetry_config: &TelemetryConfig,
) -> HeartbeatPayload { ) -> HeartbeatPayload {
let os = sysinfo::System::long_os_version().unwrap_or_else(|| { let os = sysinfo::System::long_os_version().unwrap_or_else(|| {
@ -164,7 +422,6 @@ pub fn build_heartbeat_payload(
}; };
HeartbeatPayload { HeartbeatPayload {
server_id,
server_type: "pumpkin".to_string(), server_type: "pumpkin".to_string(),
server_version: env!("CARGO_PKG_VERSION").to_string(), server_version: env!("CARGO_PKG_VERSION").to_string(),
minecraft_version: pumpkin_data::packet::CURRENT_MC_VERSION.to_string(), minecraft_version: pumpkin_data::packet::CURRENT_MC_VERSION.to_string(),
@ -190,6 +447,7 @@ pub fn start_telemetry(server: Arc<Server>) {
} }
/// Starts the background telemetry reporting task with a specific configuration. /// Starts the background telemetry reporting task with a specific configuration.
#[allow(clippy::needless_pass_by_value)]
pub fn start_telemetry_with_config(server: Arc<Server>, config: &TelemetryConfig) { pub fn start_telemetry_with_config(server: Arc<Server>, config: &TelemetryConfig) {
if !config.enabled { if !config.enabled {
tracing::trace!("Telemetry is disabled in configuration."); tracing::trace!("Telemetry is disabled in configuration.");
@ -202,7 +460,7 @@ pub fn start_telemetry_with_config(server: Arc<Server>, config: &TelemetryConfig
); );
let user_agent = format!("Pumpkin-Server/{}", env!("CARGO_PKG_VERSION")); 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)) .timeout(Duration::from_secs(10))
.user_agent(&user_agent) .user_agent(&user_agent)
.build() .build()
@ -214,74 +472,107 @@ pub fn start_telemetry_with_config(server: Arc<Server>, config: &TelemetryConfig
} }
}; };
let server_id = resolve_server_id(); let signing_key = resolve_identity_key();
let interval_secs = config.interval_secs.max(60); let telemetry_client = Arc::new(TelemetryClient::new(
let endpoint = config.endpoint.clone(); signing_key,
let config = config.clone(); 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)); let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
while !crate::SHOULD_STOP.load(Ordering::Relaxed) { 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) { if crate::SHOULD_STOP.load(Ordering::Relaxed) {
break; break;
} }
let payload = build_heartbeat_payload(&server, server_id, &config); let payload = build_heartbeat_payload(&server_task, &config);
let res = client match telemetry_client.send_heartbeat(&payload).await {
.post(&endpoint)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header(reqwest::header::USER_AGENT, &user_agent)
.json(&payload)
.send()
.await;
match res {
Ok(resp) => { Ok(resp) => {
if resp.status().is_success() { tracing::trace!(
tracing::trace!("Telemetry heartbeat successfully transmitted"); "Telemetry heartbeat successfully transmitted (status: {}, public_key: {})",
} else { resp.status,
tracing::debug!( resp.server_public_key
"Telemetry heartbeat received non-success HTTP status: {}",
resp.status()
); );
} }
}
Err(err) => { Err(err) => {
tracing::debug!("Telemetry heartbeat failed to send: {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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::Router;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::post;
#[test] #[test]
fn server_id_generation_and_persistence() { fn identity_key_generation_and_persistence() {
let dir = tempfile::tempdir().unwrap(); 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()); assert!(!key_path.exists());
let id1 = get_or_create_server_id(&id_path); let key1 = TelemetryClient::load_or_generate_key(&key_path).unwrap();
assert!(id_path.exists()); assert!(key_path.exists());
let id2 = get_or_create_server_id(&id_path); // Check key file has 32 bytes
assert_eq!(id1, id2); let file_bytes = std::fs::read(&key_path).unwrap();
assert_eq!(file_bytes.len(), 32);
// Corrupted file test #[cfg(unix)]
std::fs::write(&id_path, "not-a-uuid").unwrap(); assert_eq!(
let id3 = get_or_create_server_id(&id_path); std::os::unix::fs::PermissionsExt::mode(
assert_ne!(id1, id3); &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] #[test]
fn heartbeat_payload_serialization() { fn heartbeat_payload_serialization() {
let payload = HeartbeatPayload { let payload = HeartbeatPayload {
server_id: Uuid::parse_str("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d").unwrap(),
server_type: "pumpkin".to_string(), server_type: "pumpkin".to_string(),
server_version: "0.3.2".to_string(), server_version: "0.3.2".to_string(),
minecraft_version: "1.21.4".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 json_str = serde_json::to_string_pretty(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).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_type"], "pumpkin");
assert_eq!(parsed["server_version"], "0.3.2"); assert_eq!(parsed["server_version"], "0.3.2");
assert_eq!(parsed["minecraft_version"], "1.21.4"); assert_eq!(parsed["minecraft_version"], "1.21.4");
@ -334,17 +627,234 @@ mod tests {
let json_data = r#"{ let json_data = r#"{
"status": "ok", "status": "ok",
"message": "Heartbeat accepted", "message": "Heartbeat accepted",
"server_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "server_public_key": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"next_ping_seconds": 300 "next_ping_seconds": 300
}"#; }"#;
let resp: HeartbeatResponse = serde_json::from_str(json_data).unwrap(); let resp: HeartbeatResponse = serde_json::from_str(json_data).unwrap();
assert_eq!(resp.status, "ok"); assert_eq!(resp.status, "ok");
assert_eq!(resp.message.as_deref(), Some("Heartbeat accepted")); assert_eq!(resp.message, "Heartbeat accepted");
assert_eq!( assert_eq!(
resp.server_id.as_deref(), resp.server_public_key,
Some("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d") "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);
} }
} }