Send client logs to the host — the log-escape hatch for locked-down platforms #247
+284
-1
@@ -10,9 +10,242 @@
|
||||
"name": "MIT OR Apache-2.0",
|
||||
"identifier": "MIT OR Apache-2.0"
|
||||
},
|
||||
"version": "0.28.1"
|
||||
"version": "0.29.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/client-logs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "List uploaded client log bundles",
|
||||
"description": "Every stored bundle's metadata, newest first.",
|
||||
"operationId": "clientLogsList",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Stored bundles, newest first",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ClientLogMeta"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "Upload a client log bundle",
|
||||
"description": "A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming\ncertificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are\ncapped at 1 MiB and only the newest few per device are kept. The operator downloads them from\nthe console's Logs page. This is deliberately write-only for devices: uploading grants no read.",
|
||||
"operationId": "clientLogsUpload",
|
||||
"requestBody": {
|
||||
"description": "The client's log text",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Bundle stored",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClientLogUploaded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "No paired-device certificate on the connection",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The device's access has expired (per-client access)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Bundle exceeds the size cap",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Empty body",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not store the bundle",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/client-logs/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "Download a client log bundle",
|
||||
"description": "The bundle body as plain text, for saving or attaching to a report.",
|
||||
"operationId": "clientLogsGet",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The bundle id (its filename stem)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The bundle body",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No bundle with that id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "The bundle file is unreadable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"logs"
|
||||
],
|
||||
"summary": "Delete a client log bundle",
|
||||
"description": "Removes the bundle `id` from disk. `404` if there is no such bundle.",
|
||||
"operationId": "clientLogsDelete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "The bundle id (its filename stem)",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Bundle deleted"
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid bearer token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No bundle with that id",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Could not delete the bundle",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/clients": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -4669,6 +4902,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClientLogMeta": {
|
||||
"type": "object",
|
||||
"description": "One stored bundle, as the console lists it.",
|
||||
"required": [
|
||||
"id",
|
||||
"device_name",
|
||||
"fingerprint_prefix",
|
||||
"received_ms",
|
||||
"size_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"device_name": {
|
||||
"type": "string",
|
||||
"description": "The paired device's name at upload time (sanitized for the filesystem)."
|
||||
},
|
||||
"fingerprint_prefix": {
|
||||
"type": "string",
|
||||
"description": "First 16 hex chars of the device's pairing fingerprint — enough to correlate with the\npaired-devices roster without repeating the full identity in every filename."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The bundle id (its filename stem) — pass to the fetch/delete endpoints."
|
||||
},
|
||||
"received_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Upload time (unix ms, from the file's mtime).",
|
||||
"minimum": 0
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Bundle size in bytes.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClientLogUploaded": {
|
||||
"type": "object",
|
||||
"description": "Response to a successful upload.",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The stored bundle's id."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ClientRef": {
|
||||
"type": "object",
|
||||
"description": "The connecting/disconnecting client's identity.",
|
||||
|
||||
@@ -457,6 +457,45 @@ impl ServiceState {
|
||||
trust::parse_hex32(&fp_hex),
|
||||
);
|
||||
}
|
||||
ConsoleCmd::SendLogs {
|
||||
addr,
|
||||
mgmt,
|
||||
fp_hex,
|
||||
host_name,
|
||||
} => {
|
||||
// Blocking network (5 s connect / 10 s global, the library agent's budgets) —
|
||||
// a worker thread keeps the service loop's host refresh alive meanwhile. The
|
||||
// result lands as a shared-model notice; the shell toasts it on its next sync.
|
||||
let identity = self.identity.clone();
|
||||
let pin = trust::parse_hex32(&fp_hex);
|
||||
let console = self.console.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-sendlogs".into())
|
||||
.spawn(move || {
|
||||
let header = format!(
|
||||
"punktfunk-session {} ({} {}) — client log bundle",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
);
|
||||
match pf_client_core::logring::send_to_host(
|
||||
&addr, mgmt, &identity, pin, &header,
|
||||
) {
|
||||
Ok(id) => {
|
||||
tracing::info!(host = %host_name, id, "client logs uploaded");
|
||||
console.set_notice(format!(
|
||||
"Logs sent to {host_name} — download them from its web \
|
||||
console's Logs page"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(host = %host_name, error = %e, "client log upload failed");
|
||||
console.set_notice(format!("Couldn't send logs — {e}"));
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
ConsoleCmd::Pair {
|
||||
addr,
|
||||
port,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
#[cfg(all(any(target_os = "linux", windows), feature = "ui"))]
|
||||
mod console;
|
||||
mod ring_layer;
|
||||
|
||||
/// The session control socket: a line-per-connection unix socket other same-user
|
||||
/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which
|
||||
@@ -618,14 +619,29 @@ mod session_main {
|
||||
}
|
||||
|
||||
pub fn run() -> u8 {
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines).
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
// Logs to STDERR — stdout is the machine interface (ready/stats/error lines) — plus
|
||||
// the in-process ring (`pf_client_core::logring`, DEBUG+ regardless of RUST_LOG) that
|
||||
// "Send logs to host" uploads. The env filter scopes the STDERR layer only: the ring
|
||||
// exists precisely for the diagnostics nobody enabled before the bug happened.
|
||||
{
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::Layer;
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
),
|
||||
)
|
||||
.with(
|
||||
crate::ring_layer::RingLayer
|
||||
.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
// Before ANY Vulkan call — and that includes the two probe flags below, which is the
|
||||
// whole reason this sits at the top of `run` instead of beside the session setup it
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Thin `tracing` layer feeding `pf_client_core::logring` — the source for the console's
|
||||
//! "Send logs to host" action. Captures at DEBUG+ regardless of `RUST_LOG` (its own filter is
|
||||
//! applied at install), mirroring the host's `log_capture::RingLayer`: the whole point is that
|
||||
//! a field report carries the diagnostics nobody thought to enable beforehand.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::layer::Context;
|
||||
|
||||
pub(crate) struct RingLayer;
|
||||
|
||||
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
struct V(String);
|
||||
impl Visit for V {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
// The message leads; fields follow. Events put it first anyway, so
|
||||
// this is belt-and-braces against odd macro orderings.
|
||||
let rest = std::mem::take(&mut self.0);
|
||||
let _ = write!(self.0, "{value:?}");
|
||||
self.0.push_str(&rest);
|
||||
} else {
|
||||
let _ = write!(self.0, " {}={:?}", field.name(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = V(String::new());
|
||||
event.record(&mut v);
|
||||
let meta = event.metadata();
|
||||
pf_client_core::logring::note(format!(
|
||||
"{} {:5} {} {}",
|
||||
wallclock(),
|
||||
meta.level().as_str(),
|
||||
meta.target(),
|
||||
v.0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
|
||||
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
|
||||
fn wallclock() -> String {
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let secs = (ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
// Howard Hinnant's civil_from_days.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if mo <= 2 { y + 1 } else { y };
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
|
||||
ms % 1000
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,8 @@ pub mod gamepad;
|
||||
pub mod keymap;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod library;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod logring;
|
||||
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
|
||||
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -298,7 +298,7 @@ pub fn spawn_art_fetch(
|
||||
rx
|
||||
}
|
||||
|
||||
fn classify(e: ureq::Error) -> LibraryError {
|
||||
pub(crate) fn classify(e: ureq::Error) -> LibraryError {
|
||||
match e {
|
||||
ureq::Error::StatusCode(401 | 403) => LibraryError::NotPaired,
|
||||
ureq::Error::StatusCode(code) => LibraryError::Http(code),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The client's own recent-log ring + the "send logs to host" uploader.
|
||||
//!
|
||||
//! Why: on locked-down platforms (a Steam Deck in Gaming Mode, tvOS, webOS) the user cannot get
|
||||
//! the client's log off the device, so field reports arrive host-log-only and the client half of
|
||||
//! any stutter/latency story is invisible. The cure is inverted collection: the client keeps its
|
||||
//! newest few thousand log lines here, and an explicit user action posts them to the PAIRED host
|
||||
//! (`POST /api/v1/client-logs`, authenticated by the same mTLS identity the stream uses), where
|
||||
//! the web console lists them next to the host's own logs.
|
||||
//!
|
||||
//! The ring is deliberately dependency-free (no `tracing-subscriber` in this crate): each shell
|
||||
//! installs a thin `Layer` that formats events into [`note`] — see `punktfunk-session`'s
|
||||
//! `ring_layer`. Bounded by lines AND bytes so a log-storm can't grow memory; the byte budget
|
||||
//! stays under the host's 1 MiB upload cap so a full ring always uploads whole.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// Newest lines kept (matches the host's own ring depth).
|
||||
pub const MAX_LINES: usize = 4096;
|
||||
|
||||
/// Byte budget for the ring — under the host's 1 MiB bundle cap with headroom for the header.
|
||||
pub const MAX_BYTES: usize = 768 * 1024;
|
||||
|
||||
struct Ring {
|
||||
lines: VecDeque<String>,
|
||||
bytes: usize,
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
static RING: LazyLock<Mutex<Ring>> = LazyLock::new(|| {
|
||||
Mutex::new(Ring {
|
||||
lines: VecDeque::new(),
|
||||
bytes: 0,
|
||||
dropped: 0,
|
||||
})
|
||||
});
|
||||
|
||||
/// Append one formatted log line (no trailing newline). Oversized lines are truncated to keep a
|
||||
/// single event from evicting the whole ring.
|
||||
pub fn note(mut line: String) {
|
||||
if line.len() > 2048 {
|
||||
line.truncate(2048);
|
||||
line.push('…');
|
||||
}
|
||||
let mut r = RING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
r.bytes += line.len();
|
||||
r.lines.push_back(line);
|
||||
while r.lines.len() > MAX_LINES || r.bytes > MAX_BYTES {
|
||||
if let Some(evicted) = r.lines.pop_front() {
|
||||
r.bytes -= evicted.len();
|
||||
r.dropped += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own
|
||||
/// identity line — binary name, version, platform) and an eviction note when the ring wrapped.
|
||||
pub fn render(header: &str) -> String {
|
||||
let r = RING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut out = String::with_capacity(r.bytes + header.len() + 64);
|
||||
out.push_str(header);
|
||||
out.push('\n');
|
||||
if r.dropped > 0 {
|
||||
out.push_str(&format!(
|
||||
"… {} older lines evicted from the ring …\n",
|
||||
r.dropped
|
||||
));
|
||||
}
|
||||
for line in &r.lines {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Upload the ring to the paired host `addr` and return the stored bundle id. Same transport +
|
||||
/// trust as the library fetch: TLS client auth with the device identity, host pinned by
|
||||
/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier
|
||||
/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply.
|
||||
pub fn send_to_host(
|
||||
addr: &str,
|
||||
mgmt_port: u16,
|
||||
identity: &(String, String),
|
||||
pin: Option<[u8; 32]>,
|
||||
header: &str,
|
||||
) -> Result<String, crate::library::LibraryError> {
|
||||
use crate::library::LibraryError;
|
||||
let body = render(header);
|
||||
let agent = crate::library::agent(identity, pin)?;
|
||||
let url = format!(
|
||||
"{}/api/v1/client-logs",
|
||||
crate::library::base_url(addr, mgmt_port)
|
||||
);
|
||||
match agent
|
||||
.post(&url)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.send(body.as_bytes())
|
||||
{
|
||||
Ok(mut resp) => {
|
||||
let text = resp
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?;
|
||||
let id = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.get("id")?.as_str().map(str::to_string))
|
||||
.unwrap_or_default();
|
||||
Ok(id)
|
||||
}
|
||||
Err(e) => Err(crate::library::classify(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ring_bounds_and_renders_with_eviction_note() {
|
||||
// The ring is process-global, so this single test owns the whole lifecycle (parallel
|
||||
// tests over one global would interleave).
|
||||
for i in 0..(MAX_LINES + 10) {
|
||||
note(format!("line {i}"));
|
||||
}
|
||||
let text = render("punktfunk-client test");
|
||||
assert!(text.starts_with("punktfunk-client test\n"));
|
||||
assert!(text.contains("older lines evicted"));
|
||||
assert!(
|
||||
!text.contains("\nline 0\n"),
|
||||
"oldest line survived eviction"
|
||||
);
|
||||
assert!(text.ends_with(&format!("line {}\n", MAX_LINES + 9)));
|
||||
// A pathological line is truncated, not ring-flushing.
|
||||
note("x".repeat(10_000));
|
||||
let text = render("h");
|
||||
assert!(text.contains('…'));
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,10 @@ struct ConsoleState {
|
||||
hosts_gen: u64,
|
||||
pair: PairPhase,
|
||||
wake: Option<WakeStatus>,
|
||||
/// A one-shot toast from a service worker (e.g. the log-upload result). The shell
|
||||
/// takes it on its next sync; unlike [`PairPhase`] there is no modal state to track,
|
||||
/// so a plain take-once string is the whole protocol.
|
||||
notice: Option<String>,
|
||||
}
|
||||
|
||||
/// The shared handle. Service threads write; the shell polls per frame (cheap locks,
|
||||
@@ -131,6 +135,16 @@ impl ConsoleShared {
|
||||
pub(crate) fn wake(&self) -> Option<WakeStatus> {
|
||||
self.0.lock().unwrap().wake.clone()
|
||||
}
|
||||
|
||||
/// Post a one-shot toast from a service worker. A newer notice replaces an unshown
|
||||
/// older one — the shell polls per frame, so in practice nothing is ever dropped.
|
||||
pub fn set_notice(&self, text: String) {
|
||||
self.0.lock().unwrap().notice = Some(text);
|
||||
}
|
||||
|
||||
pub(crate) fn take_notice(&self) -> Option<String> {
|
||||
self.0.lock().unwrap().notice.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Work the shell asks the binary to do. Everything here blocks (network/disk), so it
|
||||
@@ -150,6 +164,16 @@ pub enum ConsoleCmd {
|
||||
pin: String,
|
||||
device_name: String,
|
||||
},
|
||||
/// Upload the client's recent log ring to this PAIRED host's management API — the
|
||||
/// "send logs to host" escape hatch for platforms whose own logs are unreachable
|
||||
/// (Deck Gaming Mode, tvOS). Same transport + trust as `FetchLibrary`; the result
|
||||
/// comes back as a shared-model notice toast.
|
||||
SendLogs {
|
||||
addr: String,
|
||||
mgmt: u16,
|
||||
fp_hex: String,
|
||||
host_name: String,
|
||||
},
|
||||
/// Save a manually entered host (unpaired) and refresh the rows.
|
||||
SaveHost {
|
||||
name: String,
|
||||
|
||||
@@ -25,6 +25,7 @@ use skia_safe::{Canvas, Rect};
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Action {
|
||||
Wake,
|
||||
SendLogs,
|
||||
CopyLink,
|
||||
Edit,
|
||||
Forget,
|
||||
@@ -86,6 +87,14 @@ impl HostOptionsScreen {
|
||||
if self.host.can_wake && !self.host.online {
|
||||
a.push(Action::Wake);
|
||||
}
|
||||
// "Send logs" needs a paired identity (the upload authenticates with the streaming
|
||||
// cert) and a reachable host — on anything else the row would only ever toast an
|
||||
// error. This is the log-escape hatch for platforms whose own filesystem the user
|
||||
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
|
||||
// its web console next to the host's own logs.
|
||||
if self.host.paired && self.host.online {
|
||||
a.push(Action::SendLogs);
|
||||
}
|
||||
a.extend([
|
||||
Action::CopyLink,
|
||||
Action::Edit,
|
||||
@@ -98,6 +107,7 @@ impl HostOptionsScreen {
|
||||
fn label(&self, a: Action) -> String {
|
||||
match a {
|
||||
Action::Wake => "Wake host".into(),
|
||||
Action::SendLogs => "Send logs to host".into(),
|
||||
Action::CopyLink => "Copy link".into(),
|
||||
Action::Edit => "Edit\u{2026}".into(),
|
||||
Action::Forget if self.armed => "Forget \u{2014} press again".into(),
|
||||
@@ -168,6 +178,16 @@ impl HostOptionsScreen {
|
||||
});
|
||||
fx.pop();
|
||||
}
|
||||
Action::SendLogs => {
|
||||
fx.cmds.push(ConsoleCmd::SendLogs {
|
||||
addr: self.host.addr.clone(),
|
||||
mgmt: self.host.mgmt_port,
|
||||
fp_hex: self.host.fp_hex.clone(),
|
||||
host_name: self.host.name.clone(),
|
||||
});
|
||||
fx.toast = Some(format!("Sending logs to {}\u{2026}", self.host.name));
|
||||
fx.pop();
|
||||
}
|
||||
Action::CopyLink => {
|
||||
match crate::screens::host_link(&self.host) {
|
||||
Some(url) => {
|
||||
|
||||
@@ -278,6 +278,11 @@ impl Shell {
|
||||
(self.hosts, self.hosts_gen) = self.console.hosts_snapshot();
|
||||
}
|
||||
|
||||
// Service-worker notices (e.g. the log-upload result) become plain toasts.
|
||||
if let Some(text) = self.console.take_notice() {
|
||||
self.show_toast(text);
|
||||
}
|
||||
|
||||
let pair = self.console.pair();
|
||||
match &pair {
|
||||
PairPhase::Idle => {}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! On-disk store for log bundles PAIRED CLIENTS upload to this host.
|
||||
//!
|
||||
//! Why this exists: on locked-down client platforms (a Steam Deck in Gaming Mode, tvOS, webOS)
|
||||
//! the user has no realistic way to get the client's own log off the device — so every field
|
||||
//! report from those platforms arrives host-log-only, and the client half of the story
|
||||
//! (de-jitter, decode rungs, playout underruns) is invisible. "Send logs to host" inverts that:
|
||||
//! the client POSTs its recent log over the management API with its paired mTLS cert, the bundle
|
||||
//! lands here, and the web console lists it next to the host's own log export — one place to
|
||||
//! collect both halves.
|
||||
//!
|
||||
//! Deliberately a FILE store, not `log_capture::ring()`: the ring holds the host's newest ~4096
|
||||
//! entries, and ingesting a multi-thousand-line client bundle there would evict the host log —
|
||||
//! destroying the other half of the very report this feature exists to complete. The host log
|
||||
//! gets one INFO breadcrumb per received bundle instead.
|
||||
//!
|
||||
//! Quota: a paired device may upload at will, so the store must be bounded without operator
|
||||
//! attention — per device (by fingerprint prefix) only the newest [`KEEP_PER_DEVICE`] bundles
|
||||
//! survive, and a single bundle is capped at [`MAX_BUNDLE_BYTES`] by the endpoint. Paired
|
||||
//! devices are operator-admitted and enumerable, so the total is bounded too.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Newest bundles kept per device; older ones are pruned on the next upload from that device.
|
||||
pub const KEEP_PER_DEVICE: usize = 5;
|
||||
|
||||
/// Upload size cap, enforced by the endpoint before the store sees the body. Client rings render
|
||||
/// to a few hundred KiB; anything past this is not a log bundle.
|
||||
pub const MAX_BUNDLE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// The default store directory: `<config-dir>/client-logs/`, beside `captures/`.
|
||||
pub fn default_dir() -> PathBuf {
|
||||
pf_paths::config_dir().join("client-logs")
|
||||
}
|
||||
|
||||
/// One stored bundle, as the console lists it.
|
||||
#[derive(Clone, Serialize, ToSchema)]
|
||||
pub struct ClientLogMeta {
|
||||
/// The bundle id (its filename stem) — pass to the fetch/delete endpoints.
|
||||
pub id: String,
|
||||
/// The paired device's name at upload time (sanitized for the filesystem).
|
||||
pub device_name: String,
|
||||
/// First 16 hex chars of the device's pairing fingerprint — enough to correlate with the
|
||||
/// paired-devices roster without repeating the full identity in every filename.
|
||||
pub fingerprint_prefix: String,
|
||||
/// Upload time (unix ms, from the file's mtime).
|
||||
pub received_ms: u64,
|
||||
/// Bundle size in bytes.
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// The store: a flat directory of `<ts>_<fp16>_<name>.log` files.
|
||||
pub struct ClientLogStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Same id gate as `stats_recorder::valid_id`: the exact charset [`bundle_id`] emits, and the
|
||||
/// charset excludes `/` and `\`, so `dir.join(id + ".log")` is always a single child of `dir`.
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id != "."
|
||||
&& id != ".."
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
}
|
||||
|
||||
/// Squeeze a paired device's display name into the id charset (never empty — the id must parse
|
||||
/// back into its three fields, and an all-symbols name would otherwise leave a dangling `_`).
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
|
||||
.take(24)
|
||||
.collect();
|
||||
if cleaned.is_empty() {
|
||||
"device".into()
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_ms_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `2026-08-15T10-22-33Z_9785592d05ef1234_SteamDeck` — timestamp first so a plain directory sort
|
||||
/// is newest-last, dashes (not colons) in the time so the stem is a valid Windows filename.
|
||||
/// Underscores separate the three fields, so name/fp keep to `[A-Za-z0-9.-]`.
|
||||
fn bundle_id(unix_ms: u64, fp_hex: &str, name: &str) -> String {
|
||||
let secs = (unix_ms / 1000) as i64;
|
||||
let days = secs.div_euclid(86_400);
|
||||
let tod = secs.rem_euclid(86_400);
|
||||
let (y, mo, d) = crate::stats_recorder::civil_from_days(days);
|
||||
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||
let fp16: String = fp_hex
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.take(16)
|
||||
.collect();
|
||||
format!(
|
||||
"{y:04}-{mo:02}-{d:02}T{h:02}-{mi:02}-{s:02}Z_{fp16}_{}",
|
||||
sanitize_name(name)
|
||||
)
|
||||
}
|
||||
|
||||
impl ClientLogStore {
|
||||
/// Open the store, creating `dir` (owner-private, best-effort) if missing.
|
||||
pub fn new(dir: PathBuf) -> std::sync::Arc<Self> {
|
||||
if let Err(e) = pf_paths::create_private_dir(&dir) {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "could not create client-logs dir");
|
||||
}
|
||||
std::sync::Arc::new(ClientLogStore { dir })
|
||||
}
|
||||
|
||||
/// Store a bundle from the paired device `fp_hex` named `device_name`; returns the new id.
|
||||
/// Prunes that device's older bundles past [`KEEP_PER_DEVICE`] (best-effort).
|
||||
pub fn save(&self, fp_hex: &str, device_name: &str, body: &[u8]) -> std::io::Result<String> {
|
||||
let id = bundle_id(unix_ms_now(), fp_hex, device_name);
|
||||
std::fs::write(self.dir.join(format!("{id}.log")), body)?;
|
||||
// Prune this device's older bundles. The fp16 field is position 2 of the stem, and ids
|
||||
// sort chronologically because the timestamp leads.
|
||||
let fp16: String = fp_hex
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.take(16)
|
||||
.collect();
|
||||
let mut mine: Vec<String> = self
|
||||
.stems()
|
||||
.into_iter()
|
||||
.filter(|stem| stem.split('_').nth(1) == Some(fp16.as_str()))
|
||||
.collect();
|
||||
mine.sort();
|
||||
if mine.len() > KEEP_PER_DEVICE {
|
||||
for stale in &mine[..mine.len() - KEEP_PER_DEVICE] {
|
||||
let _ = std::fs::remove_file(self.dir.join(format!("{stale}.log")));
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Every stored bundle's metadata, newest first.
|
||||
pub fn list(&self) -> Vec<ClientLogMeta> {
|
||||
let mut out: Vec<ClientLogMeta> = self
|
||||
.stems()
|
||||
.into_iter()
|
||||
.filter_map(|stem| {
|
||||
let mut parts = stem.splitn(3, '_');
|
||||
let _ts = parts.next()?;
|
||||
let fp = parts.next()?.to_string();
|
||||
let name = parts.next()?.to_string();
|
||||
let md = std::fs::metadata(self.dir.join(format!("{stem}.log"))).ok()?;
|
||||
let received_ms = md
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
Some(ClientLogMeta {
|
||||
id: stem,
|
||||
device_name: name,
|
||||
fingerprint_prefix: fp,
|
||||
received_ms,
|
||||
size_bytes: md.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.id.cmp(&a.id)); // timestamp-led stems ⇒ lexicographic = chronological
|
||||
out
|
||||
}
|
||||
|
||||
/// The full bundle body for `id`. `NotFound` for unknown AND invalid ids — an id that fails
|
||||
/// the charset gate must be indistinguishable from an absent one.
|
||||
pub fn load(&self, id: &str) -> std::io::Result<Vec<u8>> {
|
||||
if !valid_id(id) {
|
||||
return Err(std::io::ErrorKind::NotFound.into());
|
||||
}
|
||||
std::fs::read(self.dir.join(format!("{id}.log")))
|
||||
}
|
||||
|
||||
/// Delete the bundle `id` (same invalid-id handling as [`Self::load`]).
|
||||
pub fn delete(&self, id: &str) -> std::io::Result<()> {
|
||||
if !valid_id(id) {
|
||||
return Err(std::io::ErrorKind::NotFound.into());
|
||||
}
|
||||
std::fs::remove_file(self.dir.join(format!("{id}.log")))
|
||||
}
|
||||
|
||||
/// Filename stems of every `.log` in the store (unsorted).
|
||||
fn stems(&self) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(&self.dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
rd.filter_map(|e| {
|
||||
let name = e.ok()?.file_name();
|
||||
let name = name.to_str()?;
|
||||
let stem = name.strip_suffix(".log")?;
|
||||
valid_id(stem).then(|| stem.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> (std::sync::Arc<ClientLogStore>, PathBuf) {
|
||||
// pid+timestamp alone COLLIDED: the tests run in parallel in one process and both can
|
||||
// land on the same millisecond — then one test's cleanup deletes the other's live dir.
|
||||
// A process-wide counter makes each call unique regardless of timing.
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pf-client-logs-test-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
(ClientLogStore::new(dir.clone()), dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_list_load_delete_roundtrip() {
|
||||
let (s, dir) = store();
|
||||
let id = s
|
||||
.save("abcdef0123456789ff", "Steam Deck!", b"hello log")
|
||||
.unwrap();
|
||||
assert!(id.contains("abcdef0123456789"), "{id}");
|
||||
assert!(id.ends_with("SteamDeck"), "sanitized name: {id}");
|
||||
let listed = s.list();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, id);
|
||||
assert_eq!(listed[0].device_name, "SteamDeck");
|
||||
assert_eq!(listed[0].size_bytes, 9);
|
||||
assert_eq!(s.load(&id).unwrap(), b"hello log");
|
||||
s.delete(&id).unwrap();
|
||||
assert!(s.list().is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_ids_read_as_absent() {
|
||||
let (s, dir) = store();
|
||||
for bad in ["../cert", "..", ".", "a/b", "a\\b", ""] {
|
||||
assert_eq!(
|
||||
s.load(bad).unwrap_err().kind(),
|
||||
std::io::ErrorKind::NotFound,
|
||||
"{bad:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
s.delete(bad).unwrap_err().kind(),
|
||||
std::io::ErrorKind::NotFound,
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_device_quota_prunes_oldest() {
|
||||
let (s, dir) = store();
|
||||
// Same device, KEEP_PER_DEVICE + 2 uploads. Ids share a second-resolution timestamp in a
|
||||
// fast test, so disambiguate chronology by writing distinct bodies and checking survival
|
||||
// by count + the other device's bundle being untouched.
|
||||
let other = s.save("ffff00000000000000", "other", b"keep me").unwrap();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..(KEEP_PER_DEVICE + 2) {
|
||||
// Distinct mtime-independent ids: the timestamp field has 1 s resolution, so append
|
||||
// uniqueness through the name (the id embeds it).
|
||||
ids.push(
|
||||
s.save("abcdef0123456789", &format!("dev{i}"), b"x")
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let listed = s.list();
|
||||
let mine = listed
|
||||
.iter()
|
||||
.filter(|m| m.fingerprint_prefix == "abcdef0123456789")
|
||||
.count();
|
||||
assert_eq!(mine, KEEP_PER_DEVICE);
|
||||
assert!(listed.iter().any(|m| m.id == other), "other device pruned");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ mod interactive;
|
||||
// What this host launched, for whom, and when — so a client that re-dials and re-sends its
|
||||
// `Hello::launch` verbatim neither gets a second copy of its game nor loses sight of the one it has
|
||||
// (design/session-game-lifetime.md).
|
||||
mod client_logs;
|
||||
mod launchreg;
|
||||
mod library;
|
||||
mod log_capture;
|
||||
|
||||
@@ -30,6 +30,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
mod auth;
|
||||
mod client_logs;
|
||||
mod clients;
|
||||
mod display;
|
||||
mod events;
|
||||
@@ -181,6 +182,9 @@ pub(crate) struct MgmtState {
|
||||
/// Shared streaming-stats recorder — the same handle the streaming loops emit into, so an
|
||||
/// operator can arm/stop a capture here and review/list/delete saved recordings.
|
||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
/// Log bundles paired clients uploaded (`crate::client_logs`) — constructed here (mgmt-only
|
||||
/// state, nothing streams into it) and listed/served back to the console.
|
||||
client_logs: Arc<crate::client_logs::ClientLogStore>,
|
||||
/// Whether this host runs the GameStream/Moonlight-compat planes (`--gamestream`). Surfaced in
|
||||
/// [`HostInfo`] so the web console can hide the Moonlight-only pairing UI on the secure default
|
||||
/// (native-only) host, where a Moonlight PIN can never arrive.
|
||||
@@ -238,12 +242,14 @@ pub async fn run(
|
||||
opts.bind.port(),
|
||||
native,
|
||||
stats,
|
||||
crate::client_logs::default_dir(),
|
||||
gamestream_enabled,
|
||||
);
|
||||
serve_https(opts.bind, app, tls).await
|
||||
}
|
||||
|
||||
/// Compose the full management router (also used directly by the handler tests).
|
||||
#[allow(clippy::too_many_arguments)] // the composition root wires one state struct; a param per field
|
||||
fn app(
|
||||
state: Arc<AppState>,
|
||||
token: Option<String>,
|
||||
@@ -251,12 +257,16 @@ fn app(
|
||||
port: u16,
|
||||
native: Option<Arc<crate::native_pairing::NativePairing>>,
|
||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
// Where uploaded client log bundles live — a parameter (not `default_dir()` inline) so the
|
||||
// handler tests point it at a temp dir instead of the real config dir.
|
||||
client_logs_dir: std::path::PathBuf,
|
||||
gamestream_enabled: bool,
|
||||
) -> Router {
|
||||
let shared = Arc::new(MgmtState {
|
||||
app: state,
|
||||
native,
|
||||
stats,
|
||||
client_logs: crate::client_logs::ClientLogStore::new(client_logs_dir),
|
||||
gamestream_enabled,
|
||||
token,
|
||||
plugin_token,
|
||||
@@ -365,6 +375,14 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
||||
stats::stats_recording_delete
|
||||
))
|
||||
.routes(routes!(stats::logs_get))
|
||||
.routes(routes!(
|
||||
client_logs::client_logs_upload,
|
||||
client_logs::client_logs_list
|
||||
))
|
||||
.routes(routes!(
|
||||
client_logs::client_logs_get,
|
||||
client_logs::client_logs_delete
|
||||
))
|
||||
.routes(routes!(events::stream_events))
|
||||
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
|
||||
.routes(routes!(plugins::list_plugins))
|
||||
|
||||
@@ -289,6 +289,13 @@ fn path_matches(pattern: &str, path: &str) -> bool {
|
||||
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
|
||||
/// edit the library). `/health` is handled separately (always open).
|
||||
pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
|
||||
// The ONE write on this lane: a paired device uploading its own log bundle for the operator
|
||||
// ("send logs to host" — the only way logs escape a Deck in Gaming Mode or a tvOS box).
|
||||
// Deliberately write-only: the device gets an id back and can read NOTHING — not the bundle
|
||||
// list, not even its own upload. Size- and quota-capped in the handler/store.
|
||||
if method == Method::POST && path == "/api/v1/client-logs" {
|
||||
return true;
|
||||
}
|
||||
method == Method::GET
|
||||
&& (matches!(
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Client log bundles: the upload endpoint paired devices POST to, and the admin list/fetch/
|
||||
//! delete surface the web console reads. See `crate::client_logs` for why this is a file store.
|
||||
//!
|
||||
//! Lane split (see `auth`): the UPLOAD is the one write a paired streaming cert may perform —
|
||||
//! it is write-only (a device can never read anything back, not even its own bundle), size-capped,
|
||||
//! and quota-bounded per device. Listing/fetching/deleting are operator business: bundles can
|
||||
//! contain whatever the client logged (addresses, host names), so reading them stays on the
|
||||
//! loopback-only bearer lane with the host's own logs.
|
||||
|
||||
use super::shared::*;
|
||||
use crate::client_logs::{ClientLogMeta, MAX_BUNDLE_BYTES};
|
||||
use crate::gamestream::tls::PeerCertFingerprint;
|
||||
use axum::body::Bytes;
|
||||
use axum::Extension;
|
||||
|
||||
/// Response to a successful upload.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(crate) struct ClientLogUploaded {
|
||||
/// The stored bundle's id.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Upload a client log bundle
|
||||
///
|
||||
/// A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming
|
||||
/// certificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are
|
||||
/// capped at 1 MiB and only the newest few per device are kept. The operator downloads them from
|
||||
/// the console's Logs page. This is deliberately write-only for devices: uploading grants no read.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/client-logs",
|
||||
tag = "logs",
|
||||
operation_id = "clientLogsUpload",
|
||||
request_body(content = String, content_type = "text/plain", description = "The client's log text"),
|
||||
responses(
|
||||
(status = CREATED, description = "Bundle stored", body = ClientLogUploaded),
|
||||
(status = BAD_REQUEST, description = "No paired-device certificate on the connection", body = ApiError),
|
||||
(status = FORBIDDEN, description = "The device's access has expired (per-client access)", body = ApiError),
|
||||
(status = PAYLOAD_TOO_LARGE, description = "Bundle exceeds the size cap", body = ApiError),
|
||||
(status = UNPROCESSABLE_ENTITY, description = "Empty body", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not store the bundle", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn client_logs_upload(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
fp: Option<Extension<PeerCertFingerprint>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
// The auth middleware admits this route for paired certs AND (like everything) the admin
|
||||
// bearer token — but an upload without a device identity has no owner to file it under, so
|
||||
// the bearer path is a caller error, not a second way in.
|
||||
let Some(Extension(PeerCertFingerprint(Some(fp)))) = fp else {
|
||||
return api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"client log upload requires a paired device certificate",
|
||||
);
|
||||
};
|
||||
if body.is_empty() {
|
||||
return api_error(StatusCode::UNPROCESSABLE_ENTITY, "empty log bundle");
|
||||
}
|
||||
if body.len() > MAX_BUNDLE_BYTES {
|
||||
return api_error(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"log bundle exceeds the 1 MiB cap — send the tail",
|
||||
);
|
||||
}
|
||||
// Per-client access (design/per-client-access.md): the auth gate's `is_paired` is
|
||||
// EXPIRY-BLIND by design — right for the read-only status GETs (an expired guest still
|
||||
// appears in rosters), wrong for this lane's one WRITE. `effective` is the authorization
|
||||
// verb: `None` = unpaired or expired ⇒ a lapsed guest can't keep writing bundles to the
|
||||
// operator's disk. No specific grant BIT is required — uploading one's own logs is not an
|
||||
// input capability, and a view-only guest mid-session is exactly who a debug bundle is
|
||||
// wanted from.
|
||||
let now_unix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if st
|
||||
.native
|
||||
.as_ref()
|
||||
.and_then(|n| n.effective(&fp, now_unix))
|
||||
.is_none()
|
||||
{
|
||||
return api_error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"this device's access has expired — ask the host's operator to approve it again",
|
||||
);
|
||||
}
|
||||
// Resolve the device's display name from the paired roster (the auth gate already proved
|
||||
// membership; a race with an unpair between the gate and here just falls back to the prefix).
|
||||
let device_name = st
|
||||
.native
|
||||
.as_ref()
|
||||
.and_then(|n| {
|
||||
n.list()
|
||||
.into_iter()
|
||||
.find(|c| c.fingerprint.eq_ignore_ascii_case(&fp))
|
||||
.map(|c| c.name)
|
||||
})
|
||||
.unwrap_or_else(|| fp.chars().take(16).collect());
|
||||
match st.client_logs.save(&fp, &device_name, &body) {
|
||||
Ok(id) => {
|
||||
tracing::info!(
|
||||
device = %device_name,
|
||||
id = %id,
|
||||
bytes = body.len(),
|
||||
"client log bundle received — listed on the console's Logs page"
|
||||
);
|
||||
(StatusCode::CREATED, Json(ClientLogUploaded { id })).into_response()
|
||||
}
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not store the bundle: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// List uploaded client log bundles
|
||||
///
|
||||
/// Every stored bundle's metadata, newest first.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/client-logs",
|
||||
tag = "logs",
|
||||
operation_id = "clientLogsList",
|
||||
responses(
|
||||
(status = OK, description = "Stored bundles, newest first", body = [ClientLogMeta]),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn client_logs_list(State(st): State<Arc<MgmtState>>) -> Json<Vec<ClientLogMeta>> {
|
||||
Json(st.client_logs.list())
|
||||
}
|
||||
|
||||
/// Download a client log bundle
|
||||
///
|
||||
/// The bundle body as plain text, for saving or attaching to a report.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/client-logs/{id}",
|
||||
tag = "logs",
|
||||
operation_id = "clientLogsGet",
|
||||
params(("id" = String, Path, description = "The bundle id (its filename stem)")),
|
||||
responses(
|
||||
(status = OK, description = "The bundle body", body = String, content_type = "text/plain"),
|
||||
(status = NOT_FOUND, description = "No bundle with that id", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "The bundle file is unreadable", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn client_logs_get(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
match st.client_logs.load(&id) {
|
||||
Ok(body) => (
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
body,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
api_error(StatusCode::NOT_FOUND, "no bundle with that id")
|
||||
}
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not read the bundle: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a client log bundle
|
||||
///
|
||||
/// Removes the bundle `id` from disk. `404` if there is no such bundle.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/client-logs/{id}",
|
||||
tag = "logs",
|
||||
operation_id = "clientLogsDelete",
|
||||
params(("id" = String, Path, description = "The bundle id (its filename stem)")),
|
||||
responses(
|
||||
(status = NO_CONTENT, description = "Bundle deleted"),
|
||||
(status = NOT_FOUND, description = "No bundle with that id", body = ApiError),
|
||||
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||
(status = INTERNAL_SERVER_ERROR, description = "Could not delete the bundle", body = ApiError),
|
||||
)
|
||||
)]
|
||||
pub(crate) async fn client_logs_delete(
|
||||
State(st): State<Arc<MgmtState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
match st.client_logs.delete(&id) {
|
||||
Ok(()) => {
|
||||
tracing::info!(id, "management API: client log bundle deleted");
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
api_error(StatusCode::NOT_FOUND, "no bundle with that id")
|
||||
}
|
||||
Err(e) => api_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("could not delete the bundle: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,15 @@ use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// A throwaway client-logs dir (same shape as [`test_stats`] — never the real config dir).
|
||||
fn test_client_logs_dir() -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"pf-mgmt-clientlogs-{}-{:p}",
|
||||
std::process::id(),
|
||||
&0u8 as *const u8
|
||||
))
|
||||
}
|
||||
|
||||
/// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir).
|
||||
fn test_stats() -> Arc<crate::stats_recorder::StatsRecorder> {
|
||||
crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
|
||||
@@ -94,6 +103,7 @@ fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
|
||||
DEFAULT_PORT,
|
||||
None,
|
||||
stats,
|
||||
test_client_logs_dir(),
|
||||
// GameStream-compat planes off (the secure default the native-only tests model).
|
||||
false,
|
||||
)
|
||||
@@ -110,6 +120,7 @@ fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePa
|
||||
DEFAULT_PORT,
|
||||
Some(np),
|
||||
stats,
|
||||
test_client_logs_dir(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
@@ -1347,6 +1358,14 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
|
||||
("GET", "/api/v1/compositors", true, true),
|
||||
("GET", "/api/v1/events", true, false),
|
||||
("GET", "/api/v1/logs", true, false),
|
||||
// ---- client log bundles: the UPLOAD is the cert lane's single write — write-only,
|
||||
// size/quota-capped ("send logs to host" from a Deck in Gaming Mode / tvOS). Reading
|
||||
// bundles back is operator business (they can contain whatever the client logged), so
|
||||
// list/fetch/delete stay bearer-only in both lanes.
|
||||
("POST", "/api/v1/client-logs", false, true),
|
||||
("GET", "/api/v1/client-logs", false, false),
|
||||
("GET", "/api/v1/client-logs/{id}", false, false),
|
||||
("DELETE", "/api/v1/client-logs/{id}", false, false),
|
||||
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
|
||||
// removal is pairing administration in both lanes.
|
||||
("GET", "/api/v1/clients", true, false),
|
||||
|
||||
@@ -200,7 +200,8 @@ fn capture_id(unix_ms: u64, width: u32, height: u32) -> String {
|
||||
}
|
||||
|
||||
/// Civil (Y, M, D) from a count of days since the Unix epoch (Howard Hinnant's `civil_from_days`).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
/// `pub(crate)`: `client_logs::bundle_id` builds its timestamp stem the same way.
|
||||
pub(crate) fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
|
||||
@@ -427,6 +427,17 @@
|
||||
"logs_copy": "Logs in die Zwischenablage kopieren",
|
||||
"logs_copied": "Logs in die Zwischenablage kopiert",
|
||||
"logs_share_failed": "Logs konnten nicht geteilt werden",
|
||||
"client_logs_title": "Client-Logs",
|
||||
"client_logs_subtitle": "Log-Pakete, die deine Geräte mit „Logs an Host senden“ geschickt haben — von Plattformen, deren eigene Dateien unerreichbar sind, etwa einem Steam Deck im Gaming Mode oder einem Apple TV.",
|
||||
"client_logs_col_received": "Empfangen",
|
||||
"client_logs_col_device": "Gerät",
|
||||
"client_logs_col_size": "Größe",
|
||||
"client_logs_download": "Paket herunterladen",
|
||||
"client_logs_download_failed": "Paket konnte nicht heruntergeladen werden",
|
||||
"client_logs_delete": "Löschen",
|
||||
"client_logs_delete_confirm": "Dieses Paket löschen?",
|
||||
"client_logs_delete_body": "Das Gerät kann jederzeit ein neues senden.",
|
||||
"client_logs_delete_failed": "Paket konnte nicht gelöscht werden",
|
||||
"stats_title": "Leistung",
|
||||
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
||||
"stats_capture_title": "Aufzeichnung",
|
||||
|
||||
@@ -427,6 +427,17 @@
|
||||
"logs_copy": "Copy logs to clipboard",
|
||||
"logs_copied": "Logs copied to clipboard",
|
||||
"logs_share_failed": "Couldn't share the logs",
|
||||
"client_logs_title": "Client logs",
|
||||
"client_logs_subtitle": "Log bundles your devices sent with “Send logs to host” — from platforms whose own files are out of reach, like a Steam Deck in Gaming Mode or an Apple TV.",
|
||||
"client_logs_col_received": "Received",
|
||||
"client_logs_col_device": "Device",
|
||||
"client_logs_col_size": "Size",
|
||||
"client_logs_download": "Download bundle",
|
||||
"client_logs_download_failed": "Couldn't download the bundle",
|
||||
"client_logs_delete": "Delete",
|
||||
"client_logs_delete_confirm": "Delete this bundle?",
|
||||
"client_logs_delete_body": "The device can send a fresh one at any time.",
|
||||
"client_logs_delete_failed": "Couldn't delete the bundle",
|
||||
"stats_title": "Performance",
|
||||
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
||||
"stats_capture_title": "Capture",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
|
||||
import {
|
||||
clientLogsGet,
|
||||
getClientLogsListQueryKey,
|
||||
useClientLogsDelete,
|
||||
useClientLogsList,
|
||||
} from "@/api/gen/logs/logs";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { apiErrorMessage } from "@/lib/errors";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { fmtTimestamp } from "../Stats/helpers";
|
||||
|
||||
/** `123.4 KB` — bundles are ≤1 MiB, so two units cover the whole range. */
|
||||
const fmtSize = (bytes: number): string =>
|
||||
bytes >= 1024 * 1024
|
||||
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${(bytes / 1024).toFixed(1)} KB`;
|
||||
|
||||
/**
|
||||
* Container: log bundles paired clients uploaded via "Send logs to host" — the log-escape hatch
|
||||
* for platforms whose own files the user can't reach (a Deck in Gaming Mode, tvOS). Owns the
|
||||
* list query, the text download, and delete.
|
||||
*/
|
||||
export const ClientLogsSection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
const bundles = useClientLogsList();
|
||||
const del = useClientLogsDelete();
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
const ok = await confirm({
|
||||
title: m.client_logs_delete_confirm(),
|
||||
description: m.client_logs_delete_body(),
|
||||
confirmLabel: m.client_logs_delete(),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
del.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: getClientLogsListQueryKey() }),
|
||||
onError: (e) =>
|
||||
toast.error(apiErrorMessage(e) ?? m.client_logs_delete_failed()),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Plain-text bundle → blob download, same shape as the recordings JSON export.
|
||||
const onDownload = async (id: string) => {
|
||||
try {
|
||||
const text = await clientLogsGet(id);
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${id}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e) ?? m.client_logs_download_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientLogsCard
|
||||
bundles={bundles}
|
||||
onDownload={onDownload}
|
||||
onDelete={onDelete}
|
||||
isDeleting={del.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** Uploaded client log bundles, newest first, with Download / Delete row actions. */
|
||||
export const ClientLogsCard: FC<{
|
||||
bundles: Loadable<ClientLogMeta[]>;
|
||||
onDownload: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isDeleting: boolean;
|
||||
}> = ({ bundles, onDownload, onDelete, isDeleting }) => {
|
||||
const rows = bundles.data ?? [];
|
||||
// No bundles is the ordinary state (nothing was ever sent) — an empty card would just be
|
||||
// noise on every visit, so the whole card only appears once something arrived. Errors and
|
||||
// loading still render: a broken list must not look like "nothing was sent".
|
||||
if (!bundles.isLoading && !bundles.error && rows.length === 0) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-medium">{m.client_logs_title()}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.client_logs_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<QueryState
|
||||
isLoading={bundles.isLoading}
|
||||
error={bundles.error}
|
||||
refetch={bundles.refetch}
|
||||
>
|
||||
<CardContent flush>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{m.client_logs_col_received()}</TableHead>
|
||||
<TableHead>{m.client_logs_col_device()}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{m.client_logs_col_size()}
|
||||
</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="whitespace-nowrap font-medium">
|
||||
{fmtTimestamp(r.received_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span>{r.device_name}</span>
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{r.fingerprint_prefix}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmtSize(r.size_bytes)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.client_logs_download()}
|
||||
title={m.client_logs_download()}
|
||||
onClick={() => onDownload(r.id)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.client_logs_delete()}
|
||||
title={m.client_logs_delete()}
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(r.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</QueryState>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { FC } from "react";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { ClientLogsSection } from "./ClientLogsCard";
|
||||
import { LogsSection } from "./LogsCard";
|
||||
import { LogsView } from "./view";
|
||||
|
||||
// Logs = one self-contained viewer card owning its polling; this container only binds the layout.
|
||||
// Client-uploaded bundles ("Send logs to host") render beneath the live host log — same page a
|
||||
// reporter already exports the host log from, so both halves of a report live in one place.
|
||||
export const SectionLogs: FC = () => {
|
||||
useLocale();
|
||||
return <LogsView viewer={<LogsSection />} />;
|
||||
return (
|
||||
<LogsView
|
||||
viewer={
|
||||
<>
|
||||
<LogsSection />
|
||||
<ClientLogsSection />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user