mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-17 10:17:55 +00:00
efa89a98ed
* refactor(logging): standardize object capacity events * refactor(logging): standardize protocol server events * refactor(logging): standardize swift protocol events * refactor(logging): standardize observability events * refactor(logging): move masking helper and extend guardrails
318 lines
14 KiB
Rust
318 lines
14 KiB
Rust
// Copyright 2024 RustFS Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Per-session liveness watchdog.
|
|
//!
|
|
//! Detects sessions that are silent at the SFTP handler layer while
|
|
//! the underlying TCP connection is in CLOSE_WAIT, and cancels them
|
|
//! so the server does not accumulate orphaned per-session resources
|
|
//! (handle table entries, in-flight multipart uploads, read caches).
|
|
//!
|
|
//! The watchdog runs one tokio task per session. Every
|
|
//! WEDGE_WATCHDOG_TICK_SECS it inspects the session's last-activity
|
|
//! stamp and the kernel TCP state for the connection. A wedged
|
|
//! session shows two coincident signals: silence past
|
|
//! WEDGE_FAST_KILL_SILENCE_SECS, and a TCP state of CLOSE_WAIT
|
|
//! (peer FIN'd, application has not closed). A healthy idle session
|
|
//! shows ESTABLISHED. Two consecutive positive ticks are required
|
|
//! before the watchdog cancels.
|
|
//!
|
|
//! The TCP-state probe lives in lifecycle::probe_tcp_state and reads
|
|
//! /proc/net/tcp[6] to look up the row matching the session's local
|
|
//! and peer addresses. CLOSE_WAIT is unambiguous, so a slow S3
|
|
//! backend operation that pipelines into a still-ESTABLISHED socket
|
|
//! cannot be misdiagnosed as a wedge.
|
|
//!
|
|
//! Compiled on Linux only. Non-Linux targets use fallback_watchdog, a
|
|
//! silence-only backstop at WEDGE_FALLBACK_KILL_SILENCE_SECS without the
|
|
//! procfs CLOSE_WAIT probe.
|
|
//!
|
|
//! Worst-case fast-kill latency on Linux is WEDGE_FAST_KILL_SILENCE_SECS
|
|
//! plus one to two ticks (approximately 45 to 60 s) from the moment a
|
|
//! session is both silent past WEDGE_FAST_KILL_SILENCE_SECS and the
|
|
//! kernel reports CLOSE_WAIT. The WEDGE_FALLBACK_KILL_SILENCE_SECS
|
|
//! ceiling is the backstop for the procfs probe when /proc/net/tcp is
|
|
//! unreadable (filesystem permissions, namespace tricks) or when the
|
|
//! wedge is in a TCP state other than CLOSE_WAIT.
|
|
//!
|
|
//! On cancel the watchdog calls shutdown(Both) on the duplicated
|
|
//! socket so russh's inner select unwedges via EOF propagation,
|
|
//! then signals the shared CancellationToken so the outer session
|
|
//! task drops the RunningSession.
|
|
|
|
use super::constants::limits::{WEDGE_FALLBACK_KILL_SILENCE_SECS, WEDGE_FAST_KILL_SILENCE_SECS, WEDGE_WATCHDOG_TICK_SECS};
|
|
use super::lifecycle::{SessionDiag, TcpState, probe_tcp_state};
|
|
use socket2::Socket;
|
|
use std::net::Shutdown;
|
|
use std::os::fd::AsFd;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
use tokio::net::TcpStream;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
|
|
const LOG_SUBSYSTEM_SFTP_WATCHDOG: &str = "sftp_watchdog";
|
|
const EVENT_SFTP_WATCHDOG_STATE: &str = "sftp_watchdog_state";
|
|
|
|
/// Reason a watchdog cancelled its session. Included in the warn log
|
|
/// the watchdog writes at cancel time so operators can correlate the
|
|
/// cancel with the upstream client behaviour.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum WedgeReason {
|
|
/// Two consecutive ticks observed silence past the fast threshold
|
|
/// AND a TCP state of CLOSE_WAIT (peer FIN'd, application has not
|
|
/// drained the SSH stream) on the second tick. The CLOSE_WAIT
|
|
/// observation on the cancelling tick is the load-bearing claim
|
|
/// in the operator log line.
|
|
TcpStateCloseWaitConfirmed,
|
|
/// Two consecutive ticks observed silence past the fast threshold
|
|
/// AND the TCP-state probe failed to return a known state on the
|
|
/// second tick (closed dup, missing /proc, kernel without procfs
|
|
/// entries). Session is not coming back and the kernel state was
|
|
/// not decisively observable when the cancel fired.
|
|
ProbeFailedConfirmed,
|
|
/// Silence past WEDGE_FALLBACK_KILL_SILENCE_SECS regardless of
|
|
/// the TCP_STATE probe result. Backstop for the case where the
|
|
/// wedge is in a state other than CLOSE_WAIT and probes
|
|
/// kept returning healthy or non-decisive.
|
|
FallbackSilence,
|
|
}
|
|
|
|
impl WedgeReason {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::TcpStateCloseWaitConfirmed => "tcp_state_close_wait_confirmed",
|
|
Self::ProbeFailedConfirmed => "probe_failed_confirmed",
|
|
Self::FallbackSilence => "fallback_silence",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Duplicate the TcpStream's underlying socket via the safe AsFd path
|
|
/// and wrap the result in a socket2::Socket. The dup exists solely so
|
|
/// the watchdog can call shutdown(Both) on the wedged session without
|
|
/// racing russh for the original fd. Returns None when the dup fails.
|
|
/// Callers should treat None as "no watchdog this session, accept-loop
|
|
/// continues".
|
|
pub(super) fn dup_socket(stream: &TcpStream) -> Option<Socket> {
|
|
let cloned = stream.as_fd().try_clone_to_owned().ok()?;
|
|
Some(Socket::from(cloned))
|
|
}
|
|
|
|
/// Spawn a per-session watchdog tick task.
|
|
///
|
|
/// The task owns the duplicated socket (closed on task end via
|
|
/// Socket::Drop) and a clone of the session's CancellationToken.
|
|
/// The task exits when it cancels the session itself or when the
|
|
/// outer session task cancels the token after a clean session end.
|
|
pub(super) fn spawn_for_session(session_diag: Arc<SessionDiag>, socket: Socket, cancel_token: CancellationToken) {
|
|
tokio::spawn(async move {
|
|
let session_id = session_diag.session_id;
|
|
let local = session_diag.local;
|
|
let peer = session_diag.peer;
|
|
let mut tick = tokio::time::interval(Duration::from_secs(WEDGE_WATCHDOG_TICK_SECS));
|
|
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
// First tick fires immediately. Skip it so the watchdog never
|
|
// makes a decision before one full silence window has elapsed.
|
|
tick.tick().await;
|
|
let mut wedge_suspected = false;
|
|
loop {
|
|
tokio::select! {
|
|
_ = cancel_token.cancelled() => break,
|
|
_ = tick.tick() => {
|
|
let silence_secs = silence_secs(&session_diag);
|
|
let probe = probe_tcp_state(local, peer);
|
|
let outcome = evaluate(silence_secs, probe, wedge_suspected);
|
|
match outcome {
|
|
Decision::Quiet => {
|
|
wedge_suspected = false;
|
|
}
|
|
Decision::SuspectedFirstTick => {
|
|
wedge_suspected = true;
|
|
}
|
|
Decision::Cancel(reason) => {
|
|
tracing::warn!(
|
|
event = EVENT_SFTP_WATCHDOG_STATE,
|
|
component = LOG_COMPONENT_PROTOCOLS,
|
|
subsystem = LOG_SUBSYSTEM_SFTP_WATCHDOG,
|
|
session_id,
|
|
peer = %peer,
|
|
silence_secs,
|
|
reason = reason.as_str(),
|
|
"sftp watchdog state changed",
|
|
);
|
|
cancel_token.cancel();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Shut down the duplicated socket on every exit path. The
|
|
// cancellation could come from this watchdog's own kill
|
|
// decision, from the session task after a clean session end,
|
|
// or from the listener-wide shutdown cascade. In the wedge
|
|
// and shutdown-cascade cases the russh inner task is parked
|
|
// at chan.send(...).await on a backpressured mpsc and only
|
|
// unblocks when its read or write socket fails. shutdown
|
|
// here makes the next I/O on the original fd return EOF,
|
|
// which propagates through russh-sftp and drops the mpsc
|
|
// receiver. In the clean-end case russh has already returned
|
|
// and dropped its half of the fd. This call sends a final
|
|
// FIN on the still-open dup, which the peer's stack
|
|
// tolerates.
|
|
let _ = socket.shutdown(Shutdown::Both);
|
|
});
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Decision {
|
|
/// No wedge signal this tick. Reset any suspected state.
|
|
Quiet,
|
|
/// First tick to observe silence past the fast threshold AND a
|
|
/// non-healthy probe result. Hold suspected state for one more
|
|
/// tick before deciding.
|
|
SuspectedFirstTick,
|
|
/// Cancel the session for the given reason.
|
|
Cancel(WedgeReason),
|
|
}
|
|
|
|
fn silence_secs(session_diag: &SessionDiag) -> u64 {
|
|
let now_ms = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as u64)
|
|
.unwrap_or(0);
|
|
let last_ms = session_diag.last_activity_ms.load(Ordering::Relaxed);
|
|
now_ms.saturating_sub(last_ms) / 1000
|
|
}
|
|
|
|
/// Pure decision function. Takes the silence count, the TCP-state
|
|
/// probe outcome (Some(state) for a known kernel TCP state, None for
|
|
/// probe failure), and the previous tick's suspected flag. Returns
|
|
/// the action the watchdog should take.
|
|
///
|
|
/// CLOSE_WAIT is the unambiguous wedge signature: peer FIN'd and the
|
|
/// application has not closed. Other states (ESTABLISHED, FIN_WAIT_*,
|
|
/// transient close-handshake states) are treated as not-wedge.
|
|
///
|
|
/// Probe failures (None) are treated as wedge-suspect rather than
|
|
/// healthy: a session whose probe has failed and which has been silent
|
|
/// past the fast threshold is at minimum not coming back, and the
|
|
/// fallback silence threshold is the absolute backstop.
|
|
fn evaluate(silence_secs: u64, probe: Option<TcpState>, wedge_suspected: bool) -> Decision {
|
|
if silence_secs >= WEDGE_FALLBACK_KILL_SILENCE_SECS {
|
|
return Decision::Cancel(WedgeReason::FallbackSilence);
|
|
}
|
|
if silence_secs < WEDGE_FAST_KILL_SILENCE_SECS {
|
|
return Decision::Quiet;
|
|
}
|
|
let wedge_signal = match probe {
|
|
Some(TcpState::CloseWait) => true,
|
|
Some(TcpState::Established) | Some(TcpState::Other(_)) => false,
|
|
None => true,
|
|
};
|
|
if !wedge_signal {
|
|
return Decision::Quiet;
|
|
}
|
|
if wedge_suspected {
|
|
let reason = if probe.is_none() {
|
|
WedgeReason::ProbeFailedConfirmed
|
|
} else {
|
|
WedgeReason::TcpStateCloseWaitConfirmed
|
|
};
|
|
Decision::Cancel(reason)
|
|
} else {
|
|
Decision::SuspectedFirstTick
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn silence_below_fast_threshold_is_quiet() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS - 1, Some(TcpState::Established), false);
|
|
assert_eq!(decision, Decision::Quiet);
|
|
}
|
|
|
|
#[test]
|
|
fn silence_above_fast_with_established_is_quiet() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::Established), false);
|
|
assert_eq!(decision, Decision::Quiet);
|
|
}
|
|
|
|
#[test]
|
|
fn silence_above_fast_with_transient_close_state_is_quiet() {
|
|
// FIN_WAIT_2 (0x05): the connection is in a clean close
|
|
// handshake initiated by the local side. Not a wedge.
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::Other(0x05)), false);
|
|
assert_eq!(decision, Decision::Quiet);
|
|
}
|
|
|
|
#[test]
|
|
fn silence_above_fast_with_close_wait_first_tick_is_suspected() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), false);
|
|
assert_eq!(decision, Decision::SuspectedFirstTick);
|
|
}
|
|
|
|
#[test]
|
|
fn silence_above_fast_with_close_wait_second_tick_cancels() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), true);
|
|
assert_eq!(decision, Decision::Cancel(WedgeReason::TcpStateCloseWaitConfirmed));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_failed_silence_above_fast_first_tick_is_suspected() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, false);
|
|
assert_eq!(decision, Decision::SuspectedFirstTick);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_failed_silence_above_fast_second_tick_cancels_with_probe_failed_reason() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, true);
|
|
assert_eq!(decision, Decision::Cancel(WedgeReason::ProbeFailedConfirmed));
|
|
}
|
|
|
|
#[test]
|
|
fn close_wait_first_tick_then_probe_fail_second_tick_cancels_with_probe_failed_reason() {
|
|
// The cancel reason names the second tick's probe outcome
|
|
// because that is the kernel state at the moment the cancel
|
|
// fires. CLOSE_WAIT was no longer observable when the kill
|
|
// happened, so the operator log should not claim it was.
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, None, true);
|
|
assert_eq!(decision, Decision::Cancel(WedgeReason::ProbeFailedConfirmed));
|
|
}
|
|
|
|
#[test]
|
|
fn probe_fail_first_tick_then_close_wait_second_tick_cancels_with_close_wait_reason() {
|
|
let decision = evaluate(WEDGE_FAST_KILL_SILENCE_SECS, Some(TcpState::CloseWait), true);
|
|
assert_eq!(decision, Decision::Cancel(WedgeReason::TcpStateCloseWaitConfirmed));
|
|
}
|
|
|
|
#[test]
|
|
fn silence_above_fallback_cancels_regardless_of_probe() {
|
|
let decision = evaluate(WEDGE_FALLBACK_KILL_SILENCE_SECS, Some(TcpState::Established), false);
|
|
assert_eq!(decision, Decision::Cancel(WedgeReason::FallbackSilence));
|
|
}
|
|
|
|
#[test]
|
|
fn wedge_reason_as_str_covers_all_variants() {
|
|
assert_eq!(WedgeReason::TcpStateCloseWaitConfirmed.as_str(), "tcp_state_close_wait_confirmed");
|
|
assert_eq!(WedgeReason::ProbeFailedConfirmed.as_str(), "probe_failed_confirmed");
|
|
assert_eq!(WedgeReason::FallbackSilence.as_str(), "fallback_silence");
|
|
}
|
|
}
|