Repository navigation
feat(diagnostics): list and retrieve retained log files - #2185
Marc-André Moreau (mamoreau-devolutions) merged 6 commits into
Conversation
Issue: DVLS-15665
Let maintainers know that an action is required on their side
|
There was a problem hiding this comment.
🟡 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
Issue: DVLS-15665 Security: yes
Issue: DVLS-15665 Changelog: ignore
Issue: DVLS-15665
irvingouj@Devolutions (irvingoujAtDevolution)
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
irvingouj@Devolutions (irvingoujAtDevolution)
left a comment
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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") { |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
| right | ||
| .modified | ||
| .cmp(&left.modified) | ||
| .then_with(|| right.name.cmp(&left.name)) |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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"), |
There was a problem hiding this comment.
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.
|
|
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
3a21b56
into
master

Summary
GET /jet/diagnostics/logs/filesto list retained Gateway log files with size and modification time.GET /jet/diagnostics/logs/files/{name}to retrieve a listed file, restricted to the configured log directory and Gateway log prefix.gateway.diagnostics.read.Jira: https://devolutions.atlassian.net/browse/DVLS-15665
Validation
cargo fmt --all -- --checkcargo check -p devolutions-gateway --features openapicargo test -p devolutions-log --lib(passes; this crate currently has zero library tests)