printer: read printers and jobs from the spooler API (#2577)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-09 09:36:59 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cb5b4cb0bf
commit 7c200078ef
7 changed files with 774 additions and 138 deletions
+144
View File
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package winspool
import (
"golang.org/x/sys/windows"
)
// EnumPrinters flags.
// https://learn.microsoft.com/en-us/windows/win32/printdocs/enumprinters
const (
PRINTER_ENUM_LOCAL uint32 = 0x00000002
PRINTER_ENUM_CONNECTIONS uint32 = 0x00000004
)
// PRINTER_STATUS_* values of PRINTER_INFO_2.Status.
// https://learn.microsoft.com/en-us/windows/win32/printdocs/printer-info-2
const (
PRINTER_STATUS_PAUSED uint32 = 0x00000001
PRINTER_STATUS_ERROR uint32 = 0x00000002
PRINTER_STATUS_PENDING_DELETION uint32 = 0x00000004
PRINTER_STATUS_PAPER_JAM uint32 = 0x00000008
PRINTER_STATUS_PAPER_OUT uint32 = 0x00000010
PRINTER_STATUS_MANUAL_FEED uint32 = 0x00000020
PRINTER_STATUS_PAPER_PROBLEM uint32 = 0x00000040
PRINTER_STATUS_OFFLINE uint32 = 0x00000080
PRINTER_STATUS_IO_ACTIVE uint32 = 0x00000100
PRINTER_STATUS_BUSY uint32 = 0x00000200
PRINTER_STATUS_PRINTING uint32 = 0x00000400
PRINTER_STATUS_OUTPUT_BIN_FULL uint32 = 0x00000800
PRINTER_STATUS_NOT_AVAILABLE uint32 = 0x00001000
PRINTER_STATUS_WAITING uint32 = 0x00002000
PRINTER_STATUS_PROCESSING uint32 = 0x00004000
PRINTER_STATUS_INITIALIZING uint32 = 0x00008000
PRINTER_STATUS_WARMING_UP uint32 = 0x00010000
PRINTER_STATUS_TONER_LOW uint32 = 0x00020000
PRINTER_STATUS_NO_TONER uint32 = 0x00040000
PRINTER_STATUS_PAGE_PUNT uint32 = 0x00080000
PRINTER_STATUS_USER_INTERVENTION uint32 = 0x00100000
PRINTER_STATUS_OUT_OF_MEMORY uint32 = 0x00200000
PRINTER_STATUS_DOOR_OPEN uint32 = 0x00400000
PRINTER_STATUS_SERVER_UNKNOWN uint32 = 0x00800000
PRINTER_STATUS_POWER_SAVE uint32 = 0x01000000
PRINTER_STATUS_SERVER_OFFLINE uint32 = 0x02000000
PRINTER_STATUS_DRIVER_UPDATE_NEEDED uint32 = 0x04000000
)
// JOB_STATUS_* values of JOB_INFO_1.Status.
// https://learn.microsoft.com/en-us/windows/win32/printdocs/job-info-1
const (
JOB_STATUS_PAUSED uint32 = 0x00000001
JOB_STATUS_ERROR uint32 = 0x00000002
JOB_STATUS_DELETING uint32 = 0x00000004
JOB_STATUS_SPOOLING uint32 = 0x00000008
JOB_STATUS_PRINTING uint32 = 0x00000010
JOB_STATUS_OFFLINE uint32 = 0x00000020
JOB_STATUS_PAPEROUT uint32 = 0x00000040
JOB_STATUS_PRINTED uint32 = 0x00000080
JOB_STATUS_DELETED uint32 = 0x00000100
JOB_STATUS_BLOCKED_DEVQ uint32 = 0x00000200
JOB_STATUS_USER_INTERVENTION uint32 = 0x00000400
JOB_STATUS_RESTART uint32 = 0x00000800
JOB_STATUS_COMPLETE uint32 = 0x00001000
JOB_STATUS_RETAINED uint32 = 0x00002000
)
// printerInfo2 is PRINTER_INFO_2.
// https://learn.microsoft.com/en-us/windows/win32/printdocs/printer-info-2
type printerInfo2 struct {
pServerName *uint16
pPrinterName *uint16
pShareName *uint16
pPortName *uint16
pDriverName *uint16
pComment *uint16
pLocation *uint16
pDevMode uintptr
pSepFile *uint16
pPrintProcessor *uint16
pDatatype *uint16
pParameters *uint16
pSecurityDescriptor uintptr
Attributes uint32
Priority uint32
DefaultPriority uint32
StartTime uint32
UntilTime uint32
Status uint32
cJobs uint32
AveragePPM uint32
}
// jobInfo1 is JOB_INFO_1.
// https://learn.microsoft.com/en-us/windows/win32/printdocs/job-info-1
type jobInfo1 struct {
JobId uint32
pPrinterName *uint16
pMachineName *uint16
pUserName *uint16
pDocument *uint16
pDatatype *uint16
pStatus *uint16
Status uint32
Priority uint32
Position uint32
TotalPages uint32
PagesPrinted uint32
Submitted windows.Systemtime
}
// Printer contains the PRINTER_INFO_2 fields used by windows_exporter.
type Printer struct {
Name string
// Status is a combination of PRINTER_STATUS_* values.
Status uint32
// Jobs is the number of jobs currently queued for the printer (cJobs).
Jobs uint32
}
// Job contains the JOB_INFO_1 fields used by windows_exporter.
type Job struct {
ID uint32
PrinterName string
// Status is a combination of JOB_STATUS_* values.
Status uint32
// StatusText is the driver or port monitor status string (pStatus).
// It is empty if the spooler reports no custom status.
StatusText string
}
+179
View File
@@ -0,0 +1,179 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
// Package winspool reads printers and print jobs through the print spooler API.
//
// EnumPrintersW and EnumJobsW only read the spooler state. Unlike the CIMWin32
// Win32_Printer provider, they never call DeviceCapabilities, which can block
// for about a minute on an unreachable network printer.
package winspool
import (
"errors"
"fmt"
"unsafe"
"golang.org/x/sys/windows"
)
//nolint:gochecknoglobals
var (
modWinspool = windows.NewLazySystemDLL("winspool.drv")
procEnumPrintersW = modWinspool.NewProc("EnumPrintersW")
procOpenPrinterW = modWinspool.NewProc("OpenPrinterW")
procClosePrinter = modWinspool.NewProc("ClosePrinter")
procEnumJobsW = modWinspool.NewProc("EnumJobsW")
)
// EnumPrinters returns the printers selected by flags, a combination of PRINTER_ENUM_* values.
func EnumPrinters(flags uint32) ([]Printer, error) {
buf, count, err := enum(func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error) {
r1, _, err := procEnumPrintersW.Call(
uintptr(flags),
0, // local machine
2, // PRINTER_INFO_2
uintptr(unsafe.Pointer(buf)),
uintptr(size),
uintptr(unsafe.Pointer(needed)),
uintptr(unsafe.Pointer(returned)),
)
return r1, err
})
if err != nil {
return nil, fmt.Errorf("EnumPrintersW: %w", err)
}
if count == 0 {
return []Printer{}, nil
}
infos := unsafe.Slice((*printerInfo2)(unsafe.Pointer(&buf[0])), count)
printers := make([]Printer, 0, count)
for _, info := range infos {
printers = append(printers, Printer{
Name: windows.UTF16PtrToString(info.pPrinterName),
Status: info.Status,
Jobs: info.cJobs,
})
}
return printers, nil
}
// OpenPrinter opens printerName with the default access rights of the caller.
// The handle must be closed with ClosePrinter.
func OpenPrinter(printerName string) (windows.Handle, error) {
printerNamePtr, err := windows.UTF16PtrFromString(printerName)
if err != nil {
return 0, fmt.Errorf("invalid printer name %q: %w", printerName, err)
}
var handle windows.Handle
r1, _, err := procOpenPrinterW.Call(
uintptr(unsafe.Pointer(printerNamePtr)),
uintptr(unsafe.Pointer(&handle)),
0, // PRINTER_DEFAULTS
)
if r1 == 0 {
return 0, fmt.Errorf("OpenPrinterW(%s): %w", printerName, err)
}
return handle, nil
}
func ClosePrinter(handle windows.Handle) error {
r1, _, err := procClosePrinter.Call(uintptr(handle))
if r1 == 0 {
return fmt.Errorf("ClosePrinter: %w", err)
}
return nil
}
// EnumJobs returns all jobs of the printer in queue order.
func EnumJobs(handle windows.Handle) ([]Job, error) {
buf, count, err := enum(func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error) {
r1, _, err := procEnumJobsW.Call(
uintptr(handle),
0, // FirstJob
0xFFFFFFFF, // NoJobs
1, // JOB_INFO_1
uintptr(unsafe.Pointer(buf)),
uintptr(size),
uintptr(unsafe.Pointer(needed)),
uintptr(unsafe.Pointer(returned)),
)
return r1, err
})
if err != nil {
return nil, fmt.Errorf("EnumJobsW: %w", err)
}
if count == 0 {
return []Job{}, nil
}
infos := unsafe.Slice((*jobInfo1)(unsafe.Pointer(&buf[0])), count)
jobs := make([]Job, 0, count)
for _, info := range infos {
jobs = append(jobs, Job{
ID: info.JobId,
PrinterName: windows.UTF16PtrToString(info.pPrinterName),
Status: info.Status,
StatusText: windows.UTF16PtrToString(info.pStatus),
})
}
return jobs, nil
}
// enum calls a spooler enumeration function until the buffer is large enough.
// The returned buffer holds count structures followed by the strings they point to.
func enum(call func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error)) ([]byte, uint32, error) {
var (
buf []byte
needed uint32
returned uint32
)
// The required size can grow between two calls if printers or jobs are added.
for range 5 {
var bufPtr *byte
if len(buf) > 0 {
bufPtr = &buf[0]
}
r1, err := call(bufPtr, uint32(len(buf)), &needed, &returned)
if r1 != 0 {
return buf, returned, nil
}
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
return nil, 0, err
}
buf = make([]byte, needed)
}
return nil, 0, windows.ERROR_INSUFFICIENT_BUFFER
}
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build windows
package winspool
import (
"path/filepath"
"slices"
"testing"
"unsafe"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
//nolint:gochecknoglobals
var (
procStartDocPrinterW = modWinspool.NewProc("StartDocPrinterW")
procAbortPrinter = modWinspool.NewProc("AbortPrinter")
)
// docInfo1 is DOC_INFO_1.
type docInfo1 struct {
pDocName *uint16
pOutputFile *uint16
pDatatype *uint16
}
func TestLayout(t *testing.T) {
t.Parallel()
if unsafe.Sizeof(uintptr(0)) != 8 {
t.Skip("offsets are checked for 64-bit Windows only")
}
// Offsets of the 64-bit SDK structures.
require.Equal(t, uintptr(136), unsafe.Sizeof(printerInfo2{}))
require.Equal(t, uintptr(0x08), unsafe.Offsetof(printerInfo2{}.pPrinterName))
require.Equal(t, uintptr(0x7c), unsafe.Offsetof(printerInfo2{}.Status))
require.Equal(t, uintptr(0x80), unsafe.Offsetof(printerInfo2{}.cJobs))
require.Equal(t, uintptr(96), unsafe.Sizeof(jobInfo1{}))
require.Equal(t, uintptr(0x08), unsafe.Offsetof(jobInfo1{}.pPrinterName))
require.Equal(t, uintptr(0x30), unsafe.Offsetof(jobInfo1{}.pStatus))
require.Equal(t, uintptr(0x38), unsafe.Offsetof(jobInfo1{}.Status))
require.Equal(t, uintptr(0x4c), unsafe.Offsetof(jobInfo1{}.Submitted))
}
func TestEnumPrinters(t *testing.T) {
t.Parallel()
printers, err := EnumPrinters(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS)
require.NoError(t, err)
require.NotNil(t, printers)
for _, printer := range printers {
require.NotEmpty(t, printer.Name)
}
}
func TestOpenPrinterInvalidName(t *testing.T) {
t.Parallel()
_, err := OpenPrinter("windows_exporter test printer that does not exist")
require.ErrorIs(t, err, windows.ERROR_INVALID_PRINTER_NAME)
}
// TestEnumJobs starts a document without writing data, so the job stays in the
// spooling state and never reaches the port. AbortPrinter deletes it again.
func TestEnumJobs(t *testing.T) {
t.Parallel()
printers, err := EnumPrinters(PRINTER_ENUM_LOCAL)
require.NoError(t, err)
// CIPrinter is created by the CI workflow. Microsoft Print to PDF exists on most desktops.
idx := slices.IndexFunc(printers, func(p Printer) bool {
return p.Name == "CIPrinter" || p.Name == "Microsoft Print to PDF"
})
if idx == -1 {
t.Skip("no test printer available")
}
printerName := printers[idx].Name
handle, err := OpenPrinter(printerName)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, ClosePrinter(handle))
})
docName, err := windows.UTF16PtrFromString("windows_exporter test job")
require.NoError(t, err)
// An output file keeps Microsoft Print to PDF from prompting for a file name.
outputFile, err := windows.UTF16PtrFromString(filepath.Join(t.TempDir(), "job.out"))
require.NoError(t, err)
docInfo := docInfo1{
pDocName: docName,
pOutputFile: outputFile,
}
jobID, _, err := procStartDocPrinterW.Call(uintptr(handle), 1, uintptr(unsafe.Pointer(&docInfo)))
require.NotZero(t, jobID, "StartDocPrinterW: %v", err)
t.Cleanup(func() {
r1, _, err := procAbortPrinter.Call(uintptr(handle))
require.NotZero(t, r1, "AbortPrinter: %v", err)
})
jobs, err := EnumJobs(handle)
require.NoError(t, err)
idx = slices.IndexFunc(jobs, func(j Job) bool { return j.ID == uint32(jobID) })
require.NotEqual(t, -1, idx, "job %d not found in %+v", jobID, jobs)
job := jobs[idx]
require.Equal(t, printerName, job.PrinterName)
require.NotZero(t, job.Status&JOB_STATUS_SPOOLING, "status 0x%08X", job.Status)
printers, err = EnumPrinters(PRINTER_ENUM_LOCAL)
require.NoError(t, err)
idx = slices.IndexFunc(printers, func(p Printer) bool { return p.Name == printerName })
require.NotEqual(t, -1, idx)
require.NotZero(t, printers[idx].Jobs)
}