1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13use tracing::debug;
14
15pub struct SshProtocol;
21
22impl Default for SshProtocol {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28impl SshProtocol {
29 pub fn new() -> Self {
30 Self
31 }
32}
33
34impl Protocol for SshProtocol {
36 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
37 Vec::new()
40 }
41 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
42 Vec::new()
46 }
47 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
48 vec![
49 send_ssh_data_action(),
50 wait_for_more_action(),
51 close_this_connection_action(),
52 sftp_handle_action(),
53 sftp_directory_listing_action(),
54 sftp_file_content_action(),
55 sftp_file_attributes_action(),
56 sftp_error_action(),
57 ]
58 }
59 fn protocol_name(&self) -> &'static str {
60 "SSH"
61 }
62 fn get_event_types(&self) -> Vec<EventType> {
63 get_ssh_event_types()
64 }
65 fn stack_name(&self) -> &'static str {
66 "ETH>IP>TCP>SSH"
67 }
68 fn keywords(&self) -> Vec<&'static str> {
69 vec!["ssh"]
70 }
71 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
72 use crate::protocol::metadata::{
73 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
74 };
75
76 ProtocolMetadataV2::builder()
77 .state(DevelopmentState::Beta)
100 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(22))
101 .well_known_port(22)
102 .max_inbound_bytes(crate::server::ssh::MAX_SHELL_LINE_BYTES)
105 .implementation("russh v0.45, russh-sftp v2.1; ephemeral Ed25519 host key")
106 .llm_control("Auth decisions, shell banner and output, SFTP reads and listings")
107 .e2e_testing(
108 "libssh2 (via the ssh2 crate - a binding to the C library, sharing no code \
109 with the russh this server is built on) completes real sessions, in tests \
110 that are neither #[ignore]d nor skip-gated. \
111 test_sftp_basic_operations is the evidence: transport handshake, password \
112 auth, the SFTP subsystem, opendir/readdir, open+read and lstat, with the \
113 file's bytes and the declared size asserted exactly. \
114 test_ssh_python_auth_script and test_ssh_script_fallback_to_llm drive \
115 libssh2 password auth through a script handler and through the model, and \
116 llm_failure_test asserts libssh2 sees a refusal on all three fail-closed \
117 paths. test_ssh_connection_attempt asserts libssh2 completes the handshake \
118 and is then refused a login no handler granted. Alongside these, bare \
119 TcpStream tests cover the banner, version exchange and concurrent connects. \
120 SECOND CLIENT: the real OpenSSH `ssh` binary, in \
121 tests/server/ssh/real_client_test.rs. A third implementation again - neither \
122 russh nor libssh2 - and the one an operator actually types. It authenticates \
123 with a generated ed25519 key (BatchMode, so nothing can prompt), runs a \
124 one-shot `ssh host <command>` over an exec channel, and the test asserts the \
125 exact bytes on stdout AND the exit status OpenSSH reports from the SSH \
126 exit-status request; a second session with a username the model refuses is \
127 asserted to come back as OpenSSH's own `Permission denied` and exit 255. It \
128 FAILS rather than skips when ssh/ssh-keygen are absent, and is not #[ignore]d. \
129 Verified non-vacuous by inverting llm_auth_decision's accepted branch (the \
130 admitted session became `Permission denied` and exit 255) and by forcing \
131 exec_request's success exit status to 69 (stdout was still byte-for-byte \
132 correct and ONLY the exit code moved, so a test reading stdout alone would \
133 have passed). \
134 WHAT THE SECOND CLIENT FOUND (1), now FIXED: NetGet sent \
135 SSH_MSG_CHANNEL_CLOSE twice on an exec channel - once from exec_request with \
136 the exit status, once from channel_eof when the client's own EOF arrived. \
137 RFC 4254 section 5.3 allows one per party. OpenSSH disconnected with `oclose \
138 packet referred to nonexistent channel 0` and exit 255 whenever it had \
139 already freed the channel, so `ssh host cmd` failed about one run in five \
140 AFTER the output and exit-status 0 had arrived intact. libssh2 ignores the \
141 second CLOSE, so nothing else here could see it. SshHandler::close_channel_once \
142 is the fix; measured 4 failures in 20 runs before, 0 in 25 after. \
143 WHAT THE SECOND CLIENT FOUND (2), NOT fixed: NetGet applies \
144 normalize_line_endings on the \
145 EXEC path, where no PTY exists, so `ssh host cmd` returns CRLF where a real \
146 sshd returns LF - the CRLF an interactive session shows comes from the pty's \
147 ONLCR, not from the server. `OUT=$(ssh host cmd)` therefore leaves a stray \
148 CR. Not fixed here (mod.rs handles no pty_request at all, so the honest fix \
149 is channel bookkeeping); the test asserts and names the current behaviour. \
150 STILL UNPROVEN: openssh's `sftp` binary, and an interactive shell through any \
151 third-party client - only exec and SFTP are covered.",
152 )
153 .notes(
154 "FAILS CLOSED: an LLM error, a handler that returns no ssh_auth_decision, and \
155 a malformed decision all deny, logged as decision=fail_closed_* and never as \
156 decision=model_reject. Shell and SFTP only: no port forwarding, no X11, no \
157 keyboard-interactive. SFTP is read-only (write/remove/mkdir/rmdir/rename are \
158 not implemented) and serves no real filesystem — every listing, stat and byte \
159 comes from the model. Host key is regenerated on every start, so clients warn \
160 about a changed key. Shell output has \\n rewritten to \\r\\n on EVERY path, \
161 including the one-shot exec channel where no PTY exists and a real sshd would \
162 not translate - so `ssh host cmd` returns CRLF and `$(ssh host cmd)` keeps a \
163 trailing CR.",
164 )
165 .build()
166 }
167 fn description(&self) -> &'static str {
168 "Secure shell server for remote access"
169 }
170 fn example_prompt(&self) -> &'static str {
171 "Pretent to be a shell via SSH on port 2222"
172 }
173 fn group_name(&self) -> &'static str {
174 "Core"
175 }
176
177 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
178 use crate::llm::actions::StartupExamples;
179 use serde_json::json;
180
181 let script = r#"import json, sys
184data = json.load(sys.stdin)
185event = data["event"]
186if data["event_type_id"] == "ssh_shell_command":
187 actions = [{"type": "ssh_shell_response", "response": "command not found\n"}]
188else:
189 actions = []
190print(json.dumps({"actions": actions}))"#;
191
192 StartupExamples::new(
193 json!({
195 "type": "open_server",
196 "port": 2222,
197 "base_stack": "ssh",
198 "instruction": "SSH server providing secure shell access"
199 }),
200 json!({
202 "type": "open_server",
203 "port": 2222,
204 "base_stack": "ssh",
205 "event_handlers": [{
206 "event_pattern": "ssh_shell_command",
207 "handler": {
208 "type": "script",
209 "language": "python",
210 "code": script
211 }
212 }]
213 }),
214 json!({
216 "type": "open_server",
217 "port": 2222,
218 "base_stack": "ssh",
219 "event_handlers": [{
220 "event_pattern": "ssh_auth",
221 "handler": {
222 "type": "static",
223 "actions": [{
224 "type": "ssh_auth_decision",
225 "allowed": true
226 }]
227 }
228 }]
229 }),
230 )
231 }
232}
233
234impl Server for SshProtocol {
236 fn spawn(
237 &self,
238 ctx: crate::protocol::SpawnContext,
239 ) -> std::pin::Pin<
240 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
241 > {
242 Box::pin(async move {
243 use crate::server::ssh::SshServer;
244 #[allow(deprecated)]
245 let listen_addr = ctx.socket_addr().unwrap_or(ctx.legacy_listen_addr());
246
247 SshServer::spawn_with_llm_actions(
248 listen_addr,
249 ctx.llm_client,
250 ctx.state,
251 ctx.status_tx,
252 ctx.server_id,
253 )
254 .await
255 })
256 }
257 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
258 let action_type = action
259 .get("type")
260 .and_then(|v| v.as_str())
261 .context("Missing 'type' field in action")?;
262
263 match action_type {
264 "send_ssh_data" => self.execute_send_ssh_data(action),
265 "wait_for_more" => Ok(ActionResult::WaitForMore),
266 "close_this_connection" => Ok(ActionResult::CloseConnection),
267 "ssh_auth_decision" => self.execute_ssh_auth_decision(action),
268 "ssh_send_banner" => self.execute_ssh_send_banner(action),
269 "ssh_shell_response" => self.execute_ssh_shell_response(action),
270 "sftp_handle"
273 | "sftp_directory_listing"
274 | "sftp_file_content"
275 | "sftp_file_attributes"
276 | "sftp_error" => Ok(ActionResult::Custom {
277 name: action_type.to_string(),
278 data: action,
279 }),
280 _ => Err(anyhow::anyhow!("Unknown SSH action: {}", action_type)),
281 }
282 }
283}
284
285impl SshProtocol {
286 fn execute_send_ssh_data(&self, action: serde_json::Value) -> Result<ActionResult> {
287 let data = action
288 .get("data")
289 .and_then(|v| v.as_str())
290 .context("Missing 'data' parameter")?;
291
292 Ok(ActionResult::Output(data.as_bytes().to_vec()))
293 }
294
295 fn execute_ssh_auth_decision(&self, action: serde_json::Value) -> Result<ActionResult> {
296 let allowed = action
299 .get("allowed")
300 .context("Missing 'allowed' parameter (a JSON boolean: true to accept the login, false to reject it)")?
301 .as_bool()
302 .context("'allowed' must be a JSON boolean, true or false - not a string or number")?;
303
304 debug!("SSH auth decision action: allowed={}", allowed);
305
306 Ok(ActionResult::Custom {
308 name: "ssh_auth_decision".to_string(),
309 data: json!({"allowed": allowed}),
310 })
311 }
312
313 fn execute_ssh_send_banner(&self, action: serde_json::Value) -> Result<ActionResult> {
314 let banner = action
315 .get("banner")
316 .and_then(|v| v.as_str())
317 .context("Missing 'banner' parameter")?;
318
319 debug!("SSH sending banner: {}", banner);
320 Ok(ActionResult::Output(banner.as_bytes().to_vec()))
321 }
322
323 fn execute_ssh_shell_response(&self, action: serde_json::Value) -> Result<ActionResult> {
324 let response = action
325 .get("response")
326 .and_then(|v| v.as_str())
327 .context("Missing 'response' parameter")?;
328
329 debug!("SSH shell response: {}", response);
330 Ok(ActionResult::Output(response.as_bytes().to_vec()))
331 }
332}
333
334fn send_ssh_data_action() -> ActionDefinition {
335 ActionDefinition {
336 name: "send_ssh_data".to_string(),
337 description: "Write text to the client's terminal on the current shell channel. This is \
338 a lower-level alias for ssh_shell_response and behaves identically, including the \
339 \\n -> \\r\\n conversion; prefer ssh_shell_response when answering a command. This \
340 is NOT a way to send raw SSH protocol bytes - the transport is encrypted by the \
341 server and you never see or write wire-level SSH packets."
342 .to_string(),
343 parameters: vec![Parameter {
344 name: "data".to_string(),
345 type_hint: "string".to_string(),
346 description: "Text for the terminal. Bare \\n is converted to \\r\\n before sending, \
347 so you may write either"
348 .to_string(),
349 required: true,
350 }],
351 example: json!({
352 "type": "send_ssh_data",
353 "data": "connection reset by peer\n"
354 }),
355 log_template: Some(
356 LogTemplate::new()
357 .with_info("-> SSH {output_bytes}B")
358 .with_debug("SSH send_ssh_data: {output_bytes}B"),
359 ),
360 }
361}
362
363fn wait_for_more_action() -> ActionDefinition {
364 ActionDefinition {
365 name: "wait_for_more".to_string(),
366 description: "Send nothing and wait for the client's next input line. The shell reads \
367 one line at a time, so this simply means 'no output for this command'. Note the \
368 server still writes its own \"$ \" prompt afterwards."
369 .to_string(),
370 parameters: vec![],
371 example: json!({
372 "type": "wait_for_more"
373 }),
374 log_template: Some(LogTemplate::new().with_debug("SSH waiting for more data")),
375 }
376}
377
378fn close_this_connection_action() -> ActionDefinition {
379 ActionDefinition {
380 name: "close_this_connection".to_string(),
381 description: "End the shell session on the channel that raised this event: sends exit \
382 status 0, then EOF, then closes the channel. Use it for 'exit', 'logout' or Ctrl-D. \
383 Emit any farewell text with ssh_shell_response in the same response, before this \
384 action."
385 .to_string(),
386 parameters: vec![],
387 example: json!({
388 "type": "close_this_connection"
389 }),
390 log_template: Some(
391 LogTemplate::new()
392 .with_info("SSH connection closed")
393 .with_debug("SSH close_this_connection"),
394 ),
395 }
396}
397
398fn sftp_handle_action() -> ActionDefinition {
406 ActionDefinition {
407 name: "sftp_handle".to_string(),
408 description: "Answer an SFTP 'open' or 'opendir' request by granting a handle for the \
409 path. The handle is an opaque string you choose; the client passes it back on every \
410 later read/readdir/close for that file or directory, and the server remembers which \
411 path it stood for. Reply with sftp_error instead if the path should not exist."
412 .to_string(),
413 parameters: vec![Parameter {
414 name: "handle".to_string(),
415 type_hint: "string".to_string(),
416 description: "Opaque identifier for this open file or directory. Must be unique \
417 among currently-open handles on this session. If omitted, the requested path is \
418 used as the handle"
419 .to_string(),
420 required: false,
421 }],
422 example: json!({
423 "type": "sftp_handle",
424 "handle": "dir-1"
425 }),
426 log_template: Some(
427 LogTemplate::new()
428 .with_info("-> SFTP handle {handle}")
429 .with_debug("SFTP sftp_handle: {handle}"),
430 ),
431 }
432}
433
434fn sftp_directory_listing_action() -> ActionDefinition {
435 ActionDefinition {
436 name: "sftp_directory_listing".to_string(),
437 description: "Answer an SFTP 'readdir' request with the full contents of the directory. \
438 Send every entry in one response - the server reports end-of-directory on the next \
439 readdir for the same handle, so a second call will not be made. An empty 'entries' \
440 array is a valid empty directory."
441 .to_string(),
442 parameters: vec![Parameter {
443 name: "entries".to_string(),
444 type_hint: "array".to_string(),
445 description: "Array of objects, one per directory entry. 'name' (string, the bare \
446 file name with no path) is required; an entry without it is dropped. 'is_dir' \
447 (boolean, default false) and 'size' (number of bytes, default 0) are optional \
448 but should be set - clients display them, and 'size' should match what \
449 sftp_file_attributes reports for the same file. Permissions are derived from \
450 'is_dir' (0755 for directories, 0644 for files)"
451 .to_string(),
452 required: true,
453 }],
454 example: json!({
455 "type": "sftp_directory_listing",
456 "entries": [
457 {"name": "readme.txt", "is_dir": false, "size": 24},
458 {"name": "logs", "is_dir": true, "size": 4096}
459 ]
460 }),
461 log_template: Some(
462 LogTemplate::new()
463 .with_info("-> SFTP listing {entries_len} entries")
464 .with_debug("SFTP sftp_directory_listing: {entries_len} entries"),
465 ),
466 }
467}
468
469fn sftp_file_content_action() -> ActionDefinition {
470 ActionDefinition {
471 name: "sftp_file_content".to_string(),
472 description: "Answer an SFTP 'read' request with the contents of the file. IMPORTANT: \
473 return the WHOLE file every time, not the slice the request asked for - the server \
474 applies the request's offset and length for you. Keep the content consistent with \
475 the 'size' you reported from sftp_file_attributes for the same path, otherwise the \
476 client's download will be truncated or will not terminate."
477 .to_string(),
478 parameters: vec![Parameter {
479 name: "content".to_string(),
480 type_hint: "string".to_string(),
481 description: "The complete file contents as text. Binary files cannot be served: \
482 the string's UTF-8 bytes are what the client receives"
483 .to_string(),
484 required: true,
485 }],
486 example: json!({
487 "type": "sftp_file_content",
488 "content": "Hello from NetGet SFTP!\n"
489 }),
490 log_template: Some(
491 LogTemplate::new()
492 .with_info("-> SFTP content {content_len}B")
493 .with_debug("SFTP sftp_file_content: {content_len}B"),
494 ),
495 }
496}
497
498fn sftp_file_attributes_action() -> ActionDefinition {
499 ActionDefinition {
500 name: "sftp_file_attributes".to_string(),
501 description: "Answer an SFTP 'lstat' or 'fstat' request with the metadata of a path. \
502 Clients call this before downloading to learn the file size, so 'size' must match \
503 the length of what sftp_file_content will return for the same path. Reply with \
504 sftp_error instead if the path should not exist."
505 .to_string(),
506 parameters: vec![
507 Parameter {
508 name: "size".to_string(),
509 type_hint: "number".to_string(),
510 description: "File size in bytes. Must equal the byte length of the 'content' \
511 you would return for this path"
512 .to_string(),
513 required: true,
514 },
515 Parameter {
516 name: "is_dir".to_string(),
517 type_hint: "boolean".to_string(),
518 description: "True if this path is a directory. Sets the directory bit in the \
519 reported mode, which is what makes `ls` and `cd` treat it as one"
520 .to_string(),
521 required: true,
522 },
523 Parameter {
524 name: "permissions".to_string(),
525 type_hint: "number".to_string(),
526 description: "Optional Unix mode as a decimal number (e.g. 420 for 0644, 493 \
527 for 0755). Defaults to 0644, with the directory bit added when is_dir is true"
528 .to_string(),
529 required: false,
530 },
531 ],
532 example: json!({
533 "type": "sftp_file_attributes",
534 "size": 24,
535 "is_dir": false
536 }),
537 log_template: Some(
538 LogTemplate::new()
539 .with_info("-> SFTP attrs size={size} dir={is_dir}")
540 .with_debug("SFTP sftp_file_attributes: size={size}, is_dir={is_dir}"),
541 ),
542 }
543}
544
545fn sftp_error_action() -> ActionDefinition {
546 ActionDefinition {
547 name: "sftp_error".to_string(),
548 description: "Fail the SFTP request with an error status. Use this for paths that should \
549 not exist, permission denials, and operations you do not want to support. Returning \
550 no action at all also fails the request, but with a less specific status - prefer \
551 this so the client shows the right message."
552 .to_string(),
553 parameters: vec![Parameter {
554 name: "code".to_string(),
555 type_hint: "string".to_string(),
556 description: "One of: \"no_such_file\" (the default, for a path that does not \
557 exist), \"permission_denied\", \"failure\" (generic), \"op_unsupported\", or \
558 \"eof\". Any other value is treated as \"failure\""
559 .to_string(),
560 required: false,
561 }],
562 example: json!({
563 "type": "sftp_error",
564 "code": "no_such_file"
565 }),
566 log_template: Some(
567 LogTemplate::new()
568 .with_info("-> SFTP error {code}")
569 .with_debug("SFTP sftp_error: {code}"),
570 ),
571 }
572}
573
574pub static SSH_SEND_BANNER_ACTION: LazyLock<ActionDefinition> =
580 LazyLock::new(|| ActionDefinition {
581 name: "ssh_send_banner".to_string(),
582 description: "Write the greeting shown the moment an SSH shell opens, before the user \
583 has typed anything - a welcome line, MOTD or fake 'Last login' banner. Answers the \
584 ssh_banner event. If the server should open silently, return no action at all \
585 (or show_message, which is not sent to the client)."
586 .to_string(),
587 parameters: vec![Parameter {
588 name: "banner".to_string(),
589 type_hint: "string".to_string(),
590 description: "The text to display. Use \\n for line breaks; it is converted to the \
591 \\r\\n that terminals require. Do not append a \"$ \" prompt - the server writes \
592 one after each command on its own"
593 .to_string(),
594 required: true,
595 }],
596 example: json!({
597 "type": "ssh_send_banner",
598 "banner": "Welcome to NetGet SSH Server!\nType 'help' for available commands.\n"
599 }),
600 log_template: Some(
601 LogTemplate::new()
602 .with_info("-> SSH banner")
603 .with_debug("SSH ssh_send_banner")
604 .with_trace("SSH banner: {preview(banner,100)}"),
605 ),
606 });
607
608pub static SSH_AUTH_DECISION_ACTION: LazyLock<ActionDefinition> =
610 LazyLock::new(|| ActionDefinition {
611 name: "ssh_auth_decision".to_string(),
612 description: "Accept or reject the login attempt in the ssh_auth event. This is the ONLY \
613 valid answer to ssh_auth - if you return anything else, or nothing, the login is \
614 rejected. Decide from the event's 'username', 'auth_type' and (for password logins) \
615 'password' together with your instruction. There is no way to send a message with \
616 the rejection; use ssh_send_banner after a successful login instead."
617 .to_string(),
618 parameters: vec![Parameter {
619 name: "allowed".to_string(),
620 type_hint: "boolean".to_string(),
621 description: "JSON boolean, not a string: true accepts the login, false rejects it \
622 and lets the client retry with another method"
623 .to_string(),
624 required: true,
625 }],
626 example: json!({
627 "type": "ssh_auth_decision",
628 "allowed": true
629 }),
630 log_template: Some(
631 LogTemplate::new()
632 .with_info("SSH auth: {allowed}")
633 .with_debug("SSH ssh_auth_decision: allowed={allowed}"),
634 ),
635 });
636
637pub static SSH_SHELL_RESPONSE_ACTION: LazyLock<ActionDefinition> =
639 LazyLock::new(|| ActionDefinition {
640 name: "ssh_shell_response".to_string(),
641 description: "Write the output of a shell command to the client's terminal. Answers the \
642 ssh_shell_command event. Produce what the real command would print (ls, pwd, cat, \
643 uname, whoami, ...), or a shell-style error such as \"bash: foo: command not \
644 found\". There is no real filesystem behind this - invent one and use memory \
645 (set_memory/append_memory) to keep the current directory and any files consistent \
646 across commands. To log the user out, add close_this_connection after this action."
647 .to_string(),
648 parameters: vec![Parameter {
649 name: "response".to_string(),
650 type_hint: "string".to_string(),
651 description: "The command's output. Use \\n for line breaks; it is converted to the \
652 \\r\\n terminals require, so plain Unix output works. Do not add a \"$ \" \
653 prompt - the server writes one after your output. An empty string prints nothing"
654 .to_string(),
655 required: true,
656 }],
657 example: json!({
658 "type": "ssh_shell_response",
659 "response": "/home/user\n"
660 }),
661 log_template: Some(
662 LogTemplate::new()
663 .with_info("-> SSH response {output_bytes}B")
664 .with_debug("SSH ssh_shell_response: {output_bytes}B")
665 .with_trace("SSH response: {preview(response,200)}"),
666 ),
667 });
668
669pub static SSH_CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
671 ActionDefinition {
672 name: "close_this_connection".to_string(),
673 description: "Close the SSH connection. Use this when the user types 'exit', 'logout', \
674 or explicitly requests to close/disconnect. The connection will be terminated gracefully.".to_string(),
675 parameters: vec![],
676 example: json!({
677 "type": "close_this_connection"
678 }),
679 log_template: Some(
680 LogTemplate::new()
681 .with_info("SSH connection closed")
682 .with_debug("SSH close_this_connection"),
683 ),
684 }
685});
686
687pub static SSH_AUTH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
694 EventType::new(
695 "ssh_auth",
696 "A client is trying to authenticate. You must answer with ssh_auth_decision: anything \
697 else, including an error, is treated as a denial. The client may retry with a different \
698 method or username, so expect this event more than once per connection.",
699 json!({
700 "type": "ssh_auth_decision",
701 "allowed": true
702 }),
703 )
704 .with_parameters(vec![
705 Parameter {
706 name: "username".to_string(),
707 type_hint: "string".to_string(),
708 description: "Username the client is attempting to log in as".to_string(),
709 required: true,
710 },
711 Parameter {
712 name: "auth_type".to_string(),
713 type_hint: "string".to_string(),
714 description: "Authentication method, exactly \"password\" or \"publickey\". Match on \
715 this value in a script or static handler"
716 .to_string(),
717 required: true,
718 },
719 Parameter {
720 name: "password".to_string(),
721 type_hint: "string".to_string(),
722 description: "The password the client sent, in the clear. Present only when \
723 auth_type is \"password\"; absent for \"publickey\". The key itself is verified \
724 by the SSH transport and is not exposed here, so for publickey you are deciding \
725 on the username alone"
726 .to_string(),
727 required: false,
728 },
729 ])
730 .with_action(SSH_AUTH_DECISION_ACTION.clone())
731 .with_log_template(
732 LogTemplate::new()
733 .with_info("SSH auth: {username} ({auth_type}) from {client_ip}")
734 .with_debug("SSH auth: user={username}, type={auth_type}")
735 .with_trace("SSH auth: {json_pretty(.)}"),
736 )
737});
738
739pub static SSH_BANNER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
741 EventType::new(
742 "ssh_banner",
743 "A shell session just opened, after successful authentication and before the user has \
744 typed anything. Answer with ssh_send_banner to greet them, or return nothing for a \
745 silent login.",
746 json!({
747 "type": "ssh_send_banner",
748 "banner": "Welcome to NetGet SSH Server!\n"
749 }),
750 )
751 .with_action(SSH_SEND_BANNER_ACTION.clone())
753 .with_log_template(
754 LogTemplate::new()
755 .with_info("SSH session opened from {client_ip}")
756 .with_debug("SSH banner request from {client_ip}")
757 .with_trace("SSH banner: {json_pretty(.)}"),
758 )
759});
760
761pub static SSH_SHELL_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
763 EventType::new(
764 "ssh_shell_command",
765 "The user pressed Enter (or a control key) in an SSH shell, or ran a one-shot command \
766 with `ssh host <command>`. Answer with ssh_shell_response. The server echoes typed \
767 characters and writes a \"$ \" prompt after your output on its own, so do not include a \
768 prompt unless you want a second one.",
769 json!({
770 "type": "ssh_shell_response",
771 "response": "/home/user\n"
772 }),
773 )
774 .with_parameters(vec![
775 Parameter {
776 name: "command".to_string(),
777 type_hint: "string".to_string(),
778 description: "The line the user typed, including the trailing newline, or the whole \
779 command line for `ssh host <command>`. Control characters such as Ctrl-C (0x03) \
780 appear here literally; the flags below are the reliable way to detect them"
781 .to_string(),
782 required: true,
783 },
784 Parameter {
785 name: "first_input".to_string(),
786 type_hint: "boolean".to_string(),
787 description: "True on the first Enter of this shell session. Useful for printing a \
788 message-of-the-day or the initial prompt"
789 .to_string(),
790 required: true,
791 },
792 Parameter {
793 name: "empty_input".to_string(),
794 type_hint: "boolean".to_string(),
795 description: "True when the user pressed Enter on an empty line. Return an empty \
796 response - the server prints the prompt itself"
797 .to_string(),
798 required: true,
799 },
800 Parameter {
801 name: "control".to_string(),
802 type_hint: "array".to_string(),
803 description: "Names of the control keys present in this input, as strings: \
804 \"ctrl_c\" (interrupt), \"ctrl_d\" (end of file - usually means log out), \
805 \"ctrl_z\" (suspend). Empty for ordinary commands"
806 .to_string(),
807 required: true,
808 },
809 ])
810 .with_actions(vec![
811 SSH_SHELL_RESPONSE_ACTION.clone(),
812 SSH_CLOSE_CONNECTION_ACTION.clone(),
813 ])
814 .with_log_template(
815 LogTemplate::new()
816 .with_info("SSH cmd: {command}")
817 .with_debug("SSH shell command: '{command}'")
818 .with_trace("SSH command: {json_pretty(.)}"),
819 )
820});
821
822pub static SFTP_OPERATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
824 EventType::new(
825 "sftp_operation",
826 "An SFTP client asked for a filesystem operation. You are the filesystem: nothing is \
827 read from or written to disk, so invent a consistent virtual tree and keep it in memory \
828 across operations. Answer with exactly one reply action, chosen by 'operation': \
829 opendir/open -> sftp_handle, readdir -> sftp_directory_listing, read -> \
830 sftp_file_content, lstat/fstat -> sftp_file_attributes. Any operation may instead be \
831 refused with sftp_error.",
832 json!({
833 "type": "sftp_directory_listing",
834 "entries": [{"name": "readme.txt", "is_dir": false, "size": 24}]
835 }),
836 )
837 .with_parameters(vec![
838 Parameter {
839 name: "operation".to_string(),
840 type_hint: "string".to_string(),
841 description: "Which request this is: \"opendir\", \"readdir\", \"open\", \"read\" or \
842 \"lstat\". A client's fstat is resolved to the handle's path and arrives as \
843 \"lstat\"; close and realpath are answered by the server and never reach you"
844 .to_string(),
845 required: true,
846 },
847 Parameter {
848 name: "path".to_string(),
849 type_hint: "string".to_string(),
850 description: "Absolute path being operated on. For handle-based operations \
851 (readdir, read, fstat) this is the path the handle was opened with, resolved for \
852 you"
853 .to_string(),
854 required: true,
855 },
856 Parameter {
857 name: "handle".to_string(),
858 type_hint: "string".to_string(),
859 description: "The open handle this request refers to. Present for readdir, read and \
860 fstat; absent for opendir, open and lstat"
861 .to_string(),
862 required: false,
863 },
864 Parameter {
865 name: "offset".to_string(),
866 type_hint: "number".to_string(),
867 description: "Byte offset the client wants to read from ('read' only). Informational \
868 - return the whole file in sftp_file_content and the server slices it"
869 .to_string(),
870 required: false,
871 },
872 Parameter {
873 name: "length".to_string(),
874 type_hint: "number".to_string(),
875 description: "Number of bytes the client wants ('read' only). Informational, as for \
876 'offset'"
877 .to_string(),
878 required: false,
879 },
880 ])
881 .with_actions(vec![
882 sftp_handle_action(),
883 sftp_directory_listing_action(),
884 sftp_file_content_action(),
885 sftp_file_attributes_action(),
886 sftp_error_action(),
887 ])
888 .with_log_template(
889 LogTemplate::new()
890 .with_info("SFTP {operation} {path}")
891 .with_debug("SFTP op={operation}, path={path}, handle={handle}")
892 .with_trace("SFTP operation: {json_pretty(.)}"),
893 )
894});
895
896pub fn get_ssh_event_types() -> Vec<EventType> {
898 vec![
899 SSH_AUTH_EVENT.clone(),
900 SSH_BANNER_EVENT.clone(),
901 SSH_SHELL_COMMAND_EVENT.clone(),
902 SFTP_OPERATION_EVENT.clone(),
903 ]
904}