1use std::io::{Cursor, Read};
4use std::sync::Arc;
5
6use axum::body::Bytes;
7use axum::extract::{Path, State};
8use axum::http::{header, StatusCode};
9use axum::response::IntoResponse;
10use axum::routing::{get, post};
11use axum::Router;
12use ipp::model::Operation;
13use ipp::parser::IppParser;
14use ipp::model::StatusCode as IppStatus;
15use ipp::prelude::*;
16use ipp::reader::IppReader;
17use num_traits::FromPrimitive;
18use crate::attributes::{
19 self, build_get_jobs_response, build_job_attrs_response, get_printer_attributes,
20 print_job_accepted, validate_job,
21};
22use crate::device::DeviceBackend;
23use crate::job::{JobId, JobRegistry, JobState};
24use crate::printer::{PrinterRecord, PrinterRegistry};
25use crate::raster::JobFailure;
26use crate::state::PersistedState;
27
28#[derive(Clone)]
31#[allow(missing_docs)]
32pub struct JobContext {
33 pub id: JobId,
34 pub printer_name: String,
35 pub cancel_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
36 pub document_format: String,
41}
42
43pub type PrintJobFn = Arc<
48 dyn Fn(JobContext, Vec<u8>, u32) -> Result<(), JobFailure>
49 + Send
50 + Sync,
51>;
52
53#[allow(missing_docs)]
55pub struct ServerOptions {
56 pub host: String,
57 pub port: u16,
58 pub printers: PrinterRegistry,
59 pub device_backend: Arc<dyn DeviceBackend>,
60 pub print_job: PrintJobFn,
61 pub state_path: std::path::PathBuf,
62 pub advertise_mdns: bool,
71}
72
73#[derive(Clone)]
76#[allow(missing_docs)]
77pub struct AppState {
78 pub host: String,
79 pub port: u16,
80 pub printers: PrinterRegistry,
81 pub print_job: PrintJobFn,
82 pub state_path: std::path::PathBuf,
83 pub jobs: JobRegistry,
84 pub device_backend: Arc<dyn DeviceBackend>,
85}
86
87pub struct Server;
89
90impl Server {
91 pub fn router(opts: ServerOptions) -> Router {
94 let state = AppState {
95 host: opts.host.clone(),
96 port: opts.port,
97 printers: opts.printers.clone(),
98 print_job: opts.print_job,
99 state_path: opts.state_path,
100 jobs: JobRegistry::new(),
101 device_backend: opts.device_backend,
102 };
103
104 Router::new()
105 .route("/", get(index_handler))
106 .route("/icon.png", get(icon_handler))
107 .route("/ipp/print/{name}", post(ipp_handler))
108 .route("/ipp/print/{name}/", post(ipp_handler))
109 .with_state(state)
110 }
111
112 pub async fn run(opts: ServerOptions) -> std::io::Result<()> {
116 let addr = format!("{}:{}", opts.host, opts.port);
117 let listener = tokio::net::TcpListener::bind(&addr).await?;
118 log::info!("ipp-printer-app listening on http://{addr}");
119
120 let _status = crate::status::spawn(opts.device_backend.clone(), opts.printers.clone());
122
123 #[cfg(feature = "mdns")]
126 let _advertiser = if opts.advertise_mdns {
127 match crate::mdns::Advertiser::register_all(&opts.printers, opts.port) {
128 Ok(adv) => Some(adv),
129 Err(e) => {
130 log::warn!("mdns: failed to register printers: {e}");
131 None
132 }
133 }
134 } else {
135 None
136 };
137
138 axum::serve(listener, Self::router(opts)).await
139 }
140
141 pub fn bootstrap_printers(
143 registry: &PrinterRegistry,
144 backend: &dyn DeviceBackend,
145 state_path: &std::path::Path,
146 make_config: impl Fn(&str, &str, &str, &str) -> Option<crate::printer::PrinterConfig>,
147 ) {
148 let mut records: Vec<PrinterRecord> = PersistedState::load(state_path)
149 .printers
150 .into_iter()
151 .map(PrinterRecord::new)
152 .collect();
153
154 backend.list(&mut |info, uri, device_id| {
155 let driver = match backend.driver_for_device(device_id, uri) {
156 Some(d) => d,
157 None => return true,
158 };
159 let name = printer_name_from_uri(uri, info);
160 if records.iter().any(|r| r.config.device_uri == uri) {
161 return true;
162 }
163 let Some(cfg) = make_config(&name, &driver, uri, device_id) else {
164 return true;
165 };
166 log::info!("auto-add printer {name} -> {uri}");
167 records.push(PrinterRecord::new(cfg));
168 true
169 });
170
171 *registry.write() = records;
172 Self::persist(registry, state_path);
173 }
174
175 pub fn persist(registry: &PrinterRegistry, state_path: &std::path::Path) {
179 let configs: Vec<_> = registry
180 .read()
181 .iter()
182 .map(|r| r.config.clone())
183 .collect();
184 let _ = PersistedState { printers: configs }.save(state_path);
185 }
186}
187
188fn printer_name_from_uri(uri: &str, info: &str) -> String {
192 let source = if info.is_empty() { uri } else { info };
193 let slug: String = source
194 .chars()
195 .map(|c| {
196 if c.is_ascii_alphanumeric() {
197 c.to_ascii_lowercase()
198 } else {
199 '-'
200 }
201 })
202 .collect();
203 let trimmed = slug.trim_matches('-');
204 let collapsed: String = trimmed
205 .split('-')
206 .filter(|s| !s.is_empty())
207 .collect::<Vec<_>>()
208 .join("-");
209 if collapsed.is_empty() {
210 "printer".to_string()
211 } else {
212 collapsed
213 }
214}
215
216async fn index_handler(State(state): State<AppState>) -> impl IntoResponse {
217 let printers = state.printers.read();
218 let mut html = String::from(
219 "<!DOCTYPE html><html><head><title>ipp-printer-app</title></head><body>\
220 <h1>ipp-printer-app</h1><ul>",
221 );
222 for p in printers.iter() {
223 let uri = p.config.printer_uri(&state.host, state.port);
224 html.push_str(&format!(
225 "<li><b>{}</b> — <code>{uri}</code> — device <code>{}</code></li>",
226 p.config.name, p.config.device_uri
227 ));
228 }
229 html.push_str(&format!(
230 "</ul><p>Register with CUPS: <code>lpadmin -p NAME -E -v \
231 ipp://{}:{}/ipp/print/NAME -m everywhere</code></p></body></html>",
232 if state.host.is_empty() || state.host == "0.0.0.0" || state.host == "::" {
233 "localhost"
234 } else {
235 &state.host
236 },
237 state.port,
238 ));
239 (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html)
240}
241
242async fn icon_handler() -> impl IntoResponse {
246 const ICON_PNG: &[u8] = &[
247 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
248 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
249 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x62, 0x00,
250 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
251 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
252 ];
253 (
254 StatusCode::OK,
255 [(header::CONTENT_TYPE, "image/png")],
256 ICON_PNG,
257 )
258}
259
260async fn ipp_handler(
261 State(state): State<AppState>,
262 Path(name): Path<String>,
263 body: Bytes,
264) -> impl IntoResponse {
265 match handle_ipp(&state, &name, &body) {
266 Ok(bytes) => (
267 StatusCode::OK,
268 [(header::CONTENT_TYPE, "application/ipp")],
269 bytes,
270 ),
271 Err((status, msg)) => (
272 status,
273 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
274 msg.into_bytes(),
275 ),
276 }
277}
278
279fn handle_ipp(state: &AppState, name: &str, body: &[u8]) -> Result<Vec<u8>, (StatusCode, String)> {
280 let mut req = IppParser::new(IppReader::new(Cursor::new(body.to_vec())))
281 .parse()
282 .map_err(|e| (StatusCode::BAD_REQUEST, format!("IPP parse error: {e}")))?;
283
284 let version = req.header().version;
285 let request_id = req.header().request_id;
286 let op_code = req.header().operation_or_status;
287
288 let major = version.0 >> 8;
290 if major != 1 && major != 2 {
291 let resp = IppRequestResponse::new_response(
292 IppVersion::v1_1(),
293 IppStatus::ServerErrorVersionNotSupported,
294 request_id,
295 )
296 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
297 return Ok(resp.to_bytes().to_vec());
298 }
299
300 if !operation_attributes_well_ordered(body) {
306 let resp = IppRequestResponse::new_response(
307 version,
308 IppStatus::ClientErrorBadRequest,
309 request_id,
310 )
311 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
312 return Ok(resp.to_bytes().to_vec());
313 }
314
315 if request_id == 0 {
319 let resp = IppRequestResponse::new_response(
320 version,
321 IppStatus::ClientErrorBadRequest,
322 request_id,
323 )
324 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
325 return Ok(resp.to_bytes().to_vec());
326 }
327
328 if !has_operation_attr(&req, "printer-uri") && !has_operation_attr(&req, "job-uri") {
332 let resp = IppRequestResponse::new_response(
333 version,
334 IppStatus::ClientErrorBadRequest,
335 request_id,
336 )
337 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
338 return Ok(resp.to_bytes().to_vec());
339 }
340
341 let record = {
342 let guard = state.printers.read();
343 guard
344 .iter()
345 .find(|p| p.config.name == name)
346 .cloned()
347 .ok_or((StatusCode::NOT_FOUND, format!("printer not found: {name}")))?
348 };
349
350 const OP_CANCEL_MY_JOBS: u16 = 0x0039;
353 const OP_CLOSE_JOB: u16 = 0x003b;
354 const OP_IDENTIFY_PRINTER: u16 = 0x003c;
355 match op_code {
356 OP_CLOSE_JOB => {
357 let status = match extract_job_id(&req).and_then(|id| state.jobs.get(id)) {
360 Some(_) => IppStatus::SuccessfulOk,
361 None => IppStatus::ClientErrorNotFound,
362 };
363 let resp = IppRequestResponse::new_response(version, status, request_id)
364 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
365 return Ok(resp.to_bytes().to_vec());
366 }
367 OP_CANCEL_MY_JOBS => {
368 for j in state.jobs.jobs_for_printer(name) {
369 state.jobs.cancel(j.id);
370 }
371 let resp =
372 IppRequestResponse::new_response(version, IppStatus::SuccessfulOk, request_id)
373 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
374 return Ok(resp.to_bytes().to_vec());
375 }
376 OP_IDENTIFY_PRINTER => {
377 let actions = extract_identify_actions(&req);
378 state.device_backend.identify(&record.config, &actions);
379 let resp =
380 IppRequestResponse::new_response(version, IppStatus::SuccessfulOk, request_id)
381 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
382 return Ok(resp.to_bytes().to_vec());
383 }
384 _ => {}
385 }
386
387 let op = Operation::from_u16(op_code)
388 .ok_or((StatusCode::BAD_REQUEST, "unknown IPP operation".into()))?;
389
390 let resp = match op {
391 Operation::GetPrinterAttributes => {
392 let requested = extract_requested_attributes(&req);
393 get_printer_attributes(
394 version,
395 request_id,
396 &record,
397 &state.host,
398 state.port,
399 requested.as_ref(),
400 )
401 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
402 }
403 Operation::ValidateJob => validate_job(version, request_id, &record, &state.host, state.port)
404 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?,
405 Operation::PrintJob => {
406 let copies = extract_copies(&req);
407 let format = extract_document_format(&req);
408 let mut payload = Vec::new();
409 req.payload_mut()
410 .read_to_end(&mut payload)
411 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
412
413 let job = state.jobs.create(name.to_string(), requesting_user(&req));
414 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
415 let accepted = print_job_accepted(version, request_id, &job, &printer_uri_str)
416 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
417
418 spawn_print_worker(state, name.to_string(), job, payload, copies, format);
419 accepted
420 }
421 Operation::CreateJob => {
422 let job = state.jobs.create(name.to_string(), requesting_user(&req));
424 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
425 print_job_accepted(version, request_id, &job, &printer_uri_str)
426 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
427 }
428 Operation::SendDocument => {
429 if !has_operation_attr(&req, "last-document") {
436 let resp = IppRequestResponse::new_response(
437 version,
438 IppStatus::ClientErrorBadRequest,
439 request_id,
440 )
441 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
442 return Ok(resp.to_bytes().to_vec());
443 }
444 let job_id = extract_job_id(&req).ok_or((
445 StatusCode::BAD_REQUEST,
446 "Send-Document missing job-id".to_string(),
447 ))?;
448 let job = state.jobs.get(job_id).ok_or((
449 StatusCode::NOT_FOUND,
450 format!("job not found: {job_id}"),
451 ))?;
452 let copies = extract_copies(&req);
453 let format = extract_document_format(&req);
454 let mut payload = Vec::new();
455 req.payload_mut()
456 .read_to_end(&mut payload)
457 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
458
459 if !payload.is_empty() {
460 spawn_print_worker(state, name.to_string(), job.clone(), payload, copies, format);
461 }
462 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
463 build_job_attrs_response(version, request_id, &job, &printer_uri_str, None)
464 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
465 }
466 Operation::GetJobs => {
467 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
468 let mut jobs = state.jobs.jobs_for_printer(name);
469 if my_jobs_flag(&req) {
472 let user = requesting_user(&req);
473 jobs.retain(|j| j.owner == user);
474 }
475 let requested = extract_requested_attributes(&req);
478 let default_set = ["job-uri".to_string(), "job-id".to_string()]
479 .into_iter()
480 .collect();
481 let filter = effective_filter(requested.as_ref(), &default_set);
482 build_get_jobs_response(version, request_id, &jobs, &printer_uri_str, filter)
483 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
484 }
485 Operation::GetJobAttributes => {
486 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
487 let job_id = extract_job_id(&req).ok_or((
488 StatusCode::BAD_REQUEST,
489 "Get-Job-Attributes missing job-id".to_string(),
490 ))?;
491 let job = state.jobs.get(job_id).ok_or((
492 StatusCode::NOT_FOUND,
493 format!("job not found: {job_id}"),
494 ))?;
495 let requested = extract_requested_attributes(&req);
498 let all = std::collections::BTreeSet::new();
499 let filter = effective_filter(requested.as_ref(), &all);
500 build_job_attrs_response(version, request_id, &job, &printer_uri_str, filter)
501 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
502 }
503 Operation::CancelJob => {
504 let job_id = extract_job_id(&req).ok_or((
505 StatusCode::BAD_REQUEST,
506 "Cancel-Job missing job-id".to_string(),
507 ))?;
508 let status = match state.jobs.cancel(job_id) {
509 None => IppStatus::ClientErrorNotFound,
510 Some(JobState::Canceled) => IppStatus::SuccessfulOk,
511 Some(_) => IppStatus::ClientErrorNotPossible,
512 };
513 IppRequestResponse::new_response(version, status, request_id)
514 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
515 }
516 _ => {
517 return Err((
518 StatusCode::BAD_REQUEST,
519 format!("unsupported IPP operation: {op:?}"),
520 ));
521 }
522 };
523
524 Ok(resp.to_bytes().to_vec())
525}
526
527fn operation_attributes_well_ordered(body: &[u8]) -> bool {
533 if body.len() <= 8 || body[8] != ipp::model::DelimiterTag::OperationAttributes as u8 {
535 return false;
536 }
537 let mut i = 9;
538 let mut names: Vec<&[u8]> = Vec::new();
539 while i < body.len() && names.len() < 2 {
540 let tag = body[i];
541 if tag <= 0x0f {
542 break; }
544 i += 1;
545 if i + 2 > body.len() {
546 return false;
547 }
548 let name_len = u16::from_be_bytes([body[i], body[i + 1]]) as usize;
549 i += 2;
550 if i + name_len > body.len() {
551 return false;
552 }
553 if name_len > 0 {
555 names.push(&body[i..i + name_len]);
556 }
557 i += name_len;
558 if i + 2 > body.len() {
559 return false;
560 }
561 let value_len = u16::from_be_bytes([body[i], body[i + 1]]) as usize;
562 i += 2 + value_len;
563 }
564 names.first() == Some(&b"attributes-charset".as_slice())
565 && names.get(1) == Some(&b"attributes-natural-language".as_slice())
566}
567
568fn has_operation_attr(req: &IppRequestResponse, name: &str) -> bool {
571 req.attributes()
572 .groups()
573 .iter()
574 .filter(|g| g.tag() == ipp::model::DelimiterTag::OperationAttributes)
575 .any(|g| g.attributes().keys().any(|k| k.as_str() == name))
576}
577
578fn extract_job_id(req: &IppRequestResponse) -> Option<JobId> {
579 for group in req.attributes().groups() {
580 for attr in group.attributes().values() {
581 if attr.name().as_str() == "job-id" {
582 if let IppValue::Integer(n) = attr.value() {
583 return Some((*n) as JobId);
584 }
585 }
586 if attr.name().as_str() == "job-uri" {
587 if let IppValue::Uri(s) = attr.value() {
588 return s.as_str().rsplit('/').next().and_then(|s| s.parse().ok());
589 }
590 }
591 }
592 }
593 None
594}
595
596fn extract_copies(req: &IppRequestResponse) -> u32 {
597 for group in req.attributes().groups() {
598 for attr in group.attributes().values() {
599 if attr.name().as_str() == "copies" {
600 if let IppValue::Integer(n) = attr.value() {
601 return (*n).max(1) as u32;
602 }
603 }
604 }
605 }
606 0
607}
608
609fn extract_document_format(req: &IppRequestResponse) -> String {
612 for group in req.attributes().groups() {
613 for attr in group.attributes().values() {
614 if attr.name().as_str() == "document-format" {
615 if let IppValue::MimeMediaType(s) = attr.value() {
616 return s.as_str().to_string();
617 }
618 }
619 }
620 }
621 "application/octet-stream".to_string()
622}
623
624fn extract_requested_attributes(req: &IppRequestResponse) -> Option<std::collections::BTreeSet<String>> {
627 for group in req.attributes().groups() {
628 for attr in group.attributes().values() {
629 if attr.name().as_str() == "requested-attributes" {
630 let mut set = std::collections::BTreeSet::new();
631 for v in attr.value().into_iter() {
632 if let IppValue::Keyword(k) = v {
633 set.insert(k.as_str().to_string());
634 }
635 }
636 return Some(set);
637 }
638 }
639 }
640 None
641}
642
643fn effective_filter<'a>(
647 requested: Option<&'a std::collections::BTreeSet<String>>,
648 default: &'a std::collections::BTreeSet<String>,
649) -> Option<&'a std::collections::BTreeSet<String>> {
650 match requested {
651 None => (!default.is_empty()).then_some(default),
652 Some(set) if set.is_empty() || set.contains("all") => None,
653 Some(set) => Some(set),
654 }
655}
656
657fn requesting_user(req: &IppRequestResponse) -> String {
660 for group in req.attributes().groups() {
661 for attr in group.attributes().values() {
662 if attr.name().as_str() == "requesting-user-name" {
663 if let IppValue::NameWithoutLanguage(s) = attr.value() {
664 return s.as_str().to_string();
665 }
666 }
667 }
668 }
669 "anonymous".to_string()
670}
671
672fn my_jobs_flag(req: &IppRequestResponse) -> bool {
674 for group in req.attributes().groups() {
675 for attr in group.attributes().values() {
676 if attr.name().as_str() == "my-jobs" {
677 if let IppValue::Boolean(b) = attr.value() {
678 return *b;
679 }
680 }
681 }
682 }
683 false
684}
685
686fn extract_identify_actions(req: &IppRequestResponse) -> Vec<String> {
688 for group in req.attributes().groups() {
689 for attr in group.attributes().values() {
690 if attr.name().as_str() == "identify-actions" {
691 return attr
692 .value()
693 .into_iter()
694 .filter_map(|v| match v {
695 IppValue::Keyword(k) => Some(k.as_str().to_string()),
696 _ => None,
697 })
698 .collect();
699 }
700 }
701 }
702 Vec::new()
703}
704
705fn spawn_print_worker(
709 state: &AppState,
710 printer_name: String,
711 job: crate::job::JobRecord,
712 payload: Vec<u8>,
713 copies: u32,
714 document_format: String,
715) {
716 let state_clone = state.clone();
717 let name_owned = printer_name;
718 let job_for_worker = job;
719 std::thread::spawn(move || {
720 {
721 let mut guard = state_clone.printers.write();
722 if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
723 attributes::set_printer_processing(p);
724 }
725 }
726 state_clone
727 .jobs
728 .set_state(job_for_worker.id, JobState::Processing);
729 let ctx = JobContext {
730 id: job_for_worker.id,
731 printer_name: name_owned.clone(),
732 cancel_flag: job_for_worker.cancel_flag.clone(),
733 document_format,
734 };
735 let result = (state_clone.print_job)(ctx, payload, copies);
736 {
737 let mut guard = state_clone.printers.write();
738 if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
739 attributes::set_printer_idle(p);
740 match &result {
741 Ok(()) => p.reasons = crate::flags::PrinterReason::empty(),
742 Err(f) => p.reasons = f.printer_reasons,
743 }
744 }
745 }
746 match result {
747 Ok(()) => {
748 if !job_for_worker.cancel_flag.load(std::sync::atomic::Ordering::Acquire) {
751 state_clone
752 .jobs
753 .set_state(job_for_worker.id, JobState::Completed);
754 }
755 }
756 Err(f) => {
757 log::error!(
758 "print job {} failed: {} (reasons={:?})",
759 job_for_worker.id,
760 f.message,
761 f.printer_reasons,
762 );
763 state_clone
764 .jobs
765 .set_failure(job_for_worker.id, f.printer_reasons, f.message);
766 }
767 }
768 Server::persist(&state_clone.printers, &state_clone.state_path);
769 });
770}