Skip to main content

netget/client/ssh/
actions.rs

1//! SSH commands and authenticated read-only SFTP actions.
2use crate::llm::actions::{
3    client_trait::{Client, ClientActionResult},
4    protocol_trait::Protocol,
5    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::{ConnectContext, EventType};
9use crate::state::AppState;
10use anyhow::{ensure, Context, Result};
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13fn field(name: &str, hint: &str, description: &str, required: bool) -> Parameter {
14    Parameter {
15        name: name.into(),
16        type_hint: hint.into(),
17        description: description.into(),
18        required,
19    }
20}
21fn action(
22    name: &str,
23    description: &str,
24    parameters: Vec<Parameter>,
25    example: Value,
26    log_template: &str,
27) -> ActionDefinition {
28    ActionDefinition {
29        name: name.into(),
30        description: description.into(),
31        parameters,
32        example,
33        log_template: Some(LogTemplate::new().with_info(log_template)),
34    }
35}
36fn event(id: &str, description: &str, fields: Vec<Parameter>) -> EventType {
37    EventType::new(id, description, json!({"type":"wait_for_more"}))
38        .with_parameters(fields)
39        .with_actions(SshClientProtocol.get_sync_actions())
40}
41pub static SSH_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
42    event(
43        "ssh_connected",
44        "SSH session authenticated",
45        vec![
46            field("remote_addr", "string", "Remote SSH server", true),
47            field("username", "string", "Authenticated username", true),
48            field(
49                "host_key_verified",
50                "boolean",
51                "Whether the configured SHA256 host-key pin matched",
52                true,
53            ),
54        ],
55    )
56});
57pub static SSH_CLIENT_OUTPUT_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58    event(
59        "ssh_output_received",
60        "SSH command completed",
61        vec![
62            field("command", "string", "Executed command", true),
63            field(
64                "output",
65                "string",
66                "Captured standard output from the completed command",
67                true,
68            ),
69            field("stderr", "string", "Command stderr when present", false),
70            field(
71                "exit_code",
72                "number",
73                "Exit status when supplied by the peer",
74                false,
75            ),
76        ],
77    )
78});
79pub static SSH_SFTP_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
80    event(
81        "ssh_sftp_result",
82        "SFTP v3 operation completed",
83        vec![
84            field(
85                "operation",
86                "string",
87                "stat, list_directory or read_file",
88                true,
89            ),
90            field(
91                "path",
92                "string",
93                "Remote file or directory path selected for SFTP",
94                true,
95            ),
96            field(
97                "attributes",
98                "object",
99                "File size, permissions, type, owners and timestamps when supplied",
100                false,
101            ),
102            field(
103                "entries",
104                "array",
105                "Directory entries with name and typed attributes",
106                false,
107            ),
108            field("offset", "number", "Read window start", false),
109            field("bytes_read", "number", "Bytes in the UTF-8 window", false),
110            field(
111                "eof",
112                "boolean",
113                "Whether the server reported EOF before the window filled",
114                false,
115            ),
116            field("text", "string", "UTF-8 file window", false),
117        ],
118    )
119});
120pub static SSH_OPERATION_FAILED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
121    event(
122        "ssh_operation_failed",
123        "SSH command or SFTP operation failed",
124        vec![
125            field(
126                "action_type",
127                "string",
128                "Name of the rejected SSH or SFTP action",
129                true,
130            ),
131            field("path", "string", "Remote path for SFTP", false),
132            field("command", "string", "Command for exec", false),
133            field(
134                "error",
135                "string",
136                "Transport, status, framing or local validation error",
137                true,
138            ),
139        ],
140    )
141});
142#[derive(Default)]
143pub struct SshClientProtocol;
144impl SshClientProtocol {
145    pub fn new() -> Self {
146        Self
147    }
148}
149impl Protocol for SshClientProtocol {
150    fn protocol_name(&self) -> &'static str {
151        "SSH"
152    }
153    fn stack_name(&self) -> &'static str {
154        "ETH>IP>TCP>SSH"
155    }
156    fn description(&self) -> &'static str {
157        "SSH commands and pinned SFTP v3 stat, directory listing and UTF-8 file reads"
158    }
159    fn keywords(&self) -> Vec<&'static str> {
160        vec!["ssh", "secure shell", "sftp", "connect to ssh"]
161    }
162    fn group_name(&self) -> &'static str {
163        "Network Infrastructure"
164    }
165    fn example_prompt(&self) -> &'static str {
166        "Connect with a trusted SHA256 host-key pin and list /reports over SFTP"
167    }
168    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
169        self.get_sync_actions()
170    }
171    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
172        vec![
173            action(
174                "execute_command",
175                "Execute a shell command; bounded stdout/stderr and exit status become ssh_output_received",
176                vec![field("command", "string", "Shell command, 1..4096 bytes", true)],
177                json!({"type":"execute_command","command":"pwd"}),
178                "Execute SSH command: {command}",
179            ),
180            action(
181                "sftp_stat",
182                "Inspect remote file or directory attributes using pinned SFTP v3",
183                vec![
184                    field("path", "string", "Remote path, 1..4096 bytes without NUL", true),
185                    field("follow_symlinks", "boolean", "Use STAT instead of LSTAT; default false", false),
186                ],
187                json!({"type":"sftp_stat","path":"/reports"}),
188                "Inspect SFTP path {path}",
189            ),
190            action(
191                "sftp_list_directory",
192                "List a remote directory, at most 1024 entries, with typed attributes",
193                vec![field("path", "string", "Remote directory, 1..4096 bytes without NUL", true)],
194                json!({"type":"sftp_list_directory","path":"/reports"}),
195                "List SFTP directory {path}",
196            ),
197            action(
198                "sftp_read_file",
199                "Read a bounded UTF-8 window from a remote file; no local file is created",
200                vec![
201                    field("path", "string", "Remote path, 1..4096 bytes without NUL", true),
202                    field("offset", "number", "Unsigned starting byte offset, default 0", false),
203                    field("length", "number", "Maximum bytes, 1..1048576, default 65536", false),
204                ],
205                json!({"type":"sftp_read_file","path":"/reports/today.txt","offset":0,"length":65536}),
206                "Read SFTP file window from {path}",
207            ),
208            action(
209                "disconnect",
210                "Disconnect SSH and cancel every command, SFTP operation and response handler",
211                vec![],
212                json!({"type":"disconnect"}),
213                "Disconnect SSH and cancel active operations",
214            ),
215            action(
216                "wait_for_more",
217                "Take no action and wait for another result",
218                vec![],
219                json!({"type":"wait_for_more"}),
220                "Wait for SSH command or SFTP result",
221            ),
222        ]
223    }
224    fn get_event_types(&self) -> Vec<EventType> {
225        vec![
226            SSH_CLIENT_CONNECTED_EVENT.clone(),
227            SSH_CLIENT_OUTPUT_RECEIVED_EVENT.clone(),
228            SSH_SFTP_RESULT_EVENT.clone(),
229            SSH_OPERATION_FAILED_EVENT.clone(),
230        ]
231    }
232    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
233        let p = |name: &str,
234                 hint: &str,
235                 description: &str,
236                 required: bool,
237                 example: Value,
238                 default: Option<Value>| ParameterDefinition {
239            name: name.into(),
240            type_hint: hint.into(),
241            description: description.into(),
242            required,
243            example,
244            default,
245        };
246        vec![
247            p("username","string","SSH account used to authenticate this connection",true,json!("user"),None),
248            p("password","string","Password for password authentication",false,json!("password"),None),
249            p("private_key_path","string","Operator's OpenSSH private-key file; read only at connect",false,json!("/home/user/.ssh/id_ed25519"),None),
250            p("private_key_passphrase","string","Passphrase for the private key",false,json!("passphrase"),None),
251            p("auth_method","string","password or publickey; defaults to publickey when private_key_path is set",false,json!("publickey"),None),
252            p("host_key_sha256","string","Trusted OpenSSH SHA256: host-key fingerprint. Required for SFTP; when supplied a mismatch fails the SSH handshake",false,json!("SHA256:<trusted fingerprint>"),None),
253            p("handshake_timeout_secs","number","Resolution, TCP, SSH handshake and authentication deadline, 1..60 seconds",false,json!(10),Some(json!(super::HANDSHAKE_TIMEOUT.as_secs()))),
254            p("operation_timeout_secs","number","Whole command or SFTP exchange deadline, 1..300 seconds",false,json!(30),Some(json!(super::OPERATION_TIMEOUT.as_secs()))),
255            p("idle_timeout_secs","number","SSH inactivity deadline, 1..3600 seconds",false,json!(300),Some(json!(super::IDLE_TIMEOUT.as_secs()))),
256        ]
257    }
258    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
259        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
260        ProtocolMetadataV2::builder().state(DevelopmentState::Experimental)
261            .implementation("russh 0.45 SSH transport with owned socket shutdown; bounded SFTP v3 request/response codec without detached library session tasks")
262            .llm_control("Shell command and stdout/stderr/exit status; SFTP stat, list_directory and read_file with structured attributes, directory entries and UTF-8 file windows. Shared handlers, memory, command injection and four follow-up levels")
263            .e2e_testing("Independent OpenSSH sshd command and SFTP sessions, host-key pin negatives and direct NetGet pairing; tests under tests/client/ssh. No tests skip missing peers")
264            .notes("SFTP requires an explicit SHA256 host-key pin. Command-only legacy sessions without a pin accept the peer key and are vulnerable to active attackers. SFTP packets 64 KiB, paths 4 KiB, read windows 1 MiB, directories 1024 entries and 16 empty batches, whole response budget 2 MiB; 16 total active operations/handlers. SSH stdout/stderr 1 MiB combined; channel payload 3 MiB and 4096 messages, at most 16 channels until peer CLOSE. A transport bound closes SSH; callback checks may include one extra upstream packet capped at 256 KiB. No SFTP writes, binary file action, protocol extensions, known_hosts, SSH agent, keyboard-interactive, certificate auth, PTY or forwarding. Experimental: no second independent SFTP server, pcap oracle or fuzz target")
265            .build()
266    }
267    fn get_startup_examples(&self) -> StartupExamples {
268        let base = || json!({"type":"open_client","protocol":"ssh","remote_addr":"localhost:22","startup_params":{"username":"user","private_key_path":"/home/user/.ssh/id_ed25519","host_key_sha256":"SHA256:<trusted fingerprint>"}});
269        let mut llm = base();
270        llm["instruction"] = json!("On ssh_connected, list /reports with sftp_list_directory. Inspect the resulting entries, then disconnect. Disconnect if the operation fails. Use only read-only SFTP actions.");
271        let mut script = base();
272        script["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python","code":r#"import json, sys
273event_type = json.load(sys.stdin)['event_type_id']
274actions = []
275if event_type == 'ssh_connected':
276    actions = [{'type': 'sftp_list_directory', 'path': '/reports'}]
277elif event_type in ('ssh_sftp_result', 'ssh_operation_failed'):
278    actions = [{'type': 'disconnect'}]
279print(json.dumps({'actions': actions}))"#}}]);
280        let mut static_handler = base();
281        static_handler["event_handlers"] = json!([
282            {"event_pattern":"ssh_connected","handler":{"type":"static","actions":[{"type":"sftp_list_directory","path":"/reports"}]}},
283            {"event_pattern":"ssh_sftp_result","handler":{"type":"static","actions":[{"type":"disconnect"}]}},
284            {"event_pattern":"ssh_operation_failed","handler":{"type":"static","actions":[{"type":"disconnect"}]}},
285            {"event_pattern":"*","handler":{"type":"static","actions":[]}}
286        ]);
287        StartupExamples::new(llm, script, static_handler)
288    }
289}
290impl Client for SshClientProtocol {
291    fn connect(
292        &self,
293        ctx: ConnectContext,
294    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
295    {
296        Box::pin(super::SshClient::connect(ctx))
297    }
298    fn execute_action(&self, action: Value) -> Result<ClientActionResult> {
299        match action["type"]
300            .as_str()
301            .context("Missing 'type' field in action")?
302        {
303            "execute_command" => {
304                let command = action["command"]
305                    .as_str()
306                    .context("Missing 'command' field")?;
307                ensure!(
308                    !command.is_empty() && command.len() <= super::sftp::MAX_PATH,
309                    "command must contain 1..4096 bytes"
310                );
311                Ok(ClientActionResult::Custom {
312                    name: "execute_command".into(),
313                    data: json!({"command":command}),
314                })
315            }
316            "sftp_stat" | "sftp_list_directory" | "sftp_read_file" => {
317                super::sftp::validate_action(&action)?;
318                Ok(ClientActionResult::Custom {
319                    name: "sftp_operation".into(),
320                    data: action,
321                })
322            }
323            "disconnect" => Ok(ClientActionResult::Disconnect),
324            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
325            other => anyhow::bail!("Unknown SSH client action: {other}"),
326        }
327    }
328}