package main import ( "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" ) var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`) func checkPrinter(p string) error { if !queueName.MatchString(p) || strings.HasPrefix(p, "-") { return fmt.Errorf("%q is not a printer queue's name", p) } return nil } var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`) // optionsOf reads the print options object. func optionsOf(args map[string]any) (map[string]string, error) { v, ok := args["options"] if !ok || v == nil { return nil, nil } m, ok := v.(map[string]any) if !ok { return nil, fmt.Errorf("options must be an object of names to values") } out := map[string]string{} for k, val := range m { s, ok := val.(string) if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) { return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val) } out[k] = s } return out, nil } // lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes. func lpoptionsOf(s string) map[string]string { out := map[string]string{} s = strings.TrimSpace(s) i := 0 for i < len(s) { for i < len(s) && s[i] == ' ' { i++ } start := i for i < len(s) && s[i] != '=' && s[i] != ' ' { i++ } key := s[start:i] if i >= len(s) || s[i] != '=' { if key != "" { out[key] = "" } continue } i++ var b strings.Builder for i < len(s) && s[i] != ' ' { switch s[i] { case '\'': i++ for i < len(s) && s[i] != '\'' { if s[i] == '\\' && i+1 < len(s) { i++ } b.WriteByte(s[i]) i++ } i++ case '\\': if i+1 < len(s) { b.WriteByte(s[i+1]) } i += 2 default: b.WriteByte(s[i]) i++ } } out[key] = b.String() } return out } // Marker is one supply the printer reports. type Marker struct { Name string `json:"name"` Type string `json:"type,omitempty"` Level int `json:"level_percent"` Low bool `json:"low"` } // Printer is one queue. type Printer struct { Name string `json:"name"` State string `json:"state"` Enabled bool `json:"enabled"` Accepting bool `json:"accepting"` Default bool `json:"default"` URI string `json:"uri"` MakeModel string `json:"make_and_model"` Driverless bool `json:"driverless"` Shared bool `json:"shared"` Reasons []string `json:"reasons"` Markers []Marker `json:"markers"` Since string `json:"since,omitempty"` Message string `json:"message,omitempty"` } // PrintersAnswer is what cups_printers answers. type PrintersAnswer struct { Scheduler string `json:"scheduler"` Default string `json:"default,omitempty"` Printers []Printer `json:"printers"` } func lpstat(args ...string) (string, error) { r, err := call(Cmd{Name: "lpstat", Args: args}) if err != nil { if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") { return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr)) } // lpstat answers "No destinations added." with a non-zero status: that is no printers. if strings.Contains(r.Stderr, "No destinations added") { return "", nil } return "", err } return r.Stdout, nil } func defaultPrinter() (string, error) { out, err := lpstat("-d") if err != nil { return "", err } if _, after, ok := strings.Cut(out, "system default destination: "); ok { return strings.TrimSpace(firstLine(after)), nil } return "", nil } // Printers answers every queue with its state, device, model and supplies. func Printers() (PrintersAnswer, error) { out := PrintersAnswer{Printers: []Printer{}} sched, err := lpstat("-r") if err != nil { return out, err } out.Scheduler = strings.TrimSpace(sched) if out.Default, err = defaultPrinter(); err != nil { return out, err } ps, err := lpstat("-p") if err != nil { return out, err } var cur *Printer for _, l := range strings.Split(ps, "\n") { if strings.HasPrefix(l, "printer ") { f := strings.Fields(l) if len(f) < 3 { continue } out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}}) cur = &out.Printers[len(out.Printers)-1] rest := strings.Join(f[2:], " ") switch { case strings.HasPrefix(rest, "is idle"): cur.State = "idle" case strings.HasPrefix(rest, "now printing"): cur.State = "printing" default: cur.State = "stopped" } // "disabled since …" (stopped), or "enabled since …" after the state. cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since") if _, since, found := strings.Cut(rest, " since "); found { cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -") } continue } if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" { cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l)) } } acc, err := lpstat("-a") if err != nil { return out, err } accepting := map[string]bool{} for _, l := range lines(acc) { if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" { accepting[f[0]] = true } } for i := range out.Printers { p := &out.Printers[i] p.Accepting = accepting[p.Name] p.Default = p.Name == out.Default r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}}) if err != nil { return out, err } o := lpoptionsOf(r.Stdout) p.URI = o["device-uri"] p.MakeModel = o["printer-make-and-model"] p.Driverless = driverless(p.MakeModel, p.URI) p.Shared = o["printer-is-shared"] == "true" for _, reason := range strings.Split(o["printer-state-reasons"], ",") { if reason = strings.TrimSpace(reason); reason != "" && reason != "none" { p.Reasons = append(p.Reasons, reason) } } p.Markers = markersOf(o) } return out, nil } // driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a // driverless URI. func driverless(model, uri string) bool { m := strings.ToLower(model) return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") || strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere") } func markersOf(o map[string]string) []Marker { split := func(k string) []string { if o[k] == "" { return nil } return strings.Split(o[k], ",") } names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types") out := []Marker{} for i, n := range names { if i >= len(levels) { break } level, err := strconv.Atoi(strings.TrimSpace(levels[i])) if err != nil { continue } m := Marker{Name: strings.TrimSpace(n), Level: level} if i < len(types) { m.Type = strings.TrimSpace(types[i]) } if i < len(lows) { if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low { m.Low = true } } out = append(out, m) } return out } // Job is one print job. type Job struct { ID string `json:"id"` Printer string `json:"printer"` User string `json:"user"` Bytes int64 `json:"bytes"` Submitted string `json:"submitted"` } // Queue answers the jobs waiting, or the finished ones. func Queue(printer string, completed bool, limit int) (map[string]any, error) { args := []string{} if completed { args = append(args, "-W", "completed") } args = append(args, "-o") if printer != "" { if err := checkPrinter(printer); err != nil { return nil, err } args = append(args, printer) } out, err := lpstat(args...) if err != nil { return nil, err } jobs := []Job{} for _, l := range lines(out) { f := strings.Fields(l) if len(f) < 4 { continue } i := strings.LastIndex(f[0], "-") if i <= 0 { continue } size, _ := strconv.ParseInt(f[2], 10, 64) jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")}) } sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) }) total := len(jobs) if len(jobs) > limit { jobs = jobs[:limit] } return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil } func jobNumber(id string) int { n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:]) return n } var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`) // refused says CUPS kept an act for its administrators. func refused(r Result) bool { s := r.Stderr + r.Stdout return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed") } // asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account. func asAccountThenRoot(c Cmd) (Result, bool, error) { r := run(c) if r.Status == 0 && r.Error == "" { return r, false, nil } if !refused(r) { return r, false, failure(c, r) } c.Root = true r, err := call(c) return r, true, err } // Cancel cancels one job, or every job on a printer. func Cancel(job, printer string, all bool) (map[string]any, error) { var c Cmd switch { case all: if printer == "" { return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered") } if err := checkPrinter(printer); err != nil { return nil, err } c = Cmd{Name: "cancel", Args: []string{"-a", printer}} case job != "": if !jobID.MatchString(job) { return nil, fmt.Errorf("%q is not a job id", job) } c = Cmd{Name: "cancel", Args: []string{job}} default: return nil, fmt.Errorf("give job, or printer with all") } _, escalated, err := asAccountThenRoot(c) if err != nil { return nil, err } return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil } var requestID = regexp.MustCompile(`request id is (\S+)`) // statFile tells a regular file. Tests replace it. var statFile = func(p string) error { info, err := os.Stat(p) if err != nil { return err } if !info.Mode().IsRegular() { return fmt.Errorf("%s is not a regular file", p) } return nil } // Print sends a file to a printer. func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) { if !filepath.IsAbs(file) { return nil, fmt.Errorf("file must be an absolute path, not %q", file) } if err := statFile(file); err != nil { return nil, fmt.Errorf("cannot print %s: %v", file, err) } args := []string{} if printer != "" { if err := checkPrinter(printer); err != nil { return nil, err } args = append(args, "-d", printer) } args = append(args, "-n", strconv.Itoa(copies)) names := make([]string, 0, len(opts)) for k := range opts { names = append(names, k) } sort.Strings(names) for _, k := range names { args = append(args, "-o", k+"="+opts[k]) } if title == "" { title = filepath.Base(file) } args = append(args, "-t", title, "--", file) r, err := call(Cmd{Name: "lp", Args: args}) if err != nil { if strings.Contains(r.Stderr, "No default destination") { return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default") } return nil, err } m := requestID.FindStringSubmatch(r.Stdout) if m == nil { return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr)) } return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil } // Default answers the default printer, or sets it. func Default(printer string) (map[string]any, error) { if printer == "" { d, err := defaultPrinter() if err != nil { return nil, err } return map[string]any{"default": d}, nil } if err := checkPrinter(printer); err != nil { return nil, err } was, err := defaultPrinter() if err != nil { return nil, err } if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil { return nil, err } return map[string]any{"default": printer, "was": was}, nil } // Resume enables a printer and makes it accept jobs. func Resume(printer string) (map[string]any, error) { if err := checkPrinter(printer); err != nil { return nil, err } for _, c := range []string{"cupsenable", "cupsaccept"} { if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil { return nil, err } } return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil } // DriverPackage is a package that brings filters or backends. type DriverPackage struct { Package string `json:"package"` Version string `json:"version"` Foreign bool `json:"foreign"` } // Model is one driver model CUPS offers. type Model struct { PPD string `json:"ppd"` Description string `json:"description"` } // QueueDriver is how one queue prints. type QueueDriver struct { Printer string `json:"printer"` MakeModel string `json:"make_and_model"` Driverless bool `json:"driverless"` URI string `json:"uri"` } // DriversAnswer is what cups_drivers answers. type DriversAnswer struct { Queues []QueueDriver `json:"queues"` Packages []DriverPackage `json:"driver_packages"` Models []Model `json:"models"` Matched int `json:"models_matched"` Findings []string `json:"findings"` } // driverDirs are where drivers put what CUPS runs. var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"} // basePackages bring CUPS's own filters and backends, not a printer's driver. var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false} // Drivers answers how each queue prints and which packages bring drivers. func Drivers(match string, limit int) (DriversAnswer, error) { out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}} ps, err := Printers() if err != nil { return out, err } for _, p := range ps.Printers { out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI}) } r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)}) if r.Error != "" { return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r) } seen := map[string]bool{} for _, l := range lines(r.Stdout) { if _, after, ok := strings.Cut(l, " is owned by "); ok { f := strings.Fields(after) if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] { seen[f[0]] = true out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]}) } } } if len(out.Packages) > 0 { r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}}) foreign := map[string]bool{} for _, l := range lines(r.Stdout) { foreign[strings.TrimSpace(l)] = true } for i := range out.Packages { out.Packages[i].Foreign = foreign[out.Packages[i].Package] } } sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package }) m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}}) if err != nil { return out, err } for _, l := range lines(m.Stdout) { ppd, desc, _ := strings.Cut(l, " ") if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) { continue } out.Matched++ if len(out.Models) < limit { out.Models = append(out.Models, Model{PPD: ppd, Description: desc}) } } // Which driver packages no queue prints through. allDriverless := len(out.Queues) > 0 for _, q := range out.Queues { allDriverless = allDriverless && q.Driverless } for _, p := range out.Packages { if p.Foreign { out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories") } if allDriverless { out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package) } } return out, nil }