chore: improve pumpkin-command
does not depend on pumpkin-world anymore
This commit is contained in:
parent
4f88d77ab1
commit
df8687fe6e
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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> {
|
||||||
@ -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> {
|
||||||
@ -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)?;
|
||||||
|
}
|
||||||
|
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<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, ×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::<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, ×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(())
|
||||||
}
|
}
|
||||||
id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the server ID by checking default locations or generating a new one.
|
/// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user