Research 027 asked for cups with the printer's driver. Measured: both Brother queues already print through IPP Everywhere, so cups and cups-filters are the whole driver, and the AUR vendor packages beside them serve no queue. The desktop's Canon is the exception: its 2012 driver is AUR-only and waits for the mesh's package repository; it stays as found. Seven Go tools: printers (state, device, driverless or not, supply levels), queue, cancel (the account first, sudo -n when CUPS refuses it), print, default, resume, and drivers (which packages bring drivers, which are foreign, which no queue uses).
561 lines
16 KiB
Go
561 lines
16 KiB
Go
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
|
|
}
|