diff --git a/crates/devolutions-log/src/lib.rs b/crates/devolutions-log/src/lib.rs index bd885c3c31..b863ca9b4d 100644 --- a/crates/devolutions-log/src/lib.rs +++ b/crates/devolutions-log/src/lib.rs @@ -21,6 +21,14 @@ pub trait StaticLogConfig { const LOG_FILE_PREFIX: &'static str; } +/// Metadata for a retained log file in the configured log directory. +pub struct LogFile { + pub path: std::path::PathBuf, + pub name: String, + pub size: u64, + pub modified: SystemTime, +} + pub struct LoggerGuard { _file_guard: WorkerGuard, _stdio_guard: WorkerGuard, @@ -91,38 +99,90 @@ pub fn init( }) } -/// Find latest log file (by age) +/// Finds the newest retained regular log file by modification time. /// -/// Given path is used to filter out by file name prefix. +/// Uses the same file filter and order as [`list_log_files`]. #[instrument] pub async fn find_latest_log_file(prefix: &Utf8Path) -> anyhow::Result { - let cfg = LogPathCfg::::from_path(prefix)?; + list_log_files::(prefix) + .await? + .into_iter() + .next() + .map(|file| file.path) + .context("no file found") +} + +/// Lists the regular log files retained under the configured log path. +pub async fn list_log_files(path: &Utf8Path) -> anyhow::Result> { + let cfg = LogPathCfg::::from_path(path)?; + let mut read_dir = fs::read_dir(cfg.folder).await.context("couldn't read log directory")?; + let mut files = Vec::new(); - let mut read_dir = fs::read_dir(cfg.folder).await.context("couldn't read directory")?; + while let Some(entry) = read_dir.next_entry().await.context("couldn't read log entry")? { + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; - let mut most_recent_time = SystemTime::UNIX_EPOCH; - let mut most_recent = None; + if !name.starts_with(cfg.prefix) || !name.ends_with(".log") { + continue; + } - while let Ok(Some(entry)) = read_dir.next_entry().await { - match entry.file_name().to_str() { - Some(file_name) if file_name.starts_with(cfg.prefix) && file_name.contains("log") => { - debug!(file_name, "Found a log file"); - match entry.metadata().await.and_then(|metadata| metadata.modified()) { - Ok(modified) if modified > most_recent_time => { - most_recent_time = modified; - most_recent = Some(entry.path()); - } - Ok(_) => {} - Err(error) => { - warn!(%error, file_name, "Couldn't retrieve metadata for file"); - } - } + let file_type = match entry.file_type().await { + Ok(file_type) => file_type, + Err(error) => { + warn!(%error, name, "Couldn't read log file type"); + continue; } - _ => continue, + }; + if !file_type.is_file() { + continue; } + + let metadata = match entry.metadata().await { + Ok(metadata) => metadata, + Err(error) => { + warn!(%error, name, "Couldn't read log metadata"); + continue; + } + }; + let modified = match metadata.modified() { + Ok(modified) => modified, + Err(error) => { + warn!(%error, name, "Couldn't read log modification time"); + continue; + } + }; + files.push(LogFile { + path: entry.path(), + name, + size: metadata.len(), + modified, + }); + } + + files.sort_by(|left, right| { + right + .modified + .cmp(&left.modified) + .then_with(|| compare_log_file_names(&right.name, &left.name)) + }); + Ok(files) +} + +fn compare_log_file_names(left: &str, right: &str) -> std::cmp::Ordering { + let rotations = left + .strip_suffix(".log") + .and_then(|name| name.rsplit_once('.')) + .zip(right.strip_suffix(".log").and_then(|name| name.rsplit_once('.'))); + + if let Some(((left_base, left_rotation), (right_base, right_rotation))) = rotations + && left_base == right_base + && let (Ok(left_rotation), Ok(right_rotation)) = (left_rotation.parse::(), right_rotation.parse::()) + { + return left_rotation.cmp(&right_rotation).then_with(|| left.cmp(right)); } - most_recent.context("no file found") + left.cmp(right) } /// File deletion task (by age) diff --git a/devolutions-gateway/openapi/gateway-api.yaml b/devolutions-gateway/openapi/gateway-api.yaml index f3ed77ab0e..9bda555e14 100644 --- a/devolutions-gateway/openapi/gateway-api.yaml +++ b/devolutions-gateway/openapi/gateway-api.yaml @@ -107,6 +107,63 @@ paths: security: - scope_token: - gateway.diagnostics.read + /jet/diagnostics/logs/files: + get: + tags: + - Diagnostics + summary: Lists the log files retained by this Gateway. + operationId: ListLogFiles + responses: + '200': + description: Retained log files + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LogFileInfo' + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '500': + description: Failed to list log files + security: + - scope_token: + - gateway.diagnostics.read + /jet/diagnostics/logs/files/{name}: + get: + tags: + - Diagnostics + summary: Retrieves one retained log file by its listed name. + operationId: GetLogFile + parameters: + - name: name + in: path + description: Name returned by ListLogFiles + required: true + schema: + type: string + responses: + '200': + description: Log file contents + content: + text/plain: + schema: + type: string + '400': + description: Invalid log file name + '401': + description: Invalid or missing authorization token + '403': + description: Insufficient permissions + '404': + description: Log file not found + '500': + description: Failed to read log file + security: + - scope_token: + - gateway.diagnostics.read /jet/health: get: tags: @@ -2223,6 +2280,22 @@ components: internal_url: type: string description: URL to use on local network + LogFileInfo: + type: object + required: + - name + - size + - modifiedUnixMillis + properties: + modifiedUnixMillis: + type: integer + format: int64 + name: + type: string + size: + type: integer + format: int64 + minimum: 0 MonitorDefinition: type: object required: diff --git a/devolutions-gateway/src/api/diagnostics.rs b/devolutions-gateway/src/api/diagnostics.rs index 2bc44a2735..3b4b49c37a 100644 --- a/devolutions-gateway/src/api/diagnostics.rs +++ b/devolutions-gateway/src/api/diagnostics.rs @@ -1,8 +1,11 @@ -use axum::extract::State; +use std::io; +use std::path::Path as FsPath; + +use axum::extract::{Path, State}; use axum::response::{IntoResponse as _, Response}; use axum::routing::get; use axum::{Json, Router}; -use tokio::fs::File; +use tokio::fs::{File, OpenOptions}; use uuid::Uuid; use crate::DgwState; @@ -15,6 +18,8 @@ use crate::log::GatewayLog; pub fn make_router(state: DgwState) -> Router { Router::new() .route("/logs", get(get_logs)) + .route("/logs/files", get(list_log_files)) + .route("/logs/files/{name}", get(get_log_file)) .route("/clock", get(get_clock)) .route("/configuration", get(get_configuration)) .with_state(state) @@ -128,13 +133,148 @@ async fn get_logs( .await .map_err(HttpError::internal().with_msg("latest log file not found").err())?; - let file = File::open(&latest_log_file_path) + let file = open_regular_log_file(&latest_log_file_path) .await .map_err(HttpError::internal().err())?; Ok(axum_extra::body::AsyncReadBody::new(file).into_response()) } +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LogFileInfo { + name: String, + size: u64, + modified_unix_millis: i64, +} + +/// Lists the log files retained by this Gateway. +#[cfg_attr(feature = "openapi", utoipa::path( + get, + operation_id = "ListLogFiles", + tag = "Diagnostics", + path = "/jet/diagnostics/logs/files", + responses( + (status = 200, description = "Retained log files", body = [LogFileInfo]), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 500, description = "Failed to list log files"), + ), + security(("scope_token" = ["gateway.diagnostics.read"])), +))] +async fn list_log_files( + State(DgwState { conf_handle, .. }): State, + _scope: DiagnosticsReadScope, +) -> Result>, HttpError> { + let conf = conf_handle.get_conf(); + let files = devolutions_log::list_log_files::(conf.log_file.as_path()) + .await + .map_err(HttpError::internal().err())?; + + Ok(Json( + files + .into_iter() + .map(|file| LogFileInfo { + name: file.name, + size: file.size, + modified_unix_millis: i64::try_from( + time::OffsetDateTime::from(file.modified).unix_timestamp_nanos() / 1_000_000, + ) + .expect("log timestamp fits in i64 milliseconds"), + }) + .collect(), + )) +} + +/// Retrieves one retained log file by its listed name. +#[cfg_attr(feature = "openapi", utoipa::path( + get, + operation_id = "GetLogFile", + tag = "Diagnostics", + path = "/jet/diagnostics/logs/files/{name}", + params(("name" = String, Path, description = "Name returned by ListLogFiles")), + responses( + (status = 200, description = "Log file contents", body = String, content_type = "text/plain"), + (status = 400, description = "Invalid log file name"), + (status = 401, description = "Invalid or missing authorization token"), + (status = 403, description = "Insufficient permissions"), + (status = 404, description = "Log file not found"), + (status = 500, description = "Failed to read log file"), + ), + security(("scope_token" = ["gateway.diagnostics.read"])), +))] +async fn get_log_file( + State(DgwState { conf_handle, .. }): State, + _scope: DiagnosticsReadScope, + Path(name): Path, +) -> Result { + let conf = conf_handle.get_conf(); + let files = devolutions_log::list_log_files::(conf.log_file.as_path()) + .await + .map_err(HttpError::internal().err())?; + let file = files + .into_iter() + .find(|file| file.name == name) + .ok_or_else(|| HttpError::not_found().msg("log file not found"))?; + let file = open_regular_log_file(&file.path).await.map_err(|error| { + let unavailable = matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::InvalidData); + #[cfg(unix)] + let unavailable = unavailable || error.raw_os_error() == Some(libc::ELOOP); + if unavailable { + HttpError::not_found().build(error) + } else { + HttpError::internal().build(error) + } + })?; + + Ok(( + [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + axum_extra::body::AsyncReadBody::new(file), + ) + .into_response()) +} + +async fn open_regular_log_file(path: &FsPath) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + + #[cfg(unix)] + // UNTESTED: A FIFO swapped in after listing must not block this open, so diagnostics stays responsive. + // Check a FIFO replacement before changing O_NONBLOCK. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + + #[cfg(windows)] + options.custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT); + + let file = options.open(path).await?; + let metadata = file.metadata().await?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "log path is not a regular file", + )); + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt as _; + + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + // UNTESTED: A reparse-point file must not be streamed, but the test skips on hosts without symlink privilege. + // Exercise a file symlink on a Windows host that can create one before changing this check. + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "log path is a reparse point", + )); + } + } + + Ok(file) +} + /// Retrieves a subset of the configuration, for diagnosis purposes. /// /// This route primary function is to help with configuration diagnosis (e.g.: ID mismatch, hostname mismatch, @@ -181,3 +321,105 @@ async fn get_configuration( async fn get_clock() -> Json { Json(ClockDiagnostic::now()) } + +#[cfg(test)] +mod tests { + use super::open_regular_log_file; + use crate::log::GatewayLog; + + // oracle: mutant selecting a log-like file without the .log suffix. + #[tokio::test] + async fn latest_log_file_uses_retained_log_filter() { + let dir = tempfile::tempdir().expect("create temporary directory"); + let retained = dir.path().join("gateway.2026-11-09.0.log"); + let other = dir.path().join("gateway.2026-11-09.1.log.tmp"); + std::fs::write(&retained, b"log data").expect("create retained log"); + std::fs::write(&other, b"other data").expect("create other file"); + let older = std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000)); + let newer = std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_060)); + std::fs::OpenOptions::new() + .write(true) + .open(&retained) + .expect("open retained log") + .set_times(older) + .expect("set retained log time"); + std::fs::OpenOptions::new() + .write(true) + .open(&other) + .expect("open other file") + .set_times(newer) + .expect("set other file time"); + + let log_path = camino::Utf8Path::from_path(dir.path()).expect("UTF-8 log path"); + let latest = devolutions_log::find_latest_log_file::(log_path) + .await + .expect("find latest log"); + assert_eq!(latest, retained); + } + + // oracle: mutant sorting rotation numbers as strings when modification times match. + #[tokio::test] + async fn latest_log_file_orders_numeric_rotations() { + let dir = tempfile::tempdir().expect("create temporary directory"); + let older_rotation = dir.path().join("gateway.2026-11-09.9.log"); + let newer_rotation = dir.path().join("gateway.2026-11-09.10.log"); + std::fs::write(&older_rotation, b"older rotation").expect("create older rotation"); + std::fs::write(&newer_rotation, b"newer rotation").expect("create newer rotation"); + let modified = std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000)); + for path in [&older_rotation, &newer_rotation] { + std::fs::OpenOptions::new() + .write(true) + .open(path) + .expect("open rotation") + .set_times(modified) + .expect("set rotation time"); + } + + let log_path = camino::Utf8Path::from_path(dir.path()).expect("UTF-8 log path"); + let latest = devolutions_log::find_latest_log_file::(log_path) + .await + .expect("find latest log"); + assert_eq!(latest, newer_rotation); + } + + // oracle: mutant following a symlink outside the configured log directory. + #[tokio::test] + async fn replaced_log_file_symlink_is_rejected() { + let dir = tempfile::tempdir().expect("create temporary directory"); + let log_dir = dir.path().join("logs"); + std::fs::create_dir(&log_dir).expect("create log directory"); + let outside = dir.path().join("outside.txt"); + let selected_log = log_dir.join("gateway.log"); + std::fs::write(&outside, b"private data").expect("create outside file"); + std::fs::write(&selected_log, b"log data").expect("create selected log"); + std::fs::remove_file(&selected_log).expect("replace selected log"); + + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, &selected_log).expect("replace log with symlink"); + #[cfg(windows)] + if let Err(error) = std::os::windows::fs::symlink_file(&outside, &selected_log) { + if error.raw_os_error().and_then(|code| u32::try_from(code).ok()) + == Some(windows_sys::Win32::Foundation::ERROR_PRIVILEGE_NOT_HELD) + { + return; + } + panic!("replace log with symlink: {error}"); + } + + assert!(open_regular_log_file(&selected_log).await.is_err()); + } + + // oracle: mutant accepting a directory or rejecting a regular log file. + #[tokio::test] + async fn opened_log_handle_must_be_a_regular_file() { + let dir = tempfile::tempdir().expect("create temporary directory"); + let regular_file = dir.path().join("gateway.log"); + std::fs::write(®ular_file, b"log data").expect("create regular file"); + + assert!(open_regular_log_file(®ular_file).await.is_ok()); + assert!(open_regular_log_file(dir.path()).await.is_err()); + } +} diff --git a/devolutions-gateway/src/openapi.rs b/devolutions-gateway/src/openapi.rs index e1166a9396..d0c73d0a26 100644 --- a/devolutions-gateway/src/openapi.rs +++ b/devolutions-gateway/src/openapi.rs @@ -20,6 +20,8 @@ use crate::provisioner_tasks::recording_ai_analysis::{BadAnswer, Failure, Recove crate::api::sessions::get_sessions, crate::api::session::terminate_session, crate::api::diagnostics::get_logs, + crate::api::diagnostics::list_log_files, + crate::api::diagnostics::get_log_file, crate::api::diagnostics::get_configuration, crate::api::diagnostics::get_clock, crate::api::config::patch_config, @@ -63,6 +65,7 @@ use crate::provisioner_tasks::recording_ai_analysis::{BadAnswer, Failure, Recove Subscriber, crate::api::diagnostics::ConfigDiagnostic, crate::api::diagnostics::ClockDiagnostic, + crate::api::diagnostics::LogFileInfo, SubProvisionerKey, ConfigPatch, crate::api::jrl::JrlInfo, diff --git a/testsuite/tests/cli/dgw/diagnostics.rs b/testsuite/tests/cli/dgw/diagnostics.rs new file mode 100644 index 0000000000..7314816529 --- /dev/null +++ b/testsuite/tests/cli/dgw/diagnostics.rs @@ -0,0 +1,112 @@ +//! Retained Gateway log API tests. + +use anyhow::Context as _; +use base64::Engine as _; +use testsuite::cli::start_dgw; +use testsuite::dgw_config::{DgwConfig, DgwConfigHandle}; +use uuid::Uuid; + +const LOG_NAME: &str = "gateway.contract.log"; +const LOG_CONTENT: &str = "diagnostics route contract\n"; + +fn scope_token(scope: &str) -> String { + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let header = serde_json::json!({ "typ": "JWT", "alg": "RS256" }); + let payload = serde_json::json!({ + "type": "scope", + "jti": Uuid::new_v4().to_string(), + "iat": 1_733_669_999, + "nbf": 1_733_669_999, + "exp": 3_331_553_599i64, + "scope": scope, + }); + + format!( + "{}.{}.{}", + b64.encode(header.to_string()), + b64.encode(payload.to_string()), + b64.encode("signature-not-checked"), + ) +} + +async fn start_with_log_file() -> anyhow::Result<(DgwConfigHandle, tokio::process::Child)> { + let config = DgwConfig::builder().disable_token_validation(true).build().init()?; + std::fs::write(config.config_dir().join(LOG_NAME), LOG_CONTENT).context("write retained log")?; + std::fs::write(config.config_dir().join("gateway.contract.log.tmp"), "ignored") + .context("write non-retained file")?; + let process = start_dgw(&config).await?; + Ok((config, process)) +} + +// oracle: mutant omitting retained files, changing response fields, or returning different download bytes. +#[tokio::test] +async fn lists_and_downloads_retained_log_files() -> anyhow::Result<()> { + let (config, _process) = start_with_log_file().await?; + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{}/jet/diagnostics/logs/files", config.http_port()); + let token = scope_token("gateway.diagnostics.read"); + + let response = client.get(&base).bearer_auth(&token).send().await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let files: serde_json::Value = response.json().await?; + let files = files.as_array().context("log list is an array")?; + let log = files + .iter() + .find(|file| file["name"] == LOG_NAME) + .context("fixture log is listed")?; + assert_eq!(log["size"], serde_json::json!(LOG_CONTENT.len())); + assert!(log["modifiedUnixMillis"].as_i64().is_some()); + assert!(!files.iter().any(|file| file["name"] == "gateway.contract.log.tmp")); + + let response = client + .get(format!("{base}/{LOG_NAME}")) + .bearer_auth(&token) + .send() + .await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .context("content type")?, + "text/plain; charset=utf-8" + ); + assert_eq!(response.text().await?, LOG_CONTENT); + + let response = client + .get(format!("{base}/gateway.missing.log")) + .bearer_auth(&token) + .send() + .await?; + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + + Ok(()) +} + +// oracle: mutant bypassing diagnostics scope or extracting a malformed path before checking scope. +#[tokio::test] +async fn log_routes_enforce_diagnostics_scope_before_path_extraction() -> anyhow::Result<()> { + let (config, _process) = start_with_log_file().await?; + let client = reqwest::Client::new(); + let base = format!("http://127.0.0.1:{}/jet/diagnostics/logs/files", config.http_port()); + let wrong_scope = scope_token("gateway.sessions.read"); + + for url in [base.clone(), format!("{base}/{LOG_NAME}"), format!("{base}/%FF")] { + let response = client.get(&url).bearer_auth(&wrong_scope).send().await?; + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN, "{url}"); + } + + let response = client + .get(format!("{base}/%FF")) + .bearer_auth(scope_token("gateway.diagnostics.read")) + .send() + .await?; + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + + for url in [base.clone(), format!("{base}/{LOG_NAME}")] { + let response = client.get(&url).send().await?; + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED, "{url}"); + } + + Ok(()) +} diff --git a/testsuite/tests/cli/dgw/mod.rs b/testsuite/tests/cli/dgw/mod.rs index 85e2b02bcd..b7dfa74cd2 100644 --- a/testsuite/tests/cli/dgw/mod.rs +++ b/testsuite/tests/cli/dgw/mod.rs @@ -1,6 +1,7 @@ mod ai_analysis; mod benign_disconnect; mod cli_args; +mod diagnostics; mod heartbeat; mod jrec_push; mod kdc_proxy;