346 lines
11 KiB
Go
346 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// IPP (RFC 8010, RFC 8011) by hand: Get-Printer-Attributes and Print-Job, neither of which needs a login.
|
|
|
|
const (
|
|
ippGetPrinterAttributes = 0x000B
|
|
ippPrintJob = 0x0002
|
|
ippOperation = 0x01
|
|
ippJob = 0x02
|
|
ippEnd = 0x03
|
|
ippInteger = 0x21
|
|
ippEnum = 0x23
|
|
ippNameNoLang = 0x42
|
|
ippKeyword = 0x44
|
|
ippURItag = 0x45
|
|
ippMimeType = 0x49
|
|
ippCharset = 0x47
|
|
ippLanguage = 0x48
|
|
)
|
|
|
|
// statusAttributes are the attributes printer.status asks for.
|
|
var statusAttributes = []string{
|
|
"printer-state", "printer-state-reasons", "marker-names", "marker-levels", "marker-colors", "printer-make-and-model",
|
|
}
|
|
|
|
// ippURL is where a printer's IPP service answers: the port IPP is registered on, and the path the printer
|
|
// serves (CUPS shows ipp://<printer>/ipp/port1). A variable so a test can put a server of its own in its place.
|
|
var ippURL = func(host string) string {
|
|
h := host
|
|
if hh, _, err := net.SplitHostPort(host); err == nil {
|
|
h = hh
|
|
}
|
|
if strings.Contains(h, ":") {
|
|
h = "[" + h + "]"
|
|
}
|
|
return "http://" + h + ":631/ipp/port1"
|
|
}
|
|
|
|
// ippPrinterURI is the printer-uri operation attribute for a host.
|
|
func ippPrinterURI(host string) string {
|
|
h := host
|
|
if hh, _, err := net.SplitHostPort(host); err == nil {
|
|
h = hh
|
|
}
|
|
return "ipp://" + h + "/ipp/port1"
|
|
}
|
|
|
|
func appendAttr(b []byte, tag byte, name, value string) []byte {
|
|
b = append(b, tag)
|
|
b = binary.BigEndian.AppendUint16(b, uint16(len(name)))
|
|
b = append(b, name...)
|
|
b = binary.BigEndian.AppendUint16(b, uint16(len(value)))
|
|
return append(b, value...)
|
|
}
|
|
|
|
// EncodeGetPrinterAttributes is the request: IPP 2.0, operation 0x000B, request id 1, the operation attributes
|
|
// RFC 8011 requires first (charset, natural language, printer-uri), and the attributes asked for.
|
|
func EncodeGetPrinterAttributes(printerURI string, requested []string) []byte {
|
|
b := []byte{0x02, 0x00, 0x00, byte(ippGetPrinterAttributes), 0, 0, 0, 1, ippOperation}
|
|
b = appendAttr(b, ippCharset, "attributes-charset", "utf-8")
|
|
b = appendAttr(b, ippLanguage, "attributes-natural-language", "en")
|
|
b = appendAttr(b, ippURItag, "printer-uri", printerURI)
|
|
for i, r := range requested {
|
|
name := ""
|
|
if i == 0 {
|
|
name = "requested-attributes"
|
|
}
|
|
b = appendAttr(b, ippKeyword, name, r)
|
|
}
|
|
return append(b, ippEnd)
|
|
}
|
|
|
|
// IPPValue is one value of an attribute: an integer or enum, or text.
|
|
type IPPValue struct {
|
|
Tag byte
|
|
Integer int
|
|
Text string
|
|
}
|
|
|
|
// IPPAnswer is a decoded response: its status code and its attributes by name, each with its values in order.
|
|
type IPPAnswer struct {
|
|
Status int
|
|
Attributes map[string][]IPPValue
|
|
}
|
|
|
|
// DecodeIPP reads a response. It reads only what Get-Printer-Attributes answers hold: groups of attributes of
|
|
// integers, enums and strings, whose additional values have an empty name.
|
|
func DecodeIPP(b []byte) (IPPAnswer, error) {
|
|
a, _, err := decodeIPP(b)
|
|
return a, err
|
|
}
|
|
|
|
// decodeIPP is DecodeIPP and the offset after the end-of-attributes tag, where a request's document begins.
|
|
func decodeIPP(b []byte) (IPPAnswer, int, error) {
|
|
a := IPPAnswer{Attributes: map[string][]IPPValue{}}
|
|
if len(b) < 9 {
|
|
return a, 0, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b))
|
|
}
|
|
a.Status = int(binary.BigEndian.Uint16(b[2:4]))
|
|
i := 8
|
|
last := ""
|
|
for i < len(b) {
|
|
tag := b[i]
|
|
i++
|
|
if tag == ippEnd {
|
|
return a, i, nil
|
|
}
|
|
if tag <= 0x05 { // a group's start
|
|
last = ""
|
|
continue
|
|
}
|
|
if i+2 > len(b) {
|
|
return a, 0, fmt.Errorf("the IPP answer ends inside an attribute")
|
|
}
|
|
nl := int(binary.BigEndian.Uint16(b[i:]))
|
|
i += 2
|
|
if i+nl+2 > len(b) {
|
|
return a, 0, fmt.Errorf("the IPP answer ends inside an attribute name")
|
|
}
|
|
name := string(b[i : i+nl])
|
|
i += nl
|
|
vl := int(binary.BigEndian.Uint16(b[i:]))
|
|
i += 2
|
|
if i+vl > len(b) {
|
|
return a, 0, fmt.Errorf("the IPP answer ends inside the value of %q", name)
|
|
}
|
|
raw := b[i : i+vl]
|
|
i += vl
|
|
if name == "" {
|
|
name = last
|
|
}
|
|
last = name
|
|
v := IPPValue{Tag: tag}
|
|
if (tag == ippInteger || tag == ippEnum) && vl == 4 {
|
|
v.Integer = int(int32(binary.BigEndian.Uint32(raw)))
|
|
} else {
|
|
v.Text = string(raw)
|
|
}
|
|
a.Attributes[name] = append(a.Attributes[name], v)
|
|
}
|
|
return a, 0, fmt.Errorf("the IPP answer has no end-of-attributes tag")
|
|
}
|
|
|
|
func (a IPPAnswer) texts(name string) []string {
|
|
var out []string
|
|
for _, v := range a.Attributes[name] {
|
|
out = append(out, v.Text)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// printerStates are the values RFC 8011 gives printer-state.
|
|
var printerStates = map[int]string{3: "idle", 4: "processing", 5: "stopped"}
|
|
|
|
// Marker is one consumable of the printer: a toner, a drum, a belt. A level below 0 is the standard's way
|
|
// to say it is unknown (-1) or not reported (-2).
|
|
type Marker struct {
|
|
Name string `json:"name"`
|
|
Level int `json:"level"`
|
|
Color string `json:"color,omitempty"`
|
|
}
|
|
|
|
// PrinterState is what printer.status reads over IPP.
|
|
type PrinterState struct {
|
|
State string `json:"state"`
|
|
StateCode int `json:"state-code"`
|
|
Reasons []string `json:"state-reasons"`
|
|
MakeAndModel string `json:"make-and-model"`
|
|
Markers []Marker `json:"markers"`
|
|
}
|
|
|
|
// State reads the answer into the printer's state; an answer whose status is not a success is an error.
|
|
func (a IPPAnswer) State() (PrinterState, error) {
|
|
if a.Status >= 0x0100 {
|
|
return PrinterState{}, fmt.Errorf("the printer refused the IPP request with status 0x%04x", a.Status)
|
|
}
|
|
st := a.Attributes["printer-state"]
|
|
if len(st) == 0 {
|
|
return PrinterState{}, fmt.Errorf("the IPP answer holds no printer-state")
|
|
}
|
|
code := st[0].Integer
|
|
name, ok := printerStates[code]
|
|
if !ok {
|
|
name = fmt.Sprintf("unknown (%d)", code)
|
|
}
|
|
s := PrinterState{State: name, StateCode: code, Reasons: a.texts("printer-state-reasons"), Markers: []Marker{}}
|
|
if s.Reasons == nil {
|
|
s.Reasons = []string{}
|
|
}
|
|
if m := a.texts("printer-make-and-model"); len(m) > 0 {
|
|
s.MakeAndModel = m[0]
|
|
}
|
|
names, levels, colors := a.texts("marker-names"), a.Attributes["marker-levels"], a.texts("marker-colors")
|
|
for i, n := range names {
|
|
m := Marker{Name: n, Level: -1}
|
|
if i < len(levels) {
|
|
m.Level = levels[i].Integer
|
|
}
|
|
if i < len(colors) {
|
|
m.Color = colors[i]
|
|
}
|
|
s.Markers = append(s.Markers, m)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// PrinterStateOf asks the printer for its state over IPP. The printer is asked on its IPP port, not through the
|
|
// pinned HTTPS client, and nothing secret is sent.
|
|
func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
|
|
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), statusAttributes)))
|
|
if err != nil {
|
|
return PrinterState{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/ipp")
|
|
c := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return PrinterState{}, fmt.Errorf("the printer's IPP service does not answer: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return PrinterState{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return PrinterState{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
|
|
}
|
|
ans, err := DecodeIPP(body)
|
|
if err != nil {
|
|
return PrinterState{}, err
|
|
}
|
|
return ans.State()
|
|
}
|
|
|
|
// PrintOptions are what a Print-Job asks of the printer.
|
|
type PrintOptions struct {
|
|
Format string // document-format, application/pdf or image/jpeg
|
|
Copies int
|
|
Sides string
|
|
Media string
|
|
Name string // job-name
|
|
}
|
|
|
|
// SniffFormat is the document format of the bytes the printer accepts: PDF or JPEG, by their first bytes, and
|
|
// otherwise an error.
|
|
func SniffFormat(doc []byte) (format, ext string, err error) {
|
|
switch {
|
|
case bytes.HasPrefix(doc, []byte("%PDF-")):
|
|
return "application/pdf", "pdf", nil
|
|
case len(doc) > 3 && doc[0] == 0xFF && doc[1] == 0xD8 && doc[2] == 0xFF:
|
|
return "image/jpeg", "jpg", nil
|
|
}
|
|
return "", "", fmt.Errorf("the document is neither a PDF nor a JPEG: the printer is sent those two only")
|
|
}
|
|
|
|
// EncodePrintJob is the Print-Job request (RFC 8011 §4.2.1): the operation attributes in the order the standard
|
|
// requires, the job's copies, sides and media, and the document after the end-of-attributes tag.
|
|
func EncodePrintJob(printerURI string, o PrintOptions, doc []byte) []byte {
|
|
b := []byte{0x02, 0x00, 0x00, byte(ippPrintJob), 0, 0, 0, 1, ippOperation}
|
|
b = appendAttr(b, ippCharset, "attributes-charset", "utf-8")
|
|
b = appendAttr(b, ippLanguage, "attributes-natural-language", "en")
|
|
b = appendAttr(b, ippURItag, "printer-uri", printerURI)
|
|
b = appendAttr(b, ippNameNoLang, "requesting-user-name", "brother")
|
|
b = appendAttr(b, ippNameNoLang, "job-name", o.Name)
|
|
b = appendAttr(b, ippMimeType, "document-format", o.Format)
|
|
b = append(b, ippJob)
|
|
b = appendAttr(b, ippInteger, "copies", string(binary.BigEndian.AppendUint32(nil, uint32(o.Copies))))
|
|
b = appendAttr(b, ippKeyword, "sides", o.Sides)
|
|
b = appendAttr(b, ippKeyword, "media", o.Media)
|
|
b = append(b, ippEnd)
|
|
return append(b, doc...)
|
|
}
|
|
|
|
// jobStates are the values RFC 8011 gives job-state.
|
|
var jobStates = map[int]string{3: "pending", 4: "pending-held", 5: "processing", 6: "processing-stopped", 7: "canceled", 8: "aborted", 9: "completed"}
|
|
|
|
// PrintResult is what a Print-Job answered: the job's id and state.
|
|
type PrintResult struct {
|
|
JobID int `json:"job-id"`
|
|
State string `json:"job-state"`
|
|
StateCode int `json:"job-state-code"`
|
|
Reasons []string `json:"job-state-reasons"`
|
|
}
|
|
|
|
// PrintResult reads a Print-Job answer; a failing status is an error naming it.
|
|
func (a IPPAnswer) PrintResult() (PrintResult, error) {
|
|
if a.Status >= 0x0100 {
|
|
return PrintResult{}, fmt.Errorf("the printer refused the print job with IPP status 0x%04x", a.Status)
|
|
}
|
|
id := a.Attributes["job-id"]
|
|
if len(id) == 0 {
|
|
return PrintResult{}, fmt.Errorf("the printer's answer to the print job names no job-id")
|
|
}
|
|
r := PrintResult{JobID: id[0].Integer, Reasons: a.texts("job-state-reasons")}
|
|
if r.Reasons == nil {
|
|
r.Reasons = []string{}
|
|
}
|
|
if st := a.Attributes["job-state"]; len(st) > 0 {
|
|
r.StateCode = st[0].Integer
|
|
r.State = jobStates[r.StateCode]
|
|
if r.State == "" {
|
|
r.State = fmt.Sprintf("unknown (%d)", r.StateCode)
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// PrintJob sends the document to the printer's IPP service as a Print-Job, and answers the job's id and state.
|
|
func PrintJob(ctx context.Context, host string, o PrintOptions, doc []byte) (PrintResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host), bytes.NewReader(EncodePrintJob(ippPrinterURI(host), o, doc)))
|
|
if err != nil {
|
|
return PrintResult{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/ipp")
|
|
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
|
|
if err != nil {
|
|
return PrintResult{}, fmt.Errorf("the printer's IPP service does not answer: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return PrintResult{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return PrintResult{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
|
|
}
|
|
ans, err := DecodeIPP(body)
|
|
if err != nil {
|
|
return PrintResult{}, err
|
|
}
|
|
return ans.PrintResult()
|
|
}
|