Skip to content

feat(diagnostics): list and retrieve retained log files - #2185

Merged
Marc-André Moreau (mamoreau-devolutions) merged 6 commits into
masterfrom
feature/DVLS-15665-gateway-log-files
Nov 9, 2026
Merged

Marc-André Moreau (mamoreau-devolutions) merged 6 commits into
masterfrom
feature/DVLS-15665-gateway-log-files

Conversation

@MederickDevo

Copy link
Copy Markdown
Contributor

Summary

  • Add GET /jet/diagnostics/logs/files to list retained Gateway log files with size and modification time.
  • Add GET /jet/diagnostics/logs/files/{name} to retrieve a listed file, restricted to the configured log directory and Gateway log prefix.
  • Document both endpoints in OpenAPI. Both require gateway.diagnostics.read.

Jira: https://devolutions.atlassian.net/browse/DVLS-15665

Validation

  • cargo fmt --all -- --check
  • cargo check -p devolutions-gateway --features openapi
  • cargo test -p devolutions-log --lib (passes; this crate currently has zero library tests)

Copilot AI balanced review requested due to automatic review settings November 4, 2026 20:56
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Let maintainers know that an action is required on their side

  • Add the label release-required Please cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module) when you request a maintainer to cut a new release (Devolutions Gateway, Devolutions Agent, Jetsocat, PowerShell module)

  • Add the label release-blocker Follow-up is required before cutting a new release if a follow-up is required before cutting a new release

  • Add the label publish-required Please publish libraries (`Devolutions.Gateway.Utils`, OpenAPI clients, etc) when you request a maintainer to publish libraries (Devolutions.Gateway.Utils, OpenAPI clients, etc.)

  • Add the label publish-blocker Follow-up is required before publishing libraries if a follow-up is required before publishing libraries

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical check-to-open race in log retrieval allows a local process with write access to the log directory to substitute a symlink and expose files outside that directory using the Gateway service’s permissions.

1 open issue
What changed in this PR

Adds retained-log discovery and download endpoints to Gateway diagnostics, both requiring gateway.diagnostics.read.

Changes:

  • Lists retained log files with their names, sizes, and modification times.
  • Streams a selected log file by its listed name.
  • Registers both endpoints and their response schema in OpenAPI.
File Description
devolutions-gateway/​src/​openapi.rs Registers the new diagnostics operations and schema.
devolutions-gateway/​src/​api/​diagnostics.rs Adds authenticated log listing and download handlers.
devolutions-gateway/​openapi/​gateway-api.yaml Documents both endpoints and log-file metadata.
crates/​devolutions-log/​src/​lib.rs Enumerates matching regular log files and sorts them by modification time.

Review effort: Balanced

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of things that go against our AGENTS.md rules, details inline.


LLM Wrote, Unaudited — Read With a Grain of Salt.

mod tests {
use super::open_regular_log_file;

// oracle: regression Devolutions/devolutions-gateway#2185 review comment 4473309953.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regression has to point at a bug report (Jira key, GitHub issue) or a fuzz crash file per test-oracle-reviewer. A review comment on this PR isn't one, and AGENTS.md says committed text states rationale, not history. Same for line 350, and that second test isn't really a regression test anyway.


LLM Wrote, Unaudited — Read With a Grain of Salt.

options.read(true);

#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing tests that O_NONBLOCK keeps a FIFO from hanging the open, so per AGENTS.md (Untested-property comments) this needs an UNTESTED: comment saying why it's there and what to check when touching it.


LLM Wrote, Unaudited — Read With a Grain of Salt.

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() == Some(1314) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When symlink creation fails with 1314 (ERROR_PRIVILEGE_NOT_HELD) the test returns and passes without asserting anything, so the Windows reparse-point check is never exercised there. Either make sure it runs somewhere or mark that check UNTESTED: per AGENTS.md. Named constant instead of 1314 would be nice too.


LLM Wrote, Unaudited — Read With a Grain of Salt.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more, mostly non-blocking.

note: ListLogFiles and GetLogFile aren't in the generated clients yet (openapi/dotnet-client etc.). Fine to leave it for a chore(openapi) follow-up, unless DVLS needs Devolutions.Gateway.Client for this.


LLM Wrote, Unaudited — Read With a Grain of Salt.

.map_err(HttpError::internal().err())?;
let file = files
.into_iter()
.find(|file| file.name == name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Nice, only serving an exact name from the listing means ../whatever just can't match, no path sanitizing needed. And checking the opened handle instead of the path closes the swap race properly 👍


LLM Wrote, Unaudited — Read With a Grain of Salt.

continue;
};

if !name.starts_with(cfg.prefix) || !name.ends_with(".log") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: find_latest_log_file uses contains("log") while this uses ends_with(".log"), so /logs and /logs/files can disagree on what a log file is. Also get_logs still does a plain File::open that follows symlinks. Could get_logs just take the first entry of list_log_files and go through open_regular_log_file? Then we have one definition and the same hardening on both. Can be a follow up!


LLM Wrote, Unaudited — Read With a Grain of Salt.

if !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"log path is not a regular file",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: InvalidData ends up as a 500 here, and the OpenAPI doc says 500 is Failed to read log file. A symlink or non-regular file isn't really a server failure, from the caller's point of view it's not a log file we serve. What about mapping it to 404 like the not-listed case? Same for the reparse point one below.


LLM Wrote, Unaudited — Read With a Grain of Salt.

Comment thread crates/devolutions-log/src/lib.rs Outdated
right
.modified
.cmp(&left.modified)
.then_with(|| right.name.cmp(&left.name))

@irvingoujAtDevolution irvingouj@Devolutions (irvingoujAtDevolution) Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): the tie-break compares names as strings, so in descending order gateway.2026-11-09.9.log comes before gateway.2026-11-09.10.log even though 10 is newer. Only matters when mtimes are equal, so feel free to ignore.


LLM Wrote, Unaudited — Read With a Grain of Salt.

modified_unix_millis: file
.modified
.duration_since(UNIX_EPOCH)
.unwrap_or_default()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unwrap_or_default() and unwrap_or(u64::MAX) silently turn odd timestamps into 0 / max. ClockDiagnostic right above uses i64 millis from the time crate, I would rather stay consistent with that (OffsetDateTime::from(file.modified)), which also removes both fallbacks.


LLM Wrote, Unaudited — Read With a Grain of Salt.

const LOG_FILE_PREFIX: &'static str;
}

pub struct LogFile {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing a doc comment on this new public type. Also the rest of this crate's API speaks Utf8Path, while path here is a std::path::PathBuf. Not a blocker.


LLM Wrote, Unaudited — Read With a Grain of Salt.

The latest-log route could select files outside the retained log filter and follow a swapped symlink. Share retained-file selection and guarded opening across both routes, return 404 for unavailable files, preserve signed modification times, and order numeric rotations correctly when timestamps match.

Issue: DVLS-15665
))]
async fn get_log_file(
State(DgwState { conf_handle, .. }): State<DgwState>,
Path(name): Path<String>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you validate the diagnostics scope before extracting the path? An invalid UTF-8 path segment (for example %FF) can fail extraction with 400 before the scope check, and that 400 is also missing from the OpenAPI responses. Please move the scope extractor before the path extractor and document the 400 response if it remains possible.

Note

Human-tuned, LLM-assisted content.

),
security(("scope_token" = ["gateway.diagnostics.read"])),
))]
async fn list_log_files(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add tests for the HTTP endpoints themselves? The new tests cover log-file selection and opening, but do not exercise either route's response body/status or diagnostics-scope enforcement. A route-level test would catch regressions in the listing/download contract and access control.

Note

Human-tuned, LLM-assisted content.

(status = 200, description = "Log file contents", body = String, content_type = "text/plain"),
(status = 401, description = "Invalid or missing authorization token"),
(status = 403, description = "Insufficient permissions"),
(status = 404, description = "Log file is no longer available"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you change this description to Log file not found? The endpoint returns 404 both when a requested name is not in the retained-file list and when a listed file disappears before it is opened, so no longer available describes only one case.

Note

Human-tuned, LLM-assisted content.

@MederickDevo

Copy link
Copy Markdown
Contributor Author

UNTESTED: properties in the current change:

  • On Unix, O_NONBLOCK must keep diagnostics responsive if a FIFO replaces a listed log file before it is opened. The current tests do not perform a FIFO replacement. Exercise that race before changing the open flags.
  • On Windows, a reparse-point file must not be streamed. The symlink test can skip when the host lacks symlink privilege, so it does not prove this property on every test host. Exercise a file symlink on a Windows host that can create one before changing the reparse-point check.

Malformed log names could fail extraction before diagnostics scope validation. Check the scope first, document the possible bad request and both not-found cases, and exercise the list and download contract through the shipped Gateway HTTP routes.

Issue: DVLS-15665

Security: yes
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 3a21b56 into master Nov 9, 2026
53 checks passed
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) deleted the feature/DVLS-15665-gateway-log-files branch November 9, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants