Skip to main content

netget/client/inetd/
actions.rs

1//! Clients for the six classic inetd services, sharing one engine.
2use crate::llm::actions::{
3    client_trait::{Client, ClientActionResult},
4    protocol_trait::Protocol,
5    ActionDefinition, ParameterDefinition,
6};
7use crate::protocol::{ConnectContext, EventType};
8use crate::server::inetd::{
9    actions::{action, parameter, Service},
10    wire,
11};
12use crate::state::app_state::AppState;
13use anyhow::{bail, ensure, Result};
14use serde_json::{json, Value};
15use std::sync::LazyLock;
16
17/// Characters read from a chargen stream when the action names no count.
18pub const DEFAULT_CHARGEN_READ: u64 = 1024;
19/// Upper bound on a chargen read.
20pub const MAX_CHARGEN_READ: u64 = 64 * 1024;
21
22pub fn query_action_name(service: Service) -> &'static str {
23    match service {
24        Service::Echo => "echo_send",
25        Service::Discard => "discard_send",
26        Service::Daytime => "daytime_query",
27        Service::Qotd => "qotd_query",
28        Service::Chargen => "chargen_query",
29        Service::Time => "time_query",
30    }
31}
32
33fn query_action(service: Service) -> ActionDefinition {
34    let name = query_action_name(service);
35    match service {
36        Service::Echo => action(
37            name,
38            "Send data to the echo service and read it back; the reply arrives as echo_response.",
39            vec![
40                parameter("data", "string", "Bytes to send, as text or hex per encoding", true),
41                parameter("encoding", "string", "Encoding of data: utf8 (default) or hex", false),
42            ],
43            json!({"type":name,"data":"ping"}),
44        ),
45        Service::Discard => action(
46            name,
47            "Send data to the discard service, which answers nothing; discard_sent confirms it left.",
48            vec![
49                parameter("data", "string", "Bytes to send, as text or hex per encoding", true),
50                parameter("encoding", "string", "Encoding of data: utf8 (default) or hex", false),
51            ],
52            json!({"type":name,"data":"into the void"}),
53        ),
54        Service::Chargen => action(
55            name,
56            "Read generated characters; the text arrives as chargen_response with a check against the RFC 864 pattern.",
57            vec![parameter("bytes", "number", "Bytes to read over TCP before closing (default 1024, at most 65536)", false)],
58            json!({"type":name}),
59        ),
60        Service::Daytime => action(name, "Ask for the date and time; the line arrives as daytime_response.", vec![], json!({"type":name})),
61        Service::Qotd => action(name, "Ask for the quote of the day; it arrives as qotd_response.", vec![], json!({"type":name})),
62        Service::Time => action(name, "Ask for the RFC 868 time; it arrives as time_response, decoded.", vec![], json!({"type":name})),
63    }
64}
65
66fn disconnect_action() -> ActionDefinition {
67    action(
68        "disconnect",
69        "Stop this client",
70        vec![],
71        json!({"type":"disconnect"}),
72    )
73}
74
75fn actions(service: Service) -> Vec<ActionDefinition> {
76    vec![query_action(service), disconnect_action()]
77}
78
79pub fn response_event_id(service: Service) -> &'static str {
80    match service {
81        Service::Echo => "echo_response",
82        Service::Discard => "discard_sent",
83        Service::Daytime => "daytime_response",
84        Service::Qotd => "qotd_response",
85        Service::Chargen => "chargen_response",
86        Service::Time => "time_response",
87    }
88}
89
90fn ready_event_id(service: Service) -> &'static str {
91    match service {
92        Service::Echo => "echo_ready",
93        Service::Discard => "discard_ready",
94        Service::Daytime => "daytime_ready",
95        Service::Qotd => "qotd_ready",
96        Service::Chargen => "chargen_ready",
97        Service::Time => "time_ready",
98    }
99}
100
101fn build_ready(service: Service) -> EventType {
102    EventType::new(
103        ready_event_id(service),
104        "The client is ready; each query is one TCP connection or one UDP datagram.",
105        query_action(service).example.clone(),
106    )
107    .with_parameters(vec![
108        parameter(
109            "remote_addr",
110            "string",
111            "Address of the service being queried",
112            true,
113        ),
114        parameter(
115            "transport",
116            "string",
117            "tcp or udp, from the transport startup parameter",
118            true,
119        ),
120    ])
121    .with_actions(actions(service))
122}
123
124fn build_response(service: Service) -> EventType {
125    let transport = parameter(
126        "transport",
127        "string",
128        "tcp or udp: how the exchange was made",
129        true,
130    );
131    let params = match service {
132        Service::Echo => vec![
133            transport,
134            parameter(
135                "data",
136                "string",
137                "What came back, as text or hex per encoding",
138                true,
139            ),
140            parameter(
141                "encoding",
142                "string",
143                "utf8 when the bytes are printable text, otherwise hex",
144                true,
145            ),
146            parameter("bytes", "number", "Number of bytes received", true),
147            parameter(
148                "matches",
149                "boolean",
150                "True when the reply equals what was sent",
151                true,
152            ),
153        ],
154        Service::Discard => vec![
155            transport,
156            parameter("bytes", "number", "Bytes sent and discarded", true),
157        ],
158        Service::Daytime => vec![
159            transport,
160            parameter(
161                "text",
162                "string",
163                "The date and time line, without its line ending",
164                true,
165            ),
166        ],
167        Service::Qotd => vec![
168            transport,
169            parameter(
170                "quote",
171                "string",
172                "The quote, line endings normalised to newlines",
173                true,
174            ),
175        ],
176        Service::Chargen => vec![
177            transport,
178            parameter("text", "string", "The characters received", true),
179            parameter("bytes", "number", "Number of bytes received", true),
180            parameter(
181                "conforms",
182                "boolean",
183                "True when the text follows RFC 864's rotating printable-ASCII pattern",
184                true,
185            ),
186        ],
187        Service::Time => vec![
188            transport,
189            parameter(
190                "seconds_since_1900",
191                "number",
192                "The raw 32-bit RFC 868 value",
193                true,
194            ),
195            parameter(
196                "unix_seconds",
197                "number",
198                "The same instant as Unix seconds",
199                true,
200            ),
201            parameter(
202                "iso8601",
203                "string",
204                "The same instant as an RFC 3339 timestamp",
205                true,
206            ),
207        ],
208    };
209    EventType::new(
210        response_event_id(service),
211        "The service's answer to one query.",
212        query_action(service).example.clone(),
213    )
214    .with_parameters(params)
215    .with_actions(actions(service))
216}
217
218macro_rules! events {
219    ($ready:ident, $response:ident, $service:expr) => {
220        pub static $ready: LazyLock<EventType> = LazyLock::new(|| build_ready($service));
221        pub static $response: LazyLock<EventType> = LazyLock::new(|| build_response($service));
222    };
223}
224events!(ECHO_READY, ECHO_RESPONSE, Service::Echo);
225events!(DISCARD_READY, DISCARD_RESPONSE, Service::Discard);
226events!(DAYTIME_READY, DAYTIME_RESPONSE, Service::Daytime);
227events!(QOTD_READY, QOTD_RESPONSE, Service::Qotd);
228events!(CHARGEN_READY, CHARGEN_RESPONSE, Service::Chargen);
229events!(TIME_READY, TIME_RESPONSE, Service::Time);
230
231pub fn ready_event(service: Service) -> &'static EventType {
232    match service {
233        Service::Echo => &ECHO_READY,
234        Service::Discard => &DISCARD_READY,
235        Service::Daytime => &DAYTIME_READY,
236        Service::Qotd => &QOTD_READY,
237        Service::Chargen => &CHARGEN_READY,
238        Service::Time => &TIME_READY,
239    }
240}
241
242pub fn response_event(service: Service) -> &'static EventType {
243    match service {
244        Service::Echo => &ECHO_RESPONSE,
245        Service::Discard => &DISCARD_RESPONSE,
246        Service::Daytime => &DAYTIME_RESPONSE,
247        Service::Qotd => &QOTD_RESPONSE,
248        Service::Chargen => &CHARGEN_RESPONSE,
249        Service::Time => &TIME_RESPONSE,
250    }
251}
252
253/// A validated query: bytes to send (Echo, Discard) and bytes to read (Chargen).
254#[derive(Debug, Clone)]
255pub struct Query {
256    pub payload: Vec<u8>,
257    pub read_bytes: u64,
258}
259
260pub fn parse_query(service: Service, v: &Value) -> Result<Query> {
261    ensure!(
262        v["type"].as_str() == Some(query_action_name(service)),
263        "Unknown {} client action",
264        service.name()
265    );
266    let payload = match service {
267        Service::Echo | Service::Discard => {
268            let data = v["data"]
269                .as_str()
270                .ok_or_else(|| anyhow::anyhow!("data must be a string"))?;
271            let bytes = wire::decode(data, v["encoding"].as_str())?;
272            ensure!(
273                bytes.len() <= wire::READ_CHUNK,
274                "data exceeds {} bytes",
275                wire::READ_CHUNK
276            );
277            ensure!(
278                !bytes.is_empty() || service == Service::Discard,
279                "echo data must not be empty"
280            );
281            bytes
282        }
283        _ => Vec::new(),
284    };
285    let read_bytes = match v.get("bytes").filter(|b| !b.is_null()) {
286        None => DEFAULT_CHARGEN_READ,
287        Some(b) => {
288            let n = b
289                .as_u64()
290                .ok_or_else(|| anyhow::anyhow!("bytes must be a positive integer"))?;
291            ensure!(
292                (1..=MAX_CHARGEN_READ).contains(&n),
293                "bytes must be 1 to {MAX_CHARGEN_READ}"
294            );
295            n
296        }
297    };
298    Ok(Query {
299        payload,
300        read_bytes,
301    })
302}
303
304pub fn startup_parameters() -> Vec<ParameterDefinition> {
305    vec![ParameterDefinition {
306        name: "transport".into(),
307        type_hint: "string".into(),
308        description: "tcp (one connection per query) or udp (one datagram per query)".into(),
309        required: false,
310        example: json!("udp"),
311        default: Some(json!(super::DEFAULT_TRANSPORT)),
312    }]
313}
314
315pub fn metadata(service: Service) -> crate::protocol::metadata::ProtocolMetadataV2 {
316    use crate::protocol::metadata::*;
317    ProtocolMetadataV2::builder()
318        .state(DevelopmentState::Experimental)
319        .privilege_requirement(PrivilegeRequirement::None)
320        .well_known_port(match service {
321            Service::Echo => 7,
322            Service::Discard => 9,
323            Service::Daytime => 13,
324            Service::Qotd => 17,
325            Service::Chargen => 19,
326            Service::Time => 37,
327        })
328        .implementation("One query per TCP connection or UDP datagram, bounded and with deadlines")
329        .llm_control("When to query the service and what to do with each answer")
330        .e2e_testing("tests/client/inetd: xinetd's own built-in services (and an external QOTD program) as the independent server")
331        .notes("Replies are bounded to 64 KiB on TCP and 8 KiB per datagram; connect, write and reply deadlines are 30 s on TCP, 5 s for a UDP reply. A UDP query that gets no reply raises no event and is logged.")
332        .max_inbound_bytes(wire::MAX_STREAM_REPLY)
333        .build()
334}
335
336pub fn startup_examples(service: Service) -> crate::llm::actions::StartupExamples {
337    let name = service.feature_name();
338    let llm = json!({"type":"open_client","protocol":name,"remote_addr":format!("127.0.0.1:{}", 10000 + service.port()),"instruction":format!("Query the {} service once", service.name())});
339    let query = query_action(service).example;
340    let mut static_example = llm.clone();
341    static_example["event_handlers"] = json!([
342        {"event_pattern": ready_event_id(service), "handler": {"type":"static","actions":[query.clone()]}},
343        {"event_pattern": response_event_id(service), "handler": {"type":"static","actions":[{"type":"disconnect"}]}}
344    ]);
345    let mut scripted = static_example.clone();
346    scripted["event_handlers"][0]["handler"] = json!({"type":"script","language":"python","code":format!("import json\nprint(json.dumps({{'actions':[{query}]}}))")});
347    crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
348}
349
350pub fn execute(service: Service, v: Value) -> Result<ClientActionResult> {
351    match v["type"].as_str() {
352        Some("disconnect") => Ok(ClientActionResult::Disconnect),
353        Some(name) if name == query_action_name(service) => {
354            parse_query(service, &v)?;
355            Ok(ClientActionResult::Custom {
356                name: name.to_string(),
357                data: v,
358            })
359        }
360        _ => bail!("Unknown {} client action", service.name()),
361    }
362}
363
364macro_rules! inetd_client {
365    ($ty:ident, $service:expr, $prompt:literal) => {
366        #[derive(Default)]
367        pub struct $ty;
368        impl $ty {
369            pub fn new() -> Self {
370                Self
371            }
372        }
373        impl Protocol for $ty {
374            fn protocol_name(&self) -> &'static str {
375                $service.name()
376            }
377            fn stack_name(&self) -> &'static str {
378                $service.stack_name()
379            }
380            fn keywords(&self) -> Vec<&'static str> {
381                $service.keywords()
382            }
383            fn description(&self) -> &'static str {
384                match $service {
385                    Service::Echo => "Echo client (RFC 862) over TCP or UDP",
386                    Service::Discard => "Discard client (RFC 863) over TCP or UDP",
387                    Service::Daytime => "Daytime client (RFC 867) over TCP or UDP",
388                    Service::Qotd => "Quote of the Day client (RFC 865) over TCP or UDP",
389                    Service::Chargen => "Character Generator client (RFC 864) over TCP or UDP",
390                    Service::Time => "Time client (RFC 868) over TCP or UDP",
391                }
392            }
393            fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
394                actions($service)
395            }
396            fn get_sync_actions(&self) -> Vec<ActionDefinition> {
397                vec![]
398            }
399            fn get_event_types(&self) -> Vec<EventType> {
400                vec![
401                    ready_event($service).clone(),
402                    response_event($service).clone(),
403                ]
404            }
405            fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
406                startup_parameters()
407            }
408            fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
409                metadata($service)
410            }
411            fn example_prompt(&self) -> &'static str {
412                $prompt
413            }
414            fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
415                startup_examples($service)
416            }
417            fn group_name(&self) -> &'static str {
418                "Network Services"
419            }
420        }
421        impl Client for $ty {
422            fn connect(
423                &self,
424                ctx: ConnectContext,
425            ) -> std::pin::Pin<
426                Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>,
427            > {
428                Box::pin(super::connect(ctx, $service))
429            }
430            fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
431                execute($service, v)
432            }
433        }
434    };
435}
436
437inetd_client!(
438    EchoClientProtocol,
439    Service::Echo,
440    "Send 'hello' to the echo service at 127.0.0.1:10007 and check it comes back"
441);
442inetd_client!(
443    DiscardClientProtocol,
444    Service::Discard,
445    "Send a test line to the discard service at 127.0.0.1:10009"
446);
447inetd_client!(
448    DaytimeClientProtocol,
449    Service::Daytime,
450    "Ask the daytime service at 127.0.0.1:10013 for the time"
451);
452inetd_client!(
453    QotdClientProtocol,
454    Service::Qotd,
455    "Fetch the quote of the day from 127.0.0.1:10017"
456);
457inetd_client!(
458    ChargenClientProtocol,
459    Service::Chargen,
460    "Read 2 KB from the chargen service at 127.0.0.1:10019"
461);
462inetd_client!(
463    TimeClientProtocol,
464    Service::Time,
465    "Ask the RFC 868 time server at 127.0.0.1:10037 for the time"
466);
467
468pub fn protocol(service: Service) -> &'static dyn Client {
469    match service {
470        Service::Echo => &EchoClientProtocol,
471        Service::Discard => &DiscardClientProtocol,
472        Service::Daytime => &DaytimeClientProtocol,
473        Service::Qotd => &QotdClientProtocol,
474        Service::Chargen => &ChargenClientProtocol,
475        Service::Time => &TimeClientProtocol,
476    }
477}