Skip to main content

netget/server/lpd/
actions.rs

1use crate::llm::actions::{
2    protocol_trait::{ActionResult, Protocol, Server},
3    ActionDefinition, Parameter, ParameterDefinition,
4};
5use crate::protocol::log_template::LogTemplate;
6use crate::protocol::{EventType, SpawnContext};
7use crate::state::app_state::AppState;
8use anyhow::{bail, ensure, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct LpdProtocol;
14impl LpdProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
21    Parameter {
22        name: name.into(),
23        type_hint: kind.into(),
24        description: description.into(),
25        required,
26    }
27}
28
29pub fn action(
30    name: &str,
31    description: &str,
32    parameters: Vec<Parameter>,
33    example: Value,
34) -> ActionDefinition {
35    let log_template = match name {
36        "lpd_job_reply" => LogTemplate::new().with_info("-> LPD job accept={accept}"),
37        "lpd_queue_status" => {
38            LogTemplate::new().with_info("-> LPD queue status={status} jobs={preview(jobs,120)}")
39        }
40        "lpd_remove_result" => {
41            LogTemplate::new().with_info("-> LPD removed={preview(removed,120)}")
42        }
43        "lpd_print" => {
44            LogTemplate::new().with_info("-> LPD print queue={queue} job_name={job_name}")
45        }
46        "lpd_queue" => LogTemplate::new().with_info("-> LPD queue query queue={queue} long={long}"),
47        "lpd_remove" => LogTemplate::new().with_info("-> LPD remove queue={queue} agent={agent}"),
48        "lpd_start_queue" => {
49            LogTemplate::new().with_info("-> LPD print waiting jobs queue={queue}")
50        }
51        _ => LogTemplate::new().with_info(format!("-> LPD {name}")),
52    };
53    ActionDefinition {
54        name: name.into(),
55        description: description.into(),
56        parameters,
57        example,
58        log_template: Some(log_template),
59    }
60}
61
62fn job_action() -> ActionDefinition {
63    action(
64        "lpd_job_reply",
65        "Accept or refuse the print job. Acceptance acknowledges the job's last file with 0; refusal sends a nonzero acknowledgement, which tells the client the job was not queued.",
66        vec![
67            parameter("accept", "boolean", "True to queue the job", true),
68            parameter("reason", "string", "Why, for the log; LPD has no field to carry it to the client", false),
69        ],
70        json!({"type":"lpd_job_reply","accept":true}),
71    )
72}
73
74fn queue_action() -> ActionDefinition {
75    action(
76        "lpd_queue_status",
77        "Describe the queue for lpq. Rust renders the short or long listing the client asked for.",
78        vec![
79            parameter("status", "string", "First line, such as 'raw is ready and printing'", false),
80            parameter(
81                "jobs",
82                "array",
83                "Queued jobs in order: [{owner, job_id: number, files: string, size: bytes, rank?: 'active' or a number}]",
84                false,
85            ),
86        ],
87        json!({"type":"lpd_queue_status","status":"raw is ready","jobs":[{"owner":"alice","job_id":42,"files":"report.txt","size":1024}]}),
88    )
89}
90
91fn remove_action() -> ActionDefinition {
92    action(
93        "lpd_remove_result",
94        "Report which jobs the removal request dequeued; each is listed back to lprm as dequeued.",
95        vec![parameter(
96            "removed",
97            "array",
98            "Job numbers removed, such as [42]; empty when nothing matched",
99            true,
100        )],
101        json!({"type":"lpd_remove_result","removed":[42]}),
102    )
103}
104
105pub static JOB_EVENT: LazyLock<EventType> = LazyLock::new(|| {
106    EventType::new(
107        "lpd_print_job",
108        "A complete print job: the control file and every data file it names have arrived. Decide whether to queue it.",
109        job_action().example.clone(),
110    )
111    .with_parameters(vec![
112        parameter("queue", "string", "Queue the client sent the job to", true),
113        parameter("job_id", "string", "Three-digit job number from the control file name", true),
114        parameter("host", "string", "Originating host (H line)", false),
115        parameter("user", "string", "Submitting user (P line)", false),
116        parameter("job_name", "string", "Job name (J line)", false),
117        parameter("title", "string", "Title for pr formatting (T line)", false),
118        parameter("class", "string", "Job class from the control file (C line)", false),
119        parameter("mail", "string", "Address to mail on completion (M line)", false),
120        parameter(
121            "files",
122            "array",
123            "[{name, source_name, format, size, text}] - text is the first 64 KiB when the file is text, null when binary",
124            true,
125        ),
126    ])
127    .with_actions(vec![job_action()])
128});
129
130pub static QUEUE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
131    EventType::new(
132        "lpd_queue_query",
133        "A client (lpq) asked for the state of a queue.",
134        queue_action().example.clone(),
135    )
136    .with_parameters(vec![
137        parameter(
138            "queue",
139            "string",
140            "Name of the print queue on the server",
141            true,
142        ),
143        parameter("long", "boolean", "True for the long format (lpq -l)", true),
144        parameter(
145            "list",
146            "array",
147            "Users or job numbers the listing is restricted to",
148            true,
149        ),
150    ])
151    .with_actions(vec![queue_action()])
152});
153
154pub static REMOVE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
155    EventType::new(
156        "lpd_remove_request",
157        "A client (lprm) asked to remove jobs. The agent is the user asking.",
158        remove_action().example.clone(),
159    )
160    .with_parameters(vec![
161        parameter(
162            "queue",
163            "string",
164            "Name of the print queue on the server",
165            true,
166        ),
167        parameter("agent", "string", "User requesting removal", true),
168        parameter(
169            "list",
170            "array",
171            "Users or job numbers to remove; empty means the agent's current job",
172            true,
173        ),
174    ])
175    .with_actions(vec![remove_action()])
176});
177
178impl Protocol for LpdProtocol {
179    fn protocol_name(&self) -> &'static str {
180        "LPD"
181    }
182    fn stack_name(&self) -> &'static str {
183        "ETH>IP>TCP>LPD"
184    }
185    fn description(&self) -> &'static str {
186        "LPD line printer daemon (RFC 1179): print jobs, queue listings and removals"
187    }
188    fn keywords(&self) -> Vec<&'static str> {
189        vec![
190            "lpd",
191            "lpr",
192            "lpq",
193            "lprm",
194            "rfc1179",
195            "line printer",
196            "print server",
197        ]
198    }
199    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
200        vec![]
201    }
202    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
203        vec![job_action(), queue_action(), remove_action()]
204    }
205    fn get_event_types(&self) -> Vec<EventType> {
206        vec![JOB_EVENT.clone(), QUEUE_EVENT.clone(), REMOVE_EVENT.clone()]
207    }
208    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
209        vec![
210            ParameterDefinition {
211                name: "queues".into(),
212                type_hint: "array".into(),
213                description: "Queue names to accept; a job for any other queue is refused at once. Omit to accept every queue.".into(),
214                required: false,
215                example: json!(["raw", "laser"]),
216                default: None,
217            },
218            ParameterDefinition {
219                name: "max_job_bytes".into(),
220                type_hint: "number".into(),
221                description: "Total size of the data files in one job (1..=268435456)".into(),
222                required: false,
223                example: json!(1048576),
224                default: Some(json!(super::wire::DEFAULT_MAX_JOB_BYTES)),
225            },
226            ParameterDefinition {
227                name: "idle_timeout_secs".into(),
228                type_hint: "number".into(),
229                description: "Seconds to wait for each command line or file transfer (1..=3600)".into(),
230                required: false,
231                example: json!(60),
232                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
233            },
234        ]
235    }
236    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
237        use crate::protocol::metadata::*;
238        ProtocolMetadataV2::builder()
239            .state(DevelopmentState::Experimental)
240            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(515))
241            .well_known_port(515)
242            .implementation("RFC 1179 over Tokio TCP: receive job (control and data files in either order, abort), short and long queue state, remove jobs, print waiting jobs")
243            .llm_control("Whether each complete print job is queued, what the queue listing says, and which jobs a removal dequeues")
244            .e2e_testing("tests/server/lpd: raw-wire jobs and bounds; LPRng lpr/lpq/lprm and the CUPS lpd backend as independent clients")
245            .notes("No spool: the handler sees every job's control fields and the text of each data file (first 64 KiB, null when binary) and decides; nothing is printed or kept. Source ports are not checked against the RFC's 721-731 range. A data file must declare its exact size (no count-0 streaming). Bounds: 1024-byte command lines, 64 KiB control files, 52 data files and max_job_bytes per job, per-read idle deadline. A handler failure refuses the job (nonzero acknowledgement), answers a queue query with an unavailable line, and removes nothing.")
246            .request_only("LPD answers the one command each connection carries; the server never speaks first")
247            .max_inbound_bytes(super::wire::MAX_COMMAND_LINE)
248            .build()
249    }
250    fn example_prompt(&self) -> &'static str {
251        "LPD print server on port 5515 with a queue named raw that accepts plain-text jobs"
252    }
253    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
254        let llm = json!({"type":"open_server","base_stack":"lpd","port":5515,"instruction":"Accept text jobs on queue raw","startup_params":{"queues":["raw"]}});
255        let mut scripted = llm.clone();
256        scripted["event_handlers"] = json!([
257            {"event_pattern":"lpd_print_job","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nok=all(f['text'] is not None for f in e['files'])\nprint(json.dumps({'actions':[{'type':'lpd_job_reply','accept':ok}]}))"}},
258            {"event_pattern":"lpd_queue_query","handler":{"type":"static","actions":[{"type":"lpd_queue_status","status":"raw is ready","jobs":[]}]}},
259            {"event_pattern":"lpd_remove_request","handler":{"type":"static","actions":[{"type":"lpd_remove_result","removed":[]}]}}
260        ]);
261        let mut static_example = llm.clone();
262        static_example["event_handlers"] = json!([
263            {"event_pattern":"lpd_print_job","handler":{"type":"static","actions":[{"type":"lpd_job_reply","accept":true}]}},
264            {"event_pattern":"lpd_queue_query","handler":{"type":"static","actions":[{"type":"lpd_queue_status","status":"raw is ready","jobs":[]}]}},
265            {"event_pattern":"lpd_remove_request","handler":{"type":"static","actions":[{"type":"lpd_remove_result","removed":[]}]}}
266        ]);
267        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
268    }
269    fn group_name(&self) -> &'static str {
270        "Web & File"
271    }
272}
273
274impl Server for LpdProtocol {
275    fn spawn(
276        &self,
277        ctx: SpawnContext,
278    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
279    {
280        Box::pin(super::spawn(ctx))
281    }
282    fn execute_action(&self, v: Value) -> Result<ActionResult> {
283        match v["type"].as_str() {
284            Some("lpd_job_reply") => {
285                ensure!(v["accept"].is_boolean(), "accept must be a boolean");
286                Ok(ActionResult::Custom {
287                    name: "lpd_job_reply".into(),
288                    data: v,
289                })
290            }
291            Some("lpd_queue_status") => {
292                ensure!(
293                    v.get("status").is_none_or(Value::is_string),
294                    "status must be a string"
295                );
296                if let Some(jobs) = v.get("jobs") {
297                    let jobs = jobs
298                        .as_array()
299                        .ok_or_else(|| anyhow::anyhow!("jobs must be an array"))?;
300                    ensure!(jobs.len() <= 1000, "at most 1000 jobs in one listing");
301                    for job in jobs {
302                        ensure!(job.is_object(), "each job must be an object");
303                    }
304                }
305                Ok(ActionResult::Custom {
306                    name: "lpd_queue_status".into(),
307                    data: v,
308                })
309            }
310            Some("lpd_remove_result") => {
311                let removed = v["removed"]
312                    .as_array()
313                    .ok_or_else(|| anyhow::anyhow!("removed must be an array"))?;
314                ensure!(removed.len() <= 1000, "at most 1000 removed jobs");
315                for id in removed {
316                    ensure!(
317                        id.is_u64() || id.as_str().is_some_and(super::wire::valid_token),
318                        "each removed job must be a number"
319                    );
320                }
321                Ok(ActionResult::Custom {
322                    name: "lpd_remove_result".into(),
323                    data: v,
324                })
325            }
326            _ => bail!("Unknown LPD server action"),
327        }
328    }
329}