Files
mesh-catalog/modules/brother/cmd/brother/ipp.go
T

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()
}