mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)
* feat(psylab): rename probe UI, add Tuning tab and log copy/export PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain selectable text, toolbar copy/export, and a selection-only context menu. * fix(logging): redact secrets and mask remote hosts in runtime logs Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab: Subsonic/auth query params, bearer tokens, password fields, URL userinfo; remote hostnames partially starred, LAN/localhost left readable. * fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream logs), killing tokio workers and aborting playback. Iterate by char boundary; add infallible wrapper on the append hot path. * docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||
|
||||
pub mod cover_cache_layout;
|
||||
pub mod log_sanitize;
|
||||
pub mod media_layout;
|
||||
pub mod logging;
|
||||
pub mod ports;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Redact secrets and partially mask remote server hostnames before log lines
|
||||
//! are stored or exported (PsyLab / Settings log export).
|
||||
|
||||
const SENSITIVE_QUERY_KEYS: &[&str] = &[
|
||||
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
|
||||
"access_token", "refresh_token", "auth",
|
||||
];
|
||||
|
||||
const SENSITIVE_KV_KEYS: &[&str] = &[
|
||||
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
|
||||
"refresh_token", "authorization", "auth",
|
||||
];
|
||||
|
||||
/// Sanitize one runtime log line for display and export.
|
||||
pub fn sanitize_log_line(line: &str) -> String {
|
||||
let mut out = redact_bearer_tokens(line);
|
||||
out = redact_sensitive_key_values(&out);
|
||||
out = redact_urls_in_text(&out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Never panic on the logging hot path — fall back to the raw line if needed.
|
||||
pub fn sanitize_log_line_infallible(line: &str) -> String {
|
||||
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
|
||||
fn redact_bearer_tokens(line: &str) -> String {
|
||||
let marker = "Bearer ";
|
||||
let mut s = line.to_string();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = s[search_from..].find(marker) {
|
||||
let idx = search_from + rel;
|
||||
let start = idx + marker.len();
|
||||
let end = s[start..]
|
||||
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(s.len());
|
||||
if end > start {
|
||||
s.replace_range(start..end, "REDACTED");
|
||||
}
|
||||
search_from = start + "REDACTED".len();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn redact_sensitive_key_values(line: &str) -> String {
|
||||
let mut out = line.to_string();
|
||||
for key in SENSITIVE_KV_KEYS {
|
||||
for sep in [':', '='] {
|
||||
let needle = format!("{key}{sep}");
|
||||
let lower = out.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
while let Some(rel) = lower[search_from..].find(&needle) {
|
||||
let idx = search_from + rel;
|
||||
let val_start = idx + needle.len();
|
||||
let slice = &out[val_start..];
|
||||
let trimmed = slice.trim_start();
|
||||
let ws = slice.len().saturating_sub(trimmed.len());
|
||||
let val_start = val_start + ws;
|
||||
let end = trimmed
|
||||
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
|
||||
.unwrap_or(trimmed.len());
|
||||
if end > 0 {
|
||||
out.replace_range(val_start..val_start + end, "REDACTED");
|
||||
}
|
||||
search_from = val_start + "REDACTED".len();
|
||||
if search_from >= out.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
|
||||
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
|
||||
return true;
|
||||
}
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
if let Some(next) = s[byte_off..].chars().nth(1) {
|
||||
return next.is_whitespace() || next == '"' || next == '\'';
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn redact_urls_in_text(line: &str) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut cursor = 0;
|
||||
while cursor < line.len() {
|
||||
let slice = &line[cursor..];
|
||||
let rel = match (slice.find("http://"), slice.find("https://")) {
|
||||
(Some(h), Some(s)) => Some(h.min(s)),
|
||||
(Some(h), None) => Some(h),
|
||||
(None, Some(s)) => Some(s),
|
||||
(None, None) => None,
|
||||
};
|
||||
let Some(rel) = rel else {
|
||||
out.push_str(slice);
|
||||
break;
|
||||
};
|
||||
out.push_str(&slice[..rel]);
|
||||
let url_start = cursor + rel;
|
||||
let url_slice = &line[url_start..];
|
||||
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
|
||||
let mut url_end = scheme_len;
|
||||
for (off, ch) in url_slice[scheme_len..].char_indices() {
|
||||
let abs = scheme_len + off;
|
||||
if url_char_ends_url(ch, url_slice, abs) {
|
||||
break;
|
||||
}
|
||||
url_end = abs + ch.len_utf8();
|
||||
}
|
||||
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
|
||||
cursor = url_start + url_end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn redact_url(raw: &str) -> String {
|
||||
let (url, suffix) = split_trailing_punct(raw);
|
||||
let mut out = String::new();
|
||||
|
||||
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
|
||||
out.push_str(&url[..scheme_end]);
|
||||
|
||||
let mut rest = &url[scheme_end..];
|
||||
if let Some(at) = rest.rfind('@') {
|
||||
// Drop userinfo entirely.
|
||||
out.push_str("***@");
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
|
||||
let (hostport, path) = split_host_path(rest);
|
||||
let (host, port) = split_host_port(&hostport);
|
||||
let masked_host = mask_hostname(&host);
|
||||
out.push_str(&masked_host);
|
||||
if let Some(p) = port {
|
||||
out.push(':');
|
||||
out.push_str(&p);
|
||||
}
|
||||
if let Some((path_only, query)) = path.split_once('?') {
|
||||
out.push_str(path_only);
|
||||
out.push('?');
|
||||
out.push_str(&redact_query_string(query));
|
||||
} else {
|
||||
out.push_str(&path);
|
||||
}
|
||||
|
||||
format!("{out}{suffix}")
|
||||
}
|
||||
|
||||
fn split_trailing_punct(raw: &str) -> (&str, &str) {
|
||||
let mut end = raw.len();
|
||||
while end > 0 {
|
||||
let ch = raw.as_bytes()[end - 1] as char;
|
||||
if ch == ')' || ch == ']' || ch == ',' {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
(&raw[..end], &raw[end..])
|
||||
}
|
||||
|
||||
fn split_host_path(rest: &str) -> (String, String) {
|
||||
if rest.starts_with('[') {
|
||||
if let Some(end) = rest.find(']') {
|
||||
let hostport = &rest[..=end];
|
||||
return (hostport.to_string(), rest[end + 1..].to_string());
|
||||
}
|
||||
}
|
||||
if let Some(slash) = rest.find('/') {
|
||||
(rest[..slash].to_string(), rest[slash..].to_string())
|
||||
} else {
|
||||
(rest.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn split_host_port(hostport: &str) -> (String, Option<String>) {
|
||||
if hostport.starts_with('[') {
|
||||
if let Some(end) = hostport.find("]:") {
|
||||
return (
|
||||
hostport[..=end].to_string(),
|
||||
Some(hostport[end + 2..].to_string()),
|
||||
);
|
||||
}
|
||||
return (hostport.to_string(), None);
|
||||
}
|
||||
if let Some((h, p)) = hostport.rsplit_once(':') {
|
||||
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
|
||||
return (h.to_string(), Some(p.to_string()));
|
||||
}
|
||||
}
|
||||
(hostport.to_string(), None)
|
||||
}
|
||||
|
||||
fn redact_query_string(query: &str) -> String {
|
||||
query
|
||||
.split('&')
|
||||
.map(|pair| {
|
||||
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if is_sensitive_query_key(k) {
|
||||
format!("{k}=REDACTED")
|
||||
} else {
|
||||
pair.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn is_sensitive_query_key(key: &str) -> bool {
|
||||
let k = key.trim().to_ascii_lowercase();
|
||||
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
|
||||
}
|
||||
|
||||
fn is_lan_ipv4(ip: &str) -> bool {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let Ok(a) = parts[0].parse::<u8>() else { return false };
|
||||
let Ok(b) = parts[1].parse::<u8>() else { return false };
|
||||
a == 127
|
||||
|| a == 10
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
}
|
||||
|
||||
fn is_lan_ipv6(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
if h == "::1" {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
|
||||
return true;
|
||||
}
|
||||
if h.starts_with("fc") || h.starts_with("fd") {
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = h.strip_prefix("::ffff:") {
|
||||
if rest.contains('.') {
|
||||
return is_lan_ipv4(rest);
|
||||
}
|
||||
if let Some((a, b)) = rest.split_once(':') {
|
||||
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
|
||||
let ip = format!(
|
||||
"{}.{}.{}.{}",
|
||||
(v1 >> 8) & 0xff,
|
||||
v1 & 0xff,
|
||||
(v2 >> 8) & 0xff,
|
||||
v2 & 0xff
|
||||
);
|
||||
return is_lan_ipv4(&ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_lan_host(host: &str) -> bool {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
let lower = stripped.to_ascii_lowercase();
|
||||
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
|
||||
return true;
|
||||
}
|
||||
if stripped.contains(':') {
|
||||
return is_lan_ipv6(stripped);
|
||||
}
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return is_lan_ipv4(stripped);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn mask_label_prefix(label: &str) -> String {
|
||||
let mut chars = label.chars();
|
||||
let c1 = chars.next();
|
||||
let c2 = chars.next();
|
||||
match (c1, c2) {
|
||||
(None, _) => "*".to_string(),
|
||||
(Some(a), None) => a.to_string(),
|
||||
(Some(a), Some(b)) => {
|
||||
let rest = label.chars().count().saturating_sub(2);
|
||||
let stars = rest.clamp(1, 4);
|
||||
format!("{a}{b}{}", "*".repeat(stars))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_public_ipv4(ip: &str) -> String {
|
||||
let parts: Vec<&str> = ip.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return "***".to_string();
|
||||
}
|
||||
format!("{}.*.*.{}", parts[0], parts[3])
|
||||
}
|
||||
|
||||
fn mask_hostname(host: &str) -> String {
|
||||
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
|
||||
if is_lan_host(stripped) {
|
||||
return host.to_string();
|
||||
}
|
||||
|
||||
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
|
||||
return mask_public_ipv4(stripped);
|
||||
}
|
||||
|
||||
if stripped.contains(':') {
|
||||
return "[ipv6-redacted]".to_string();
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = stripped.split('.').collect();
|
||||
if parts.is_empty() {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let masked_first = mask_label_prefix(parts[0]);
|
||||
|
||||
if parts.len() == 1 {
|
||||
masked_first
|
||||
} else {
|
||||
format!("{}.{}", masked_first, parts[1..].join("."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_subsonic_wire_auth_params() {
|
||||
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("t=REDACTED"));
|
||||
assert!(out.contains("s=REDACTED"));
|
||||
assert!(out.contains("p=REDACTED"));
|
||||
assert!(!out.contains("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_remote_hostname_keeps_lan_ip() {
|
||||
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
|
||||
assert!(remote.contains("my****.example.com"));
|
||||
assert!(!remote.contains("my-server.example.com"));
|
||||
|
||||
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
|
||||
assert!(lan.contains("192.168.1.42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_bearer_and_password_kv() {
|
||||
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("Bearer REDACTED"));
|
||||
assert!(!out.contains("eyJhbGci"));
|
||||
assert!(out.contains("password=REDACTED"));
|
||||
assert!(!out.contains("sekrit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_url_userinfo() {
|
||||
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains("***@10.0.0.5"));
|
||||
assert!(!out.contains("user:pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_log_with_em_dash_does_not_panic() {
|
||||
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
|
||||
let out = sanitize_log_line(line);
|
||||
assert!(out.contains('—'));
|
||||
assert!(out.contains("RangedHttpSource"));
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ pub fn should_log_debug() -> bool {
|
||||
}
|
||||
|
||||
pub fn append_log_line(line: String) {
|
||||
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
|
||||
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
{
|
||||
let mut buf = log_buffer().lock().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user