From b2384afb4eb274b50ae7b4689bfb55efb36d0bef Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:00:44 +0200 Subject: [PATCH] feat: log censoring (#925) --- ...hide_tokens_and_show_the_launch_command.md | 5 ++ Cargo.lock | 1 + packages/oneclient_cluster/Cargo.toml | 1 + packages/oneclient_cluster/src/logs/manage.rs | 2 +- packages/oneclient_cluster/src/logs/mclogs.rs | 2 +- packages/oneclient_cluster/src/logs/mod.rs | 2 +- packages/oneclient_cluster/src/logs/parse.rs | 16 ++++ packages/oneclient_core/src/game/launch.rs | 84 +++++++++++++++++-- packages/oneclient_core/src/game/tail.rs | 2 +- 9 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 .changeset/game_logs_hide_tokens_and_show_the_launch_command.md diff --git a/.changeset/game_logs_hide_tokens_and_show_the_launch_command.md b/.changeset/game_logs_hide_tokens_and_show_the_launch_command.md new file mode 100644 index 00000000..23fbb876 --- /dev/null +++ b/.changeset/game_logs_hide_tokens_and_show_the_launch_command.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# Game logs hide account tokens, and the game output starts with the launch command ready to paste into a terminal diff --git a/Cargo.lock b/Cargo.lock index 8b6266ee..62a11a74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4995,6 +4995,7 @@ dependencies = [ "oneclient_db", "oneclient_net", "polyio", + "regex", "reqwest 0.13.5", "serde", "serde_json", diff --git a/packages/oneclient_cluster/Cargo.toml b/packages/oneclient_cluster/Cargo.toml index 9643c8f3..0b918b3c 100644 --- a/packages/oneclient_cluster/Cargo.toml +++ b/packages/oneclient_cluster/Cargo.toml @@ -25,6 +25,7 @@ bytes.workspace = true chrono.workspace = true image.workspace = true reqwest.workspace = true +regex.workspace = true serde.workspace = true serde_repr.workspace = true strum.workspace = true diff --git a/packages/oneclient_cluster/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs index c2ad8558..7245b691 100644 --- a/packages/oneclient_cluster/src/logs/manage.rs +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -109,7 +109,7 @@ pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { lines.push(LogLine { number: idx + 1, level, - text: raw.to_string(), + text: super::censor(raw).into_owned(), }); } diff --git a/packages/oneclient_cluster/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs index 1dc22673..a174cc69 100644 --- a/packages/oneclient_cluster/src/logs/mclogs.rs +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -32,7 +32,7 @@ pub async fn upload_log_at( path: &Path, ) -> ClusterResult { let path = ensure_allowed(path)?; - let mut content = read_file_string(&path).await?; + let mut content = super::censor(&read_file_string(&path).await?).into_owned(); let line_count = content.lines().count(); if line_count > MAX_LINES { diff --git a/packages/oneclient_cluster/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs index c4483dbb..adff7df5 100644 --- a/packages/oneclient_cluster/src/logs/mod.rs +++ b/packages/oneclient_cluster/src/logs/mod.rs @@ -10,7 +10,7 @@ use chrono::{DateTime, Utc}; pub use error::LogsError; pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at}; pub use mclogs::upload_log_at; -pub use parse::parse_level; +pub use parse::{censor, parse_level}; /// No launcher variant on purpose the launcher's own logs live in the log console #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/packages/oneclient_cluster/src/logs/parse.rs b/packages/oneclient_cluster/src/logs/parse.rs index 089afb32..1c8320b2 100644 --- a/packages/oneclient_cluster/src/logs/parse.rs +++ b/packages/oneclient_cluster/src/logs/parse.rs @@ -1,5 +1,21 @@ +use std::borrow::Cow; +use std::sync::LazyLock; + +use regex::Regex; + use super::LogLevel; +static SECRET: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(--accessToken|--session(?:Id)?)([\s,=]+)[^\s,\[\]'"]+|(token:)[^:\s]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*"#, + ) + .expect("secret pattern") +}); + +pub fn censor(line: &str) -> Cow<'_, str> { + SECRET.replace_all(line, "${1}${2}${3}[censored]") +} + pub fn parse_level(line: &str) -> Option { let bytes = line.as_bytes(); let mut i = 0; diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index ca2dec8d..dc0195d2 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::path::Path; use std::process::Stdio; use std::sync::Arc; @@ -5,7 +6,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use interfrost::api::minecraft::ArgumentType; -use tokio::io::AsyncReadExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use crate::ClusterStage; @@ -395,6 +396,14 @@ async fn start( .args(mc_args) .current_dir(&cwd); + let header = format!( + "OneClient {} · {} · {version_name} · Java {}\n\n{}\n\n", + env!("CARGO_PKG_VERSION"), + cluster.name, + java.major, + command_line(command.as_std(), &version_info.main_class), + ); + let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?; if let Some(parent) = log_path.parent() { polyio::create_dir_all(parent).await.ok(); @@ -405,10 +414,15 @@ async fn start( // The cloned handle shares the file offset // so stdout and stderr interleave instead of overwriting let handles = match tokio::fs::File::create(&log_path).await { - Ok(out) => match out.try_clone().await { - Ok(err) => Ok((out.into_std().await, err.into_std().await)), - Err(err) => Err(err), - }, + Ok(mut out) => { + if let Err(err) = out.write_all(header.as_bytes()).await { + tracing::warn!(cluster_id, error = %err, "failed to write the launch header"); + } + match out.try_clone().await { + Ok(err) => Ok((out.into_std().await, err.into_std().await)), + Err(err) => Err(err), + } + } Err(err) => Err(err), }; @@ -752,6 +766,66 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> (Command, Opt (command, Some(program.to_string())) } +fn command_line(command: &std::process::Command, main_class: &str) -> String { + #[cfg(not(windows))] + const CONTINUATION: &str = " \\\n "; + #[cfg(windows)] + const CONTINUATION: &str = " `\n "; + + let mut out = String::new(); + if let Some(dir) = command.get_current_dir() { + let _ = writeln!(out, "cd {}", shell_quote(&dir.to_string_lossy())); + } + + for (key, value) in command.get_envs() { + let (key, Some(value)) = (key.to_string_lossy(), value) else { + continue; + }; + let value = shell_quote(&value.to_string_lossy()); + #[cfg(not(windows))] + let _ = write!(out, "{key}={value} "); + #[cfg(windows)] + let _ = writeln!(out, "$env:{key} = {value}"); + } + + #[cfg(windows)] + { + out += "& "; + } + out += &shell_quote(&command.get_program().to_string_lossy()); + + for arg in command.get_args() { + let arg = arg.to_string_lossy(); + let arg = oneclient_cluster::logs::censor(&arg); + out += if arg.starts_with('-') || arg == main_class { + CONTINUATION + } else { + " " + }; + out += &shell_quote(&arg); + } + + out +} + +#[cfg(not(windows))] +fn shell_quote(arg: &str) -> String { + let plain = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-./=:,+@%".contains(&b)); + if plain { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(windows)] +fn shell_quote(arg: &str) -> String { + format!("'{}'", arg.replace('\'', "''")) +} + fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { command.env_remove("_JAVA_OPTIONS"); diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs index e63493fb..6fd1cb43 100644 --- a/packages/oneclient_core/src/game/tail.rs +++ b/packages/oneclient_core/src/game/tail.rs @@ -129,7 +129,7 @@ async fn emit( crash_watch: &CrashWatch, ) { // Blank lines are kept so the console reads the way the log does - let text = line.trim_end_matches(['\n', '\r']).to_string(); + let text = oneclient_cluster::logs::censor(line.trim_end_matches(['\n', '\r'])).into_owned(); if let Some(recorder) = recorder { recorder.observe(&text).await; }