feat(client): logging & error-visibility hardening

- tauri-plugin-log: rotating Rust log file in the app-log dir so shipped users
  can retrieve proxy/TLS/TOFU diagnostics (a release build detaches the console)
- log the health-check failure cause; log persist failures in save_settings /
  store_cert_fingerprint; add a TOFU cert-pin accept/change audit trail; log
  http/livekit proxy-loop panics instead of swallowing them
- stop persisting the raw WS frame content and the auth token prefix to disk
- drain the pre-init in-memory log buffer so bootstrap logs reach disk
- surface the server X-Request-Id on API errors for cross-tier correlation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-24 11:10:38 +02:00
co-authored by Claude Opus 4.8
parent 4fc21cb372
commit 1423b17993
13 changed files with 216 additions and 74 deletions
+61 -49
View File
@@ -32,6 +32,23 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -1256,27 +1273,14 @@ dependencies = [
[[package]]
name = "env_filter"
version = "2.0.0"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1340,6 +1344,15 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -2350,30 +2363,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2802,6 +2791,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
@@ -3013,7 +3011,6 @@ name = "owncord-client"
version = "1.1.0-alpha.2"
dependencies = [
"device_query",
"env_logger",
"futures-util",
"keyring",
"log",
@@ -3029,6 +3026,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
@@ -3431,15 +3429,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -5073,6 +5062,27 @@ dependencies = [
"urlpattern",
]
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6792296e6f389268016c77db21ebae1fc0568f2fccf88b1ec7e2ea71330afb4c"
dependencies = [
"android_logger",
"fern",
"log",
"objc2",
"objc2-foundation",
"serde",
"serde_json",
"serde_repr",
"swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
@@ -5442,7 +5452,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
+4 -1
View File
@@ -48,7 +48,10 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
webpki-roots = "1"
ring = "0.17"
log = "0.4"
env_logger = "0.11"
# Writes Rust logs to a rotating file under the OS app-log dir (alongside the
# TS client-logs) so a shipped user can retrieve them — a release build detaches
# the console, so stdout/stderr logging is otherwise unreachable.
tauri-plugin-log = "2"
keyring = "3"
rfd = { version = "0.16", default-features = false }
+16 -6
View File
@@ -47,6 +47,13 @@ pub fn get_settings(app: tauri::AppHandle) -> Result<Value, String> {
Ok(Value::Object(map))
}
/// Log a command failure Rust-side — so it lands in the retrievable log file —
/// and return the same message unchanged to the JS caller.
fn log_cmd_err(cmd: &str, msg: String) -> String {
log::warn!("{cmd}: {msg}");
msg
}
#[tauri::command]
pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result<(), String> {
if !is_settings_key_allowed(&key) {
@@ -55,12 +62,12 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("failed to open settings store: {e}"))?;
.map_err(|e| log_cmd_err("save_settings", format!("failed to open settings store: {e}")))?;
store.set(&key, value);
store
.save()
.map_err(|e| format!("failed to persist settings: {e}"))?;
.map_err(|e| log_cmd_err("save_settings", format!("failed to persist settings: {e}")))?;
Ok(())
}
@@ -102,9 +109,9 @@ pub fn store_cert_fingerprint(
}
}
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
let store = app.store(CERTS_STORE).map_err(|e| {
log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}"))
})?;
// Capture old value before mutating so we can restore it if save fails.
let old_value = store.get(&host);
@@ -117,7 +124,10 @@ pub fn store_cert_fingerprint(
Some(v) => { store.set(&host, v); }
None => { let _ = store.delete(&host); }
}
return Err(format!("failed to persist cert fingerprint: {e}"));
return Err(log_cmd_err(
"store_cert_fingerprint",
format!("failed to persist cert fingerprint: {e}"),
));
}
Ok(())
}
@@ -107,12 +107,21 @@ pub async fn start_http_proxy<R: Runtime>(
.port();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(run_proxy_loop(
let loop_handle = tokio::spawn(run_proxy_loop(
app.clone(),
listener,
remote_host.clone(),
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently (which
// would leave JS with a stale cached port and no error).
tokio::spawn(async move {
match loop_handle.await {
Ok(()) => info!("[http_proxy] proxy loop exited"),
Err(e) if e.is_panic() => error!("[http_proxy] proxy loop panicked: {e:?}"),
Err(e) => warn!("[http_proxy] proxy loop join error: {e:?}"),
}
});
info!(
"[http_proxy] tunnel started on 127.0.0.1:{} → {}",
+39 -8
View File
@@ -9,6 +9,26 @@ mod tray;
mod update_commands;
mod ws_proxy;
/// Map the RUST_LOG env var to a global level filter for the log plugin.
/// ponytail: only the simple global form is honoured ("debug", "info", …);
/// per-module directives like "ws_proxy=debug" fall back to Info. Add a real
/// parser only if per-module control is actually needed.
fn log_level_from_env() -> log::LevelFilter {
match std::env::var("RUST_LOG")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"trace" => log::LevelFilter::Trace,
"debug" => log::LevelFilter::Debug,
"warn" => log::LevelFilter::Warn,
"error" => log::LevelFilter::Error,
"off" => log::LevelFilter::Off,
_ => log::LevelFilter::Info,
}
}
// Only used by the desktop-only single-instance closure below.
#[cfg(desktop)]
use tauri::Manager;
@@ -31,6 +51,24 @@ pub fn run() {
}));
let builder = builder
// Log plugin registered early so logging is available to everything
// after it. Writes to stdout (dev) and a rotating file in the OS
// app-log dir so a shipped user — whose release build has no console —
// can retrieve logs.
.plugin(
tauri_plugin_log::Builder::new()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir {
file_name: Some("owncord-client".into()),
},
))
.level(log_level_from_env())
.max_file_size(10_000_000) // 10 MB rolling file (default 40 KB is too small)
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_http::init())
@@ -88,14 +126,7 @@ pub fn run() {
commands::open_devtools,
])
.setup(|app| {
// Initialize Rust logging (controlled by RUST_LOG env var, defaults to info).
// try_init avoids panic if another logger (e.g. a Tauri plugin) registered first.
let _ = env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info"),
)
.format_timestamp_millis()
.try_init();
// Rust logging is initialized by tauri_plugin_log (registered above).
tray::create_tray(app.handle())?;
Ok(())
})
@@ -134,7 +134,15 @@ pub async fn start_livekit_proxy<R: Runtime>(
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let host = remote_host.clone();
tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
// Watch the loop so a panic is logged instead of vanishing silently.
tokio::spawn(async move {
match loop_handle.await {
Ok(()) => info!("[livekit_proxy] proxy loop exited"),
Err(e) if e.is_panic() => error!("[livekit_proxy] proxy loop panicked: {e:?}"),
Err(e) => warn!("[livekit_proxy] proxy loop join error: {e:?}"),
}
});
info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host);
+15 -4
View File
@@ -307,13 +307,17 @@ pub fn accept_cert_fingerprint<R: Runtime>(
return Err("fingerprint must be SHA-256 colon-hex format (e.g. aa:bb:cc:...)".into());
}
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
let store = app.store(CERTS_STORE).map_err(|e| {
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to open certs store: {e}");
format!("failed to open certs store: {e}")
})?;
// Capture old value before mutating so we can restore it if save fails.
let old_value = store.get(&host);
store.set(&host, Value::String(fingerprint));
// A pin that replaces a *different* existing fingerprint is security-
// significant (cert rotation — or a MITM the user just accepted).
let changed = matches!(&old_value, Some(Value::String(s)) if *s != fingerprint);
store.set(&host, Value::String(fingerprint.clone()));
if let Err(e) = store.save() {
// Restore previous in-memory state: put back old fingerprint if one
// existed, or delete if there was none. Without this, the new
@@ -323,7 +327,14 @@ pub fn accept_cert_fingerprint<R: Runtime>(
Some(v) => { store.set(&host, v); }
None => { let _ = store.delete(&host); }
}
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}");
return Err(format!("failed to persist cert fingerprint: {e}"));
}
// Fingerprints are public cert hashes — safe to log; this is the TOFU audit trail.
if changed {
log::warn!("[ws_proxy] cert pin CHANGED for {host} -> {fingerprint}");
} else {
log::info!("[ws_proxy] cert pin accepted for {host} -> {fingerprint}");
}
Ok(())
}
+3
View File
@@ -129,6 +129,9 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
status: res.status,
code: err.error,
message: err.message,
// Server echoes its request ID in this header — logging it lets a
// client-side failure be matched to the server's log line for it.
reqId: res.headers.get("x-request-id") ?? undefined,
});
throw new ApiClientError(res.status, err.error, err.message);
}
+12 -1
View File
@@ -6,7 +6,7 @@
import { appLogDir, join } from "@tauri-apps/api/path";
import { mkdir, writeTextFile, readDir, remove, exists } from "@tauri-apps/plugin-fs";
import { type LogEntry, addLogListener, createLogger } from "./logger";
import { type LogEntry, addLogListener, createLogger, getLogBuffer } from "./logger";
const log = createLogger("logPersistence");
const MAX_LOG_FILES = 5;
@@ -131,7 +131,18 @@ export async function initLogPersistence(): Promise<() => void> {
currentDate = today();
initialized = true;
// Persist entries logged before this listener attached (the bootstrap
// window) so a startup-time problem lands on disk, not just in the
// in-memory ring. Runs synchronously right before addLogListener, so there
// is no gap and no double-capture.
for (const entry of getLogBuffer()) {
buffer.push(JSON.stringify(entry));
}
const removeListener = addLogListener(onLogEntry);
if (buffer.length > 0) {
scheduleFlush();
}
return () => {
removeListener(); // stop receiving new entries first
+7 -2
View File
@@ -220,7 +220,9 @@ export function createWsClient() {
try {
parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string; seq?: number };
} catch {
log.warn("Failed to parse WS message", { data: raw });
// Log the size only — `raw` is the decrypted frame (chat plaintext,
// usernames) and this line is persisted to the on-disk log.
log.warn("Failed to parse WS message", { bytes: raw.length });
return;
}
@@ -489,8 +491,11 @@ export function createWsClient() {
if (msg.includes("channel full")) {
// Outbound channel is saturated — surface the drop to listeners so an
// optimistic row fails with retry instead of silently losing the send.
// Log id + size only — a slice of `json` can contain the auth
// envelope's bearer token, and this line is persisted to disk.
log.warn("ws_send: outbound channel full, message dropped (backpressure)", {
messagePreview: json.slice(0, 120),
id,
bytes: json.length,
});
notifySendFailure(id, "NETWORK");
} else {
+4 -1
View File
@@ -226,7 +226,10 @@ function runHealthChecks(
version: health.version ?? null,
onlineUsers: health.online_users ?? null,
});
} catch {
} catch (err) {
// Record why the check failed (TLS/cert-pin/network) — otherwise a
// "can't connect" report has no logged cause to diagnose.
log.warn("health check failed", { host: profile.host, error: String(err) });
connectPage.updateHealthStatus(profile.host, {
status: "offline",
latencyMs: null,
@@ -13,6 +13,7 @@ const {
mockRemove,
mockReadTextFile,
mockAddLogListener,
mockGetLogBuffer,
} = vi.hoisted(() => ({
mockAppLogDir: vi.fn().mockResolvedValue("/mock/logs"),
mockJoin: vi.fn((...parts: string[]) => parts.join("/")),
@@ -23,6 +24,7 @@ const {
mockRemove: vi.fn().mockResolvedValue(undefined),
mockReadTextFile: vi.fn().mockResolvedValue(""),
mockAddLogListener: vi.fn(),
mockGetLogBuffer: vi.fn(() => [] as unknown[]),
}));
vi.mock("@tauri-apps/api/path", () => ({
@@ -41,6 +43,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
vi.mock("@lib/logger", () => ({
addLogListener: mockAddLogListener,
getLogBuffer: mockGetLogBuffer,
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
@@ -102,6 +105,7 @@ describe("log persistence", () => {
mockRemove.mockReset().mockResolvedValue(undefined);
mockReadTextFile.mockReset().mockResolvedValue("");
mockAddLogListener.mockReset();
mockGetLogBuffer.mockReset().mockReturnValue([]);
});
afterEach(() => {
@@ -148,6 +152,20 @@ describe("log persistence", () => {
expect(typeof mockAddLogListener.mock.calls[0]![0]).toBe("function");
});
it("persists entries buffered before init (bootstrap drain)", async () => {
mockGetLogBuffer.mockReturnValue([makeEntry({ message: "bootstrap-line" })]);
const { initLogPersistence } = await freshImport();
captureListener();
await initLogPersistence();
// The drain scheduled a flush; advance past the 2000ms debounce.
await vi.advanceTimersByTimeAsync(2500);
expect(mockWriteTextFile).toHaveBeenCalled();
const written = mockWriteTextFile.mock.calls.map((c) => String(c[1])).join("");
expect(written).toContain("bootstrap-line");
});
it("returns a no-op cleanup if already initialized", async () => {
const { initLogPersistence } = await freshImport();
captureListener();
+18
View File
@@ -42,6 +42,7 @@ vi.spyOn(console, "error").mockImplementation(() => {});
// Import after mocks are set up
import { createWsClient, toConnectionStatus } from "../../src/lib/ws";
import { addLogListener, type LogEntry } from "../../src/lib/logger";
/** Simulate Tauri emitting an event to JS */
function emitTauriEvent(event: string, payload: unknown): void {
@@ -291,6 +292,23 @@ describe("WebSocket Client (Tauri proxy)", () => {
expect(messages).toHaveLength(0);
});
it("does not log raw frame content on parse failure (no plaintext leak)", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const entries: LogEntry[] = [];
const remove = addLogListener((e) => entries.push(e));
const secret = "SUPER_SECRET_eyJhbGciOiJIUzI1NiJ9";
emitTauriEvent("ws-message", secret + " not-json{{{");
remove();
// The decrypted frame must never reach the (on-disk-persisted) log...
expect(JSON.stringify(entries)).not.toContain(secret);
// ...but the parse failure is still recorded so it stays debuggable.
expect(entries.some((e) => e.message.includes("Failed to parse WS message"))).toBe(true);
});
it("disconnect prevents reconnect", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);