Skip to main content

netget/scripting/
types.rs

1//! Types for script-based response handling
2
3use serde::{Deserialize, Serialize};
4
5/// Supported scripting languages
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum ScriptLanguage {
9    Python,
10    JavaScript,
11    Go,
12    Perl,
13}
14
15impl ScriptLanguage {
16    /// Get the command to execute this language
17    pub fn command(&self) -> &'static str {
18        match self {
19            ScriptLanguage::Python => "python3",
20            ScriptLanguage::JavaScript => "node",
21            ScriptLanguage::Go => "go",
22            ScriptLanguage::Perl => "perl",
23        }
24    }
25
26    /// Parse from string
27    pub fn parse(s: &str) -> Option<Self> {
28        match s.to_lowercase().as_str() {
29            "python" | "python3" => Some(ScriptLanguage::Python),
30            "javascript" | "js" | "node" => Some(ScriptLanguage::JavaScript),
31            "go" | "golang" => Some(ScriptLanguage::Go),
32            "perl" => Some(ScriptLanguage::Perl),
33            _ => None,
34        }
35    }
36
37    /// Convert to string
38    pub fn as_str(&self) -> &'static str {
39        match self {
40            ScriptLanguage::Python => "python",
41            ScriptLanguage::JavaScript => "javascript",
42            ScriptLanguage::Go => "go",
43            ScriptLanguage::Perl => "perl",
44        }
45    }
46
47    /// Get all available scripting languages
48    pub fn all_languages() -> Vec<Self> {
49        vec![
50            ScriptLanguage::Python,
51            ScriptLanguage::JavaScript,
52            ScriptLanguage::Go,
53            ScriptLanguage::Perl,
54        ]
55    }
56
57    /// Format all available languages as a quoted, comma-separated list
58    /// Example: "'python', 'javascript', or 'go'"
59    pub fn all_languages_formatted() -> String {
60        let all = Self::all_languages();
61        let count = all.len();
62
63        if count == 0 {
64            return String::new();
65        }
66
67        if count == 1 {
68            return format!("'{}'", all[0].as_str());
69        }
70
71        let mut result = String::new();
72        for (i, lang) in all.iter().enumerate() {
73            if i == count - 1 {
74                // Last item
75                result.push_str(&format!("or '{}'", lang.as_str()));
76            } else if i == count - 2 {
77                // Second to last
78                result.push_str(&format!("'{}', ", lang.as_str()));
79            } else {
80                // All others
81                result.push_str(&format!("'{}', ", lang.as_str()));
82            }
83        }
84        result
85    }
86}
87
88/// Source of the script code
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum ScriptSource {
91    /// Script loaded from a file path
92    FilePath(String),
93    /// Script provided as inline code
94    Inline(String),
95}
96
97impl ScriptSource {
98    /// Source code is configuration, not an unbounded stream.
99    pub const MAX_CODE_BYTES: usize = 4 * 1024 * 1024;
100
101    /// Get the script code (either by reading file or returning inline code)
102    pub fn get_code(&self) -> Result<String, std::io::Error> {
103        match self {
104            ScriptSource::FilePath(path) => {
105                use std::io::Read;
106                let mut options = std::fs::OpenOptions::new();
107                options.read(true);
108                #[cfg(unix)]
109                {
110                    use std::os::unix::fs::OpenOptionsExt;
111                    // Opening a FIFO must not wait for a writer before metadata can
112                    // reject it; validate the opened descriptor to avoid a path race.
113                    options.custom_flags(libc::O_NONBLOCK);
114                }
115                let file = options.open(path)?;
116                if !file.metadata()?.is_file() {
117                    return Err(std::io::Error::new(
118                        std::io::ErrorKind::InvalidInput,
119                        "script source must be a regular file",
120                    ));
121                }
122                let mut code = String::new();
123                file.take(Self::MAX_CODE_BYTES as u64 + 1)
124                    .read_to_string(&mut code)?;
125                Self::check_size(&code)?;
126                Ok(code)
127            }
128            ScriptSource::Inline(code) => {
129                Self::check_size(code)?;
130                Ok(code.clone())
131            }
132        }
133    }
134
135    fn check_size(code: &str) -> std::io::Result<()> {
136        if code.len() > Self::MAX_CODE_BYTES {
137            return Err(std::io::Error::new(
138                std::io::ErrorKind::InvalidData,
139                "script source exceeds the 4 MiB cap",
140            ));
141        }
142        Ok(())
143    }
144
145    /// Keep filesystem work off async workers; callers include this in their budget.
146    pub async fn get_code_async(&self) -> std::io::Result<String> {
147        if let Self::Inline(_) = self {
148            return self.get_code();
149        }
150        #[cfg(target_arch = "wasm32")]
151        return Err(std::io::Error::new(
152            std::io::ErrorKind::Unsupported,
153            "file-backed scripts are not available in the browser",
154        ));
155        #[cfg(not(target_arch = "wasm32"))]
156        {
157            let source = self.clone();
158            tokio::task::spawn_blocking(move || source.get_code())
159                .await
160                .map_err(std::io::Error::other)?
161        }
162    }
163}
164
165/// Script configuration for a server
166#[derive(Debug, Clone)]
167pub struct ScriptConfig {
168    /// Scripting language
169    pub language: ScriptLanguage,
170
171    /// Source of the script
172    pub source: ScriptSource,
173
174    /// Context types this script handles (e.g., ["ssh_auth", "ssh_banner"] or ["all"])
175    pub handles_contexts: Vec<String>,
176}
177
178impl ScriptConfig {
179    /// Check if this script handles a given event type
180    pub fn handles_context(&self, event_type_id: &str) -> bool {
181        self.handles_contexts
182            .iter()
183            .any(|context| context == "all" || context == event_type_id)
184    }
185
186    /// Add context types to the handles list
187    pub fn add_contexts(&mut self, contexts: Vec<String>) {
188        for context in contexts {
189            if !self.handles_contexts.contains(&context) {
190                self.handles_contexts.push(context);
191            }
192        }
193        // If "all" is present, no need for specific contexts
194        if self.handles_contexts.contains(&"all".to_string()) {
195            self.handles_contexts = vec!["all".to_string()];
196        }
197    }
198
199    /// Remove context types from the handles list
200    pub fn remove_contexts(&mut self, contexts: &[String]) {
201        self.handles_contexts.retain(|c| !contexts.contains(c));
202    }
203}
204
205/// Structured input sent to scripts
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ScriptInput {
208    /// Type of event/context (e.g., "ssh_auth", "ssh_banner", "http_request")
209    pub event_type_id: String,
210
211    /// Server information (absent for client-side events). Serialization skips
212    /// `None`, so scripts attached to servers see exactly the input shape they
213    /// always did.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub server: Option<ServerContext>,
216
217    /// Client information (present only for client-side events)
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub client: Option<ClientContext>,
220
221    /// Connection information (if applicable)
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub connection: Option<ConnectionContext>,
224
225    /// Protocol-specific event data
226    pub event: serde_json::Value,
227}
228
229/// Server context information
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ServerContext {
232    /// Server ID
233    pub id: u32,
234
235    /// Listening port
236    pub port: u16,
237
238    /// Protocol stack name
239    pub stack: String,
240
241    /// Server memory (state storage)
242    pub memory: String,
243
244    /// User instructions for the server
245    pub instruction: String,
246}
247
248/// Client context information (for scripts attached to client protocols)
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ClientContext {
251    /// Client ID
252    pub id: u32,
253
254    /// Remote address the client is connected to
255    pub remote_addr: String,
256
257    /// Protocol name (e.g. "TCP", "Telnet")
258    pub protocol: String,
259
260    /// Client memory (state storage)
261    pub memory: String,
262
263    /// User instructions for the client
264    pub instruction: String,
265}
266
267/// Connection context information
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ConnectionContext {
270    /// Connection ID
271    pub id: String,
272
273    /// Remote address
274    pub remote_addr: String,
275
276    /// Bytes received on this connection
277    pub bytes_received: u64,
278
279    /// Bytes sent on this connection
280    pub bytes_sent: u64,
281}
282
283/// Response from a script - object with actions array
284#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
285pub struct ScriptResponse {
286    pub actions: Vec<serde_json::Value>,
287}
288
289/// Parse script response from JSON string
290///
291/// Scripts should return a JSON object with actions array: `{"actions": [{"type": "...", ...}, ...]}`
292/// For backwards compatibility, also accepts a bare array: `[{"type": "...", ...}, ...]`
293pub fn parse_script_response(s: &str) -> anyhow::Result<ScriptResponse> {
294    // Try parsing as ScriptResponse (object with actions field)
295    if let Ok(response) = serde_json::from_str::<ScriptResponse>(s) {
296        return Ok(response);
297    }
298
299    // Try parsing as bare array for backwards compatibility
300    if let Ok(actions) = serde_json::from_str::<Vec<serde_json::Value>>(s) {
301        return Ok(ScriptResponse { actions });
302    }
303
304    // Neither format worked
305    anyhow::bail!(
306        "Failed to parse script response. Expected either {{\"actions\": [...]}} or [...]. Input: {}",
307        s
308    )
309}
310
311/// Operations for updating script configuration
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "snake_case")]
314pub enum ScriptUpdateOperation {
315    /// Replace entire script configuration
316    Set,
317
318    /// Add context types to existing configuration
319    AddContexts,
320
321    /// Remove context types from existing configuration
322    RemoveContexts,
323
324    /// Disable scripts entirely (remove configuration)
325    Disable,
326}
327
328impl ScriptUpdateOperation {
329    /// Parse from string
330    pub fn parse(s: &str) -> Option<Self> {
331        match s.to_lowercase().as_str() {
332            "set" => Some(ScriptUpdateOperation::Set),
333            "add_contexts" | "add" => Some(ScriptUpdateOperation::AddContexts),
334            "remove_contexts" | "remove" => Some(ScriptUpdateOperation::RemoveContexts),
335            "disable" | "clear" => Some(ScriptUpdateOperation::Disable),
336            _ => None,
337        }
338    }
339}