Skip to main content

netget/scripting/
manager.rs

1//! Script manager for coordinating script execution
2
3use super::executor::{execute_script, execute_script_async};
4use super::types::{ScriptConfig, ScriptInput, ScriptResponse};
5use anyhow::Result;
6use tracing::{debug, info, warn};
7
8/// Manager for script execution and context routing
9pub struct ScriptManager;
10
11impl ScriptManager {
12    /// Decide whether the configured script should handle this input.
13    ///
14    /// Returns `Some(config)` when the script applies, `None` when the caller
15    /// should fall back to the LLM.
16    fn select_config<'a>(
17        config: Option<&'a ScriptConfig>,
18        input: &ScriptInput,
19    ) -> Option<&'a ScriptConfig> {
20        // If no script configured, use LLM
21        let config = match config {
22            Some(cfg) => cfg,
23            None => {
24                debug!("No script configured, using LLM");
25                return None;
26            }
27        };
28
29        // Check if script handles this event type
30        if !config.handles_context(&input.event_type_id) {
31            debug!(
32                "Script does not handle context '{}', using LLM",
33                input.event_type_id
34            );
35            return None;
36        }
37
38        info!(
39            "Executing {} script for context '{}'",
40            config.language.as_str(),
41            input.event_type_id
42        );
43
44        Some(config)
45    }
46
47    /// Interpret the executor's result for the caller.
48    fn finish(result: Result<ScriptResponse>) -> Result<Option<ScriptResponse>> {
49        match result {
50            Ok(response) => {
51                debug!(
52                    "Script handled request with {} actions",
53                    response.actions.len()
54                );
55                Ok(Some(response))
56            }
57            Err(e) => {
58                warn!("Script execution failed: {}. Falling back to LLM", e);
59                // Return error so caller knows script failed (not just "no script")
60                Err(e)
61            }
62        }
63    }
64
65    /// Try to handle a request with a script, if configured (async).
66    ///
67    /// This is the entry point for async callers: it never parks a tokio worker
68    /// thread while the interpreter runs.
69    ///
70    /// # Arguments
71    /// * `config` - Optional script configuration
72    /// * `input` - Structured input for the script
73    ///
74    /// # Returns
75    /// * `Ok(Some(ScriptResponse))` - Script handled the request successfully (returns actions array)
76    /// * `Ok(None)` - No script configured for this context
77    /// * `Err(_)` - Script execution failed (should fallback to LLM)
78    pub async fn try_execute_async(
79        config: Option<&ScriptConfig>,
80        input: &ScriptInput,
81    ) -> Result<Option<ScriptResponse>> {
82        let Some(config) = Self::select_config(config, input) else {
83            return Ok(None);
84        };
85
86        Self::finish(execute_script_async(config, input).await)
87    }
88
89    /// Try to handle a request with a script, if configured (blocking).
90    ///
91    /// **Do not call this from async code** — use [`ScriptManager::try_execute_async`].
92    /// Retained for synchronous callers (tests, tooling).
93    ///
94    /// # Behavior
95    /// - If no config or script doesn't handle this context → returns Ok(None)
96    /// - If script executes successfully and returns actions → returns Ok(Some(response))
97    /// - If script execution fails → logs error and returns Err (caller should fallback to LLM)
98    pub fn try_execute(
99        config: Option<&ScriptConfig>,
100        input: &ScriptInput,
101    ) -> Result<Option<ScriptResponse>> {
102        let Some(config) = Self::select_config(config, input) else {
103            return Ok(None);
104        };
105
106        Self::finish(execute_script(config, input))
107    }
108
109    /// Build script configuration from action parameters
110    ///
111    /// # Arguments
112    /// * `selected_mode` - Scripting mode (On, Off, Python, JavaScript, Go, Perl)
113    /// * `script_runtime` - Runtime choice (required in ON mode when script_inline is provided)
114    /// * `script_inline` - Optional inline script code
115    /// * `handles` - Context types this script handles
116    ///
117    /// # Returns
118    /// * `Ok(Some(ScriptConfig))` - Valid configuration was provided
119    /// * `Ok(None)` - No script configuration provided
120    /// * `Err(_)` - Invalid configuration
121    pub fn build_config(
122        selected_mode: crate::state::app_state::ScriptingMode,
123        script_runtime: Option<&str>,
124        script_inline: Option<&str>,
125        handles: Option<Vec<String>>,
126    ) -> Result<Option<ScriptConfig>> {
127        // If no script_inline provided, no script
128        let code = match script_inline {
129            Some(c) => c,
130            None => return Ok(None),
131        };
132
133        // Get language from script_runtime (ON mode) or selected mode (specific language mode)
134        let language = match selected_mode {
135            crate::state::app_state::ScriptingMode::On => {
136                // In ON mode, script_runtime is required when script_inline is provided
137                let runtime = script_runtime.ok_or_else(|| {
138                    anyhow::anyhow!("script_runtime is required when script_inline is provided")
139                })?;
140
141                // Parse runtime choice
142                match runtime.to_lowercase().as_str() {
143                    "python" | "py" => super::types::ScriptLanguage::Python,
144                    "javascript" | "js" | "node" => super::types::ScriptLanguage::JavaScript,
145                    "go" | "golang" => super::types::ScriptLanguage::Go,
146                    "perl" => super::types::ScriptLanguage::Perl,
147                    _ => anyhow::bail!(
148                        "Invalid script_runtime: '{}'. Valid options: python, javascript, go, perl",
149                        runtime
150                    ),
151                }
152            }
153            crate::state::app_state::ScriptingMode::Off => return Ok(None),
154            crate::state::app_state::ScriptingMode::Python => super::types::ScriptLanguage::Python,
155            crate::state::app_state::ScriptingMode::JavaScript => {
156                super::types::ScriptLanguage::JavaScript
157            }
158            crate::state::app_state::ScriptingMode::Go => super::types::ScriptLanguage::Go,
159            crate::state::app_state::ScriptingMode::Perl => super::types::ScriptLanguage::Perl,
160        };
161
162        // Use inline source
163        let source = super::types::ScriptSource::Inline(code.to_string());
164
165        // Determine handles (default to ["all"] if not specified)
166        let handles_contexts = handles.unwrap_or_else(|| vec!["all".to_string()]);
167
168        if handles_contexts.is_empty() {
169            anyhow::bail!("Script handles_contexts cannot be empty");
170        }
171
172        Ok(Some(ScriptConfig {
173            language,
174            source,
175            handles_contexts,
176        }))
177    }
178
179    /// Parse context type from event description
180    ///
181    /// This extracts the context type from the event description string.
182    /// Examples:
183    /// - "SSH authentication request for user 'alice'" → "ssh_auth"
184    /// - "SSH shell session opened - send banner/greeting" → "ssh_banner"
185    /// - "SSH shell command received: 'ls -la'" → "ssh_shell"
186    /// - "HTTP request: GET /api/users" → "http_request"
187    ///
188    /// # Arguments
189    /// * `event_description` - The event description string
190    ///
191    /// # Returns
192    /// * Best-effort context type string (defaults to "unknown" if cannot parse)
193    pub fn extract_context_type(event_description: &str) -> String {
194        let desc_lower = event_description.to_lowercase();
195
196        // SSH patterns
197        if desc_lower.contains("ssh") {
198            if desc_lower.contains("authentication") || desc_lower.contains("auth") {
199                return "ssh_auth".to_string();
200            }
201            if desc_lower.contains("banner") || desc_lower.contains("greeting") {
202                return "ssh_banner".to_string();
203            }
204            if desc_lower.contains("shell command") {
205                return "ssh_shell".to_string();
206            }
207            return "ssh_unknown".to_string();
208        }
209
210        // HTTP patterns
211        if desc_lower.contains("http request") {
212            return "http_request".to_string();
213        }
214
215        // TCP patterns
216        if desc_lower.contains("tcp") && desc_lower.contains("data") {
217            return "tcp_data".to_string();
218        }
219
220        // DNS patterns
221        if desc_lower.contains("dns query") {
222            return "dns_query".to_string();
223        }
224
225        // DHCP patterns
226        if desc_lower.contains("dhcp") {
227            if desc_lower.contains("discover") {
228                return "dhcp_discover".to_string();
229            }
230            if desc_lower.contains("request") {
231                return "dhcp_request".to_string();
232            }
233            return "dhcp_unknown".to_string();
234        }
235
236        // SMTP patterns
237        if desc_lower.contains("smtp") {
238            if desc_lower.contains("ehlo") || desc_lower.contains("helo") {
239                return "smtp_ehlo".to_string();
240            }
241            if desc_lower.contains("mail from") {
242                return "smtp_mail_from".to_string();
243            }
244            if desc_lower.contains("data") {
245                return "smtp_data".to_string();
246            }
247            return "smtp_unknown".to_string();
248        }
249
250        // Default
251        warn!("Could not extract context type from: {}", event_description);
252        "unknown".to_string()
253    }
254}