CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
privileged.rs Hinzugefügt: Datum nicht erfasst 23,1 KB · 512 Zeilen
Code Datei herunterladen
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::ssh::exec::ExecEvent;
10use crate::core::ssh::session::{shell_quote, SessionManager, SessionRecord};
11use base64::Engine;
12use once_cell::sync::Lazy;
13use parking_lot::Mutex;
14use regex::Regex;
15use serde_json::{json, Value};
16use std::future::Future;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19use tokio::sync::{mpsc, Notify};
20use zeroize::Zeroizing;
22pub const SUDO_PROMPT: &str = "[chj-sudo-auth]";
23pub const READY_MARKER: &str = "\x1eCHJ_READY\n";
24static ERROR_MARKER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eCHJ_ERR=([A-Z0-9_]{2,64})\r?\n").unwrap());
25static FRAME_TAG: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Z]{2,16}$").unwrap());
26const MAX_PASSWORD_LENGTH: usize = 1024;
27pub const SUDO_CACHE_IDLE: Duration = Duration::from_secs(10 * 60);
29pub const PROBE_SCRIPT: &str = r#"LC_ALL=C; export LC_ALL
30PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin; export PATH
31printf 'user=%s\nuid=%s\ngroups=%s\nclient=%s\n' "$(id -un)" "$(id -u)" "$(id -Gn 2>/dev/null | tr ' ' ',')" "${SSH_CLIENT%% *}"
32if command -v sudo >/dev/null 2>&1; then
33 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)"
35else
36 printf 'sudoInstalled=0\n'
37fi"#;
39pub 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 }
51pub 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())
59pub 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()
67pub 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()
90fn 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()
94fn 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 })
102pub struct Frame {
103 pub tag: String,
104 pub data: Vec<u8>,
107impl Frame {
108 pub fn text(tag: &str, data: &str) -> Self {
109 Self { tag: tag.into(), data: data.as_bytes().to_vec() }
110 }
113pub 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,
127impl 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 }
145#[derive(Debug, Clone)]
146pub struct RunResult {
147 pub code: Option<i64>,
148 pub stdout: String,
149 pub stderr: String,
150 pub privileged: bool,
153// ---------------------------------------------------------------------------------------------
154// Interaction: wait for markers in stdout, answer on stdin.
155// ---------------------------------------------------------------------------------------------
157struct InteractionShared {
158 stdout: Mutex<String>,
159 consumed: Mutex<usize>,
160 done: Mutex<bool>,
161 notify: Notify,
164enum InteractionCommand {
165 Send(String),
166 End,
169#[derive(Clone)]
170pub struct Interaction {
171 shared: Arc<InteractionShared>,
172 commands: mpsc::UnboundedSender<InteractionCommand>,
175pub struct Matched {
176 pub groups: Vec<Option<String>>,
177 pub before: String,
180impl 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 }
214pub struct PrivilegedExecutor {
215 sessions: Arc<SessionManager>,
218impl 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": false
247 });
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 None
277 }
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 where
316 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 }
447#[cfg(test)]
448mod 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 }
SHA-256ae3acbb44106b6b0e695ea94118aaf5a8bd1d12d4d3046839a9621797725c7a3

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien