Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
872 souborů
core-20261030T220507Z-61e4558d889f
Přidáno: · Sestaveno:
1
//! Shared privileged executor (port of `sessions/privilegedExec.js`).2
//!3
//! Bounded privileged operations over an existing SSH session: callers pass a fixed script4
//! plus validated arguments — there is no API for arbitrary commands. The sudo password is5
//! 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.8
use crate::core::error::{CoreError, CoreResult};9
use crate::core::ssh::exec::ExecEvent;10
use crate::core::ssh::session::{shell_quote, SessionManager, SessionRecord};11
use base64::Engine;12
use once_cell::sync::Lazy;13
use parking_lot::Mutex;14
use regex::Regex;15
use serde_json::{json, Value};16
use std::future::Future;17
use std::sync::Arc;18
use std::time::{Duration, Instant};19
use tokio::sync::{mpsc, Notify};20
use zeroize::Zeroizing;22
pub const SUDO_PROMPT: &str = "[chj-sudo-auth]";23
pub const READY_MARKER: &str = "\x1eCHJ_READY\n";24
static ERROR_MARKER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_ERR=([A-Z0-9_]{2,64})\r?\n").unwrap());25
static FRAME_TAG: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Z]{2,16}$").unwrap());26
const MAX_PASSWORD_LENGTH: usize = 1024;27
pub const SUDO_CACHE_IDLE: Duration = Duration::from_secs(10 * 60);29
pub const PROBE_SCRIPT: &str = r#"LC_ALL=C; export LC_ALL30
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH31
printf 'user=%s\nuid=%s\ngroups=%s\nclient=%s\n' "$(id -un)" "$(id -u)" "$(id -Gn 2>/dev/null | tr ' ' ',')" "${SSH_CLIENT%% *}"32
if command -v sudo >/dev/null 2>&1; then33
out=$(sudo -n -p '' true 2>&1 </dev/null); code=$?34
printf 'sudoInstalled=1\nsudoNonInteractive=%s\nsudoMessage=%s\n' "$code" "$(printf '%s' "$out" | head -n 1 | tr -d '\r\n' | cut -c1-300)"35
else36
printf 'sudoInstalled=0\n'37
fi"#;39
pub fn sudo_message(code: &str) -> &'static str {40
match code {41
"SUDO_PASSWORD_REQUIRED" => "This operation requires the sudo password of the SSH account.",42
"SUDO_PASSWORD_INCORRECT" => "The sudo password was rejected.",43
"SUDO_NOT_PERMITTED" => "The SSH account is not permitted to run this operation with sudo.",44
"SUDO_TTY_REQUIRED" => "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.",45
"SUDO_AUTH_FAILED" => "sudo authentication failed or the account is locked.",46
"SUDO_UNAVAILABLE" => "sudo is not installed and the SSH account is not root.",47
_ => "sudo did not start the operation.",48
}49
}51
pub fn normalize_password(value: Option<&str>) -> CoreResult<String> {52
let password = value.unwrap_or("");53
if password.chars().count() > MAX_PASSWORD_LENGTH || password.contains(['\0', '\r', '\n']) {54
return Err(CoreError::new("SUDO_PASSWORD_INVALID", "Invalid sudo password."));55
}56
Ok(password.to_string())57
}59
pub fn clean_remote_message(text: &str, limit: usize) -> String {60
let without_prompt = text.split(SUDO_PROMPT).collect::<String>();61
let cleaned: String = without_prompt.chars().map(|c| if (c as u32) < 0x20 && c != '\n' && c != '\t' || c as u32 == 0x7f { ' ' } else { c }).collect();62
let lines: Vec<&str> = cleaned.split('\n').map(str::trim).filter(|l| !l.is_empty()).collect();63
let tail = &lines[lines.len().saturating_sub(6)..];64
tail.join(" | ").chars().take(limit).collect()65
}67
pub fn classify_sudo_failure(stderr: &str, code: Option<i64>, auth_failure: Option<&str>) -> String {68
if let Some(failure) = auth_failure {69
return failure.to_string();70
}71
let test = |pattern: &str| Regex::new(pattern).map(|r| r.is_match(stderr)).unwrap_or(false);72
if test(r"(?i)incorrect password attempt|Sorry, try again") {73
"SUDO_PASSWORD_INCORRECT"74
} 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") {75
"SUDO_NOT_PERMITTED"76
} else if test(r"(?i)must have a tty|a terminal is required|no tty present") {77
"SUDO_TTY_REQUIRED"78
} else if test(r"(?i)a password is required|no password was provided") {79
"SUDO_PASSWORD_REQUIRED"80
} else if test(r"(?i)account.*(locked|expired)|authentication (failure|token)|maximum number of tries") {81
"SUDO_AUTH_FAILED"82
} else if code == Some(127) || test(r"(?i)sudo: (command )?not found") {83
"SUDO_UNAVAILABLE"84
} else {85
"SUDO_FAILED"86
}87
.to_string()88
}90
fn parse_key_values(text: &str) -> std::collections::HashMap<String, String> {91
text.lines().filter_map(|l| l.split_once('=').filter(|(k, _)| !k.is_empty()).map(|(k, v)| (k.to_string(), v.to_string()))).collect()92
}94
fn public_privilege(p: &Value) -> Value {95
json!({96
"mode": p["mode"], "verified": p["verified"] == true, "username": p["username"],97
"sudoInstalled": p["sudoInstalled"] == true, "groups": p["groups"], "clientAddress": p["clientAddress"],98
"message": p["message"], "checkedAt": p["checkedAt"]99
})100
}102
pub struct Frame {103
pub tag: String,104
pub data: Vec<u8>,105
}107
impl Frame {108
pub fn text(tag: &str, data: &str) -> Self {109
Self { tag: tag.into(), data: data.as_bytes().to_vec() }110
}111
}113
pub struct RunOptions {114
pub script: String,115
pub args: Vec<String>,116
pub frames: Vec<Frame>,117
pub privileged: bool,118
pub sudo_password: Option<String>,119
pub remember_password: bool,120
pub use_cached_password: bool,121
pub purpose: String,122
pub error_prefix: String,123
pub timeout: Duration,124
pub output_limit: usize,125
}127
impl Default for RunOptions {128
fn default() -> Self {129
Self {130
script: String::new(),131
args: Vec::new(),132
frames: Vec::new(),133
privileged: true,134
sudo_password: None,135
remember_password: false,136
use_cached_password: false,137
purpose: String::new(),138
error_prefix: "REMOTE_".into(),139
timeout: Duration::from_secs(60),140
output_limit: 4 * 1024 * 1024,141
}142
}143
}145
#[derive(Debug, Clone)]146
pub struct RunResult {147
pub code: Option<i64>,148
pub stdout: String,149
pub stderr: String,150
pub privileged: bool,151
}153
// ---------------------------------------------------------------------------------------------154
// Interaction: wait for markers in stdout, answer on stdin.155
// ---------------------------------------------------------------------------------------------157
struct InteractionShared {158
stdout: Mutex<String>,159
consumed: Mutex<usize>,160
done: Mutex<bool>,161
notify: Notify,162
}164
enum InteractionCommand {165
Send(String),166
End,167
}169
#[derive(Clone)]170
pub struct Interaction {171
shared: Arc<InteractionShared>,172
commands: mpsc::UnboundedSender<InteractionCommand>,173
}175
pub struct Matched {176
pub groups: Vec<Option<String>>,177
pub before: String,178
}180
impl Interaction {181
pub async fn wait_for(&self, pattern: &Regex, timeout: Duration) -> CoreResult<Matched> {182
let deadline = tokio::time::Instant::now() + timeout;183
loop {184
let notified = self.shared.notify.notified();185
{186
let stdout = self.shared.stdout.lock();187
let mut consumed = self.shared.consumed.lock();188
let text = &stdout[(*consumed).min(stdout.len())..];189
if let Some(caps) = pattern.captures(text) {190
let whole = caps.get(0).expect("match");191
let matched = Matched { groups: caps.iter().map(|g| g.map(|m| m.as_str().to_string())).collect(), before: text[..whole.start()].to_string() };192
*consumed += whole.end();193
return Ok(matched);194
}195
if *self.shared.done.lock() {196
return Err(CoreError::new("PROTOCOL_ERROR", "The remote operation ended unexpectedly."));197
}198
}199
if tokio::time::timeout_at(deadline, notified).await.is_err() {200
return Err(CoreError::new("REMOTE_COMMAND_TIMEOUT", "The remote operation did not respond in time."));201
}202
}203
}205
pub fn send(&self, line: &str) {206
let _ = self.commands.send(InteractionCommand::Send(format!("{line}\n")));207
}209
pub fn end(&self) {210
let _ = self.commands.send(InteractionCommand::End);211
}212
}214
pub struct PrivilegedExecutor {215
sessions: Arc<SessionManager>,216
}218
impl PrivilegedExecutor {219
pub fn new(sessions: Arc<SessionManager>) -> Self {220
Self { sessions }221
}223
/// root / sudo-nopasswd / sudo-password (unverified) / none; cached per live session.224
pub async fn probe(&self, session_id: &str, refresh: bool) -> CoreResult<Value> {225
let record = self.sessions.require_connected(session_id)?;226
if !refresh {227
if let Some(p) = record.privilege.lock().clone() {228
return Ok(public_privilege(&p));229
}230
}231
let channel = self.sessions.exec_channel(&record, &format!("sh -c {}", shell_quote(PROBE_SCRIPT)), Duration::from_secs(15), 512 * 1024).await?;232
let mut channel = channel;233
channel.eof().await;234
let result = channel.finish().await?;235
let values = parse_key_values(&result.stdout);236
let get = |k: &str| values.get(k).cloned().unwrap_or_default();237
let sudo_message = get("sudoMessage");238
let mut privilege = json!({239
"username": if get("user").is_empty() { record.username.clone() } else { get("user") },240
"sudoInstalled": get("sudoInstalled") == "1",241
"groups": get("groups").split(',').filter(|s| !s.is_empty()).take(256).collect::<Vec<_>>(),242
"clientAddress": get("client").trim().chars().take(64).collect::<String>(),243
"message": clean_remote_message(&sudo_message, 300),244
"checkedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),245
"mode": "unknown",246
"verified": false247
});248
let set = |p: &mut Value, mode: &str, verified: bool| {249
p["mode"] = json!(mode);250
p["verified"] = json!(verified);251
};252
if get("uid") == "0" {253
set(&mut privilege, "root", true);254
} else if get("sudoInstalled") != "1" {255
set(&mut privilege, "none", true);256
} else if get("sudoNonInteractive") == "0" {257
set(&mut privilege, "sudo-nopasswd", true);258
} else if Regex::new(r"(?i)password is required").unwrap().is_match(&sudo_message) {259
privilege["mode"] = json!("sudo-password");260
} else if Regex::new(r"(?i)not allowed|may not run sudo|not in the sudoers").unwrap().is_match(&sudo_message) {261
set(&mut privilege, "none", true);262
}263
*record.privilege.lock() = Some(privilege.clone());264
Ok(public_privilege(&privilege))265
}267
pub fn cached_password(&self, record: &SessionRecord) -> Option<Zeroizing<String>> {268
let mut cache = record.sudo_cache.lock();269
match cache.as_mut() {270
Some((password, expires)) if *expires > Instant::now() && record.is_connected() => {271
*expires = Instant::now() + SUDO_CACHE_IDLE;272
Some(password.clone())273
}274
_ => {275
*cache = None;276
None277
}278
}279
}281
pub fn has_cached_password(&self, session_id: &str) -> bool {282
self.sessions.get(session_id).map(|r| self.cached_password(&r).is_some()).unwrap_or(false)283
}285
pub fn forget_password(&self, session_id: &str) {286
if let Some(record) = self.sessions.get(session_id) {287
*record.sudo_cache.lock() = None;288
}289
}291
fn remember_failure(&self, record: &SessionRecord, code: &str) {292
let mut guard = record.privilege.lock();293
let Some(p) = guard.as_mut() else { return };294
match code {295
"SUDO_NOT_PERMITTED" => {296
p["mode"] = json!("none");297
p["verified"] = json!(true);298
}299
"SUDO_UNAVAILABLE" => {300
p["mode"] = json!("none");301
p["verified"] = json!(true);302
p["sudoInstalled"] = json!(false);303
}304
"SUDO_PASSWORD_REQUIRED" | "SUDO_PASSWORD_INCORRECT" if p["mode"] != "sudo-password" => p["mode"] = json!("sudo-password"),305
_ => {}306
}307
}309
pub async fn run(&self, session_id: &str, options: RunOptions) -> CoreResult<RunResult> {310
self.run_interactive(session_id, options, None::<fn(Interaction) -> std::future::Ready<CoreResult<Value>>>).await.map(|(result, _)| result)311
}313
/// Runs a fixed script; `interact` (if any) drives a stdin line protocol after READY.314
pub async fn run_interactive<F, Fut>(&self, session_id: &str, options: RunOptions, interact: Option<F>) -> CoreResult<(RunResult, Option<Value>)>315
where316
F: FnOnce(Interaction) -> Fut,317
Fut: Future<Output = CoreResult<Value>> + Send,318
{319
let record = self.sessions.require_connected(session_id)?;320
let mut password = Zeroizing::new(normalize_password(options.sudo_password.as_deref())?);321
let mut used_cache = false;322
if password.is_empty() && options.use_cached_password {323
if let Some(cached) = self.cached_password(&record) {324
password = cached;325
used_cache = true;326
}327
}328
let privilege = if options.privileged { Some(self.probe(session_id, false).await?) } else { None };329
if let Some(p) = &privilege {330
if p["mode"] == "none" && p["verified"] == true {331
let code = if p["sudoInstalled"] == true { "SUDO_NOT_PERMITTED" } else { "SUDO_UNAVAILABLE" };332
return Err(CoreError::new(code, sudo_message(code)));333
}334
}335
let use_sudo = privilege.as_ref().map(|p| p["mode"] != "root").unwrap_or(false);336
let body = format!("sh -c {} chj {}", shell_quote(&options.script), options.args.iter().map(|a| shell_quote(a)).collect::<Vec<_>>().join(" "));337
let command = if use_sudo { format!("LC_ALL=C sudo -S -p {} -- {body}", shell_quote(SUDO_PROMPT)) } else { format!("LC_ALL=C {body}") };338
let mut frames = Vec::new();339
for frame in &options.frames {340
if !FRAME_TAG.is_match(&frame.tag) {341
return Err(CoreError::new("PROTOCOL_ERROR", "Invalid frame tag."));342
}343
frames.push(format!("CHJ{}:{}\n", frame.tag, base64::engine::general_purpose::STANDARD.encode(&frame.data)));344
}345
let mut channel = self.sessions.exec_channel(&record, &command, options.timeout, options.output_limit).await?;346
let shared = Arc::new(InteractionShared { stdout: Mutex::new(String::new()), consumed: Mutex::new(0), done: Mutex::new(false), notify: Notify::new() });347
let (command_tx, mut command_rx) = mpsc::unbounded_channel();348
let mut answered = 0usize;349
let mut auth_failure: Option<&'static str> = None;350
let mut started = false;351
let mut interact = interact;352
let mut pending: Option<std::pin::Pin<Box<dyn Future<Output = CoreResult<Value>> + Send>>> = None;353
let mut interaction_result: Option<CoreResult<Value>> = None;354
let mut channel_done = false;355
// Drive the channel and the optional interaction concurrently on this task.356
while !channel_done || pending.is_some() {357
tokio::select! {358
event = channel.next(), if !channel_done => {359
let event = event?;360
if use_sudo && !started {361
let prompts = channel.stderr.matches(SUDO_PROMPT).count();362
if prompts > answered {363
if answered == 0 && !password.is_empty() {364
answered = 1;365
channel.write(format!("{}\n", password.as_str()).as_bytes()).await;366
} else {367
answered = prompts;368
auth_failure = Some(if password.is_empty() { "SUDO_PASSWORD_REQUIRED" } else { "SUDO_PASSWORD_INCORRECT" });369
channel.eof().await;370
channel.close().await;371
break;372
}373
}374
}375
if !started && channel.stdout.contains(READY_MARKER) {376
started = true;377
for frame in &frames {378
channel.write(frame.as_bytes()).await;379
}380
*shared.consumed.lock() = channel.stdout.find(READY_MARKER).map(|i| i + READY_MARKER.len()).unwrap_or(0);381
match interact.take() {382
Some(callback) => pending = Some(Box::pin(callback(Interaction { shared: shared.clone(), commands: command_tx.clone() }))),383
None => channel.eof().await,384
}385
}386
*shared.stdout.lock() = channel.stdout.clone();387
if event == ExecEvent::Done {388
channel_done = true;389
*shared.done.lock() = true;390
}391
shared.notify.notify_waiters();392
}393
command = command_rx.recv() => match command {394
Some(InteractionCommand::Send(line)) => channel.write(line.as_bytes()).await,395
Some(InteractionCommand::End) | None => channel.eof().await,396
},397
result = async { pending.as_mut().expect("pending interaction").await }, if pending.is_some() => {398
pending = None;399
if result.is_err() {400
// Any failure closes stdin; the remote script treats EOF as "roll back".401
channel.eof().await;402
}403
interaction_result = Some(result);404
}405
}406
}407
let stderr_raw = channel.stderr.clone();408
let stdout_raw = channel.stdout.clone();409
let exit = if auth_failure.is_some() { None } else { channel.finish().await.map(|r| r.code).unwrap_or(None) };410
if !started {411
let code = classify_sudo_failure(&stderr_raw, exit, auth_failure);412
self.remember_failure(&record, &code);413
if code == "SUDO_PASSWORD_INCORRECT" || code == "SUDO_PASSWORD_REQUIRED" {414
*record.sudo_cache.lock() = None;415
}416
self.sessions.logger.warn(417
"Privileged remote operation was not started.",418
json!({ "sessionId": record.session_id, "host": record.host, "purpose": options.purpose, "code": code }),419
);420
return Err(CoreError::new(code.clone(), sudo_message(&code)).with("remoteMessage", clean_remote_message(&stderr_raw, 600)));421
}422
if use_sudo {423
if let Some(p) = record.privilege.lock().as_mut() {424
let mode = if (!password.is_empty() && answered > 0) || p["mode"] == "sudo-password" { "sudo-password" } else { "sudo-nopasswd" };425
p["verified"] = json!(true);426
p["mode"] = json!(mode);427
}428
if !password.is_empty() && answered > 0 && (options.remember_password || used_cache) {429
*record.sudo_cache.lock() = Some((password.clone(), Instant::now() + SUDO_CACHE_IDLE));430
}431
}432
let after_ready = stdout_raw.find(READY_MARKER).map(|i| &stdout_raw[i + READY_MARKER.len()..]).unwrap_or("").to_string();433
if let Some(caps) = ERROR_MARKER.captures(&after_ready) {434
let cleaned = clean_remote_message(&stderr_raw, 600);435
return Err(CoreError::new(format!("{}{}", options.error_prefix, &caps[1]), if cleaned.is_empty() { caps[1].to_string() } else { cleaned })436
.with("remoteExitCode", exit.map(Value::from).unwrap_or(Value::Null)));437
}438
let interaction = match interaction_result {439
Some(Ok(value)) => Some(value),440
Some(Err(error)) => return Err(error),441
None => None,442
};443
Ok((RunResult { code: exit, stdout: after_ready, stderr: clean_remote_message(&stderr_raw, 600), privileged: options.privileged }, interaction))444
}445
}447
#[cfg(test)]448
mod tests {449
use super::*;451
#[test]452
fn sudo_failure_classification() {453
assert_eq!(classify_sudo_failure("Sorry, try again.", Some(1), None), "SUDO_PASSWORD_INCORRECT");454
assert_eq!(classify_sudo_failure("bob is not in the sudoers file.", Some(1), None), "SUDO_NOT_PERMITTED");455
assert_eq!(classify_sudo_failure("sudo: sorry, you must have a tty to run sudo", Some(1), None), "SUDO_TTY_REQUIRED");456
assert_eq!(classify_sudo_failure("sh: sudo: not found", Some(127), None), "SUDO_UNAVAILABLE");457
assert_eq!(classify_sudo_failure("x", Some(1), Some("SUDO_PASSWORD_REQUIRED")), "SUDO_PASSWORD_REQUIRED");458
assert_eq!(classify_sudo_failure("other", Some(1), None), "SUDO_FAILED");459
}461
#[test]462
fn remote_message_cleanup() {463
assert_eq!(clean_remote_message("[chj-sudo-auth]line1\n\nline2\x07\n", 600), "line1 | line2");464
assert!(normalize_password(Some("a\nb")).is_err());465
assert_eq!(normalize_password(None).unwrap(), "");466
}468
fn interaction() -> (Interaction, mpsc::UnboundedReceiver<InteractionCommand>) {469
let (commands, rx) = mpsc::unbounded_channel();470
(Interaction { commands, shared: Arc::new(InteractionShared {471
stdout: Mutex::new(String::new()), consumed: Mutex::new(0),472
done: Mutex::new(false), notify: Notify::new(),473
}) }, rx)474
}476
#[tokio::test]477
async fn interaction_consumes_matches_once_and_handles_fragmented_output() {478
let (io, _) = interaction();479
let writer = io.clone();480
let pattern = Regex::new("CHJ_REPLY=([0-9]+)\\n").unwrap();481
let (matched, ()) = tokio::join!(io.wait_for(&pattern, Duration::from_secs(1)), async move {482
*writer.shared.stdout.lock() = "banner\nCHJ_RE".into();483
writer.shared.notify.notify_waiters();484
tokio::task::yield_now().await;485
writer.shared.stdout.lock().push_str("PLY=42\n");486
writer.shared.notify.notify_waiters();487
});488
let matched = matched.unwrap();489
assert_eq!(matched.before, "banner\n");490
assert_eq!(matched.groups[1].as_deref(), Some("42"));491
assert_eq!(io.wait_for(&Regex::new("CHJ_REPLY").unwrap(), Duration::ZERO).await.err().unwrap().code, "REMOTE_COMMAND_TIMEOUT");492
io.shared.stdout.lock().push_str("CHJ_REPLY=43\n");493
let next = io.wait_for(&Regex::new("CHJ_REPLY=([0-9]+)").unwrap(), Duration::ZERO).await.unwrap();494
assert_eq!(next.groups[1].as_deref(), Some("43"));495
}497
#[tokio::test]498
async fn interaction_eof_settles_waiter_and_send_end_have_exact_framing() {499
let (io, mut commands) = interaction();500
io.send("COMMIT"); io.end();501
assert!(matches!(commands.recv().await, Some(InteractionCommand::Send(s)) if s == "COMMIT\n"));502
assert!(matches!(commands.recv().await, Some(InteractionCommand::End)));503
let writer = io.clone();504
let pattern = Regex::new("NEVER").unwrap();505
let (result, ()) = tokio::join!(io.wait_for(&pattern, Duration::from_secs(1)), async move {506
*writer.shared.done.lock() = true;507
writer.shared.notify.notify_waiters();508
});509
assert_eq!(result.err().unwrap().code, "PROTOCOL_ERROR");510
}512
}SHA-256
ae3acbb44106b6b0e695ea94118aaf5a8bd1d12d4d3046839a9621797725c7a3CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df