Skip to main content

netget/server/db2/
actions.rs

1//! IBM Db2 (DRDA) protocol actions implementation.
2//!
3//! The LLM plays the Db2 server: it decides whether a login is accepted and what
4//! SQLCA a statement produces. **There is no storage** — no catalog, no tables, no
5//! row store in Rust. The DRDA/DDM wire encoding lives in [`super::drda`]; this
6//! module declares the events the model answers and the actions it answers with.
7
8use crate::llm::actions::{
9    protocol_trait::{ActionResult, Protocol, Server},
10    ActionDefinition, Parameter,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::server::connection::ConnectionId;
15use crate::state::app_state::AppState;
16use anyhow::{Context, Result};
17use serde_json::json;
18use std::sync::{Arc, LazyLock};
19use tokio::sync::mpsc;
20use tracing::debug;
21
22/// Db2 protocol action handler (stateless description; no storage).
23pub struct Db2Protocol {
24    _connection_id: ConnectionId,
25    _app_state: Arc<AppState>,
26    status_tx: mpsc::UnboundedSender<String>,
27}
28
29impl Db2Protocol {
30    pub fn new(
31        connection_id: ConnectionId,
32        app_state: Arc<AppState>,
33        status_tx: mpsc::UnboundedSender<String>,
34    ) -> Self {
35        Self {
36            _connection_id: connection_id,
37            _app_state: app_state,
38            status_tx,
39        }
40    }
41}
42
43impl Protocol for Db2Protocol {
44    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
45        // Nothing to configure at spawn time — declaring an unread parameter is a
46        // documented trap, so declare none.
47        vec![]
48    }
49
50    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
51        vec![]
52    }
53
54    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
55        vec![
56            db2_accept_connection_action(),
57            db2_reject_connection_action(),
58            db2_query_ok_action(),
59            db2_query_error_action(),
60        ]
61    }
62
63    fn protocol_name(&self) -> &'static str {
64        "Db2"
65    }
66
67    fn get_event_types(&self) -> Vec<EventType> {
68        get_db2_event_types()
69    }
70
71    fn stack_name(&self) -> &'static str {
72        "ETH>IP>TCP>DRDA>Db2"
73    }
74
75    fn keywords(&self) -> Vec<&'static str> {
76        // Distinctive, multi-word keywords — no bare "db"/"sql"/"database".
77        vec!["db2", "ibm db2", "drda"]
78    }
79
80    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
81        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
82
83        ProtocolMetadataV2::builder()
84            .state(DevelopmentState::Experimental)
85            .well_known_port(446)
86            .implementation(
87                "Hand-rolled DRDA/DDM codec (src/server/db2/drda.rs) against the public DRDA spec — \
88                 no maintained Rust DRDA crate exists",
89            )
90            .llm_control("Login accept/reject decision, and the SQLCA (SQLCODE/SQLSTATE) for a statement")
91            .e2e_testing(
92                "tests/server/db2/real_client_test.rs: two independent drivers - Apache Derby \
93                 10.17.1.0's JDBC client (through ij) and IBM's CLI driver (ibm_db 3.3.0) - log \
94                 in, are refused when the model refuses, run statements and read back the \
95                 model's row count and SQLSTATE; IBM's driver's default encrypted security \
96                 mechanism is declined. tshark's drda dissector reads both conversations. \
97                 Byte-literal tests of the codec beside it",
98            )
99            .notes(
100                "Scope: the connection handshake (EXCSAT/ACCSEC/SECCHK/ACCRDB) and the \
101                 EXCSQLIMM -> SQLCARD basic-query path (statement text extracted, model decides the \
102                 SQLCA). NOT implemented: SELECT result-set retrieval (OPNQRY/QRYDSC/QRYDTA/FDOCA \
103                 rows), PRPSQLSTT and prepared-statement parameter marshalling (refused with \
104                 CMDNSPRM), message text in the SQLCA, and TLS. RDBCMM/RDBRLLBCK are acknowledged \
105                 (there is no storage to commit). Experimental: statements only, no result \
106                 sets. Fail-closed: an LLM outage during login is a refusal (SECCHKRM \
107                 severity ERROR), never an accept.",
108            )
109            .build()
110    }
111
112    fn description(&self) -> &'static str {
113        "IBM Db2 database server (DRDA wire protocol: handshake + basic query)"
114    }
115
116    fn example_prompt(&self) -> &'static str {
117        "Start an IBM Db2 server on port 50000 that accepts logins and answers statements"
118    }
119
120    fn group_name(&self) -> &'static str {
121        "Database"
122    }
123
124    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
125        use crate::llm::actions::StartupExamples;
126
127        // Deterministic: acknowledge every statement with a success SQLCA
128        // (zero rows affected), no LLM call.
129        let script = r#"import json, sys
130data = json.load(sys.stdin)
131if data["event_type_id"] == "db2_query":
132    actions = [{"type": "db2_query_ok", "rows_affected": 0}]
133else:
134    actions = []
135print(json.dumps({"actions": actions}))"#;
136
137        StartupExamples::new(
138            // LLM mode
139            json!({
140                "type": "open_server",
141                "port": 50000,
142                "base_stack": "db2",
143                "instruction": "IBM Db2 server: accept logins for user db2inst1 and answer statements"
144            }),
145            // Script mode
146            json!({
147                "type": "open_server",
148                "port": 50000,
149                "base_stack": "db2",
150                "event_handlers": [{
151                    "event_pattern": "db2_query",
152                    "handler": {
153                        "type": "script",
154                        "language": "python",
155                        "code": script
156                    }
157                }]
158            }),
159            // Static mode
160            json!({
161                "type": "open_server",
162                "port": 50000,
163                "base_stack": "db2",
164                "event_handlers": [{
165                    "event_pattern": "db2_connect",
166                    "handler": {
167                        "type": "static",
168                        "actions": [{"type": "db2_accept_connection"}]
169                    }
170                }]
171            }),
172        )
173    }
174}
175
176impl Server for Db2Protocol {
177    fn spawn(
178        &self,
179        ctx: crate::protocol::SpawnContext,
180    ) -> std::pin::Pin<
181        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
182    > {
183        Box::pin(async move {
184            use crate::server::db2::Db2Server;
185            Db2Server::spawn_with_llm_actions(
186                ctx.legacy_listen_addr(),
187                ctx.llm_client,
188                ctx.state,
189                ctx.status_tx,
190                ctx.server_id,
191            )
192            .await
193        })
194    }
195
196    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
197        let action_type = action
198            .get("type")
199            .and_then(|v| v.as_str())
200            .context("Missing 'type' field in action")?;
201
202        match action_type {
203            "db2_accept_connection" => Ok(ActionResult::Custom {
204                name: "db2_accept_connection".to_string(),
205                data: json!({}),
206            }),
207            "db2_reject_connection" => self.execute_reject(action),
208            "db2_query_ok" => self.execute_query_ok(action),
209            "db2_query_error" => self.execute_query_error(action),
210            // The dashboard's "[ disconnect this peer ]" injects this. The generic
211            // peer-command task half-closes the write side on CloseConnection; the
212            // reader's `read() == 0` path then runs the normal teardown.
213            "close_connection" => Ok(ActionResult::CloseConnection),
214            _ => Err(anyhow::anyhow!("Unknown Db2 action: {}", action_type)),
215        }
216    }
217}
218
219impl Db2Protocol {
220    fn execute_reject(&self, action: serde_json::Value) -> Result<ActionResult> {
221        let reason = action
222            .get("reason")
223            .and_then(|v| v.as_str())
224            .unwrap_or("authentication failed")
225            .to_string();
226        let sec_check_code = action
227            .get("sec_check_code")
228            .and_then(|v| v.as_str())
229            .unwrap_or("password_invalid")
230            .to_string();
231        debug!("Db2 reject connection: {} ({})", reason, sec_check_code);
232        let _ = self
233            .status_tx
234            .send(format!("[DEBUG] Db2 ✗ reject: {}", reason));
235        Ok(ActionResult::Custom {
236            name: "db2_reject_connection".to_string(),
237            data: json!({ "reason": reason, "sec_check_code": sec_check_code }),
238        })
239    }
240
241    fn execute_query_ok(&self, action: serde_json::Value) -> Result<ActionResult> {
242        let rows_affected = action.get("rows_affected").and_then(|v| v.as_i64());
243        let sqlcode = action.get("sqlcode").and_then(|v| v.as_i64()).unwrap_or(0);
244        debug!(
245            "Db2 query ok: sqlcode={}, rows={:?}",
246            sqlcode, rows_affected
247        );
248        let _ = self.status_tx.send(format!(
249            "[DEBUG] Db2 → OK sqlcode={} rows={:?}",
250            sqlcode, rows_affected
251        ));
252        Ok(ActionResult::Custom {
253            name: "db2_query_ok".to_string(),
254            data: json!({ "rows_affected": rows_affected, "sqlcode": sqlcode }),
255        })
256    }
257
258    fn execute_query_error(&self, action: serde_json::Value) -> Result<ActionResult> {
259        let sqlcode = action
260            .get("sqlcode")
261            .and_then(|v| v.as_i64())
262            .unwrap_or(-104);
263        let sqlstate = action
264            .get("sqlstate")
265            .and_then(|v| v.as_str())
266            .unwrap_or("42601")
267            .to_string();
268        let message = action
269            .get("message")
270            .and_then(|v| v.as_str())
271            .unwrap_or("SQL error")
272            .to_string();
273        debug!(
274            "Db2 query error: sqlcode={}, sqlstate={}",
275            sqlcode, sqlstate
276        );
277        let _ = self.status_tx.send(format!(
278            "[DEBUG] Db2 ✗ SQL error sqlcode={} sqlstate={}",
279            sqlcode, sqlstate
280        ));
281        Ok(ActionResult::Custom {
282            name: "db2_query_error".to_string(),
283            data: json!({ "sqlcode": sqlcode, "sqlstate": sqlstate, "message": message }),
284        })
285    }
286}
287
288// ============================================================================
289// Action definitions
290// ============================================================================
291
292pub fn db2_accept_connection_action() -> ActionDefinition {
293    ActionDefinition {
294        name: "db2_accept_connection".to_string(),
295        description:
296            "Accept the client's login (the security check succeeds). Emit this only when \
297            the credentials should be accepted; to refuse, use db2_reject_connection. Leaving the \
298            db2_connect event unanswered is treated as a refusal."
299                .to_string(),
300        parameters: vec![],
301        example: json!({ "type": "db2_accept_connection" }),
302        log_template: Some(
303            LogTemplate::new()
304                .with_info("-> Db2 login accepted")
305                .with_debug("Db2 db2_accept_connection"),
306        ),
307    }
308}
309
310pub fn db2_reject_connection_action() -> ActionDefinition {
311    ActionDefinition {
312        name: "db2_reject_connection".to_string(),
313        description: "Refuse the client's login. The server replies with a SECCHKRM (security \
314            check reply message) at severity ERROR."
315            .to_string(),
316        parameters: vec![
317            Parameter {
318                name: "reason".to_string(),
319                type_hint: "string".to_string(),
320                description: "Human-readable reason (for logs)".to_string(),
321                required: false,
322            },
323            Parameter {
324                name: "sec_check_code".to_string(),
325                type_hint: "string".to_string(),
326                description: "Which security-check failure to report: \"password_invalid\" \
327                    (default), \"userid_unknown\", or \"userid_missing\"."
328                    .to_string(),
329                required: false,
330            },
331        ],
332        example: json!({
333            "type": "db2_reject_connection",
334            "reason": "Invalid password",
335            "sec_check_code": "password_invalid"
336        }),
337        log_template: Some(
338            LogTemplate::new()
339                .with_info("-> Db2 login rejected: {reason}")
340                .with_debug("Db2 db2_reject_connection: {sec_check_code}"),
341        ),
342    }
343}
344
345pub fn db2_query_ok_action() -> ActionDefinition {
346    ActionDefinition {
347        name: "db2_query_ok".to_string(),
348        description: "Report a successful statement. The server replies with an SQLCARD carrying \
349            the SQLCA. With sqlcode 0 and no rows_affected the SQLCA is sent NULL (the normal \
350            success reply); with rows_affected it carries that count in SQLERRD3, which a JDBC \
351            driver reports as the update count. NOTE: row data is not returned — SELECT \
352            result-set retrieval is not implemented; use this for INSERT/UPDATE/DELETE/DDL-style \
353            statements."
354            .to_string(),
355        parameters: vec![
356            Parameter {
357                name: "sqlcode".to_string(),
358                type_hint: "number".to_string(),
359                description: "SQLCODE to report (default 0 = success; e.g. 100 = no rows found)"
360                    .to_string(),
361                required: false,
362            },
363            Parameter {
364                name: "rows_affected".to_string(),
365                type_hint: "number".to_string(),
366                description: "Rows the statement inserted, updated or deleted; sent in SQLERRD3 \
367                    (with sqlcode 0)"
368                    .to_string(),
369                required: false,
370            },
371        ],
372        example: json!({ "type": "db2_query_ok", "sqlcode": 0, "rows_affected": 1 }),
373        log_template: Some(
374            LogTemplate::new()
375                .with_info("-> Db2 OK sqlcode={sqlcode}")
376                .with_debug("Db2 db2_query_ok: sqlcode={sqlcode}, rows_affected={rows_affected}"),
377        ),
378    }
379}
380
381pub fn db2_query_error_action() -> ActionDefinition {
382    ActionDefinition {
383        name: "db2_query_error".to_string(),
384        description: "Report a statement failure. The server replies with an SQLCARD whose SQLCA \
385            carries the SQLCODE and SQLSTATE you provide."
386            .to_string(),
387        parameters: vec![
388            Parameter {
389                name: "sqlcode".to_string(),
390                type_hint: "number".to_string(),
391                description:
392                    "Negative SQLCODE, e.g. -204 (object not found), -104 (syntax error), \
393                    -911 (deadlock/timeout)."
394                        .to_string(),
395                required: true,
396            },
397            Parameter {
398                name: "sqlstate".to_string(),
399                type_hint: "string".to_string(),
400                description: "5-character SQLSTATE, e.g. \"42704\", \"42601\", \"40001\""
401                    .to_string(),
402                required: true,
403            },
404            Parameter {
405                name: "message".to_string(),
406                type_hint: "string".to_string(),
407                description: "Error message (logged; the SQLCA message text field is not encoded)"
408                    .to_string(),
409                required: false,
410            },
411        ],
412        example: json!({
413            "type": "db2_query_error",
414            "sqlcode": -204,
415            "sqlstate": "42704",
416            "message": "DB2INST1.USERS is an undefined name"
417        }),
418        log_template: Some(
419            LogTemplate::new()
420                .with_info("-> Db2 ERR sqlcode={sqlcode} sqlstate={sqlstate}")
421                .with_debug("Db2 db2_query_error: sqlcode={sqlcode}, sqlstate={sqlstate}"),
422        ),
423    }
424}
425
426// ============================================================================
427// Action constants (attached to events)
428// ============================================================================
429
430pub static DB2_ACCEPT_CONNECTION_ACTION: LazyLock<ActionDefinition> =
431    LazyLock::new(db2_accept_connection_action);
432pub static DB2_REJECT_CONNECTION_ACTION: LazyLock<ActionDefinition> =
433    LazyLock::new(db2_reject_connection_action);
434pub static DB2_QUERY_OK_ACTION: LazyLock<ActionDefinition> = LazyLock::new(db2_query_ok_action);
435pub static DB2_QUERY_ERROR_ACTION: LazyLock<ActionDefinition> =
436    LazyLock::new(db2_query_error_action);
437
438// ============================================================================
439// Event types
440// ============================================================================
441
442/// Emitted after the client sends SECCHK (security check) with its credentials.
443pub static DB2_CONNECT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
444    EventType::new(
445        "db2_connect",
446        "Db2 client login (security check) received",
447        json!({ "type": "db2_accept_connection" }),
448    )
449    .with_parameters(vec![
450        Parameter {
451            name: "user_id".to_string(),
452            type_hint: "string".to_string(),
453            description: "The user id the client is authenticating as (EBCDIC-decoded)".to_string(),
454            required: false,
455        },
456        Parameter {
457            name: "rdb_name".to_string(),
458            type_hint: "string".to_string(),
459            description: "The relational database (RDBNAM) the client wants to access".to_string(),
460            required: false,
461        },
462        Parameter {
463            name: "has_password".to_string(),
464            type_hint: "boolean".to_string(),
465            description: "True if a password was supplied in the security check".to_string(),
466            required: false,
467        },
468    ])
469    .with_actions(vec![
470        DB2_ACCEPT_CONNECTION_ACTION.clone(),
471        DB2_REJECT_CONNECTION_ACTION.clone(),
472    ])
473    .with_log_template(
474        LogTemplate::new()
475            .with_info("Db2 login: {user_id}@{rdb_name}")
476            .with_debug("Db2 connect: user={user_id} rdb={rdb_name} has_password={has_password}"),
477    )
478});
479
480/// Emitted when the client executes a statement (EXCSQLIMM).
481pub static DB2_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
482    EventType::new(
483        "db2_query",
484        "Db2 SQL statement received",
485        json!({ "type": "db2_query_ok", "sqlcode": 0 }),
486    )
487    .with_parameters(vec![
488        Parameter {
489            name: "sql_text".to_string(),
490            type_hint: "string".to_string(),
491            description: "The SQL statement text, decoded from the requester's SQLSTT (UTF-8; bare EBCDIC is also read)".to_string(),
492            required: true,
493        },
494        Parameter {
495            name: "statement_type".to_string(),
496            type_hint: "string".to_string(),
497            description: "The DRDA command, e.g. \"execute_immediate\"".to_string(),
498            required: false,
499        },
500    ])
501    .with_actions(vec![
502        DB2_QUERY_OK_ACTION.clone(),
503        DB2_QUERY_ERROR_ACTION.clone(),
504    ])
505    .with_log_template(
506        LogTemplate::new()
507            .with_info("Db2 query: {preview(sql_text,80)}")
508            .with_debug("Db2 query: {sql_text}"),
509    )
510});
511
512/// All Db2 event types.
513pub fn get_db2_event_types() -> Vec<EventType> {
514    vec![DB2_CONNECT_EVENT.clone(), DB2_QUERY_EVENT.clone()]
515}