CH-J Toolsgestion de serveur sur SSH
Menu
Source publiée

CH-J Tools

Parcourez les répertoires et les fichiers pour une version spécifique de l'application.

Télécharger la source ZIP
CH-J / Server Manager 1704 fichiers
0.1.2-beta Ajouté: · Construit:
privileged.rs Ajouté: Date non enregistrée 30,4 KB · 655 lignes
Code Télécharger le fichier
1//! Shared privileged executor (port of `sessions/privilegedExec.js`).
2//!
3//! Bounded privileged operations over an existing SSH session: callers pass a fixed script
4//! plus validated arguments — there is no API for arbitrary commands. The sudo password is
5//! written to stdin only after sudo prints our prompt marker; it is never stored on disk,
6//! logged or placed in argv. Payload frames are sent only after the script's READY marker.
8use crate::core::error::{CoreError, CoreResult};
9use crate::core::matrix;
10use crate::core::ssh::exec::ExecEvent;
11use crate::core::ssh::platform::detect_platform;
12use crate::core::ssh::session::{shell_quote, SessionManager, SessionRecord};
13use base64::Engine;
14use once_cell::sync::Lazy;
15use parking_lot::Mutex;
16use regex::Regex;
17use russh::ChannelMsg;
18use serde_json::{json, Value};
19use std::future::Future;
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22use tokio::sync::{mpsc, Notify};
23use zeroize::Zeroizing;
25pub const SUDO_PROMPT: &str = "[chj-sudo-auth]";
26const STREAM_MARKER: &str = "\u{1e}CHJ_STREAM\n";
27pub const READY_MARKER: &str = "\x1eCHJ_READY\n";
28static ERROR_MARKER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_ERR=([A-Z0-9_]{2,64})\r?\n").unwrap());
29static FRAME_TAG: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Z]{2,16}$").unwrap());
30const MAX_PASSWORD_LENGTH: usize = 1024;
31pub const SUDO_CACHE_IDLE: Duration = Duration::from_secs(10 * 60);
33pub fn sudo_message(code: &str) -> &'static str {
34 match code {
35 "SUDO_PASSWORD_REQUIRED" => "This operation requires the sudo password of the SSH account.",
36 "SUDO_PASSWORD_INCORRECT" => "The sudo password was rejected.",
37 "SUDO_NOT_PERMITTED" => "The SSH account is not permitted to run this operation with sudo.",
38 "SUDO_TTY_REQUIRED" => "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.",
39 "SUDO_AUTH_FAILED" => "sudo authentication failed or the account is locked.",
40 "SUDO_UNAVAILABLE" => "sudo is not installed and the SSH account is not root.",
41 _ => "sudo did not start the operation.",
42 }
45pub fn normalize_password(value: Option<&str>) -> CoreResult<String> {
46 let password = value.unwrap_or("");
47 if password.chars().count() > MAX_PASSWORD_LENGTH || password.contains(['\0', '\r', '\n']) {
48 return Err(CoreError::new("SUDO_PASSWORD_INVALID", "Invalid sudo password."));
49 }
50 Ok(password.to_string())
53pub fn clean_remote_message(text: &str, limit: usize) -> String {
54 let without_prompt = text.split(SUDO_PROMPT).collect::<String>();
55 let cleaned: String = without_prompt.chars().map(|c| if (c as u32) < 0x20 && c != '\n' && c != '\t' || c as u32 == 0x7f { ' ' } else { c }).collect();
56 let lines: Vec<&str> = cleaned.split('\n').map(str::trim).filter(|l| !l.is_empty()).collect();
57 let tail = &lines[lines.len().saturating_sub(6)..];
58 tail.join(" | ").chars().take(limit).collect()
61pub fn classify_sudo_failure(stderr: &str, code: Option<i64>, auth_failure: Option<&str>) -> String {
62 if let Some(failure) = auth_failure {
63 return failure.to_string();
64 }
65 let test = |pattern: &str| Regex::new(pattern).map(|r| r.is_match(stderr)).unwrap_or(false);
66 if test(r"(?i)incorrect password attempt|Sorry, try again") {
67 "SUDO_PASSWORD_INCORRECT"
68 } else if test(r"(?i)is not in the sudoers file|is not allowed to run sudo|may not run sudo|not allowed to execute|is not allowed to run") {
69 "SUDO_NOT_PERMITTED"
70 } else if test(r"(?i)must have a tty|a terminal is required|no tty present") {
71 "SUDO_TTY_REQUIRED"
72 } else if test(r"(?i)a password is required|no password was provided") {
73 "SUDO_PASSWORD_REQUIRED"
74 } else if test(r"(?i)account.*(locked|expired)|authentication (failure|token)|maximum number of tries") {
75 "SUDO_AUTH_FAILED"
76 } else if code == Some(127) || test(r"(?i)sudo: (command )?not found") {
77 "SUDO_UNAVAILABLE"
78 } else {
79 "SUDO_FAILED"
80 }
81 .to_string()
84fn parse_key_values(text: &str) -> std::collections::HashMap<String, String> {
85 text.lines().filter_map(|l| l.split_once('=').filter(|(k, _)| !k.is_empty()).map(|(k, v)| (k.to_string(), v.to_string()))).collect()
88fn public_privilege(p: &Value) -> Value {
89 json!({
90 "mode": p["mode"], "verified": p["verified"] == true, "username": p["username"],
91 "sudoInstalled": p["sudoInstalled"] == true, "groups": p["groups"], "clientAddress": p["clientAddress"],
92 "message": p["message"], "checkedAt": p["checkedAt"]
93 })
96pub struct Frame {
97 pub tag: String,
98 pub data: Vec<u8>,
101impl Frame {
102 pub fn text(tag: &str, data: &str) -> Self {
103 Self { tag: tag.into(), data: data.as_bytes().to_vec() }
104 }
107pub struct RunOptions {
108 /// Matrix operation; it supplies the script and the leading arguments.
109 pub operation: String,
110 pub args: Vec<String>,
111 pub frames: Vec<Frame>,
112 pub privileged: bool,
113 pub sudo_password: Option<String>,
114 pub remember_password: bool,
115 pub use_cached_password: bool,
116 pub purpose: String,
117 pub error_prefix: String,
118 pub timeout: Duration,
119 pub output_limit: usize,
122impl Default for RunOptions {
123 fn default() -> Self {
124 Self {
125 operation: String::new(),
126 args: Vec::new(),
127 frames: Vec::new(),
128 privileged: true,
129 sudo_password: None,
130 remember_password: false,
131 use_cached_password: false,
132 purpose: String::new(),
133 error_prefix: "REMOTE_".into(),
134 timeout: Duration::from_secs(60),
135 output_limit: 4 * 1024 * 1024,
136 }
137 }
140#[derive(Debug, Clone)]
141pub struct RunResult {
142 pub code: Option<i64>,
143 pub stdout: String,
144 pub stderr: String,
145 pub privileged: bool,
148// ---------------------------------------------------------------------------------------------
149// Interaction: wait for markers in stdout, answer on stdin.
150// ---------------------------------------------------------------------------------------------
152struct InteractionShared {
153 stdout: Mutex<String>,
154 consumed: Mutex<usize>,
155 done: Mutex<bool>,
156 notify: Notify,
159enum InteractionCommand {
160 Send(String),
161 End,
164#[derive(Clone)]
165pub struct Interaction {
166 shared: Arc<InteractionShared>,
167 commands: mpsc::UnboundedSender<InteractionCommand>,
170pub struct Matched {
171 pub groups: Vec<Option<String>>,
172 pub before: String,
175impl Interaction {
176 pub async fn wait_for(&self, pattern: &Regex, timeout: Duration) -> CoreResult<Matched> {
177 let deadline = tokio::time::Instant::now() + timeout;
178 loop {
179 let notified = self.shared.notify.notified();
180 {
181 let stdout = self.shared.stdout.lock();
182 let mut consumed = self.shared.consumed.lock();
183 let text = &stdout[(*consumed).min(stdout.len())..];
184 if let Some(caps) = pattern.captures(text) {
185 let whole = caps.get(0).expect("match");
186 let matched = Matched { groups: caps.iter().map(|g| g.map(|m| m.as_str().to_string())).collect(), before: text[..whole.start()].to_string() };
187 *consumed += whole.end();
188 return Ok(matched);
189 }
190 if *self.shared.done.lock() {
191 return Err(CoreError::new("PROTOCOL_ERROR", "The remote operation ended unexpectedly."));
192 }
193 }
194 if tokio::time::timeout_at(deadline, notified).await.is_err() {
195 return Err(CoreError::new("REMOTE_COMMAND_TIMEOUT", "The remote operation did not respond in time."));
196 }
197 }
198 }
200 pub fn send(&self, line: &str) {
201 let _ = self.commands.send(InteractionCommand::Send(format!("{line}\n")));
202 }
204 pub fn end(&self) {
205 let _ = self.commands.send(InteractionCommand::End);
206 }
209#[derive(Debug)]
210pub enum StreamEvent {
211 Out(Vec<u8>),
212 Err(Vec<u8>),
213 Exit(i64),
214 /// sudo asked again: the password was wrong.
215 BadPassword,
216 Closed,
219pub struct RemoteStream {
220 read: russh::ChannelReadHalf,
221 pub write: Arc<russh::ChannelWriteHalf<russh::client::Msg>>,
222 first: Vec<StreamEvent>,
225// A server allows only a few channels per connection (MaxSessions), so an ended stream gives its channel back.
226impl Drop for RemoteStream {
227 fn drop(&mut self) {
228 let write = self.write.clone();
229 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
230 runtime.spawn(async move {
231 let _ = write.close().await;
232 });
233 }
234 }
237impl RemoteStream {
238 pub async fn next(&mut self) -> StreamEvent {
239 if !self.first.is_empty() {
240 return self.first.remove(0);
241 }
242 loop {
243 match self.read.wait().await {
244 Some(ChannelMsg::Data { data }) => return StreamEvent::Out(data.to_vec()),
245 Some(ChannelMsg::ExtendedData { data, ext: 1 }) => {
246 let text = String::from_utf8_lossy(&data);
247 if text.contains(SUDO_PROMPT) {
248 return StreamEvent::BadPassword;
249 }
250 if text.contains(STREAM_MARKER) {
251 let rest = text.replace(STREAM_MARKER, "");
252 if rest.trim().is_empty() {
253 continue;
254 }
255 return StreamEvent::Err(rest.into_bytes());
256 }
257 return StreamEvent::Err(data.to_vec());
258 }
259 Some(ChannelMsg::ExitStatus { exit_status }) => return StreamEvent::Exit(exit_status as i64),
260 Some(ChannelMsg::ExitSignal { .. }) => return StreamEvent::Exit(255),
261 Some(ChannelMsg::Close) | None => return StreamEvent::Closed,
262 Some(_) => {}
263 }
264 }
265 }
268pub struct PrivilegedExecutor {
269 sessions: Arc<SessionManager>,
272impl PrivilegedExecutor {
273 pub fn new(sessions: Arc<SessionManager>) -> Self {
274 Self { sessions }
275 }
277 pub fn sessions(&self) -> &Arc<SessionManager> {
278 &self.sessions
279 }
281 /// root / sudo-nopasswd / sudo-password (unverified) / none; cached per live session.
282 pub async fn probe(&self, session_id: &str, refresh: bool) -> CoreResult<Value> {
283 let record = self.sessions.require_connected(session_id)?;
284 if !refresh {
285 if let Some(p) = record.privilege.lock().clone() {
286 return Ok(public_privilege(&p));
287 }
288 }
289 let channel = self.sessions.exec_channel(&record, &matrix::shell(matrix::script("privilege_probe.sh")), Duration::from_secs(15), 512 * 1024).await?;
290 let mut channel = channel;
291 channel.eof().await;
292 let result = channel.finish().await?;
293 let values = parse_key_values(&result.stdout);
294 let get = |k: &str| values.get(k).cloned().unwrap_or_default();
295 let sudo_message = get("sudoMessage");
296 let mut privilege = json!({
297 "username": if get("user").is_empty() { record.username.clone() } else { get("user") },
298 "sudoInstalled": get("sudoInstalled") == "1",
299 "groups": get("groups").split(',').filter(|s| !s.is_empty()).take(256).collect::<Vec<_>>(),
300 "clientAddress": get("client").trim().chars().take(64).collect::<String>(),
301 "message": clean_remote_message(&sudo_message, 300),
302 "checkedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
303 "mode": "unknown",
304 "verified": false
305 });
306 let set = |p: &mut Value, mode: &str, verified: bool| {
307 p["mode"] = json!(mode);
308 p["verified"] = json!(verified);
309 };
310 if get("uid") == "0" {
311 set(&mut privilege, "root", true);
312 } else if get("sudoInstalled") != "1" {
313 set(&mut privilege, "none", true);
314 } else if get("sudoNonInteractive") == "0" {
315 set(&mut privilege, "sudo-nopasswd", true);
316 } else if Regex::new(r"(?i)password is required").unwrap().is_match(&sudo_message) {
317 privilege["mode"] = json!("sudo-password");
318 } else if Regex::new(r"(?i)not allowed|may not run sudo|not in the sudoers").unwrap().is_match(&sudo_message) {
319 set(&mut privilege, "none", true);
320 }
321 *record.privilege.lock() = Some(privilege.clone());
322 Ok(public_privilege(&privilege))
323 }
325 pub fn cached_password(&self, record: &SessionRecord) -> Option<Zeroizing<String>> {
326 let mut cache = record.sudo_cache.lock();
327 match cache.as_mut() {
328 Some((password, expires)) if *expires > Instant::now() && record.is_connected() => {
329 *expires = Instant::now() + SUDO_CACHE_IDLE;
330 Some(password.clone())
331 }
332 _ => {
333 *cache = None;
334 None
335 }
336 }
337 }
339 pub fn has_cached_password(&self, session_id: &str) -> bool {
340 self.sessions.get(session_id).map(|r| self.cached_password(&r).is_some()).unwrap_or(false)
341 }
343 pub fn forget_password(&self, session_id: &str) {
344 if let Some(record) = self.sessions.get(session_id) {
345 *record.sudo_cache.lock() = None;
346 }
347 }
349 fn remember_failure(&self, record: &SessionRecord, code: &str) {
350 let mut guard = record.privilege.lock();
351 let Some(p) = guard.as_mut() else { return };
352 match code {
353 "SUDO_NOT_PERMITTED" => {
354 p["mode"] = json!("none");
355 p["verified"] = json!(true);
356 }
357 "SUDO_UNAVAILABLE" => {
358 p["mode"] = json!("none");
359 p["verified"] = json!(true);
360 p["sudoInstalled"] = json!(false);
361 }
362 "SUDO_PASSWORD_REQUIRED" | "SUDO_PASSWORD_INCORRECT" if p["mode"] != "sudo-password" => p["mode"] = json!("sudo-password"),
363 _ => {}
364 }
365 }
367 pub async fn run(&self, session_id: &str, options: RunOptions) -> CoreResult<RunResult> {
368 self.run_interactive(session_id, options, None::<fn(Interaction) -> std::future::Ready<CoreResult<Value>>>).await.map(|(result, _)| result)
369 }
371 /// One operation over a new login, for when a change has just cut the running session. `sudo` is `None` for root.
372 pub async fn fresh_operation(&self, target: &crate::core::ssh::session::ConnectionTarget, credentials: &Value, os: &str, operation: &str, args: &[String], sudo: Option<&str>, timeout: Duration) -> CoreResult<Value> {
373 let binding = matrix::binding(operation, os)?;
374 let body = format!("sh -c {} chj {}", shell_quote(binding.script), binding.argv.iter().chain(args).map(|a| shell_quote(a)).collect::<Vec<_>>().join(" "));
375 let (command, input) = match sudo {
376 Some(password) => (format!("LC_ALL=C sudo -S -p '' -- {body}"), format!("{password}\n")),
377 None => (format!("LC_ALL=C {body}"), String::new()),
378 };
379 self.sessions.fresh_exec(target, credentials, &command, &input, timeout, true).await
380 }
382 /// Opens an operation as a byte stream, for rsync's own protocol. Before anything else the stream carries sudo's
383 /// prompt, answered here only if it really comes: with NOPASSWD a password would land in the data.
384 pub async fn stream(&self, session_id: &str, operation: &str, args: &[String], privileged: bool, password: Option<&str>) -> CoreResult<RemoteStream> {
385 let record = self.sessions.require_connected(session_id)?;
386 let mut secret = Zeroizing::new(normalize_password(password)?);
387 if secret.is_empty() {
388 if let Some(cached) = self.cached_password(&record) {
389 secret = cached;
390 }
391 }
392 let use_sudo = if privileged {
393 let probe = self.probe(session_id, false).await?;
394 if probe["mode"] == "none" && probe["verified"] == true {
395 let code = if probe["sudoInstalled"] == true { "SUDO_NOT_PERMITTED" } else { "SUDO_UNAVAILABLE" };
396 return Err(CoreError::new(code, sudo_message(code)));
397 }
398 probe["mode"] != "root"
399 } else {
400 false
401 };
402 let platform = detect_platform(&self.sessions, &record, false).await?;
403 let binding = matrix::binding(operation, matrix::os_of(&platform))?;
404 let body = format!("sh -c {} chj {}", shell_quote(binding.script), binding.argv.iter().chain(args).map(|a| shell_quote(a)).collect::<Vec<_>>().join(" "));
405 let command = if use_sudo { format!("LC_ALL=C sudo -S -p {} -- {body}", shell_quote(SUDO_PROMPT)) } else { format!("LC_ALL=C {body}") };
406 let handle = record.handle()?;
407 let channel = handle.channel_open_session().await.map_err(|e| CoreError::new("REMOTE_COMMAND_FAILED", e.to_string()))?;
408 channel.exec(true, command.as_bytes().to_vec()).await.map_err(|e| CoreError::new("REMOTE_COMMAND_FAILED", e.to_string()))?;
409 let (read, write) = channel.split();
410 let mut stream = RemoteStream { read, write: Arc::new(write), first: Vec::new() };
411 if use_sudo {
412 let mut stderr = String::new();
413 let mut prompts = 0;
414 loop {
415 let message = tokio::time::timeout(Duration::from_secs(30), stream.read.wait()).await.map_err(|_| CoreError::new("SUDO_TIMEOUT", sudo_message("SUDO_TIMEOUT")))?;
416 match message {
417 // The two output streams of a channel need not arrive in the order they were written.
418 Some(ChannelMsg::Data { data }) => stream.first.push(StreamEvent::Out(data.to_vec())),
419 Some(ChannelMsg::ExtendedData { data, ext: 1 }) => {
420 stderr.push_str(&String::from_utf8_lossy(&data));
421 if stderr.contains(STREAM_MARKER) {
422 let rest = stderr.replace(STREAM_MARKER, "").replace(SUDO_PROMPT, "");
423 if !rest.trim().is_empty() {
424 stream.first.push(StreamEvent::Err(rest.into_bytes()));
425 }
426 break;
427 }
428 let seen = stderr.matches(SUDO_PROMPT).count();
429 if seen > prompts {
430 prompts = seen;
431 if prompts > 1 {
432 let _ = stream.write.close().await;
433 return Err(CoreError::new("SUDO_PASSWORD_INCORRECT", sudo_message("SUDO_PASSWORD_INCORRECT")));
434 }
435 if secret.is_empty() {
436 let _ = stream.write.close().await;
437 return Err(CoreError::new("SUDO_PASSWORD_REQUIRED", sudo_message("SUDO_PASSWORD_REQUIRED")));
438 }
439 stream.write.data_bytes(format!("{}\n", secret.as_str()).into_bytes()).await.map_err(|e| CoreError::new("REMOTE_COMMAND_FAILED", e.to_string()))?;
440 }
441 }
442 Some(ChannelMsg::ExitStatus { exit_status }) => {
443 let code = classify_sudo_failure(&stderr, Some(exit_status as i64), None);
444 return Err(CoreError::new(&code, sudo_message(&code)));
445 }
446 Some(ChannelMsg::Close) | None => return Err(CoreError::new("REMOTE_COMMAND_FAILED", clean_remote_message(&stderr, 500))),
447 Some(_) => {}
448 }
449 }
450 }
451 Ok(stream)
452 }
454 /// Runs a fixed script; `interact` (if any) drives a stdin line protocol after READY.
455 pub async fn run_interactive<F, Fut>(&self, session_id: &str, options: RunOptions, interact: Option<F>) -> CoreResult<(RunResult, Option<Value>)>
456 where
457 F: FnOnce(Interaction) -> Fut,
458 Fut: Future<Output = CoreResult<Value>> + Send,
459 {
460 let record = self.sessions.require_connected(session_id)?;
461 let mut password = Zeroizing::new(normalize_password(options.sudo_password.as_deref())?);
462 let mut used_cache = false;
463 if password.is_empty() && options.use_cached_password {
464 if let Some(cached) = self.cached_password(&record) {
465 password = cached;
466 used_cache = true;
467 }
468 }
469 let privilege = if options.privileged { Some(self.probe(session_id, false).await?) } else { None };
470 if let Some(p) = &privilege {
471 if p["mode"] == "none" && p["verified"] == true {
472 let code = if p["sudoInstalled"] == true { "SUDO_NOT_PERMITTED" } else { "SUDO_UNAVAILABLE" };
473 return Err(CoreError::new(code, sudo_message(code)));
474 }
475 }
476 let use_sudo = privilege.as_ref().map(|p| p["mode"] != "root").unwrap_or(false);
477 let platform = detect_platform(&self.sessions, &record, false).await?;
478 let binding = matrix::binding(&options.operation, matrix::os_of(&platform))?;
479 let body = format!("sh -c {} chj {}", shell_quote(binding.script), binding.argv.iter().chain(&options.args).map(|a| shell_quote(a)).collect::<Vec<_>>().join(" "));
480 let command = if use_sudo { format!("LC_ALL=C sudo -S -p {} -- {body}", shell_quote(SUDO_PROMPT)) } else { format!("LC_ALL=C {body}") };
481 let mut frames = Vec::new();
482 for frame in &options.frames {
483 if !FRAME_TAG.is_match(&frame.tag) {
484 return Err(CoreError::new("PROTOCOL_ERROR", "Invalid frame tag."));
485 }
486 frames.push(format!("CHJ{}:{}\n", frame.tag, base64::engine::general_purpose::STANDARD.encode(&frame.data)));
487 }
488 let mut channel = self.sessions.exec_channel(&record, &command, options.timeout, options.output_limit).await?;
489 let shared = Arc::new(InteractionShared { stdout: Mutex::new(String::new()), consumed: Mutex::new(0), done: Mutex::new(false), notify: Notify::new() });
490 let (command_tx, mut command_rx) = mpsc::unbounded_channel();
491 let mut answered = 0usize;
492 let mut auth_failure: Option<&'static str> = None;
493 let mut started = false;
494 let mut interact = interact;
495 let mut pending: Option<std::pin::Pin<Box<dyn Future<Output = CoreResult<Value>> + Send>>> = None;
496 let mut interaction_result: Option<CoreResult<Value>> = None;
497 let mut channel_done = false;
498 // Drive the channel and the optional interaction concurrently on this task.
499 while !channel_done || pending.is_some() {
500 tokio::select! {
501 event = channel.next(), if !channel_done => {
502 let event = event?;
503 if use_sudo && !started {
504 let prompts = channel.stderr.matches(SUDO_PROMPT).count();
505 if prompts > answered {
506 if answered == 0 && !password.is_empty() {
507 answered = 1;
508 channel.write(format!("{}\n", password.as_str()).as_bytes()).await;
509 } else {
510 answered = prompts;
511 auth_failure = Some(if password.is_empty() { "SUDO_PASSWORD_REQUIRED" } else { "SUDO_PASSWORD_INCORRECT" });
512 channel.eof().await;
513 channel.close().await;
514 break;
515 }
516 }
517 }
518 if !started && channel.stdout.contains(READY_MARKER) {
519 started = true;
520 for frame in &frames {
521 channel.write(frame.as_bytes()).await;
522 }
523 *shared.consumed.lock() = channel.stdout.find(READY_MARKER).map(|i| i + READY_MARKER.len()).unwrap_or(0);
524 match interact.take() {
525 Some(callback) => pending = Some(Box::pin(callback(Interaction { shared: shared.clone(), commands: command_tx.clone() }))),
526 None => channel.eof().await,
527 }
528 }
529 *shared.stdout.lock() = channel.stdout.clone();
530 if event == ExecEvent::Done {
531 channel_done = true;
532 *shared.done.lock() = true;
533 }
534 shared.notify.notify_waiters();
535 }
536 command = command_rx.recv() => match command {
537 Some(InteractionCommand::Send(line)) => channel.write(line.as_bytes()).await,
538 Some(InteractionCommand::End) | None => channel.eof().await,
539 },
540 result = async { pending.as_mut().expect("pending interaction").await }, if pending.is_some() => {
541 pending = None;
542 if result.is_err() {
543 // Any failure closes stdin; the remote script treats EOF as "roll back".
544 channel.eof().await;
545 }
546 interaction_result = Some(result);
547 }
548 }
549 }
550 let stderr_raw = channel.stderr.clone();
551 let stdout_raw = channel.stdout.clone();
552 let exit = if auth_failure.is_some() { None } else { channel.finish().await.map(|r| r.code).unwrap_or(None) };
553 if !started {
554 let code = classify_sudo_failure(&stderr_raw, exit, auth_failure);
555 self.remember_failure(&record, &code);
556 if code == "SUDO_PASSWORD_INCORRECT" || code == "SUDO_PASSWORD_REQUIRED" {
557 *record.sudo_cache.lock() = None;
558 }
559 self.sessions.logger.warn(
560 "Privileged remote operation was not started.",
561 json!({ "sessionId": record.session_id, "host": record.host, "purpose": options.purpose, "code": code }),
562 );
563 return Err(CoreError::new(code.clone(), sudo_message(&code)).with("remoteMessage", clean_remote_message(&stderr_raw, 600)));
564 }
565 if use_sudo {
566 if let Some(p) = record.privilege.lock().as_mut() {
567 let mode = if (!password.is_empty() && answered > 0) || p["mode"] == "sudo-password" { "sudo-password" } else { "sudo-nopasswd" };
568 p["verified"] = json!(true);
569 p["mode"] = json!(mode);
570 }
571 if !password.is_empty() && answered > 0 && (options.remember_password || used_cache) {
572 *record.sudo_cache.lock() = Some((password.clone(), Instant::now() + SUDO_CACHE_IDLE));
573 }
574 }
575 let after_ready = stdout_raw.find(READY_MARKER).map(|i| &stdout_raw[i + READY_MARKER.len()..]).unwrap_or("").to_string();
576 if let Some(caps) = ERROR_MARKER.captures(&after_ready) {
577 let cleaned = clean_remote_message(&stderr_raw, 600);
578 return Err(CoreError::new(format!("{}{}", options.error_prefix, &caps[1]), if cleaned.is_empty() { caps[1].to_string() } else { cleaned })
579 .with("remoteExitCode", exit.map(Value::from).unwrap_or(Value::Null)));
580 }
581 let interaction = match interaction_result {
582 Some(Ok(value)) => Some(value),
583 Some(Err(error)) => return Err(error),
584 None => None,
585 };
586 Ok((RunResult { code: exit, stdout: after_ready, stderr: clean_remote_message(&stderr_raw, 600), privileged: options.privileged }, interaction))
587 }
590#[cfg(test)]
591mod tests {
592 use super::*;
594 #[test]
595 fn sudo_failure_classification() {
596 assert_eq!(classify_sudo_failure("Sorry, try again.", Some(1), None), "SUDO_PASSWORD_INCORRECT");
597 assert_eq!(classify_sudo_failure("bob is not in the sudoers file.", Some(1), None), "SUDO_NOT_PERMITTED");
598 assert_eq!(classify_sudo_failure("sudo: sorry, you must have a tty to run sudo", Some(1), None), "SUDO_TTY_REQUIRED");
599 assert_eq!(classify_sudo_failure("sh: sudo: not found", Some(127), None), "SUDO_UNAVAILABLE");
600 assert_eq!(classify_sudo_failure("x", Some(1), Some("SUDO_PASSWORD_REQUIRED")), "SUDO_PASSWORD_REQUIRED");
601 assert_eq!(classify_sudo_failure("other", Some(1), None), "SUDO_FAILED");
602 }
604 #[test]
605 fn remote_message_cleanup() {
606 assert_eq!(clean_remote_message("[chj-sudo-auth]line1\n\nline2\x07\n", 600), "line1 | line2");
607 assert!(normalize_password(Some("a\nb")).is_err());
608 assert_eq!(normalize_password(None).unwrap(), "");
609 }
611 fn interaction() -> (Interaction, mpsc::UnboundedReceiver<InteractionCommand>) {
612 let (commands, rx) = mpsc::unbounded_channel();
613 (Interaction { commands, shared: Arc::new(InteractionShared {
614 stdout: Mutex::new(String::new()), consumed: Mutex::new(0),
615 done: Mutex::new(false), notify: Notify::new(),
616 }) }, rx)
617 }
619 #[tokio::test]
620 async fn interaction_consumes_matches_once_and_handles_fragmented_output() {
621 let (io, _) = interaction();
622 let writer = io.clone();
623 let pattern = Regex::new("CHJ_REPLY=([0-9]+)\\n").unwrap();
624 let (matched, ()) = tokio::join!(io.wait_for(&pattern, Duration::from_secs(1)), async move {
625 *writer.shared.stdout.lock() = "banner\nCHJ_RE".into();
626 writer.shared.notify.notify_waiters();
627 tokio::task::yield_now().await;
628 writer.shared.stdout.lock().push_str("PLY=42\n");
629 writer.shared.notify.notify_waiters();
630 });
631 let matched = matched.unwrap();
632 assert_eq!(matched.before, "banner\n");
633 assert_eq!(matched.groups[1].as_deref(), Some("42"));
634 assert_eq!(io.wait_for(&Regex::new("CHJ_REPLY").unwrap(), Duration::ZERO).await.err().unwrap().code, "REMOTE_COMMAND_TIMEOUT");
635 io.shared.stdout.lock().push_str("CHJ_REPLY=43\n");
636 let next = io.wait_for(&Regex::new("CHJ_REPLY=([0-9]+)").unwrap(), Duration::ZERO).await.unwrap();
637 assert_eq!(next.groups[1].as_deref(), Some("43"));
638 }
640 #[tokio::test]
641 async fn interaction_eof_settles_waiter_and_send_end_have_exact_framing() {
642 let (io, mut commands) = interaction();
643 io.send("COMMIT"); io.end();
644 assert!(matches!(commands.recv().await, Some(InteractionCommand::Send(s)) if s == "COMMIT\n"));
645 assert!(matches!(commands.recv().await, Some(InteractionCommand::End)));
646 let writer = io.clone();
647 let pattern = Regex::new("NEVER").unwrap();
648 let (result, ()) = tokio::join!(io.wait_for(&pattern, Duration::from_secs(1)), async move {
649 *writer.shared.done.lock() = true;
650 writer.shared.notify.notify_waiters();
651 });
652 assert_eq!(result.err().unwrap().code, "PROTOCOL_ERROR");
653 }
SHA-25609651dfe9b692f10fa281377c43a12b5d4ded1a4fa82a0576a569a8d313b4d56

CH-J Proprietary Software License 1.14 · La source est fournie sous CH-J Proprietary Software License 1.14. Sa disponibilité ne modifie pas les termes de la licence ni n'accorde de droits supplémentaires.

Archive SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fichiers