diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..abdb165 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +# Dependabot keeps the CI action pins and the Go dependencies current. +# https://docs.github.com/code-security/dependabot/dependabot-version-updates +version: 2 +updates: + # GitHub Actions used by the workflows (checkout, setup-go, codeql, ...). + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: ci + labels: + - dependencies + - github-actions + + # Go module dependencies. The repo vendors its deps, so Dependabot also + # refreshes the vendor/ tree when it bumps a module. + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + commit-message: + prefix: build + labels: + - dependencies + - go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ad81c79 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,167 @@ +# ============================================================================= +# Build & Test workflow for ipp-usb +# +# ipp-usb is a Go daemon that uses cgo to link two system libraries: +# * libusb-1.0 (raw USB access) -> // #cgo pkg-config: libusb-1.0 +# * libavahi-client (DNS-SD / mDNS) -> // #cgo pkg-config: avahi-client +# so every build/test environment needs gcc, pkg-config and the matching +# -dev packages in addition to the Go toolchain. The binary is built exactly +# like the Makefile: with the `nethttpomithttp2` build tag and vendored deps. +# +# Coverage of this workflow: +# * build-native : Go-version matrix on amd64 (oldstable -> stable) with the +# race detector, `go vet` and a coverage profile. +# * build-arch : multi-architecture matrix - arm64 (native runner) plus +# armhf and riscv64 under QEMU - the arches ipp-usb ships on. +# ============================================================================= +name: Build and Test + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # System libraries required by the cgo bindings (libusb + Avahi). + CGO_DEPS: "gcc pkg-config libusb-1.0-0-dev libavahi-client-dev" + +jobs: + # --------------------------------------------------------------------------- + # Native amd64 build + test across several Go toolchains. + # The race detector and coverage run here, where cgo + race are fully + # supported, so toolchain drift and data races are caught early. + # --------------------------------------------------------------------------- + build-native: + name: Build & Test (amd64, Go ${{ matrix.go }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + go: [ '1.21', '1.22', '1.23', 'stable' ] + + steps: + - uses: actions/checkout@v4 + + - name: Install cgo dependencies + run: | + # Retry to ride out transient Ubuntu mirror sync failures. + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 $CGO_DEPS + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache-dependency-path: go.sum + + - name: Show toolchain + run: | + go version + pkg-config --modversion libusb-1.0 avahi-client + + - name: Build + run: go build -v -ldflags "-s -w" -tags nethttpomithttp2 -mod=vendor ./... + + - name: Vet + run: go vet -tags nethttpomithttp2 -mod=vendor ./... + + - name: Test (race + coverage) + run: | + go test -mod=vendor -race \ + -covermode=atomic -coverprofile=coverage.out -v ./... + + - name: Coverage summary + if: always() + continue-on-error: true + run: go tool cover -func=coverage.out | tail -n 1 + + - name: Upload coverage profile + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: coverage-go-${{ matrix.go }} + path: coverage.out + if-no-files-found: ignore + + # --------------------------------------------------------------------------- + # Multi-architecture build + test. + # arm64 : native runner (ubuntu-24.04-arm) + # armhf : QEMU (armv7) + # riscv64: QEMU + # The race detector is amd64/arm64-only and slow under emulation, so the + # emulated legs run a plain `go test`. + # --------------------------------------------------------------------------- + build-arch: + name: Build & Test (${{ matrix.arch }}) + runs-on: ${{ matrix.runs-on }} + # Compiling under QEMU (riscv64 especially) is slow; allow head-room. + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + runs-on: ubuntu-24.04-arm + use-qemu: false + - arch: armhf + runs-on: ubuntu-latest + use-qemu: true + qemu-arch: armv7 + - arch: riscv64 + runs-on: ubuntu-latest + use-qemu: true + qemu-arch: riscv64 + + steps: + - uses: actions/checkout@v4 + + # ---- native arm64 ----------------------------------------------------- + - name: Build & Test (native) + if: matrix.use-qemu == false + run: | + set -ex + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 golang-go $CGO_DEPS + go version + go build -v -ldflags "-s -w" -tags nethttpomithttp2 -mod=vendor ./... + go vet -tags nethttpomithttp2 -mod=vendor ./... + go test -mod=vendor -v ./... + + # ---- emulated armhf / riscv64 ---------------------------------------- + - name: Build & Test (emulated) + if: matrix.use-qemu == true + uses: uraimo/run-on-arch-action@v3 + with: + arch: ${{ matrix.qemu-arch }} + distro: ubuntu24.04 + githubToken: ${{ github.token }} + install: | + apt-get update --fix-missing -y + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + golang-go gcc pkg-config \ + libusb-1.0-0-dev libavahi-client-dev \ + ca-certificates + run: | + set -ex + go version + go build -v -tags nethttpomithttp2 -mod=vendor ./... + # TestTCPClientUID* resolve a peer's UID via the NETLINK_SOCK_DIAG + # netlink API, which is not available inside the QEMU-in-Docker + # emulation ("sock_diag: socket(): protocol not supported"). They + # are run for real on the native amd64 and arm64 legs; skip them + # here so the emulated build/test coverage stays green. + echo "note: skipping TestTCPClientUID* (sock_diag unavailable under emulation)" + go test -mod=vendor -skip 'TestTCPClientUID' -v ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..dd08fd0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,71 @@ +# ============================================================================= +# CodeQL security analysis for ipp-usb (Go) +# +# ipp-usb is cgo code, so CodeQL's autobuild cannot resolve the libusb/Avahi +# headers on its own. We use build-mode: manual and compile the daemon +# ourselves (with the cgo -dev packages installed and the production build +# tag) so CodeQL traces the real build. +# ============================================================================= +name: CodeQL + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + schedule: + # Weekly scan to catch newly published query updates. + - cron: '27 4 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (Go) + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install cgo dependencies + run: | + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 \ + gcc pkg-config libusb-1.0-0-dev libavahi-client-dev + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: manual + queries: +security-and-quality + + - name: Build + run: go build -v -tags nethttpomithttp2 -mod=vendor ./... + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/emulator-test.yml b/.github/workflows/emulator-test.yml new file mode 100644 index 0000000..3409f61 --- /dev/null +++ b/.github/workflows/emulator-test.yml @@ -0,0 +1,257 @@ +# ============================================================================= +# Emulator integration test for ipp-usb +# +# This workflow exercises ipp-usb against a *virtual* IPP-over-USB printer, +# with no physical hardware, using the go-mfp MFP simulator written by +# Alexander Pevzner (https://github.com/OpenPrinting/go-mfp). +# +# The chain under test is the real thing end to end: +# +# mfp-virtual --usbip (emulated printer, speaks the USB/IP protocol) +# | TCP :3240 +# usbip attach + vhci_hcd (kernel exposes it as a local USB device) +# | +# libusb -> ipp-usb (discovers the device, tunnels HTTP over USB) +# +# What we assert: +# 1. ipp-usb discovers the emulated device (VID:PID dead:beaf). +# 2. ipp-usb completes an HTTP-over-USB round-trip - the eSCL +# ScannerCapabilities probe returns 200 OK from the simulator. +# 3. A client request routed *through* ipp-usb's TCP listener reaches the +# emulated device and comes back 200 OK (client -> ipp-usb -> USB). +# 4. A real IPP Get-Printer-Attributes, sent through ipp-usb to the printer +# path, returns successful-ok with the model's make-and-model. +# +# Notes: +# * The emulator is driven by an example MFP model (Kyocera ECOSYS M2040dn) +# shipped in go-mfp, which provides both the eSCL (scanner) and IPP +# (printer) endpoints, so both the scan and print paths are exercised. +# * The go-mfp commit is pinned for reproducibility - bump GO_MFP_REF to +# pick up newer emulator behaviour. +# ============================================================================= +name: Emulator Test + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # go-mfp (the IPP-over-USB emulator) - pinned for reproducibility. + GO_MFP_REPO: https://github.com/OpenPrinting/go-mfp.git + GO_MFP_REF: 7e0ec8c4b411a111a1cee631ba3cc3c48a3a5403 + # Example MFP model shipped in go-mfp (provides the IPP + eSCL endpoints). + GO_MFP_MODEL: modeling/examples/Kyocera-ECOSYS-M2040dn.py + # Build deps for ipp-usb (libusb + avahi) and for the go-mfp emulator + # (adds Python/JPEG/PNG for its cgo bits). usbutils/linux-tools provide + # lsusb and the usbip client; avahi-daemon lets ipp-usb publish over DNS-SD; + # cups-ipp-utils provides ipptool for the IPP round-trip assertion. + APT_DEPS: >- + gcc pkg-config make + libusb-1.0-0-dev libavahi-client-dev + libjpeg-dev libpng-dev python3-dev + linux-tools-generic linux-tools-common linux-tools-virtual + usbutils avahi-daemon cups-ipp-utils + +jobs: + emulator-test: + name: ipp-usb vs. virtual IPP-over-USB printer + runs-on: ubuntu-24.04 + steps: + - name: Check out ipp-usb + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install dependencies + run: | + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 ${{ env.APT_DEPS }} + + - name: Load the USB/IP kernel module (vhci_hcd) + run: | + # The virtual device is attached through the vhci_hcd driver. On + # GitHub-hosted runners the module lives in linux-modules-extra for + # the running kernel; install it on demand if the first load fails. + if ! sudo modprobe vhci-hcd 2>/dev/null; then + sudo apt-get install -y -o Acquire::Retries=3 \ + "linux-modules-extra-$(uname -r)" + sudo modprobe vhci-hcd + fi + lsmod | grep -q vhci_hcd + echo "vhci_hcd loaded." + + - name: Put the usbip client on PATH + run: | + # Ubuntu ships the usbip client inside linux-tools, not as a + # standalone package. It is a userspace tool and works regardless + # of the running kernel version. + usbip_bin="$(ls /usr/lib/linux-tools-*/usbip 2>/dev/null | head -1)" + test -n "$usbip_bin" + sudo ln -sf "$usbip_bin" /usr/local/bin/usbip + usbip version + + - name: Build ipp-usb + run: | + go build -tags nethttpomithttp2 -mod=vendor -o ipp-usb . + ./ipp-usb -h >/dev/null 2>&1 || true + + - name: Build the go-mfp emulator (mfp-virtual) + run: | + git clone "${GO_MFP_REPO}" go-mfp + git -C go-mfp checkout --quiet "${GO_MFP_REF}" + ( cd go-mfp && go build -o mfp-virtual ./cmd/mfp-virtual ) + test -x go-mfp/mfp-virtual + + - name: Start the emulator and attach it as a USB device + run: | + # Start the emulated IPP-over-USB printer (USB/IP server on :3240), + # driven by the example MFP model (IPP printer + eSCL scanner). + ./go-mfp/mfp-virtual -m "go-mfp/${GO_MFP_MODEL}" --usbip -d \ + > emulator.log 2>&1 & + echo $! | sudo tee /tmp/emulator.pid >/dev/null + + # Wait for the USB/IP server to advertise the device. + for i in $(seq 1 30); do + if usbip list -r localhost 2>/dev/null | grep -q '1-1'; then + break + fi + sleep 1 + done + usbip list -r localhost + + # Attach it - now it is a real local USB device. + sudo usbip attach -r localhost -b 1-1 + sleep 2 + lsusb + if ! lsusb | grep -qi 'dead:beaf'; then + echo "::error::emulated printer did not attach as a USB device" + cat emulator.log + exit 1 + fi + echo "Virtual IPP-over-USB printer attached." + + - name: Start DNS-SD stack (avahi) for ipp-usb + run: | + sudo systemctl start dbus || sudo service dbus start || true + sudo systemctl start avahi-daemon || sudo service avahi-daemon start || true + + - name: Run ipp-usb against the emulated printer + run: | + sudo mkdir -p /var/ipp-usb/dev + + # Run ipp-usb in debug mode (console logging) in the background, + # pointed at the repo's own conf + quirks so no /etc install is + # needed. It hot-plug-discovers the attached virtual printer. + sudo ./ipp-usb debug \ + -path-conf-files-srch "$PWD" \ + -path-quirks-files-srch "$PWD/ipp-usb-quirks" \ + > ippusb.log 2>&1 & + echo $! | sudo tee /tmp/ippusb.pid >/dev/null + + # Wait (up to 40s) for discovery + the HTTP-over-USB probe. + ok=0 + for i in $(seq 1 40); do + if grep -q 'ScannerCapabilities - 200 OK' ippusb.log; then + ok=1 + break + fi + sleep 1 + done + + echo "===================== ipp-usb log =====================" + cat ippusb.log + echo "=======================================================" + + # Assertion 1: ipp-usb found the emulated device. + if ! grep -q 'Found new device. VID:PID = dead:beaf' ippusb.log; then + echo "::error::ipp-usb did not discover the emulated printer" + exit 1 + fi + + # Assertion 2: an HTTP request was tunnelled over USB and answered. + if [ "$ok" -ne 1 ]; then + echo "::error::HTTP-over-USB round-trip did not complete (no 200 OK)" + exit 1 + fi + echo "OK: ipp-usb discovered the printer and completed an HTTP-over-USB round-trip." + + # Assertion 3 - done in THIS step so the background ipp-usb is still + # running (a backgrounded process does not survive into the next step). + # Route a client request *through* ipp-usb's TCP listener: + # client -> ipp-usb (TCP) -> USB -> emulator + # The device port comes from http-min-port (default 60000); prefer the + # persisted state file, fall back to the debug log, then to 60000. + port="$(sudo grep -hoE 'http-port[[:space:]]*=[[:space:]]*[0-9]+' \ + /var/ipp-usb/dev/* 2>/dev/null | grep -oE '[0-9]+' | head -1)" + port="${port:-$(grep -oE 'localhost:[0-9]+' ippusb.log | head -1 | cut -d: -f2)}" + port="${port:-60000}" + # Use the "localhost" hostname, not 127.0.0.1: ipp-usb 302-redirects + # IP-address requests to the hostname (it keys per-client auth off the + # Host header). -L follows the redirect as a safety net. + echo "Querying ipp-usb on localhost:${port}" + + code=000 + for i in $(seq 1 10); do + code="$(curl -sL -o caps.xml -w '%{http_code}' \ + "http://localhost:${port}/eSCL/ScannerCapabilities" || echo 000)" + [ "$code" = "200" ] && break + sleep 1 + done + echo "HTTP status: ${code}" + head -c 400 caps.xml || true + echo + + if [ "$code" != "200" ] || ! grep -qi 'ScannerCapabilities' caps.xml; then + echo "::error::client request through ipp-usb did not succeed" + exit 1 + fi + echo "OK: client -> ipp-usb -> USB round-trip returned 200 OK." + + # Assertion 4 - drive the IPP *print* path end to end. Send a real + # Get-Printer-Attributes through ipp-usb to /ipp/print; require the + # standard ipptool test to PASS (status successful-ok) and the + # response to carry the model's make-and-model. + echo "Running ipptool Get-Printer-Attributes on ipp://localhost:${port}/ipp/print" + if ! ipptool -tv "ipp://localhost:${port}/ipp/print" \ + get-printer-attributes.test > ipptool.log 2>&1; then + echo "::error::IPP Get-Printer-Attributes through ipp-usb failed" + cat ipptool.log + exit 1 + fi + cat ipptool.log + if ! grep -q 'ECOSYS M2040dn' ipptool.log; then + echo "::error::IPP response did not carry the expected printer model" + exit 1 + fi + echo "OK: IPP Get-Printer-Attributes round-trip returned the Kyocera model." + + - name: Collect logs on failure + if: failure() + run: | + echo "----- emulator.log -----"; cat emulator.log 2>/dev/null || true + echo "----- ippusb.log -----"; cat ippusb.log 2>/dev/null || true + echo "----- ipptool.log -----"; cat ipptool.log 2>/dev/null || true + echo "----- usbip port -----"; usbip port 2>/dev/null || true + echo "----- lsusb -----"; lsusb 2>/dev/null || true + + - name: Tear down + if: always() + run: | + sudo kill "$(cat /tmp/ippusb.pid 2>/dev/null)" 2>/dev/null || true + sudo usbip detach -p 00 2>/dev/null || true + kill "$(cat /tmp/emulator.pid 2>/dev/null)" 2>/dev/null || true diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..c570125 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,60 @@ +# ============================================================================= +# Vulnerability scanning for ipp-usb +# +# govulncheck (from golang.org/x/vuln) reports known vulnerabilities from the +# Go vulnerability database that are actually reachable from this code - both +# in the module's dependencies and in the Go standard library / toolchain the +# binary is built with. It loads packages, so the cgo -dev packages must be +# present. A weekly schedule catches newly disclosed CVEs even without a push. +# ============================================================================= +name: govulncheck + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + schedule: + - cron: '41 5 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install cgo dependencies + run: | + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 \ + gcc pkg-config libusb-1.0-0-dev libavahi-client-dev + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: go.sum + + # Installed WITHOUT -mod=vendor: `go install @latest` resolves a + # module outside this repo and is incompatible with vendor mode. + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + # The scan itself must match how the daemon is built, so use the vendored + # dependencies here via GOFLAGS. + - name: Run govulncheck + env: + GOFLAGS: "-mod=vendor" + run: govulncheck -tags nethttpomithttp2 ./... diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..635913e --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,112 @@ +# ============================================================================= +# Lint workflow for ipp-usb +# +# Three independent gates: +# * gofmt - source must be canonically formatted (fast, no cgo). +# * golangci-lint - aggregate linter (govet, staticcheck, ineffassign, ...); +# needs the cgo -dev packages because it type-checks the +# package, which imports libusb/Avahi via cgo. +# * govet - `go vet` with the production build tag (belt-and-braces; +# also covered inside golangci-lint). +# +# Linter selection lives in .golangci.yml at the repo root. +# ============================================================================= +name: Lint + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gofmt: + name: gofmt + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Check formatting + run: | + # All tracked Go files except the vendored tree. + files=$(git ls-files '*.go' | grep -v '^vendor/' || true) + unformatted=$(gofmt -l $files) + if [ -n "$unformatted" ]; then + echo "::error::The following files are not gofmt-formatted:" + echo "$unformatted" + echo "Run 'gofmt -w' on them and commit the result." + exit 1 + fi + echo "All Go files are gofmt-clean." + + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + # Lint the vendored build exactly as it is compiled. + GOFLAGS: "-mod=vendor" + steps: + - uses: actions/checkout@v4 + + - name: Install cgo dependencies + run: | + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 \ + gcc pkg-config libusb-1.0-0-dev libavahi-client-dev + + # Pin to 1.24: golangci-lint ships a binary built with a specific Go + # release and can only type-check a standard library of that version or + # older. 'stable' installs a newer Go than the linter was built with, + # which fails with "file requires newer Go version". 1.24 matches the + # linter's build toolchain and stays valid as that toolchain only moves + # forward. + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: go.sum + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --build-tags nethttpomithttp2 --timeout 5m + + govet: + name: go vet + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install cgo dependencies + run: | + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 \ + gcc pkg-config libusb-1.0-0-dev libavahi-client-dev + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: go.sum + + - name: go vet + run: go vet -tags nethttpomithttp2 -mod=vendor ./... diff --git a/.github/workflows/modules.yml b/.github/workflows/modules.yml new file mode 100644 index 0000000..e79d474 --- /dev/null +++ b/.github/workflows/modules.yml @@ -0,0 +1,73 @@ +# ============================================================================= +# Module & vendor integrity check for ipp-usb (optional / supplementary) +# +# ipp-usb vendors its dependencies and builds with -mod=vendor, so the vendor +# tree, go.mod and go.sum must stay trustworthy and in sync. +# +# * go mod verify - HARD gate: the cached modules match the hashes in +# go.sum (i.e. nothing was tampered with). +# * tidy / vendor drift - INFORMATIONAL: newer Go toolchains legitimately +# reformat go.mod / vendor/modules.txt (e.g. adding the +# `## explicit` annotations), which would make a strict +# diff flaky. We surface any drift in the log without +# failing the build. +# ============================================================================= +name: Modules + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + modules: + name: Verify modules & vendor + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: go.sum + + - name: Verify module checksums + run: go mod verify + + - name: Report go.mod / go.sum drift (informational) + continue-on-error: true + run: | + cp go.mod go.mod.orig + cp go.sum go.sum.orig + go mod tidy + if ! diff -u go.mod.orig go.mod || ! diff -u go.sum.orig go.sum; then + echo "::warning::go.mod/go.sum are not tidy for this toolchain (see diff above)." + else + echo "go.mod/go.sum are tidy." + fi + mv go.mod.orig go.mod + mv go.sum.orig go.sum + + - name: Report vendor drift (informational) + continue-on-error: true + run: | + go mod vendor + if ! git diff --quiet -- vendor; then + echo "::warning::vendor/ differs from 'go mod vendor' output for this toolchain." + git --no-pager diff --stat -- vendor || true + else + echo "vendor/ is in sync." + fi diff --git a/.github/workflows/snap-test.yml b/.github/workflows/snap-test.yml new file mode 100644 index 0000000..7f3c543 --- /dev/null +++ b/.github/workflows/snap-test.yml @@ -0,0 +1,336 @@ +# ============================================================================= +# Snap integration test for ipp-usb +# +# This workflow builds the ipp-usb snap and runs it, under *strict +# confinement*, against a virtual IPP-over-USB printer - no physical hardware. +# The virtual printer is the go-mfp MFP simulator by Alexander Pevzner +# (https://github.com/OpenPrinting/go-mfp), the same emulator used by +# emulator-test.yml. +# +# Why a separate snap job (vs. emulator-test.yml): +# emulator-test.yml runs the *unconfined* `go build` binary. This job runs +# the packaged snap with its interfaces, so it is the only place that proves +# the snap's confinement is correct - i.e. that a strictly-confined daemon can +# actually reach a USB device through the `raw-usb` plug, publish over DNS-SD +# through `avahi-control`, and bind its HTTP listener through `network-bind`. +# Snap confinement is only enforced on a real runner with AppArmor (GitHub's +# ubuntu-24.04 images), which is why this is validated in CI rather than +# locally. +# +# The chain under test: +# +# mfp-virtual --usbip (emulated printer, speaks the USB/IP protocol) +# | TCP :3240 +# usbip attach + vhci_hcd (kernel exposes it as a local USB device) +# | +# libusb -> ipp-usb (SNAP, strict confinement, raw-usb plug) +# +# What we assert: +# A. `snap run ipp-usb check` lists the emulated device (dead:beaf) with the +# model make-and-model - the confined binary reaches USB via `raw-usb`. +# B. The confined `ipp-usb-server` daemon discovers the device and completes +# an HTTP-over-USB round-trip (per-device log shows dead:beaf + 200 OK). +# C. A client request routed *through* the confined daemon's TCP listener +# comes back 200 OK (client -> ipp-usb snap -> USB). +# +# Notes: +# * Only amd64 and arm64 are exercised here: the test needs to *run* the +# confined daemon against a natively-attached USB device, which is not +# possible under qemu-user emulation. armhf/riscv64 snap builds are +# validated by the Snap Store build service. +# * Every gotcha learned on the cups-snap CI is folded in up front: start +# avahi+dbus before anything DNS-SD, a --dangerous sideload does NOT +# auto-connect the manual plugs (connect them explicitly), no argument to a +# snap app may contain spaces, and give the daemon a settle-wait/retry. +# ============================================================================= +name: Snap Test + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # go-mfp (the IPP-over-USB emulator) - pinned for reproducibility, same as + # emulator-test.yml. + GO_MFP_REPO: https://github.com/OpenPrinting/go-mfp.git + GO_MFP_REF: 7e0ec8c4b411a111a1cee631ba3cc3c48a3a5403 + # Example MFP model shipped in go-mfp (provides the IPP + eSCL endpoints). + GO_MFP_MODEL: modeling/examples/Kyocera-ECOSYS-M2040dn.py + # Host deps: the go-mfp emulator needs Python/JPEG/PNG for its cgo bits; + # usbutils/linux-tools provide lsusb and the usbip client; avahi-daemon lets + # the confined snap publish over DNS-SD. The snap itself is built in an + # isolated LXD container, so its build deps are not installed here. + APT_DEPS: >- + gcc pkg-config make + libjpeg-dev libpng-dev python3-dev + linux-tools-generic linux-tools-common linux-tools-virtual + usbutils avahi-daemon + +jobs: + snap-test: + name: ipp-usb snap vs. virtual IPP-over-USB printer (${{ matrix.arch }}) + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runs-on: ubuntu-24.04 + - arch: arm64 + runs-on: ubuntu-24.04-arm + + steps: + - name: Check out ipp-usb + uses: actions/checkout@v4 + with: + # The snap builds from `source: .` at git tag 0.9.29, so the full + # history and tags must be present. + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install host dependencies + run: | + sudo apt-get update -o Acquire::Retries=3 + sudo apt-get install -y -o Acquire::Retries=3 ${{ env.APT_DEPS }} + + # ----------------------------------------------------------------------- + # The snap's ipp-usb part pulls `source: .` at a fixed git tag, and + # `version: git` runs `git describe`. GitHub forks do NOT inherit tags, + # so that tag is absent in CI and the build's git clone fails. Create it + # at HEAD so the snap builds from *this branch's* code. Read the pinned + # value from snapcraft.yaml (the bare-semver ipp-usb tag, not goipp's + # 'v1.2.0') so it stays correct when the auto-update bot bumps the version. + # ----------------------------------------------------------------------- + - name: Pin the snap source to the current checkout + run: | + tag="$(grep -oE "source-tag:[[:space:]]*'?[0-9]+\.[0-9]+\.[0-9]+'?" \ + snap/snapcraft.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + test -n "$tag" + echo "Tagging HEAD as ${tag} for the snap build" + git tag -f "$tag" + + # ----------------------------------------------------------------------- + # Build the snap (isolated LXD build), matching the repo's existing use + # of canonical/craft-actions in registry-actions.yml. + # ----------------------------------------------------------------------- + - name: Build the ipp-usb snap + id: snapcraft + uses: canonical/craft-actions/snapcraft/pack@main + with: + path: . + + # ----------------------------------------------------------------------- + # Stand up the virtual USB printer - identical to emulator-test.yml. + # ----------------------------------------------------------------------- + - name: Load the USB/IP kernel module (vhci_hcd) + run: | + if ! sudo modprobe vhci-hcd 2>/dev/null; then + sudo apt-get install -y -o Acquire::Retries=3 \ + "linux-modules-extra-$(uname -r)" + sudo modprobe vhci-hcd + fi + lsmod | grep -q vhci_hcd + echo "vhci_hcd loaded." + + - name: Put the usbip client on PATH + run: | + # Ubuntu ships the usbip client inside linux-tools, not as a + # standalone package; it is a userspace tool independent of the + # running kernel version. + usbip_bin="$(ls /usr/lib/linux-tools-*/usbip 2>/dev/null | head -1)" + test -n "$usbip_bin" + sudo ln -sf "$usbip_bin" /usr/local/bin/usbip + usbip version + + - name: Build the go-mfp emulator (mfp-virtual) + run: | + git clone "${GO_MFP_REPO}" go-mfp + git -C go-mfp checkout --quiet "${GO_MFP_REF}" + ( cd go-mfp && go build -o mfp-virtual ./cmd/mfp-virtual ) + test -x go-mfp/mfp-virtual + + - name: Start the emulator and attach it as a USB device + run: | + # Start the emulated IPP-over-USB printer (USB/IP server on :3240), + # driven by the example MFP model (IPP printer + eSCL scanner). + ./go-mfp/mfp-virtual -m "go-mfp/${GO_MFP_MODEL}" --usbip -d \ + > emulator.log 2>&1 & + echo $! | sudo tee /tmp/emulator.pid >/dev/null + + # Wait for the USB/IP server to advertise the device. + for i in $(seq 1 30); do + if usbip list -r localhost 2>/dev/null | grep -q '1-1'; then + break + fi + sleep 1 + done + usbip list -r localhost + + # Attach it - now it is a real local USB device. + sudo usbip attach -r localhost -b 1-1 + sleep 2 + lsusb + if ! lsusb | grep -qi 'dead:beaf'; then + echo "::error::emulated printer did not attach as a USB device" + cat emulator.log + exit 1 + fi + echo "Virtual IPP-over-USB printer attached." + + - name: Start DNS-SD stack (avahi) for the snap + run: | + # Must be up before anything DNS-SD touches it (cups-snap lesson). + sudo systemctl start dbus || sudo service dbus start || true + sudo systemctl start avahi-daemon || sudo service avahi-daemon start || true + + # ----------------------------------------------------------------------- + # Install the snap under strict confinement and wire up its interfaces. + # ----------------------------------------------------------------------- + - name: Install the snap and connect its interfaces + run: | + snap_file="${{ steps.snapcraft.outputs.snap }}" + test -n "$snap_file" && test -f "$snap_file" + echo "Installing ${snap_file}" + sudo snap install --dangerous "$snap_file" + + # A --dangerous sideload does NOT auto-connect the manual plugs + # (raw-usb / hardware-observe / avahi-control); connect them now. + # network / network-bind are auto-connect and need no action. + for plug in raw-usb hardware-observe avahi-control; do + sudo snap connect "ipp-usb:${plug}" || { + echo "::error::could not connect ipp-usb:${plug}" + exit 1 + } + done + echo "Interfaces:" + snap connections ipp-usb + + # ----------------------------------------------------------------------- + # Assertion A - the confined binary reaches USB through the raw-usb plug. + # `ipp-usb check` is lock-free and needs no listener, so it is the most + # direct possible test of the confinement/plug question. Stop the + # auto-started daemon first so the two do not contend for the device. + # ----------------------------------------------------------------------- + - name: Assertion A - confined `ipp-usb check` sees the device + run: | + sudo snap stop ipp-usb.ipp-usb-server 2>/dev/null || true + sleep 1 + + echo "Running: snap run ipp-usb check" + sudo snap run ipp-usb check > check.log 2>&1 || true + cat check.log + + if ! grep -q 'IPP over USB devices:' check.log; then + echo "::error::confined 'ipp-usb check' found no IPP-over-USB device (raw-usb plug blocked?)" + exit 1 + fi + if ! grep -qi 'dead:beaf' check.log; then + echo "::error::confined 'ipp-usb check' did not report the emulated VID:PID" + exit 1 + fi + echo "OK: the strictly-confined ipp-usb reached the USB device via raw-usb." + + # ----------------------------------------------------------------------- + # Assertions B and C - the confined daemon serves the device end to end. + # Both must run while the daemon is up, so they share one step. + # ----------------------------------------------------------------------- + - name: Assertions B and C - confined daemon serves the device + run: | + log_dir=/var/snap/ipp-usb/common/var/log + dev_dir=/var/snap/ipp-usb/common/var/dev + + # Start the daemon fresh now that the plugs are connected and the + # device is attached; its startup udev scan picks up the device. + sudo snap start ipp-usb.ipp-usb-server + # The snapped daemon can churn briefly after (re)start; give it a + # settle-wait rather than a single check (cups-snap lesson). + sleep 3 + + # Assertion B - wait (up to 40s) for discovery + the HTTP-over-USB + # probe in the per-device log. + ok=0 + for i in $(seq 1 40); do + if sudo grep -rqs 'Found new device. VID:PID = dead:beaf' "$log_dir" 2>/dev/null \ + && sudo grep -rqs '200 OK' "$log_dir" 2>/dev/null; then + ok=1 + break + fi + sleep 1 + done + + echo "===================== snap logs =====================" + sudo snap logs ipp-usb.ipp-usb-server -n 50 || true + echo "================== ipp-usb device log ===============" + sudo find "$log_dir" -type f -name '*.log' -exec cat {} + 2>/dev/null || true + echo "=====================================================" + + if [ "$ok" -ne 1 ]; then + echo "::error::confined daemon did not complete an HTTP-over-USB round-trip" + exit 1 + fi + echo "OK: the confined daemon discovered the printer and answered over USB." + + # Assertion C - route a client request *through* the confined daemon's + # TCP listener: client -> ipp-usb (TCP) -> USB -> emulator. Resolve + # the per-device port from the state dir, fall back to the log, then + # to 60000. + port="$(sudo grep -rhoE 'http-port[[:space:]]*=[[:space:]]*[0-9]+' \ + "$dev_dir" 2>/dev/null | grep -oE '[0-9]+' | head -1)" + port="${port:-$(sudo grep -rhoE 'localhost:[0-9]+' "$log_dir" 2>/dev/null | head -1 | cut -d: -f2)}" + port="${port:-60000}" + # Use the "localhost" hostname, not 127.0.0.1: ipp-usb 302-redirects + # IP-address requests to the hostname; -L follows it as a safety net. + echo "Querying the confined daemon on localhost:${port}" + + code=000 + for i in $(seq 1 15); do + code="$(curl -sL -o caps.xml -w '%{http_code}' \ + "http://localhost:${port}/eSCL/ScannerCapabilities" || echo 000)" + [ "$code" = "200" ] && break + sleep 1 + done + echo "HTTP status: ${code}" + head -c 400 caps.xml || true + echo + + if [ "$code" != "200" ] || ! grep -qi 'ScannerCapabilities' caps.xml; then + echo "::error::client request through the confined daemon did not succeed" + exit 1 + fi + echo "OK: client -> ipp-usb snap -> USB round-trip returned 200 OK." + + - name: Collect logs on failure + if: failure() + run: | + echo "----- emulator.log -----"; cat emulator.log 2>/dev/null || true + echo "----- check.log -----"; cat check.log 2>/dev/null || true + echo "----- snap services -----"; snap services ipp-usb 2>/dev/null || true + echo "----- snap logs -----"; sudo snap logs ipp-usb.ipp-usb-server -n 100 2>/dev/null || true + echo "----- device logs -----" + sudo find /var/snap/ipp-usb/common/var/log -type f -exec cat {} + 2>/dev/null || true + echo "----- usbip port -----"; usbip port 2>/dev/null || true + echo "----- lsusb -----"; lsusb 2>/dev/null || true + + - name: Tear down + if: always() + run: | + sudo snap stop ipp-usb.ipp-usb-server 2>/dev/null || true + sudo usbip detach -p 00 2>/dev/null || true + kill "$(cat /tmp/emulator.pid 2>/dev/null)" 2>/dev/null || true diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..a40a7ea --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,65 @@ +# golangci-lint configuration for ipp-usb +# +# The linter set is deliberately curated rather than "everything on": +# ipp-usb is a cgo daemon, so we enable the checks that are reliable and +# high-signal on this codebase and leave the noisier style linters off. +# Add more over time as the tree is cleaned up. + +run: + # Build the same way the daemon is built so cgo files are analysed. + build-tags: + - nethttpomithttp2 + timeout: 5m + +# The vendored tree is third-party code; never lint it. +issues: + exclude-dirs: + - vendor + # Show every issue rather than golangci-lint's default per-linter caps. + max-issues-per-linter: 0 + max-same-issues: 0 + + # Narrowly-scoped exclusions for findings that are false positives or + # deliberate on this codebase. Everything else (and all new code) is still + # linted normally. + exclude-rules: + # Avahi's own C enum is spelled AVAHI_ENTRY_GROUP_UNCOMMITED (an upstream + # typo). The Go code references C.AVAHI_ENTRY_GROUP_UNCOMMITED and the log + # string mirrors that symbol, so it must keep Avahi's spelling. + - path: dnssd_avahi.go + linters: [misspell] + text: "UNCOMMITED" + + # cgo callback / function-pointer casts (libusb_*_cb_fn, device-handle + # pointer types). unconvert cannot see the C side and reports these + # required conversions as redundant. + - path: usbio_libusb.go + linters: [unconvert] + + # Deliberate: a USB transport has no external cancellation channel, so the + # (deprecated) http.Request.Cancel field is explicitly cleared on purpose. + - path: usbtransport.go + linters: [staticcheck] + text: "SA1019: outreq.Cancel" + + # DNSSdTxtRecord.export is exercised by ipp_test.go; the unused linter does + # not count that test-only user. + - path: dnssd.go + linters: [unused] + text: "DNSSdTxtRecord.export" + +linters: + disable-all: true + enable: + - govet # suspicious constructs (also run standalone in CI) + - staticcheck # extensive correctness / bug checks + - ineffassign # ineffectual assignments + - unused # unused code + - misspell # common English misspellings in comments/strings + - unconvert # unnecessary type conversions + - gofmt # canonical formatting + - goimports # import grouping / formatting + +linters-settings: + gofmt: + simplify: true diff --git a/README.md b/README.md index 81d41be..76da6b6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ ![GitHub](https://img.shields.io/github/license/OpenPrinting/ipp-usb) [![Go Report Card](https://goreportcard.com/badge/github.com/OpenPrinting/ipp-usb)](https://goreportcard.com/badge/github.com/OpenPrinting/ipp-usb) +[![Build and Test](https://github.com/OpenPrinting/ipp-usb/actions/workflows/build.yml/badge.svg)](https://github.com/OpenPrinting/ipp-usb/actions/workflows/build.yml) +[![Lint](https://github.com/OpenPrinting/ipp-usb/actions/workflows/lint.yml/badge.svg)](https://github.com/OpenPrinting/ipp-usb/actions/workflows/lint.yml) +[![CodeQL](https://github.com/OpenPrinting/ipp-usb/actions/workflows/codeql.yml/badge.svg)](https://github.com/OpenPrinting/ipp-usb/actions/workflows/codeql.yml) ## Introduction diff --git a/inifile.go b/inifile.go index b992e23..297de19 100644 --- a/inifile.go +++ b/inifile.go @@ -148,7 +148,7 @@ func (ini *IniFile) Next() (*IniRecord, error) { c, token, err = ini.token('=', false) if err == nil && c == '=' { ini.rec.Key = token - c, token, err = ini.token(-1, true) + _, token, err = ini.token(-1, true) if err == nil { ini.rec.Value = token ini.rec.Type = IniRecordKeyVal @@ -514,7 +514,7 @@ func (rec *IniRecord) LoadSize(out *int64) error { return rec.errBadValue("%q: invalid size", rec.Value) } - if sz > uint64(math.MaxInt64/units) { + if sz > math.MaxInt64/units { return rec.errBadValue("size too large") } diff --git a/ipp.go b/ipp.go index 0da3d33..5f13032 100644 --- a/ipp.go +++ b/ipp.go @@ -413,8 +413,8 @@ func (attrs ippAttrs) getPaperMax() string { xDimMax = int(dim) } case goipp.Range: - if int(dim.Upper) > xDimMax { - xDimMax = int(dim.Upper) + if dim.Upper > xDimMax { + xDimMax = dim.Upper } } } @@ -426,8 +426,8 @@ func (attrs ippAttrs) getPaperMax() string { yDimMax = int(dim) } case goipp.Range: - if int(dim.Upper) > yDimMax { - yDimMax = int(dim.Upper) + if dim.Upper > yDimMax { + yDimMax = dim.Upper } } }