Skip to main content

netget/server/ssh/
actions.rs

1//! SSH protocol actions implementation
2
3use 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
15/// SSH protocol action handler.
16///
17/// Stateless. Live connection state belongs to the per-connection `SshHandler` in
18/// `mod.rs`; the registry builds one of these for prompt generation and every handler
19/// builds its own, so anything stored here would be invisible to the other copies.
20pub 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
34// Implement Protocol trait (common functionality)
35impl Protocol for SshProtocol {
36    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
37        // No `send_first`: the server always raises ssh_banner when a shell opens, which is
38        // the SSH equivalent of speaking first, and it does so regardless of any flag.
39        Vec::new()
40    }
41    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
42        // None. close_ssh_connection and list_ssh_connections used to be advertised here, but
43        // both only produced an ActionResult::Custom that nothing consumed, and the connection
44        // map they read was never populated - they always did nothing.
45        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            // Beta, on the exact bar the Experimental comment here used to set: "promote
78            // when a real SSH client (openssh's ssh/sftp, or ssh2) completes auth and a
79            // channel exchange in a test that is neither #[ignore]d nor skipped when the
80            // binary is absent". `test_sftp_basic_operations` has done that for some time.
81            //
82            // The demotion that preceded this was right about russh and wrong about the
83            // tests. russh IS the library this server is built on, so driving the server
84            // with russh would be the circular case — that part stands. But the tests do
85            // not use russh: they use `ssh2`, which is a binding to the C libssh2 and
86            // shares no line of code with russh. The claim that libssh2 "does not complete
87            // a session" came from a stale comment in `tests/server/ssh/test.rs`,
88            // describing a runtime-blocking bug that was fixed by moving libssh2 onto
89            // `spawn_blocking`; the comment outlived the bug and was quoted here and in
90            // `src/server/ssh/CLAUDE.md` as the reason for the rating.
91            //
92            // Check what the test drives, not what the server links.
93            //
94            // Since September 2026 the rating rests on TWO independent clients rather than
95            // one: libssh2 as above, and the real OpenSSH `ssh` binary in
96            // tests/server/ssh/real_client_test.rs. One client can agree with one bug, and
97            // the second one immediately showed a real deviation from OpenSSH sshd (see
98            // `e2e_testing` on the exec-path CRLF translation).
99            .state(DevelopmentState::Beta)
100            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(22))
101            .well_known_port(22)
102            // The shell echo buffer is the only inbound allocation NetGet owns here; russh
103            // bounds the transport packets around it.
104            .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        // Deterministic: answer every shell command with a canned line, no LLM
182        // call.
183        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            // LLM mode: LLM handles all SSH responses intelligently
194            json!({
195                "type": "open_server",
196                "port": 2222,
197                "base_stack": "ssh",
198                "instruction": "SSH server providing secure shell access"
199            }),
200            // Script mode: Code-based deterministic responses
201            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            // Static mode: Fixed responses
215            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
234// Implement Server trait (server-specific functionality)
235impl 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 replies. These carry structured data back to sftp_handler.rs, which reads
271            // them out of the execution result rather than writing bytes itself.
272            "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        // Must be a real JSON boolean. The old code accepted any JSON value here, and the
297        // consumer then did `as_bool()`, so "allowed": "true" silently became a denial.
298        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        // Store the decision in the action result metadata
307        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
398// ============================================================================
399// SFTP reply actions
400//
401// Each SFTP request expects exactly ONE of these in the response. They are read back by
402// `sftp_handler.rs`, which turns them into the corresponding SSH_FXP_* packet.
403// ============================================================================
404
405fn 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
574// ============================================================================
575// SSH Action Constants
576// ============================================================================
577
578/// SSH send banner action constant
579pub 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
608/// SSH authentication decision action constant
609pub 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
637/// SSH shell response action constant
638pub 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
669/// SSH close connection action constant
670pub 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
687// ============================================================================
688// SSH Event Type Constants
689// ============================================================================
690// These are static definitions that can be referenced throughout the codebase
691
692/// SSH authentication event - triggered when a client attempts to authenticate
693pub 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
739/// SSH banner event - triggered when a shell session opens
740pub 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    // No parameters - banner is shown before any data is available
752    .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
761/// SSH shell command event - triggered when user enters a command
762pub 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
822/// SFTP operation event - triggered when SFTP client performs a filesystem operation
823pub 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
896/// Get SSH event types
897pub 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}