diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml new file mode 100644 index 0000000..4d2f485 --- /dev/null +++ b/.github/workflows/installers.yml @@ -0,0 +1,96 @@ +name: installers + +on: + push: + branches: ["**"] + pull_request: + +jobs: + netprint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + - name: Toolchain + run: sudo apt-get update && sudo apt-get install -y nsis cpio g++ git + - name: Build driver and network-printer installers + run: VERSION=0.3.4 ./scripts/build-netprint-installers.sh + - uses: actions/upload-artifact@v4 + with: + name: verae-netprint-linux + path: | + build/netprint/*.deb + build/netprint/*.exe + build/netprint/macos/** + if-no-files-found: error + + macos-pkg: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26.1" + - name: Build arm64 binary, AirDrop helper, and package + run: | + set -euo pipefail + VERSION=0.3.4 + OUT="$PWD/build/netprint/macos" + mkdir -p "$OUT" + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o "$OUT/verae-print" ./cmd/verae-print + chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/check-pkg.sh + VERSION="$VERSION" BIN="$OUT/verae-print" PKG_ARCH=arm64 OUT="$OUT" ./platform/macos/pkg/build-netprint-pkg.sh + cp -f "$OUT/verae-print" "$OUT/verae-print-arm64" + APP="$OUT/payload/Applications/Verae NetPrint.app/Contents/MacOS" + test -x "$APP/verae-airdrop" + test -x "$APP/verae-menubar" + test -x "$APP/verae-print" + test -f "$OUT/payload/Applications/Verae NetPrint.app/Contents/Info.plist" + test -f "$OUT/VeraeNetPrint-macos-arm64-${VERSION}.pkg" + "$APP/verae-menubar" --smoke | tee "$OUT/menubar-smoke.txt" + "$APP/verae-print" tray --smoke | tee "$OUT/tray-smoke.txt" + grep -E 'smoke ok|no window server' "$OUT/menubar-smoke.txt" + grep -E 'smoke ok|no window server' "$OUT/tray-smoke.txt" + - name: pkgutil --expand, pkgutil --check-signature, and installer -pkginfo + run: bash platform/macos/pkg/check-pkg.sh build/netprint/macos/VeraeNetPrint-macos-arm64-0.3.4.pkg + - uses: actions/upload-artifact@v4 + with: + name: verae-netprint-macos + path: | + build/netprint/macos/*.pkg + build/netprint/macos/verae-print + build/netprint/macos/verae-print-arm64 + if-no-files-found: error + + publish: + needs: [netprint, macos-pkg] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: verae-netprint-linux + path: dist + - uses: actions/download-artifact@v4 + with: + name: verae-netprint-macos + path: macpkg + - name: SHA256SUMS + run: | + set -euo pipefail + rm -f dist/macos/*.pkg dist/SHA256SUMS + mkdir -p dist/macos + cp -f macpkg/*.pkg macpkg/verae-print macpkg/verae-print-arm64 dist/macos/ + cd dist + sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh | tee SHA256SUMS + test -f macos/VeraeNetPrint-macos-arm64-0.3.4.pkg + - uses: actions/upload-artifact@v4 + with: + name: verae-netprint-installers + path: | + dist/*.deb + dist/*.exe + dist/SHA256SUMS + dist/macos/** + if-no-files-found: error diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7ddebd6 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: test netprint-debs netprint-windows netprint-macos installers + +VERSION ?= 0.3.4 + +test: + go test ./... + +netprint-debs: + VERSION=$(VERSION) ./platform/linux/deb/build-deb.sh amd64 + VERSION=$(VERSION) ./platform/linux/deb/build-netprint-deb.sh amd64 + +netprint-windows netprint-macos installers: + VERSION=$(VERSION) ./scripts/build-netprint-installers.sh diff --git a/README.md b/README.md index b5175d9..8b82508 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ verae-print setup verae-print stamp document.pdf ``` +**Network printer (optional, separate from the driver):** an IPP Everywhere queue named **Verae Timestamp** for other devices on the LAN. Install the standalone package, or leave the driver installer’s option off (Windows checkbox, macOS Customize, Debian debconf). Both use one service and one tray / menu-bar control. On Windows the service advertises through DNS-SD (`DnsServiceRegister`) because the DNS Client owns UDP 5353, writes `%ProgramData%\Verae\logs\netprint.log`, and the setup starts the tray for the logged-in user. Public networks and a local Windows queue stay off unless you opt in. The standalone Windows folder is `C:\Program Files\Verae\NetPrint` (the driver stays in `PrintStamp`). The LAN fallback port is **8632**, so it does not collide with the driver helper on `127.0.0.1:8631`. See [docs/network-printer.md](docs/network-printer.md). + +| OS | Standalone | With the driver (off unless you opt in) | +|----|------------|------------------------------------------| +| Windows | `VeraeNetPrint-windows-amd64-setup.exe` | Checkbox on the driver setup’s options page | +| macOS | `VeraeNetPrint-macos.pkg` | Customize → Network timestamp printer | +| Debian | `sudo apt install ./verae-netprint_amd64.deb` | `sudo apt install ./verae-print_amd64.deb` and answer the prompt, or install `verae-netprint` later | + **Linux (tarball, any distro):** ```bash @@ -64,6 +72,13 @@ printenv XVERAE_TOKEN > ~/.verae/xverae-token # or paste once chmod 600 ~/.verae/xverae-token ``` +Build the network-printer installers (Debian packages, Windows NSIS setups, macOS binaries and a `.pkg` when `pkgbuild` is available): + +```bash +make installers +# artifacts: build/netprint/ +``` + ## Usage In any app: **File → Print → Destination → Verae Timestamping → Print**. diff --git a/cmd/verae-netprint-tray/main.go b/cmd/verae-netprint-tray/main.go new file mode 100644 index 0000000..d52c8ea --- /dev/null +++ b/cmd/verae-netprint-tray/main.go @@ -0,0 +1,19 @@ +// Command verae-netprint-tray is the Windows notification-area build of the +// network-printer menu. It is the same control code as `verae-print tray`, +// linked as a GUI-subsystem binary so it does not open a console. +package main + +import ( + "context" + "fmt" + "os" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/netprint" +) + +func main() { + if err := netprint.RunTray(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/verae-print/acquire_other.go b/cmd/verae-print/acquire_other.go index 5872a56..e684390 100644 --- a/cmd/verae-print/acquire_other.go +++ b/cmd/verae-print/acquire_other.go @@ -1,4 +1,4 @@ -//go:build !windows && !darwin +//go:build !windows && !darwin && !linux package main diff --git a/cmd/verae-print/installer_cmds_test.go b/cmd/verae-print/installer_cmds_test.go index d776c2a..4e4b6b8 100644 --- a/cmd/verae-print/installer_cmds_test.go +++ b/cmd/verae-print/installer_cmds_test.go @@ -3,10 +3,52 @@ package main import ( "os" "path/filepath" + "runtime" + "strings" "testing" "unicode/utf16" ) +func TestNetworkStampUsesDesktopPath(t *testing.T) { + netprintSrc, err := os.ReadFile("netprint_cmd.go") + if err != nil { + t.Fatal(err) + } + core, err := os.ReadFile("stampcore.go") + if err != nil { + t.Fatal(err) + } + text := string(netprintSrc) + if !strings.Contains(text, "stampWithClient(") || strings.Contains(text, "mock-cert") || strings.Contains(text, `DocumentName: "document.pdf"`) { + t.Fatal("netprint stamp is not the desktop stamp path") + } + if !strings.Contains(string(core), "func stampWithClient(") || !strings.Contains(string(core), "stampWithClient(pdf, t.Title") { + t.Fatal("stampFile does not use stampWithClient") + } +} + +func TestNetprintUnknownCommandDoesNotStartServer(t *testing.T) { + if code := runNetprint([]string{"launch-server"}); code != 2 { + t.Fatalf("unknown command exit %d, want 2", code) + } + if runtime.GOOS == "windows" { + return + } + // Linux has no tray launcher. The command must fail before any listen. + if code := runNetprint([]string{"launch-tray"}); code != 1 { + t.Fatalf("launch-tray exit %d, want 1", code) + } +} + +func TestTrayUnknownArgument(t *testing.T) { + old := os.Args + t.Cleanup(func() { os.Args = old }) + os.Args = []string{"verae-print", "tray", "--nope"} + if code := runTrayCmd(); code != 2 { + t.Fatalf("tray exit %d, want 2", code) + } +} + func TestReadTextFileUTF16(t *testing.T) { s := "Email=a@b.co\r\nPassword=p=\"w\\x\r\n" u := utf16.Encode([]rune(s)) diff --git a/cmd/verae-print/keydialog_stub.go b/cmd/verae-print/keydialog_stub.go new file mode 100644 index 0000000..306afd1 --- /dev/null +++ b/cmd/verae-print/keydialog_stub.go @@ -0,0 +1,10 @@ +//go:build !windows && !linux + +package main + +import "fmt" + +func runKeyDialog() int { + fmt.Println("key-dialog is for Windows and Linux; on macOS run: verae-print mac-key-setup") + return 1 +} diff --git a/cmd/verae-print/keydialog_windows.go b/cmd/verae-print/keydialog_windows.go index 4578f97..7cbddfd 100644 --- a/cmd/verae-print/keydialog_windows.go +++ b/cmd/verae-print/keydialog_windows.go @@ -45,6 +45,8 @@ function Btn([string]$text, [int]$x, [int]$w, [string]$res) { switch ($kind) { 'login' { $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $t = Field '2FA code (if enabled)' $false '' + $fl = New-Object Windows.Forms.LinkLabel; $fl.Text = 'Forgot password?'; $fl.Left = 170; $fl.Top = $script:y; $fl.Width = 200 + $fl.Add_LinkClicked({ $script:result = 'forgot'; $f.Close() }); $f.Controls.Add($fl); $script:y += 24 $script:y += 8 $ok = Btn 'Sign in' 15 100 'login'; $f.AcceptButton = $ok Btn 'Create account' 125 120 'create' | Out-Null @@ -53,7 +55,35 @@ switch ($kind) { $f.Height = $script:y + 75 if ($pre) { $f.Add_Shown({ $p.Focus() }) } [void]$f.ShowDialog() - if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } else { $script:result } + if ($script:result -eq 'login') { "login|$(B $e.Text)|$(B $p.Text)|$(B $t.Text)" } + elseif ($script:result -eq 'forgot') { "forgot|$(B $e.Text)" } else { $script:result } + } + 'forgot' { + $e = Field 'Email' $false $pre + $script:y += 8 + $ok = Btn 'Send code' 15 100 'send'; $f.AcceptButton = $ok + $c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c + $f.Height = $script:y + 75 + [void]$f.ShowDialog() + if ($script:result -eq 'send') { "send|$(B $e.Text)" } else { 'cancel' } + } + 'reset' { + $k = Field 'Code from the email' $false ''; $p = Field 'New password' $true ''; $p2 = Field 'Confirm new password' $true '' + $script:y += 8 + $ok = Btn 'Reset password' 15 130 'reset'; $f.AcceptButton = $ok + Btn 'Send a new code' 155 130 'resend' | Out-Null + $c = Btn 'Cancel' 410 100 'cancel'; $f.CancelButton = $c + $f.Height = $script:y + 75 + [void]$f.ShowDialog() + if ($script:result -eq 'reset') { "reset|$(B $k.Text)|$(B $p.Text)|$(B $p2.Text)" } else { $script:result } + } + 'unavail' { + $ok = Btn 'Open customer.verae.ai' 15 200 'open'; $f.AcceptButton = $ok + $c = Btn 'Back' 410 100 'cancel'; $f.CancelButton = $c + $f.Height = $script:y + 75 + [void]$f.ShowDialog() + if ($script:result -eq 'open') { Start-Process 'https://customer.verae.ai/portal/' } + 'cancel' } 'create' { $n = Field 'Your name' $false ''; $e = Field 'Email' $false $pre; $p = Field 'Password' $true ''; $p2 = Field 'Confirm password' $true '' @@ -143,9 +173,21 @@ func acquireKeyInteractive(reason string) bool { } if err != nil { vlog.Errorf("sign-in: login failed for %s: %v", auth.MaskEmail(email), err) - msg = "Sign-in failed: " + err.Error() + msg = "Sign-in failed: " + err.Error() + "\r\nForgot your password? Click \"Forgot password?\" below." continue } + case "forgot": + if e := strings.TrimSpace(f[0]); e != "" { + email = e + } + k, ok, m := winForgotFlow(caps, email) + if m != "" { + msg = m + } + if !ok { + continue + } + key = k case "create": k, ok := winCreateFlow(caps, email) if !ok { @@ -231,6 +273,95 @@ func winCreateFlow(caps auth.Capabilities, email string) (string, bool) { return "", false } +// winResetUnavailable: in-app reset not supported; offer the web portal. +func winResetUnavailable() { + _, _, _ = winForm("unavail", "Password reset isn't available in the app yet.\r\n\r\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "") +} + +// winForgotFlow: emailed-code password reset. Returns (key, ok, message for the sign-in form). +func winForgotFlow(caps auth.Capabilities, email string) (string, bool, string) { + msg := "Reset your Verae password.\r\nWe'll email a 6-digit code to this address." + for i := 0; i < 10; i++ { + act, f, err := winForm("forgot", msg, email) + if err != nil || act != "send" { + return "", false, "" + } + email = strings.TrimSpace(f[0]) + if !auth.ValidEmail(email) { + msg = "Please enter a valid email address." + continue + } + vlog.Printf("sign-in: password reset requested for %s", auth.MaskEmail(email)) + if ferr := auth.PasswordForgot(email); ferr != nil { + vlog.Errorf("sign-in: password forgot %s: %v", auth.MaskEmail(email), ferr) + if errors.Is(ferr, auth.ErrResetUnavailable) { + winResetUnavailable() + return "", false, "" + } + msg = "Could not send a reset code: " + ferr.Error() + continue + } + cmsg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes). Check your spam folder.\r\n" + caps.Policy.Describe() + for j := 0; j < 15; j++ { + act, cf, err := winForm("reset", cmsg, "") + if err != nil || act == "cancel" || act == "" { + return "", false, "" + } + if act == "resend" { + if rerr := auth.PasswordForgot(email); rerr != nil { + if errors.Is(rerr, auth.ErrResetUnavailable) { + winResetUnavailable() + return "", false, "" + } + cmsg = "Could not send a new code: " + rerr.Error() + } else { + cmsg = "A new code was sent to " + email + ".\r\n" + caps.Policy.Describe() + } + continue + } + code, pw := strings.TrimSpace(cf[0]), cf[1] + switch { + case len(code) != 6 || strings.Trim(code, "0123456789") != "": + cmsg = "Enter the 6-digit code from the email.\r\n" + caps.Policy.Describe() + continue + case pw != cf[2]: + cmsg = "The passwords do not match.\r\n" + caps.Policy.Describe() + continue + } + if perr := caps.Policy.Check(pw, email); perr != nil { + cmsg = perr.Error() + "\r\n" + caps.Policy.Describe() + continue + } + key, rerr := auth.PasswordReset(email, code, pw, tokenName()) + cf[1], cf[2] = "", "" + if rerr != nil { + vlog.Errorf("sign-in: password reset %s: %v", auth.MaskEmail(email), rerr) + if errors.Is(rerr, auth.ErrResetUnavailable) { + winResetUnavailable() + return "", false, "" + } + cmsg = "Could not reset the password: " + rerr.Error() + continue + } + vlog.Printf("sign-in: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "") + if key != "" { + return key, true, "" + } + key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName()) + pw = "" + if lerr != nil { + if errors.Is(lerr, auth.ErrTOTPRequired) { + return "", false, "Your password was changed. Sign in with your new password and your 6-digit authenticator code." + } + return "", false, "Your password was changed, but signing in failed: " + lerr.Error() + "\r\nSign in with your new password." + } + return key, true, "" + } + return "", false, "" + } + return "", false, "" +} + // pasteFlow: masked key field + portal button; validated before saving. func pasteFlow(reason string) bool { msg := "Open customer.verae.ai, sign in, open Security and create an xVerae- token (shown once). Paste it below." diff --git a/cmd/verae-print/keyflow_gui.go b/cmd/verae-print/keyflow_gui.go new file mode 100644 index 0000000..40da49b --- /dev/null +++ b/cmd/verae-print/keyflow_gui.go @@ -0,0 +1,365 @@ +//go:build darwin || linux + +package main + +// Shared native sign-in flow (macOS AppleScript dialogs, Linux zenity dialogs). +// Platform files provide ask/say/choose/openURL, machineWord and keyStoreDesc. +// Passwords travel only over the dialog's stdout pipe and HTTPS; never argv, +// files or logs. Keys are logged masked only. + +import ( + "errors" + "strings" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" +) + +var errCancel = errors.New("cancelled") + +const ( + optKeep = "Keep the existing key" + optLogin = "Sign in to my Verae account (email + password)" + optCreate = "Create a new Verae account" + optPaste = "Paste an xVerae- key" + optSkip = "Skip for now" +) + +// acquireKeyInteractive: native sign-in dialogs after a 401/403 or missing key. +func acquireKeyInteractive(reason string) bool { + if !guiAvailable() { + vlog.Printf("sign-in needed but no desktop session for dialogs: %s (run: verae-print key-dialog)", reason) + return false + } + _, saved := keyFlow(reason) + return saved +} + +// keyFlow returns a masked result string and whether a NEW key was saved. +func keyFlow(reason string) (string, bool) { + existing, _ := auth.LoadXVeraeToken() + caps := auth.GetCapabilities() + vlog.Printf("mac-key-setup: installApi=%v emailVerification=%v existing=%v", caps.InstallAPI, caps.EmailVerification, existing != "") + var opts []string + intro := "No Verae signing key was found for this " + machineWord + " user. You need one to timestamp documents." + if existing != "" { + st, det := auth.ValidateXVeraeKey(existing) + vlog.Printf("mac-key-setup: existing key %s check=%s %s", auth.MaskKey(existing), st, det) + switch st { + case "valid": + intro = "A Verae signing key is already saved: " + auth.MaskKey(existing) + " (verified with Verae)." + opts = append(opts, optKeep) + case "invalid": + intro = "A saved key (" + auth.MaskKey(existing) + ") was REJECTED by Verae. Please sign in or paste a new key." + default: + intro = "A Verae signing key is saved: " + auth.MaskKey(existing) + " (could not verify right now)." + opts = append(opts, optKeep) + } + } + opts = append(opts, optLogin, optCreate, optPaste, optSkip) + if reason != "" { + intro = reason + "\n\n" + intro + } + prompt := intro + "\n\nYour password is sent only to customer.verae.ai over HTTPS and is never stored. Only the issued key is saved (" + keyStoreDesc + ")." + for { + c, err := choose(prompt, opts) + if err != nil { + c = optSkip + } + var key string + switch c { + case optKeep: + return "kept " + auth.MaskKey(existing), false + case optLogin: + key, err = macLogin(caps) + case optCreate: + key, err = macCreate(caps) + case optPaste: + key, err = macPaste() + default: + btn, _, aerr := ask("Continue without a Verae key?\n\nThe printer is installed, but documents cannot be timestamped until you add a key. You will be asked again on your first print (or run Verae Print Stamp from your apps menu).", "", false, false, []string{"Go back", "Skip"}, "Go back") + if aerr != nil || btn == "Skip" { + return "skipped", false + } + continue + } + if errors.Is(err, errCancel) { + continue // back to the menu + } + if err != nil { + vlog.Errorf("mac-key-setup: %v", err) + say("Could not get a key: " + err.Error()) + continue + } + if err := auth.SaveXVeraeToken(key); err != nil { + vlog.Errorf("mac-key-setup: save failed: %v", err) + say("The key could not be saved: " + err.Error()) + continue + } + vlog.Printf("mac-key-setup: key saved %s", auth.MaskKey(key)) + say("Your Verae signing key (" + auth.MaskKey(key) + ") is saved.\n\nPrint from any app and choose \"Verae Timestamping\".") + return "saved " + auth.MaskKey(key), true + } +} + +const btnForgot = "Forgot password?" + +func macLogin(caps auth.Capabilities) (string, error) { + email := "" + for { + btn, e, err := ask("Sign in to Verae\n\nEmail:", email, false, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + return "", errCancel + } + email = strings.TrimSpace(e) + if !auth.ValidEmail(email) { + say("Please enter a valid email address.") + continue + } + btn, pw, err := ask("Password for "+email+":", "", true, true, []string{"Back", btnForgot, "Sign in"}, "Sign in") + if err != nil || btn == "Back" { + continue + } + if btn == btnForgot { + key, ferr := macForgot(caps, email) + if errors.Is(ferr, errCancel) { + continue + } + return key, ferr + } + vlog.Printf("mac-key-setup: login %s", auth.MaskEmail(email)) + key, err := auth.InstallLogin(caps, email, pw, "", tokenName()) + if errors.Is(err, auth.ErrTOTPRequired) { + btn, code, cerr := ask("This account uses two-factor authentication.\n\nEnter the 6-digit code from your authenticator app:", "", false, true, []string{"Back", "Verify"}, "Verify") + if cerr != nil || btn == "Back" { + continue + } + key, err = auth.InstallLogin(caps, email, pw, strings.TrimSpace(code), tokenName()) + } + pw = "" + if err != nil { + vlog.Errorf("mac-key-setup: login failed for %s: %v", auth.MaskEmail(email), err) + b, _, _ := ask("Sign-in failed: "+err.Error(), "", false, false, []string{btnForgot, "Try again"}, "Try again") + if b == btnForgot { + key, ferr := macForgot(caps, email) + if errors.Is(ferr, errCancel) { + continue + } + return key, ferr + } + continue + } + return key, nil + } +} + +// offerPortalReset tells the user in-app reset is unavailable and offers the web portal. +func offerPortalReset() { + b, _, _ := ask("Password reset isn't available in the app yet.\n\nYou can reset your password on the Verae customer portal in your browser ("+auth.PortalResetURL+").", "", false, false, []string{"Back", "Open portal"}, "Open portal") + if b == "Open portal" { + openURL(auth.PortalResetURL) + } +} + +// macForgot: emailed-code password reset. Returns a minted key, or errCancel. +func macForgot(caps auth.Capabilities, email string) (string, error) { + for { + btn, e, err := ask("Reset your Verae password\n\nWe'll email a 6-digit code to this address:", email, false, true, []string{"Back", "Send code"}, "Send code") + if err != nil || btn == "Back" { + return "", errCancel + } + email = strings.TrimSpace(e) + if !auth.ValidEmail(email) { + say("Please enter a valid email address.") + continue + } + vlog.Printf("mac-key-setup: password reset requested for %s", auth.MaskEmail(email)) + if ferr := auth.PasswordForgot(email); ferr != nil { + vlog.Errorf("mac-key-setup: password forgot %s: %v", auth.MaskEmail(email), ferr) + if errors.Is(ferr, auth.ErrResetUnavailable) { + offerPortalReset() + return "", errCancel + } + say("Could not send a reset code: " + ferr.Error()) + continue + } + msg := "If " + email + " has a Verae account, we emailed it a 6-digit code (valid 15 minutes).\nIt may take a minute; check spam.\n\nCode:" + codeLoop: + for { + btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Next"}, "Next") + if err != nil || btn == "Cancel" { + return "", errCancel + } + if btn == "Send a new code" { + if rerr := auth.PasswordForgot(email); rerr != nil { + if errors.Is(rerr, auth.ErrResetUnavailable) { + offerPortalReset() + return "", errCancel + } + say("Could not send a new code: " + rerr.Error()) + } else { + say("A new code is on its way.") + } + continue + } + code = strings.TrimSpace(code) + if len(code) != 6 || strings.Trim(code, "0123456789") != "" { + say("Enter the 6-digit code from the email.") + continue + } + for { + btn, pw, err := ask("Choose a new password.\n"+caps.Policy.Describe(), "", true, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + continue codeLoop + } + if perr := caps.Policy.Check(pw, email); perr != nil { + say(perr.Error()) + continue + } + btn, pw2, err := ask("Confirm the new password:", "", true, true, []string{"Back", "Reset password"}, "Reset password") + if err != nil || btn == "Back" { + continue + } + if pw != pw2 { + say("The passwords do not match.") + continue + } + pw2 = "" + key, rerr := auth.PasswordReset(email, code, pw, tokenName()) + if rerr != nil { + pw = "" + vlog.Errorf("mac-key-setup: password reset %s: %v", auth.MaskEmail(email), rerr) + if errors.Is(rerr, auth.ErrResetUnavailable) { + offerPortalReset() + return "", errCancel + } + say("Could not reset the password: " + rerr.Error()) + continue codeLoop + } + vlog.Printf("mac-key-setup: password reset ok for %s (key returned=%v)", auth.MaskEmail(email), key != "") + if key != "" { + return key, nil + } + // No key minted (e.g. 2FA account): sign in with the new password. + key, lerr := auth.InstallLogin(caps, email, pw, "", tokenName()) + if errors.Is(lerr, auth.ErrTOTPRequired) { + b, totp, cerr := ask("Your password was changed.\n\nThis account uses two-factor authentication. Enter the 6-digit code from your authenticator app:", "", false, true, []string{"Cancel", "Verify"}, "Verify") + if cerr != nil || b == "Cancel" { + pw = "" + return "", errCancel + } + key, lerr = auth.InstallLogin(caps, email, pw, strings.TrimSpace(totp), tokenName()) + } + pw = "" + if lerr != nil { + say("Your password was changed, but signing in failed: " + lerr.Error() + "\n\nChoose \"Sign in\" and use your new password.") + return "", errCancel + } + return key, nil + } + } + } +} + +func macCreate(caps auth.Capabilities) (string, error) { + var name, email string + for { + btn, n, err := ask("Create a Verae account\n\nYour name:", name, false, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + return "", errCancel + } + name = strings.TrimSpace(n) + if name == "" { + continue + } + btn, e, err := ask("Email address:", email, false, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + continue + } + email = strings.TrimSpace(e) + if !auth.ValidEmail(email) { + say("Please enter a valid email address.") + continue + } + policy := caps.Policy.Describe() + btn, pw, err := ask("Choose a password.\n"+policy, "", true, true, []string{"Back", "Next"}, "Next") + if err != nil || btn == "Back" { + continue + } + if perr := caps.Policy.Check(pw, email); perr != nil { + say(perr.Error()) + continue + } + btn, pw2, err := ask("Confirm the password:", "", true, true, []string{"Back", "Create account"}, "Create account") + if err != nil || btn == "Back" { + continue + } + if pw != pw2 { + say("The passwords do not match.") + continue + } + pw2 = "" + vlog.Printf("mac-key-setup: register %s installApi=%v", auth.MaskEmail(email), caps.InstallAPI) + rr, err := auth.InstallRegister(caps, name, email, pw, tokenName()) + pw = "" + if err != nil { + vlog.Errorf("mac-key-setup: register failed for %s: %v", auth.MaskEmail(email), err) + say("Could not create the account: " + err.Error()) + continue + } + if rr.PendingID == "" { + return rr.Key, nil // server without email verification (legacy signup) + } + vlog.Printf("mac-key-setup: verification code sent to %s", auth.MaskEmail(email)) + msg := "We emailed a 6-digit code to " + auth.MaskEmail(email) + ".\nIt may take a minute; check spam.\n\nCode:" + for { + btn, code, err := ask(msg, "", false, true, []string{"Cancel", "Send a new code", "Verify"}, "Verify") + if err != nil || btn == "Cancel" { + return "", errCancel + } + if btn == "Send a new code" { + if rerr := auth.InstallResend(rr.PendingID); rerr != nil { + say("Could not send a new code: " + rerr.Error()) + } else { + say("A new code was sent.") + } + continue + } + key, verr := auth.InstallVerify(rr.PendingID, code, tokenName()) + if verr != nil { + say(verr.Error()) + continue + } + return key, nil + } + } +} + +func macPaste() (string, error) { + for { + btn, k, err := ask("Paste your xVerae- key.\n\nNo key yet? Click \"Open portal\", sign in, open Security and create an xVerae- token (shown once).", "", true, true, []string{"Back", "Open portal", "Save"}, "Save") + if err != nil || btn == "Back" { + return "", errCancel + } + if btn == "Open portal" { + openURL(auth.PortalSignupURL) + continue + } + k = strings.TrimSpace(k) + st, det := auth.ValidateXVeraeKey(k) + vlog.Printf("mac-key-setup: pasted key %s: %s %s", auth.MaskKey(k), st, det) + switch st { + case "valid": + return k, nil + case "invalid": + say("Verae rejected this key (" + det + "). Please check it and paste again.") + case "badformat": + say("That does not look like a Verae key: " + det) + default: + b, _, _ := ask("Could not reach Verae to check the key ("+det+").\nSave it anyway?", "", false, false, []string{"No", "Save anyway"}, "Save anyway") + if b == "Save anyway" { + return k, nil + } + } + } +} diff --git a/cmd/verae-print/keysetup_darwin.go b/cmd/verae-print/keysetup_darwin.go index 10c4b4f..96db150 100644 --- a/cmd/verae-print/keysetup_darwin.go +++ b/cmd/verae-print/keysetup_darwin.go @@ -3,14 +3,12 @@ package main import ( - "errors" "flag" "fmt" "os" "os/exec" "strings" - "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/settings" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog" ) @@ -22,6 +20,15 @@ import ( const dlgTitle = "Verae Print Stamp" +const ( + machineWord = "Mac" + keyStoreDesc = "Keychain + ~/.verae" +) + +func openURL(u string) { _ = exec.Command("open", u).Start() } + +func guiAvailable() bool { return true } + // AppleScript gets all user-visible text via argv (no string interpolation → // no injection). Output: " Receipts on this computer

+`, + htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks()), + checkedAttr(cfg.EmailOn()), htmlEscape(cfg.EmailTo), htmlEscape(cfg.SMTPHost), cfg.SMTPPort, + selectedAttr(cfg.SMTPTLS != "tls"), selectedAttr(cfg.SMTPTLS == "tls"), htmlEscape(cfg.SMTPUser), + htmlEscape(pwNote), htmlEscape(pinNote), mac) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(b.String())) +} + +func selectedAttr(on bool) string { + if on { + return "selected" + } + return "" +} + +func checkedAttr(on bool) string { + if on { + return "checked" + } + return "" +} + +func htmlEscape(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """).Replace(s) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func loopback(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// StoppedStatus is what the tray shows when the control API is down. +func StoppedStatus() Status { + return Status{ + Running: false, + Name: defaultServiceName(), + Port: 631, + SupportsPublic: supportsPublicFirewall(), + } +} + +// OpenURL opens a local admin or receipt page in the user's browser. +func OpenURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("refusing to open %q", raw) + } + return openURL(raw) +} diff --git a/internal/netprint/delivery_test.go b/internal/netprint/delivery_test.go new file mode 100644 index 0000000..fe81c7a --- /dev/null +++ b/internal/netprint/delivery_test.go @@ -0,0 +1,559 @@ +package netprint + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/cookiejar" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +func TestMailRecipientsAndDefaults(t *testing.T) { + d := Defaults() + if d.EmailOn() || d.EmailTo != DefaultEmailTo || d.SMTPHost != "smtp.gmail.com" || d.SMTPPort != 587 || d.SMTPTLS != "starttls" || d.SMTPUser != DefaultEmailTo { + t.Fatalf("%+v", d) + } + c := Config{EmailTo: "A@Example.com, a@example.com; b@x.com\nother@x.com"} + got := strings.Join(c.MailRecipients(), ",") + if got != "A@Example.com,b@x.com,other@x.com" { + t.Fatal(got) + } + if strings.Join((Config{}).MailRecipients(), ",") != DefaultEmailTo { + t.Fatal((Config{}).MailRecipients()) + } + sample := string(ExampleJSON(d.DataDir)) + if strings.Contains(sample, "smtpPassword") || strings.Contains(sample, "receiptsPin") { + t.Fatal(sample) + } +} + +func TestReceiptsNetworkAllowed(t *testing.T) { + if !receiptsNetworkAllowed(false, false) || !receiptsNetworkAllowed(true, true) || receiptsNetworkAllowed(true, false) { + t.Fatal("network gate") + } +} + +func TestWindowsSecretsUseDPAPI(t *testing.T) { + b, err := os.ReadFile("secret_windows.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + for _, needle := range []string{"CryptProtectData", "CryptUnprotectData", "CRYPTPROTECT_UI_FORBIDDEN"} { + if !strings.Contains(src, needle) { + t.Fatalf("missing %s", needle) + } + } + if strings.Contains(src, "CRYPTPROTECT_LOCAL_MACHINE") { + t.Fatal("DPAPI must stay user-scope so the service account can read it") + } +} + +func TestSecretsFileIsPrivate(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "netprint.json") + if err := Save(cfgPath, Defaults()); err != nil { + t.Fatal(err) + } + const secret = "app-secret-should-not-leak" + path := secretsPath(cfgPath, dir) + if err := saveSecrets(path, Secrets{SMTPPassword: secret, ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf("mode %o", st.Mode().Perm()) + } + saved, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(saved), secret) || strings.Contains(string(saved), "2468") { + t.Fatalf("config holds a secret:\n%s", saved) + } + got, err := loadSecrets(path) + if err != nil || got.SMTPPassword != secret || got.ReceiptsPIN != "2468" { + t.Fatalf("%+v %v", got, err) + } + if err := saveSecrets(path, Secrets{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal(err) + } +} + +func TestReceiptMailAttachesAndRetries(t *testing.T) { + s := mailFixture(t) + rec := sampleReceipt(t, s, 11, "Scan\r\nBcc: x", "mobile", "Qmab1sA9") + if err := s.store.Put(rec); err != nil { + t.Fatal(err) + } + const secret = "app-secret-should-not-leak" + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: secret}); err != nil { + t.Fatal(err) + } + prevDelay := mailRetryDelays + mailRetryDelays = []time.Duration{0, 0} + t.Cleanup(func() { mailRetryDelays = prevDelay }) + var calls int + var got mailer.Message + prevSend := sendMail + sendMail = func(cfg mailer.Config, m mailer.Message) error { + calls++ + if cfg.Pass != secret || cfg.Host != "smtp.gmail.com" || cfg.Port != 587 || cfg.ImplicitTLS { + t.Errorf("smtp %+v", cfg) + } + if strings.ContainsAny(m.Subject, "\r\n") { + t.Errorf("subject %q", m.Subject) + } + got = m + if calls == 1 { + return errors.New("dial refused " + cfg.Pass) + } + return nil + } + t.Cleanup(func() { sendMail = prevSend }) + buf := captureLog(t) + s.deliverWithRetry(rec) + if calls != 2 { + t.Fatalf("calls %d", calls) + } + if got.Subject != "Verae timestamp: Scan Bcc: x" && got.Subject != "Verae timestamp: Scan Bcc: x" { + t.Fatal(got.Subject) + } + if !strings.Contains(got.Text, "Qmab1sA9") || !strings.Contains(got.Text, rec.SHA256) || !strings.Contains(got.Text, "scan.verae.json") { + t.Fatal(got.Text) + } + if len(got.Attachments) != 2 || got.Attachments[0].Name != "certificate.pdf" || got.Attachments[1].Name != "document.pdf" { + t.Fatalf("%+v", got.Attachments) + } + stored, ok := s.store.Get(11) + if !ok || stored.State != "completed" || stored.Notified != "emailed" || stored.EmailError != "" { + t.Fatalf("%+v", stored) + } + if strings.Contains(buf.String(), secret) || strings.Contains(s.status().EmailError, secret) { + t.Fatalf("password leaked\n%s", buf.String()) + } +} + +func TestEmailFailureStaysCompleted(t *testing.T) { + s := mailFixture(t) + s.cfg.EmailTo = "marchon@gmail.com, other@example.com" + const secret = "app-secret-should-not-leak" + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: secret}); err != nil { + t.Fatal(err) + } + prevDelay := mailRetryDelays + mailRetryDelays = []time.Duration{0, 0} + t.Cleanup(func() { mailRetryDelays = prevDelay }) + prevSend := sendMail + sendMail = func(cfg mailer.Config, m mailer.Message) error { + return errors.New("dial " + cfg.Pass) + } + t.Cleanup(func() { sendMail = prevSend }) + buf := captureLog(t) + ProcessJob(s.store, s.snapshot(), nil, 11, "application/pdf", "Contract", "mobile", []byte("%PDF-1.4\n"), func(pdf, title, user string) (job.Result, error) { + dir := filepath.Dir(pdf) + cert := filepath.Join(dir, "certificate.pdf") + js := filepath.Join(dir, "contract.verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil { + return job.Result{}, err + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"Qmab1sA9\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{SHA256: strings.Repeat("ab", 32), ReceiptID: "Qmab1sA9", CertificatePDF: cert, OriginalPDF: pdf, ReceiptJSON: js}, nil + }, nil, s.queueReceiptMail) + var got Record + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + g, ok := s.store.Get(11) + if ok && g.Notified == "email failed" { + got = g + break + } + time.Sleep(15 * time.Millisecond) + } + if got.State != "completed" || got.ReceiptID != "Qmab1sA9" || got.Notified != "email failed" { + t.Fatalf("%+v", got) + } + if strings.Contains(got.EmailError, secret) || strings.Contains(buf.String(), secret) { + t.Fatalf("leaked %q\n%s", got.EmailError, buf.String()) + } + if !strings.Contains(got.EmailError, "[redacted]") { + t.Fatal(got.EmailError) + } + if s.status().EmailError != got.EmailError { + t.Fatal(s.status().EmailError) + } +} + +func TestOversizedAttachmentIsNamedInBody(t *testing.T) { + s := mailFixture(t) + rec := sampleReceipt(t, s, 3, "Big", "mobile", "Qmab1sA9") + prev := maxAttachBytes + maxAttachBytes = 4 + t.Cleanup(func() { maxAttachBytes = prev }) + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: "pw"}); err != nil { + t.Fatal(err) + } + var got mailer.Message + prevSend := sendMail + sendMail = func(cfg mailer.Config, m mailer.Message) error { + got = m + return nil + } + t.Cleanup(func() { sendMail = prevSend }) + if err := sendReceiptMail(s.snapshot(), "pw", rec); err != nil { + t.Fatal(err) + } + if len(got.Attachments) != 0 || !strings.Contains(got.Text, "certificate.pdf was not attached") { + t.Fatalf("%q %+v", got.Text, got.Attachments) + } +} + +func TestMissingPasswordDoesNotSend(t *testing.T) { + s := mailFixture(t) + called := false + prevSend := sendMail + sendMail = func(mailer.Config, mailer.Message) error { + called = true + return nil + } + t.Cleanup(func() { sendMail = prevSend }) + err := sendReceiptMail(s.snapshot(), "", sampleReceipt(t, s, 1, "Note", "mobile", "Qmab1sA9")) + if err == nil || err.Error() != "SMTP password is not set" || called { + t.Fatal(err, called) + } + if s.status().EmailError != "SMTP password is not set" { + t.Fatal(s.status().EmailError) + } +} + +func TestReceiptsPagePINAndNetwork(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + older := time.Now().Add(-time.Hour) + newer := time.Now() + sampleReceiptAt(t, s, 1, "Older", "ada", "oldreceipt", older) + sampleReceiptAt(t, s, 2, "Newer", "mobile", "Qmab1sA9", newer) + page := getBody(t, ippURL(s, "/receipts")) + if !strings.Contains(page, `name="viewport"`) || !strings.Contains(page, "min-height:44px") { + t.Fatal(page) + } + if strings.Index(page, "Newer") > strings.Index(page, "Older") || strings.Index(page, "Newer") < 0 { + t.Fatal(page) + } + for _, needle := range []string{"Qmab1sA9", "Stamped PDF", "Original", "/jobs/2/receipt.pdf", "/jobs/2/document.pdf"} { + if !strings.Contains(page, needle) { + t.Fatalf("missing %s\n%s", needle, page) + } + } + pdf := getBody(t, ippURL(s, "/jobs/2/receipt.pdf")) + if !strings.HasPrefix(pdf, "%PDF-cert-") { + t.Fatal(pdf) + } + local := getBody(t, strings.TrimRight(s.ControlURL(), "/")+"/receipts") + if !strings.Contains(local, "Qmab1sA9") { + t.Fatal(local) + } + if !strings.HasSuffix(s.printer.AdminURL, "/receipts") { + t.Fatal(s.printer.AdminURL) + } + st, err := (&Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")}).Status() + if err != nil || !strings.HasSuffix(st.ReceiptsURL, "/receipts") { + t.Fatalf("%+v %v", st, err) + } + + if err := saveSecrets(s.secretsFile(), Secrets{ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + locked := getBody(t, ippURL(s, "/receipts")) + if strings.Contains(locked, "Newer") || !strings.Contains(locked, "Access PIN") { + t.Fatal(locked) + } + hidden := getBody(t, ippURL(s, "/jobs/2/receipt.pdf")) + if strings.Contains(hidden, "%PDF") || strings.Contains(hidden, "Qmab1sA9") { + t.Fatal(hidden) + } + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + bad, err := client.PostForm(ippURL(s, "/receipts/unlock"), map[string][]string{"pin": {"0000"}}) + if err != nil { + t.Fatal(err) + } + badBody, _ := io.ReadAll(bad.Body) + bad.Body.Close() + if strings.Contains(string(badBody), "Newer") || !strings.Contains(string(badBody), "not right") { + t.Fatal(string(badBody)) + } + good, err := client.PostForm(ippURL(s, "/receipts/unlock"), map[string][]string{"pin": {"2468"}}) + if err != nil { + t.Fatal(err) + } + goodBody, _ := io.ReadAll(good.Body) + good.Body.Close() + if !strings.Contains(string(goodBody), "Qmab1sA9") { + t.Fatal(string(goodBody)) + } + gotPDF, err := client.Get(ippURL(s, "/jobs/2/document.pdf")) + if err != nil { + t.Fatal(err) + } + pdfBody, _ := io.ReadAll(gotPDF.Body) + gotPDF.Body.Close() + if !bytes.HasPrefix(pdfBody, []byte("%PDF-orig-")) { + t.Fatalf("%q", pdfBody) + } + withToken := getBody(t, ippURL(s, "/receipts?access=2468")) + if !strings.Contains(withToken, "Qmab1sA9") { + t.Fatal(withToken) + } + without := getBody(t, ippURL(s, "/receipts?access=nope")) + if strings.Contains(without, "Qmab1sA9") { + t.Fatal(without) + } + stillLocal := getBody(t, strings.TrimRight(s.ControlURL(), "/")+"/receipts") + if !strings.Contains(stillLocal, "Qmab1sA9") { + t.Fatal(stillLocal) + } + + prevNet := currentNetworkPublic + currentNetworkPublic = func() bool { return true } + t.Cleanup(func() { currentNetworkPublic = prevNet }) + res, err := http.Get(ippURL(s, "/receipts?access=2468")) + if err != nil { + t.Fatal(err) + } + blocked, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusForbidden || strings.Contains(string(blocked), "Qmab1sA9") { + t.Fatalf("%d %s", res.StatusCode, blocked) + } + if err := (&Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")}).SetAllowPublic(true); err != nil { + t.Fatal(err) + } + opened := getBody(t, ippURL(s, "/receipts?access=2468")) + if !strings.Contains(opened, "Qmab1sA9") { + t.Fatal(opened) + } +} + +func TestSettingsEmailDoesNotWipePrinter(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + c := &Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")} + if err := c.Settings("Lobby Printer", "office", 0); err != nil { + t.Fatal(err) + } + if err := c.SetEmailEnabled(true); err != nil { + t.Fatal(err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(saved) + if !strings.Contains(text, "Lobby Printer") || !strings.Contains(text, "office") || !strings.Contains(text, "marchon@gmail.com") { + t.Fatal(text) + } + const secret = "app-secret-should-not-leak" + buf := captureLog(t) + putSettings(t, c.BaseURL, `{"smtpPassword":"`+secret+`","receiptsPin":"2468"}`) + if strings.Contains(buf.String(), secret) || strings.Contains(buf.String(), "2468") { + t.Fatal(buf.String()) + } + saved, _ = os.ReadFile(path) + if strings.Contains(string(saved), secret) || strings.Contains(string(saved), "2468") { + t.Fatal(string(saved)) + } + sec, err := loadSecrets(s.secretsFile()) + if err != nil || sec.SMTPPassword != secret || sec.ReceiptsPIN != "2468" { + t.Fatalf("%+v %v", sec, err) + } + raw := getBody(t, c.BaseURL+"/api/v1/status") + if strings.Contains(raw, secret) || strings.Contains(raw, "2468") || !strings.Contains(raw, `"smtpPasswordSet": true`) || !strings.Contains(raw, `"receiptsPinSet": true`) || !strings.Contains(raw, `"receiptsUrl"`) { + t.Fatal(raw) + } + page := getBody(t, c.BaseURL+"/settings") + if !strings.Contains(page, "marchon@gmail.com") || !strings.Contains(page, "smtp.gmail.com") || strings.Contains(page, secret) { + t.Fatal(page) + } + putSettings(t, c.BaseURL, `{"clearPassword":true,"clearPin":true}`) + st, err := c.Status() + if err != nil || st.SMTPPasswordSet || st.ReceiptsPINSet || st.Name != "Lobby Printer" || st.ForwardPrinter != "office" { + t.Fatalf("%+v %v", st, err) + } +} + +func TestTrayShowsReceiptsURLAndEmail(t *testing.T) { + st := Status{ + Running: true, Name: "Verae Timestamp", Port: 631, + ReceiptsURL: "http://192.0.2.10:631/receipts", + EmailEnabled: true, EmailTo: "marchon@gmail.com", + EmailError: "SMTP password is not set", + ControlURL: "http://127.0.0.1:8630/", + } + var sawURL, sawMail, sawErr bool + var open string + for _, e := range BuildMenu(st) { + if strings.Contains(e.Label, "http://192.0.2.10:631/receipts") { + sawURL = true + } + if e.Kind == "email" { + sawMail = e.Checked && e.Enabled + } + if strings.Contains(e.Label, "SMTP password is not set") { + sawErr = true + } + if e.ID == idReceipts { + open = e.URL + } + } + var sawReceipts, sawSettings, sawPhone bool + for _, e := range BuildMenu(st) { + if e.ID == idReceipts && e.Label == "Open Receipts…" { + sawReceipts = true + } + if e.ID == idSettings && e.Label == "Open Settings…" && strings.Contains(e.URL, "/settings") { + sawSettings = true + } + if strings.Contains(e.Label, "Share → Print → Select Printer → Verae Timestamp") { + sawPhone = true + } + } + if !sawURL || !sawMail || !sawErr || open != "http://192.0.2.10:631/receipts" || !sawReceipts || !sawSettings || !sawPhone { + t.Fatalf("url %v mail %v err %v open %s receipts %v settings %v phone %v", sawURL, sawMail, sawErr, open, sawReceipts, sawSettings, sawPhone) + } +} + +func mailFixture(t *testing.T) *Server { + t.Helper() + cfg, path := testConfig(t) + on := true + cfg.EmailEnabled = &on + cfg.EmailTo = DefaultEmailTo + st, err := OpenStore(cfg.DataDir) + if err != nil { + t.Fatal(err) + } + return &Server{cfg: cfg, configPath: path, store: st, ctrl: &fakeCtrl{}, port: cfg.Port} +} + +func sampleReceipt(t *testing.T, s *Server, id int, title, user, receipt string) Record { + t.Helper() + return sampleReceiptAt(t, s, id, title, user, receipt, time.Now()) +} + +func sampleReceiptAt(t *testing.T, s *Server, id int, title, user, receipt string, when time.Time) Record { + t.Helper() + dir := filepath.Join(s.store.Dir(), fmt.Sprintf("job-%d", id)) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + cert := filepath.Join(dir, "certificate.pdf") + orig := filepath.Join(dir, "document.pdf") + js := filepath.Join(dir, "scan.verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert-"+receipt), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(orig, []byte("%PDF-orig-"+receipt), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\""+receipt+"\"}\n"), 0o600); err != nil { + t.Fatal(err) + } + rec := Record{ + ID: id, Received: when, Completed: when, User: user, Title: title, State: "completed", + ReceiptID: receipt, SHA256: strings.Repeat("ab", 32), VerifyURL: "https://explorer.verae.ai/time", + CertificatePDF: cert, OriginalPDF: orig, ReceiptJSON: js, + } + if err := s.store.Put(rec); err != nil { + t.Fatal(err) + } + return rec +} + +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := log.Writer() + flags := log.Flags() + log.SetOutput(&buf) + log.SetFlags(0) + t.Cleanup(func() { + log.SetOutput(prev) + log.SetFlags(flags) + }) + return &buf +} + +func ippURL(s *Server, path string) string { + return fmt.Sprintf("http://127.0.0.1:%d%s", s.Port(), path) +} + +func getBody(t *testing.T, url string) string { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func putSettings(t *testing.T, base, jsonBody string) { + t.Helper() + req, err := http.NewRequest(http.MethodPut, base+"/api/v1/settings", strings.NewReader(jsonBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + if res.StatusCode >= 300 { + t.Fatalf("settings %d %s", res.StatusCode, b) + } +} diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go new file mode 100644 index 0000000..27dc5c4 --- /dev/null +++ b/internal/netprint/dnsservice.go @@ -0,0 +1,457 @@ +package netprint + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "strings" + "sync" + "time" +) + +// dnsSDInstance is one DNS-SD registration. +// On Windows, InstanceName is the full "._ipp._tcp.local" and RegType +// is empty: DnsServiceRegister cannot express a subtype. +// On macOS, Name is the instance label and RegType is the dns_sd service type, +// including subtypes ("_ipp._tcp,_universal,_print"). mDNSResponder publishes +// the subtype PTRs aimed at Name._ipp._tcp.local. Host is empty so +// mDNSResponder fills in this Mac's own hostname and addresses. +type dnsSDInstance struct { + InstanceName string + Name string + RegType string + Host string + Port int + IPv4 []net.IP + TXT []string +} + +// dnsSDClient registers a service with the operating system's DNS-SD API. +// On Windows 10 and later this is DnsServiceRegister in dnsapi.dll. +type dnsSDClient interface { + Register(dnsSDInstance) error + Deregister(dnsSDInstance) error +} + +func serviceToDNS(s Service) []dnsSDInstance { + host := sanitizeLabel(s.Host) + ".local" + base := sanitizeLabel(s.Instance) + // DnsServiceRegister has no subtype field. A comma in the service type + // ("._ipp._tcp,_universal.local") is rejected (win32 87). Registering + // "._universal._sub._ipp._tcp.local" publishes a PTR whose target is + // that long name, which `dns-sd -B _ipp._tcp,_universal` does not accept, and + // those extra services also stopped plain `_ipp._tcp` browse from listing the + // printer. Only the primary instance is registered here. Subtype PTRs are + // sent by the shared UDP responder, point at this same name, and do not + // include SRV, TXT, or address records. + return []dnsSDInstance{{ + InstanceName: base + "._ipp._tcp.local", + Host: host, + Port: s.Port, + IPv4: append([]net.IP(nil), s.IPs...), + TXT: append([]string(nil), s.TXT...), + }} +} + +// serviceToDarwinDNS is the registration set for mDNSResponder. +// One IPP registration carries both AirPrint subtypes. A second registration +// advertises the stamp API. Neither name contains "._sub.", which would make +// the PTR target the subtype name instead of the primary instance. +func serviceToDarwinDNS(s Service) []dnsSDInstance { + name := sanitizeLabel(s.Instance) + return []dnsSDInstance{ + { + InstanceName: name + "._ipp._tcp.local", + Name: name, + RegType: "_ipp._tcp,_universal,_print", + Port: s.Port, + TXT: append([]string(nil), s.TXT...), + }, + { + InstanceName: name + "._verae._tcp.local", + Name: name, + RegType: "_verae._tcp", + Port: s.Port, + TXT: s.veraeTXT(), + }, + } +} + +// errNameConflict is returned when the instance name is already registered +// on the network. The advertiser picks the next "Name (2)" and tries again. +var errNameConflict = errors.New("mDNS name conflict") + +const ( + // dnsFlagNoAutoRename is kDNSServiceFlagsNoAutoRename. The callback reports + // kDNSServiceErr_NameConflict instead of silently publishing "Name (2)". + dnsFlagNoAutoRename = 8 + // dnsErrNameConflict is kDNSServiceErr_NameConflict. + dnsErrNameConflict = int32(-65548) +) + +// conflictName is the instance label for attempt. Attempt 1 is the requested +// name. Later attempts append " (2)", " (3)", and so on. +func conflictName(name string, attempt int) string { + name = strings.TrimSpace(name) + if name == "" { + name = legacyServiceName + } + if attempt <= 1 { + return name + } + return fmt.Sprintf("%s (%d)", name, attempt) +} + +func retitleTXT(txt []string, name string) []string { + if len(txt) == 0 { + return txt + } + out := make([]string, len(txt)) + for i, s := range txt { + switch { + case strings.HasPrefix(s, "ty="): + out[i] = "ty=" + trimTXT(name, 100) + case strings.HasPrefix(s, "product="): + out[i] = "product=(" + trimTXT(name, 80) + ")" + default: + out[i] = s + } + } + return out +} + +var ( + renameMu sync.Mutex + renameHook func(string) +) + +func setMDNSRename(fn func(string)) { + renameMu.Lock() + renameHook = fn + renameMu.Unlock() +} + +func noteMDNSRename(name string) { + renameMu.Lock() + fn := renameHook + renameMu.Unlock() + name = strings.TrimSpace(name) + if fn != nil && name != "" { + fn(name) + } +} + +func conflictWait(api dnsSDClient) <-chan struct{} { + type waiter interface{ Conflicts() <-chan struct{} } + if api == nil { + return nil + } + if w, ok := api.(waiter); ok { + return w.Conflicts() + } + return nil +} + +// dnsPort is the port in network byte order, which DNSServiceRegister requires. +func dnsPort(p int) uint16 { + if p < 0 || p > 65535 { + return 0 + } + v := uint16(p) + return v<<8 | v>>8 +} + +// advertiseWithDNS publishes the primary _ipp._tcp name through DnsServiceRegister +// (or a test double) and blocks until ctx is canceled. +func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + if api == nil { + return fmt.Errorf("DnsServiceRegister client is nil") + } + insts := serviceToDNS(current()) + if len(insts) == 0 { + return fmt.Errorf("DnsServiceRegister: empty service") + } + inst := insts[0] + if err := api.Register(inst); err != nil { + return fmt.Errorf("DnsServiceRegister %s: %w", inst.InstanceName, err) + } + log.Printf("netprint: mDNS advertised via DnsServiceRegister %s port %d", inst.InstanceName, inst.Port) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: fmt.Sprintf("DnsServiceRegister %s port %d", inst.InstanceName, inst.Port), + LastSent: time.Now(), + }) + } + <-ctx.Done() + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: mDNS DnsServiceDeRegister %s: %v", inst.InstanceName, err) + } + log.Printf("netprint: mDNS DnsServiceRegister removed") + return nil +} + +// advertiseForOS is the discovery strategy for goos. +// Windows registers only the primary name with DnsServiceRegister (Dnscache +// already owns UDP 5353 and answers plain _ipp._tcp browse, including SRV, +// TXT, and the host addresses). Subtype PTRs cannot be expressed there, so a +// shared UDP 5353 socket runs as well, but only after registration is decided. +// While DnsServiceRegister holds the instance, that socket answers +// _universal._sub and _print._sub PTRs and never the base _ipp._tcp PTR, SRV, +// TXT, or A/AAAA. Publishing those unique records from both stacks makes mDNS +// rename one advertisement to "Name(1)". If DnsServiceRegister fails, the UDP +// socket advertises the full set so plain browse still works. The socket sets +// SO_REUSEADDR and joins the mDNS groups so it shares the port with the +// Windows stack instead of replacing it. +// +// macOS mDNSResponder accepts subtypes in the regtype +// ("_ipp._tcp,_universal,_print"). Those PTRs target the primary instance. +// The UDP socket stays closed while that registration is up, so the Mac does +// not advertise a second copy. +func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + switch goos { + case "windows": + return advertiseWindows(ctx, current, api, sink) + case "darwin": + return advertiseDarwinDNS(ctx, current, api, sink) + default: + return advertiseUDP(ctx, current, sink) + } +} + +func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + if api == nil { + return fmt.Errorf("DNSServiceRegister client is nil") + } + base := strings.TrimSpace(current().Instance) + if base == "" { + base = legacyServiceName + } + conflicts := conflictWait(api) + for attempt := 1; attempt <= 8; attempt++ { + if conflicts != nil { + select { + case <-conflicts: + default: + } + } + name := conflictName(base, attempt) + svc := current() + svc.Instance = name + svc.TXT = retitleTXT(svc.TXT, name) + insts := serviceToDarwinDNS(svc) + if len(insts) == 0 { + return fmt.Errorf("DNSServiceRegister: empty service") + } + var ok []dnsSDInstance + var veraeErr error + conflict := false + var hard error + for i, inst := range insts { + err := api.Register(inst) + if errors.Is(err, errNameConflict) { + conflict = true + break + } + if err != nil { + if i == 0 { + hard = err + break + } + veraeErr = err + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + continue + } + ok = append(ok, inst) + } + if conflict { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: mDNS name %q is in use; renaming", name) + continue + } + if hard != nil { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: DNSServiceRegister %s: %v", insts[0].RegType, hard) + udpErr := advertiseUDP(ctx, current, sink) + if udpErr != nil && ctx.Err() == nil { + return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", insts[0].RegType, hard, udpErr) + } + return nil + } + detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) + if veraeErr != nil { + detail += "; _verae._tcp not registered" + } else if len(ok) > 1 { + detail += "; _verae._tcp" + } + if attempt > 1 { + noteMDNSRename(name) + } + log.Printf("netprint: mDNS %s", detail) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: detail, + LastSent: time.Now(), + }) + } + select { + case <-ctx.Done(): + for _, inst := range ok { + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + } + } + log.Printf("netprint: mDNS DNSServiceRegister removed") + return nil + case <-conflicts: + for _, inst := range ok { + _ = api.Deregister(inst) + } + log.Printf("netprint: mDNS name %q is in use; renaming", name) + } + } + return fmt.Errorf("mDNS name %q still conflicts", base) +} + +func advertiseWindows(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + var mu sync.Mutex + st := struct { + dnsOK bool + udpOK bool + dnsDetail string + dnsErr string + udpErr string + }{} + publish := func() { + if sink == nil { + return + } + mu.Lock() + defer mu.Unlock() + switch { + case st.dnsOK && st.udpOK: + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: st.dnsDetail + "; shared UDP 5353 subtype PTR _universal._sub and _print._sub; _verae._tcp", + LastSent: time.Now(), + }) + case st.dnsOK: + report := mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: st.dnsDetail + "; AirPrint subtypes not advertised", + LastSent: time.Now(), + } + if st.udpErr != "" { + report.Err = "shared UDP 5353: " + st.udpErr + } + sink(report) + case st.udpOK: + sink(mdnsReport{ + Advertising: true, + Mode: "udp", + Detail: "shared UDP 5353 PTR _ipp._tcp, _universal._sub, _print._sub, _verae._tcp", + Err: st.dnsErr, + LastSent: time.Now(), + }) + case st.dnsErr != "" && st.udpErr != "": + sink(mdnsReport{ + Mode: "error", + Err: st.dnsErr + "; shared UDP 5353: " + st.udpErr, + Detail: "DnsServiceRegister and shared UDP 5353 failed", + }) + } + } + dnsReady := make(chan struct{}) + dnsDone := make(chan struct{}) + var dnsOnce sync.Once + ready := func() { dnsOnce.Do(func() { close(dnsReady) }) } + go func() { + defer close(dnsDone) + err := advertiseWithDNS(ctx, current, api, func(r mdnsReport) { + mu.Lock() + st.dnsOK = r.Advertising + st.dnsDetail = r.Detail + if r.Err != "" { + st.dnsErr = r.Err + } + mu.Unlock() + ready() + publish() + }) + if err != nil && ctx.Err() == nil { + mu.Lock() + st.dnsOK = false + st.dnsErr = err.Error() + mu.Unlock() + log.Printf("netprint: %v", err) + publish() + } + ready() + }() + select { + case <-dnsReady: + case <-ctx.Done(): + <-dnsDone + return nil + } + mu.Lock() + dnsOK := st.dnsOK + mu.Unlock() + // Snapshot dnsOK. Registration does not succeed later in this call, and + // flipping SubtypesOnly on after a full announcement would already have + // probed a second instance. + udpCurrent := func() Service { + s := current() + s.SubtypesOnly = dnsOK + return s + } + udpErr := advertiseUDP(ctx, udpCurrent, func(r mdnsReport) { + mu.Lock() + if r.Advertising { + st.udpOK = true + st.udpErr = "" + } + if r.Err != "" && !r.Advertising { + st.udpOK = false + st.udpErr = r.Err + } + mu.Unlock() + publish() + }) + if udpErr != nil && ctx.Err() == nil { + mu.Lock() + st.udpOK = false + st.udpErr = udpErr.Error() + mu.Unlock() + publish() + mu.Lock() + dnsOK := st.dnsOK + dnsErr := st.dnsErr + udpMsg := st.udpErr + mu.Unlock() + if dnsOK && ctx.Err() == nil { + <-ctx.Done() + } + <-dnsDone + if dnsOK || ctx.Err() != nil { + return nil + } + if dnsErr == "" { + dnsErr = "DnsServiceRegister failed" + } + return fmt.Errorf("%s; shared UDP 5353: %s", dnsErr, udpMsg) + } + <-dnsDone + return nil +} diff --git a/internal/netprint/dnsservice_darwin.go b/internal/netprint/dnsservice_darwin.go new file mode 100644 index 0000000..14f1f2f --- /dev/null +++ b/internal/netprint/dnsservice_darwin.go @@ -0,0 +1,216 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "log" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego" +) + +// darwinDNS registers with mDNSResponder through DNSServiceRegister. +// Subtypes are part of the regtype. The call does not use cgo; libSystem is +// loaded when the process runs on a Mac. A name conflict is reported on +// conflicts so the advertiser can register "Name (2)" instead. +type darwinDNS struct { + mu sync.Mutex + refs []uintptr + stop []func() + conflicts chan struct{} +} + +func newDarwinDNS() dnsSDClient { + return &darwinDNS{conflicts: make(chan struct{}, 1)} +} + +func (d *darwinDNS) Conflicts() <-chan struct{} { return d.conflicts } + +// regState is the DNSServiceRegister context. It stays pinned until the +// registration is deallocated so the callback can read it. +type regState struct { + name string + conflicts chan struct{} +} + +var ( + dnsOnce sync.Once + dnsErr error + + dnsRegister func(sdRef *uintptr, flags uint32, ifIndex uint32, name, regtype, domain, host *byte, port uint16, txtLen uint16, txtRecord unsafe.Pointer, callback uintptr, context unsafe.Pointer) int32 + dnsProcess func(sdRef uintptr) int32 + dnsDeallocate func(sdRef uintptr) + + dnsReplyOnce sync.Once + dnsReplyPtr uintptr + + regMu sync.Mutex + regStates = map[uintptr]*regState{} +) + +func loadDNSService() error { + dnsOnce.Do(func() { + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + dnsErr = err + return + } + purego.RegisterLibFunc(&dnsRegister, lib, "DNSServiceRegister") + purego.RegisterLibFunc(&dnsProcess, lib, "DNSServiceProcessResult") + purego.RegisterLibFunc(&dnsDeallocate, lib, "DNSServiceRefDeallocate") + }) + return dnsErr +} + +func (d *darwinDNS) Register(inst dnsSDInstance) error { + if err := loadDNSService(); err != nil { + return fmt.Errorf("DNSServiceRegister: %w", err) + } + name := cString(inst.Name) + regtype := cString(inst.RegType) + domain := cString("local") + var host *byte + hostBuf := []byte{} + if inst.Host != "" { + hostBuf = append([]byte(inst.Host), 0) + host = &hostBuf[0] + } + txt := encodeDNSTXT(inst.TXT) + var txtPtr unsafe.Pointer + if len(txt) > 0 { + txtPtr = unsafe.Pointer(&txt[0]) + } + st := ®State{name: inst.Name, conflicts: d.conflicts} + var pin runtime.Pinner + pin.Pin(st) + key := uintptr(unsafe.Pointer(st)) + regMu.Lock() + regStates[key] = st + regMu.Unlock() + flags := uint32(0) + var cb uintptr + var ctx unsafe.Pointer + keep := false + if reply := dnsReplyCallback(); reply != 0 { + flags = dnsFlagNoAutoRename + cb = reply + ctx = unsafe.Pointer(st) + keep = true + } + release := func() { + regMu.Lock() + delete(regStates, key) + regMu.Unlock() + pin.Unpin() + } + if !keep { + release() + } + var ref uintptr + rc := dnsRegister(&ref, flags, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, cb, ctx) + runtime.KeepAlive(name) + runtime.KeepAlive(regtype) + runtime.KeepAlive(domain) + runtime.KeepAlive(hostBuf) + runtime.KeepAlive(txt) + runtime.KeepAlive(st) + if rc != 0 { + if keep { + release() + } + if rc == dnsErrNameConflict { + return errNameConflict + } + return fmt.Errorf("DNSServiceRegister %s %s: code %d", inst.Name, inst.RegType, rc) + } + done := make(chan struct{}) + go func() { + defer close(done) + for { + if err := dnsProcess(ref); err != 0 { + return + } + } + }() + d.mu.Lock() + d.refs = append(d.refs, ref) + d.stop = append(d.stop, func() { + dnsDeallocate(ref) + <-done + if keep { + release() + } + }) + d.mu.Unlock() + log.Printf("netprint: DNSServiceRegister %s %s port %d", inst.Name, inst.RegType, inst.Port) + return nil +} + +func dnsReplyCallback() uintptr { + dnsReplyOnce.Do(func() { + defer func() { + if recover() != nil { + dnsReplyPtr = 0 + } + }() + dnsReplyPtr = purego.NewCallback(dnsRegisterReply) + }) + return dnsReplyPtr +} + +func dnsRegisterReply(sdRef, flags, errCode, namePtr, regtype, domain, context uintptr) uintptr { + if context == 0 { + return 0 + } + regMu.Lock() + st := regStates[context] + regMu.Unlock() + if st == nil || st.conflicts == nil || int32(uint32(errCode)) != dnsErrNameConflict { + return 0 + } + select { + case st.conflicts <- struct{}{}: + default: + } + runtime.KeepAlive(namePtr) + return 0 +} + +func (d *darwinDNS) Deregister(inst dnsSDInstance) error { + d.mu.Lock() + if len(d.stop) == 0 { + d.mu.Unlock() + return nil + } + stop := d.stop[0] + d.stop = d.stop[1:] + if len(d.refs) > 0 { + d.refs = d.refs[1:] + } + d.mu.Unlock() + stop() + return nil +} + +func cString(s string) *byte { + b := append([]byte(s), 0) + return &b[0] +} + +func encodeDNSTXT(txt []string) []byte { + var b []byte + for _, s := range txt { + if s == "" { + continue + } + if len(s) > 255 { + s = s[:255] + } + b = append(b, byte(len(s))) + b = append(b, s...) + } + return b +} diff --git a/internal/netprint/dnsservice_windows.go b/internal/netprint/dnsservice_windows.go new file mode 100644 index 0000000..d7f2de0 --- /dev/null +++ b/internal/netprint/dnsservice_windows.go @@ -0,0 +1,228 @@ +//go:build windows + +package netprint + +import ( + "encoding/binary" + "fmt" + "net" + "runtime" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + dnsQueryRequestVersion1 = 1 + dnsRequestPending = 9506 +) + +// windowsDNSService calls dnsapi!DnsServiceRegister. Windows 10 and later +// send the mDNS packets from the DNS Client service, which owns UDP 5353. +type windowsDNSService struct{} + +func (windowsDNSService) Register(inst dnsSDInstance) error { + return dnsServiceCall(inst, false) +} + +func (windowsDNSService) Deregister(inst dnsSDInstance) error { + return dnsServiceCall(inst, true) +} + +var ( + dnsAPI = windows.NewLazySystemDLL("dnsapi.dll") + procConstruct = dnsAPI.NewProc("DnsServiceConstructInstance") + procFree = dnsAPI.NewProc("DnsServiceFreeInstance") + procRegister = dnsAPI.NewProc("DnsServiceRegister") + procDereg = dnsAPI.NewProc("DnsServiceDeRegister") + + dnsMu sync.Mutex + dnsWait chan uint32 + dnsCallback uintptr +) + +func init() { + dnsCallback = syscall.NewCallback(dnsComplete) +} + +func dnsComplete(status, _, _ uintptr) uintptr { + dnsMu.Lock() + ch := dnsWait + dnsMu.Unlock() + if ch != nil { + select { + case ch <- uint32(status): + default: + } + } + return 0 +} + +type dnsServiceCancel struct { + reserved uintptr +} + +type dnsServiceRegisterRequest struct { + Version uint32 + InterfaceIndex uint32 + Instance uintptr + RegisterCompletionCallback uintptr + QueryContext uintptr + Cancel uintptr + UnicastEnabled uint32 +} + +func dnsServiceCall(inst dnsSDInstance, deregister bool) error { + if err := procConstruct.Find(); err != nil { + return fmt.Errorf("DnsServiceConstructInstance: %w", err) + } + name, err := windows.UTF16PtrFromString(inst.InstanceName) + if err != nil { + return err + } + host, err := windows.UTF16PtrFromString(inst.Host) + if err != nil { + return err + } + keys, values, err := txtUTF16(inst.TXT) + if err != nil { + return err + } + ip4 := ip4ZeroTerminated(inst.IPv4) + var ip4Ptr uintptr + if len(ip4) > 0 { + ip4Ptr = uintptr(unsafe.Pointer(&ip4[0])) + } + var keysPtr, valuesPtr uintptr + if len(keys) > 0 { + keysPtr = uintptr(unsafe.Pointer(&keys[0])) + valuesPtr = uintptr(unsafe.Pointer(&values[0])) + } + r, _, callErr := procConstruct.Call( + uintptr(unsafe.Pointer(name)), + uintptr(unsafe.Pointer(host)), + ip4Ptr, + 0, + uintptr(inst.Port), + 0, + 0, + uintptr(len(keys)), + keysPtr, + valuesPtr, + ) + runtime.KeepAlive(name) + runtime.KeepAlive(host) + runtime.KeepAlive(keys) + runtime.KeepAlive(values) + runtime.KeepAlive(ip4) + if r == 0 { + return fmt.Errorf("DnsServiceConstructInstance failed (%v)", callErr) + } + defer procFree.Call(r) + + cancel := dnsServiceCancel{} + req := dnsServiceRegisterRequest{ + Version: dnsQueryRequestVersion1, + Instance: r, + RegisterCompletionCallback: dnsCallback, + Cancel: uintptr(unsafe.Pointer(&cancel)), + } + proc := procRegister + what := "DnsServiceRegister" + if deregister { + proc = procDereg + what = "DnsServiceDeRegister" + } + if err := proc.Find(); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + code, err := callDNS(func() uintptr { + ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&req)), uintptr(unsafe.Pointer(&cancel))) + return ret + }) + if err != nil { + return fmt.Errorf("%s %s: %w", what, inst.InstanceName, err) + } + runtime.KeepAlive(&req) + runtime.KeepAlive(&cancel) + if code != 0 { + return fmt.Errorf("%s %s: win32 %d", what, inst.InstanceName, code) + } + return nil +} + +func callDNS(call func() uintptr) (uint32, error) { + ch := make(chan uint32, 1) + dnsMu.Lock() + dnsWait = ch + dnsMu.Unlock() + defer func() { + dnsMu.Lock() + if dnsWait == ch { + dnsWait = nil + } + dnsMu.Unlock() + }() + ret := uint32(call()) + if ret == 0 { + return 0, nil + } + if ret != dnsRequestPending { + return ret, nil + } + select { + case st := <-ch: + return st, nil + case <-time.After(15 * time.Second): + return 0, fmt.Errorf("timed out waiting for the DNS-SD callback") + } +} + +func txtUTF16(txt []string) ([]*uint16, []*uint16, error) { + if len(txt) == 0 { + return nil, nil, nil + } + keys := make([]*uint16, 0, len(txt)) + values := make([]*uint16, 0, len(txt)) + for _, item := range txt { + k, v := item, "" + for i := 0; i < len(item); i++ { + if item[i] == '=' { + k, v = item[:i], item[i+1:] + break + } + } + if k == "" { + continue + } + kp, err := windows.UTF16PtrFromString(k) + if err != nil { + return nil, nil, err + } + vp, err := windows.UTF16PtrFromString(v) + if err != nil { + return nil, nil, err + } + keys = append(keys, kp) + values = append(values, vp) + } + return keys, values, nil +} + +func ip4ZeroTerminated(ips []net.IP) []uint32 { + var out []uint32 + for _, ip := range ips { + v4 := ip.To4() + if v4 == nil { + continue + } + out = append(out, binary.BigEndian.Uint32(v4)) + } + if len(out) == 0 { + return nil + } + return append(out, 0) +} diff --git a/internal/netprint/events.go b/internal/netprint/events.go new file mode 100644 index 0000000..b30118b --- /dev/null +++ b/internal/netprint/events.go @@ -0,0 +1,201 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// JobEvent is one completed job, told to the menu-bar app over the loopback +// control API. The daemon does not open a share sheet. +type JobEvent struct { + Seq int64 `json:"seq"` + Kind string `json:"kind"` + ID int `json:"id"` + ReceiptID string `json:"receiptId"` + Title string `json:"title"` + AirDrop bool `json:"airdrop"` + CertificateURL string `json:"certificateUrl"` + ReceiptJSONURL string `json:"receiptJsonUrl"` + At time.Time `json:"at"` +} + +type jobBus struct { + mu sync.Mutex + next int64 + recent []JobEvent + subs map[chan JobEvent]struct{} + acked map[int]bool +} + +func (s *Server) afterJob(rec Record) { + s.queueReceiptMail(rec) + s.publishJobEvent(rec) +} + +func (s *Server) publishJobEvent(rec Record) { + if rec.State != "completed" { + return + } + base := strings.TrimRight(s.controlURL, "/") + ev := JobEvent{ + Kind: "completed", + ID: rec.ID, + ReceiptID: rec.ReceiptID, + Title: rec.Title, + AirDrop: s.snapshot().AirDropOn(), + CertificateURL: fmt.Sprintf("%s/jobs/%d/receipt.pdf", base, rec.ID), + ReceiptJSONURL: fmt.Sprintf("%s/jobs/%d/receipt.json", base, rec.ID), + At: time.Now(), + } + s.jobs.publish(ev) + if ev.AirDrop { + log.Printf("netprint: job %d airdrop queued receipt=%s", rec.ID, rec.ReceiptID) + } +} + +func (b *jobBus) publish(ev JobEvent) { + b.mu.Lock() + if b.subs == nil { + b.subs = map[chan JobEvent]struct{}{} + } + if b.acked == nil { + b.acked = map[int]bool{} + } + b.next++ + ev.Seq = b.next + b.recent = append(b.recent, ev) + if len(b.recent) > 16 { + b.recent = b.recent[len(b.recent)-16:] + } + subs := make([]chan JobEvent, 0, len(b.subs)) + for ch := range b.subs { + subs = append(subs, ch) + } + b.mu.Unlock() + for _, ch := range subs { + select { + case ch <- ev: + default: + } + } +} + +func (b *jobBus) subscribe(after int64) (backlog []JobEvent, ch chan JobEvent) { + b.mu.Lock() + defer b.mu.Unlock() + if b.subs == nil { + b.subs = map[chan JobEvent]struct{}{} + } + ch = make(chan JobEvent, 32) + b.subs[ch] = struct{}{} + if after == 0 { + if ev, ok := b.pendingLocked(); ok { + backlog = []JobEvent{ev} + } + return backlog, ch + } + for _, ev := range b.recent { + if ev.Seq > after { + backlog = append(backlog, ev) + } + } + return backlog, ch +} + +func (b *jobBus) unsubscribe(ch chan JobEvent) { + b.mu.Lock() + delete(b.subs, ch) + b.mu.Unlock() +} + +func (b *jobBus) ack(id int) { + b.mu.Lock() + defer b.mu.Unlock() + if b.acked == nil { + b.acked = map[int]bool{} + } + b.acked[id] = true +} + +// pendingLocked is the newest AirDrop job the menu bar has not acknowledged. +// The caller holds b.mu. +func (b *jobBus) pendingLocked() (JobEvent, bool) { + for i := len(b.recent) - 1; i >= 0; i-- { + ev := b.recent[i] + if !ev.AirDrop || b.acked[ev.ID] { + continue + } + if time.Since(ev.At) > 10*time.Minute { + continue + } + return ev, true + } + return JobEvent{}, false +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unavailable", http.StatusInternalServerError) + return + } + after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + backlog, ch := s.jobs.subscribe(after) + defer s.jobs.unsubscribe(ch) + for _, ev := range backlog { + writeJobEvent(w, ev) + } + fmt.Fprintf(w, ": ok\n\n") + fl.Flush() + tick := time.NewTicker(15 * time.Second) + defer tick.Stop() + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + writeJobEvent(w, ev) + fl.Flush() + case <-tick.C: + fmt.Fprintf(w, ": ping\n\n") + fl.Flush() + } + } +} + +func writeJobEvent(w http.ResponseWriter, ev JobEvent) { + b, err := json.Marshal(ev) + if err != nil { + return + } + fmt.Fprintf(w, "event: job\ndata: %s\n\n", b) +} + +func (s *Server) handleAirDropAck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID int `json:"id"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil || body.ID <= 0 { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + s.jobs.ack(body.ID) + writeJSON(w, map[string]bool{"ok": true}) +} diff --git a/internal/netprint/firewall.go b/internal/netprint/firewall.go new file mode 100644 index 0000000..1111db8 --- /dev/null +++ b/internal/netprint/firewall.go @@ -0,0 +1,16 @@ +package netprint + +// firewallRemoteIP is passed to netsh as remoteip=. The value any covers +// IPv4, global IPv6, and link-local fe80::/10. The Mac resolver tries the +// fe80 address of the SRV target before IPv4; a rule that omits that prefix +// never completes the TCP handshake. +const firewallRemoteIP = "any" + +// firewallProfile is the netsh profile list. Public stays off unless the +// installer or the tray asked for it. Private and Domain stay allowed. +func firewallProfile(public bool) string { + if public { + return "private,domain,public" + } + return "private,domain" +} diff --git a/internal/netprint/firewall_other.go b/internal/netprint/firewall_other.go new file mode 100644 index 0000000..7c81d5d --- /dev/null +++ b/internal/netprint/firewall_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package netprint + +// applyFirewall is a no-op off Windows. ufw has no public/private split; +// the Debian package opens the ports when ufw is already active. +func applyFirewall(bool) {} diff --git a/internal/netprint/forward.go b/internal/netprint/forward.go new file mode 100644 index 0000000..c088ccb --- /dev/null +++ b/internal/netprint/forward.go @@ -0,0 +1,129 @@ +package netprint + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/OpenPrinting/goipp" +) + +// ForwardFunc sends the timestamped PDF to a real printer. A nil error with +// an empty printer means there is nothing to do. +type ForwardFunc func(printer, pdfPath, title string) error + +// Forward prints pdfPath. printer is a queue name (lp/lpr, or the Windows +// print verb) or an ipp:// / http:// URI (IPP Print-Job). +func Forward(printer, pdfPath, title string) error { + printer = strings.TrimSpace(printer) + if printer == "" { + return nil + } + if strings.HasPrefix(printer, "ipp://") || strings.HasPrefix(printer, "http://") || strings.HasPrefix(printer, "ipps://") || strings.HasPrefix(printer, "https://") { + return forwardIPP(printer, pdfPath, title) + } + if lp, err := exec.LookPath("lp"); err == nil { + cmd := exec.Command(lp, "-d", printer, "-t", title, pdfPath) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lp -d %s: %w (%s)", printer, err, truncate(string(out), 300)) + } + return nil + } + if lpr, err := exec.LookPath("lpr"); err == nil { + cmd := exec.Command(lpr, "-P", printer, "-J", title, pdfPath) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lpr -P %s: %w (%s)", printer, err, truncate(string(out), 300)) + } + return nil + } + if runtime.GOOS == "windows" { + return forwardWindows(printer, pdfPath) + } + return fmt.Errorf("no lp/lpr on PATH and %q is not an IPP URI", printer) +} + +func forwardWindows(printer, pdfPath string) error { + // PrintTo uses the file association (typically a PDF reader). The queue + // name is the Destination. This does not install a driver on this service. + ps := fmt.Sprintf(`Start-Process -FilePath %s -Verb PrintTo -ArgumentList %s -WindowStyle Hidden`, + psQuote(pdfPath), psQuote(printer)) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("windows PrintTo %s: %w (%s)", printer, err, truncate(string(out), 300)) + } + return nil +} + +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +func forwardIPP(printer, pdfPath, title string) error { + doc, err := os.ReadFile(pdfPath) + if err != nil { + return err + } + uri := printer + httpURL := printer + switch { + case strings.HasPrefix(printer, "ipp://"): + httpURL = "http://" + strings.TrimPrefix(printer, "ipp://") + case strings.HasPrefix(printer, "ipps://"): + httpURL = "https://" + strings.TrimPrefix(printer, "ipps://") + case strings.HasPrefix(printer, "http://"): + uri = "ipp://" + strings.TrimPrefix(printer, "http://") + case strings.HasPrefix(printer, "https://"): + uri = "ipps://" + strings.TrimPrefix(printer, "https://") + } + m := goipp.NewRequest(goipp.DefaultVersion, goipp.OpPrintJob, 1) + m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String(uri))) + m.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("verae-netprint"))) + if title != "" { + m.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(title))) + } + m.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + body, err := m.EncodeBytes() + if err != nil { + return err + } + body = append(body, doc...) + req, err := http.NewRequest(http.MethodPost, httpURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", goipp.ContentType) + client := &http.Client{Timeout: 60 * time.Second} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + var resp goipp.Message + raw, _ := io.ReadAll(res.Body) + if err := resp.Decode(bytes.NewReader(raw)); err != nil { + return fmt.Errorf("forward IPP %s: HTTP %d decode: %w", printer, res.StatusCode, err) + } + if goipp.Status(resp.Code) != goipp.StatusOk { + return fmt.Errorf("forward IPP %s: %s", printer, goipp.Status(resp.Code)) + } + return nil +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/netprint/ipp.go b/internal/netprint/ipp.go new file mode 100644 index 0000000..7f7c575 --- /dev/null +++ b/internal/netprint/ipp.go @@ -0,0 +1,594 @@ +package netprint + +import ( + "crypto/sha1" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/OpenPrinting/goipp" +) + +const ( + jobPending = 3 + jobProcessing = 5 + jobCanceled = 7 + jobAborted = 8 + jobCompleted = 9 + maxDocument = 64 << 20 +) + +// DocumentFunc receives a spooled IPP document. format is a MIME type. +type DocumentFunc func(id int, format, title, user string, doc []byte) + +// Printer is an IPP/2.0 Everywhere endpoint. Document bytes follow the IPP +// message in the same HTTP body, matching the loopback helper. +type Printer struct { + Name string + UUID string + AdminURL string + OnDocument DocumentFunc + // AllocateID, when set, returns a job id that is never reused after a + // restart. Without it, ids are process-local and start again at 1. + AllocateID func() (int, error) + + mu sync.Mutex + started time.Time + nextID int + jobs map[int]*ippJob +} + +// acceptedFormats is every document MIME type this printer consumes. +// document-format-supported and the AirPrint TXT pdl record both use it. +var acceptedFormats = []string{ + "application/pdf", + "image/urf", + "image/pwg-raster", + "image/jpeg", +} + +func pdlValue() string { return strings.Join(acceptedFormats, ",") } + +func formatAccepted(f string) bool { + switch f { + case "", "application/octet-stream": + return true + } + for _, a := range acceptedFormats { + if f == a { + return true + } + } + return false +} + +type ippJob struct { + id, state int + name, user, format, reasons, message string + created, done time.Time +} + +// ServeHTTP handles one IPP request. +func (p *Printer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if p.started.IsZero() { + p.started = time.Now() + } + if p.jobs == nil { + p.jobs = map[int]*ippJob{} + } + // Flush 100 Continue before touching the body. Clients such as CUPS hold + // the Print-Job body until they see it, and give up after about a minute. + if r.ProtoAtLeast(1, 1) && r.Header.Get("Expect") != "" && strings.Contains(strings.ToLower(r.Header.Get("Expect")), "100-continue") { + w.WriteHeader(http.StatusContinue) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + body := &countingReader{r: r.Body} + var req goipp.Message + if err := req.Decode(body); err != nil { + http.Error(w, "bad ipp", http.StatusBadRequest) + return + } + resp := p.dispatch(&req, r, body) + w.Header().Set("Content-Type", goipp.ContentType) + _ = resp.Encode(w) +} + +// countingReader counts bytes read from the HTTP body so the document can be +// read with Content-Length instead of waiting for the connection to close. +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err +} + +// SetState updates an in-memory IPP job. Unknown ids are ignored. +func (p *Printer) SetState(id, state int, reason, msg string) { + p.mu.Lock() + defer p.mu.Unlock() + j := p.jobs[id] + if j == nil { + return + } + j.state, j.reasons, j.message = state, reason, msg + if state >= jobCanceled { + j.done = time.Now() + } +} + +// Canceled reports whether the client has canceled the job. +func (p *Printer) Canceled(id int) bool { + p.mu.Lock() + defer p.mu.Unlock() + j := p.jobs[id] + return j != nil && j.state == jobCanceled +} + +func (p *Printer) dispatch(req *goipp.Message, r *http.Request, body *countingReader) *goipp.Message { + if req.RequestID == 0 { + return p.newResp(req, goipp.StatusErrorBadRequest, "request-id must be non-zero") + } + if attrString(req.Operation, "attributes-charset") == "" || attrString(req.Operation, "attributes-natural-language") == "" { + return p.newResp(req, goipp.StatusErrorBadRequest, "missing attributes-charset / attributes-natural-language") + } + if attrString(req.Operation, "printer-uri") == "" && attrString(req.Operation, "job-uri") == "" { + return p.newResp(req, goipp.StatusErrorBadRequest, "missing printer-uri") + } + switch goipp.Op(req.Code) { + case goipp.OpIdentifyPrinter, goipp.OpCancelMyJobs, goipp.OpCloseJob: + return p.newResp(req, goipp.StatusOk, "") + case goipp.OpGetPrinterAttributes: + resp := p.newResp(req, goipp.StatusOk, "") + resp.Printer = p.printerAttrs(req, r) + return resp + case goipp.OpValidateJob: + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + return p.newResp(req, goipp.StatusOk, "") + case goipp.OpPrintJob: + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + j, err := p.createJob(req) + if err != nil { + return p.newResp(req, goipp.StatusErrorInternal, err.Error()) + } + return p.receive(req, r, body, j) + case goipp.OpCreateJob: + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + j, err := p.createJob(req) + if err != nil { + return p.newResp(req, goipp.StatusErrorInternal, err.Error()) + } + resp := p.newResp(req, goipp.StatusOk, "") + resp.Job = p.jobAttrs(j, r) + return resp + case goipp.OpSendDocument: + id := int(attrInt(req.Operation, "job-id")) + p.mu.Lock() + j := p.jobs[id] + p.mu.Unlock() + if j == nil { + return p.newResp(req, goipp.StatusErrorNotFound, "no such job") + } + if f := attrString(req.Operation, "document-format"); f != "" { + p.mu.Lock() + j.format = f + p.mu.Unlock() + } + if st, msg := p.checkFormat(req); st != goipp.StatusOk { + return p.newResp(req, st, msg) + } + return p.receive(req, r, body, j) + case goipp.OpGetJobAttributes: + id := int(attrInt(req.Operation, "job-id")) + p.mu.Lock() + j := p.jobs[id] + p.mu.Unlock() + if j == nil { + return p.newResp(req, goipp.StatusErrorNotFound, "no such job") + } + resp := p.newResp(req, goipp.StatusOk, "") + resp.Job = p.jobAttrs(j, r) + return resp + case goipp.OpGetJobs: + resp := p.newResp(req, goipp.StatusOk, "") + which := attrString(req.Operation, "which-jobs") + p.mu.Lock() + var groups goipp.Groups + groups.Add(goipp.Group{Tag: goipp.TagOperationGroup, Attrs: resp.Operation}) + for _, j := range p.jobs { + done := j.state >= jobCanceled + if (which == "completed") != done { + continue + } + groups.Add(goipp.Group{Tag: goipp.TagJobGroup, Attrs: p.jobAttrsLocked(j, r)}) + } + p.mu.Unlock() + resp.Groups = groups + return resp + case goipp.OpCancelJob: + id := int(attrInt(req.Operation, "job-id")) + p.SetState(id, jobCanceled, "job-canceled-by-user", "canceled") + return p.newResp(req, goipp.StatusOk, "") + default: + return p.newResp(req, goipp.StatusErrorOperationNotSupported, "operation not supported") + } +} + +func (p *Printer) receive(req *goipp.Message, r *http.Request, body *countingReader, j *ippJob) *goipp.Message { + doc, err := readDocument(r, body) + if err != nil { + p.SetState(j.id, jobAborted, "aborted-by-system", err.Error()) + return p.newResp(req, goipp.StatusErrorInternal, "read failed") + } + if len(doc) == 0 || len(doc) > maxDocument { + p.SetState(j.id, jobAborted, "aborted-by-system", "empty or oversized document") + return p.newResp(req, goipp.StatusErrorBadRequest, "empty or oversized document") + } + format := sniffFormat(j.format, doc) + p.mu.Lock() + j.format = format + title, user := j.name, j.user + id := j.id + p.mu.Unlock() + if p.OnDocument != nil { + go p.OnDocument(id, format, title, user, append([]byte(nil), doc...)) + } + resp := p.newResp(req, goipp.StatusOk, "") + resp.Job = p.jobAttrs(j, r) + return resp +} + +// readDocument returns the bytes after the IPP message. +// When Content-Length is set, it reads exactly the remaining bytes and returns +// without waiting for the client to close the connection. +func readDocument(r *http.Request, body *countingReader) ([]byte, error) { + if r.ContentLength >= 0 { + remain := r.ContentLength - body.n + if remain < 0 { + return nil, fmt.Errorf("ipp message longer than Content-Length") + } + if remain == 0 { + return nil, nil + } + if remain > maxDocument+1 { + remain = maxDocument + 1 + } + buf := make([]byte, remain) + _, err := io.ReadFull(body, buf) + return buf, err + } + return io.ReadAll(io.LimitReader(body, maxDocument+1)) +} + +func sniffFormat(format string, doc []byte) string { + switch { + case len(doc) >= 5 && string(doc[:5]) == "%PDF-": + return "application/pdf" + case len(doc) >= 8 && string(doc[:8]) == "UNIRAST\x00": + return "image/urf" + case len(doc) >= 4 && (string(doc[:4]) == "RaS2" || string(doc[:4]) == "2SaR"): + return "image/pwg-raster" + case len(doc) >= 3 && doc[0] == 0xff && doc[1] == 0xd8 && doc[2] == 0xff: + return "image/jpeg" + default: + if format == "" { + return "application/octet-stream" + } + return format + } +} + +func (p *Printer) checkFormat(req *goipp.Message) (goipp.Status, string) { + f := strings.ToLower(strings.TrimSpace(attrString(req.Operation, "document-format"))) + if formatAccepted(f) { + return goipp.StatusOk, "" + } + return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f +} + +func (p *Printer) createJob(req *goipp.Message) (*ippJob, error) { + id, err := p.allocJobID() + if err != nil { + return nil, err + } + p.mu.Lock() + defer p.mu.Unlock() + if p.jobs == nil { + p.jobs = map[int]*ippJob{} + } + j := &ippJob{ + id: id, + name: firstNonEmpty(attrString(req.Operation, "job-name"), attrString(req.Operation, "document-name"), "document"), + user: attrString(req.Operation, "requesting-user-name"), + format: firstNonEmpty(attrString(req.Operation, "document-format"), "application/octet-stream"), + state: jobPending, + reasons: "none", + created: time.Now(), + } + p.jobs[j.id] = j + return j, nil +} + +func (p *Printer) allocJobID() (int, error) { + if p.AllocateID != nil { + return p.AllocateID() + } + p.mu.Lock() + defer p.mu.Unlock() + p.nextID++ + return p.nextID, nil +} + +func (p *Printer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message { + resp := goipp.NewResponse(req.Version, st, req.RequestID) + resp.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + resp.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + if msg != "" { + resp.Operation.Add(goipp.MakeAttribute("status-message", goipp.TagText, goipp.String(msg))) + } + return resp +} + +func (p *Printer) jobAttrs(j *ippJob, r *http.Request) goipp.Attributes { + p.mu.Lock() + defer p.mu.Unlock() + return p.jobAttrsLocked(j, r) +} + +func (p *Printer) jobAttrsLocked(j *ippJob, r *http.Request) goipp.Attributes { + host := r.Host + if host == "" { + host = "localhost" + } + var a goipp.Attributes + a.Add(goipp.MakeAttribute("job-id", goipp.TagInteger, goipp.Integer(j.id))) + a.Add(goipp.MakeAttribute("job-uri", goipp.TagURI, goipp.String(fmt.Sprintf("ipp://%s/ipp/print/%d", host, j.id)))) + a.Add(goipp.MakeAttribute("job-printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print"))) + a.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(j.name))) + a.Add(goipp.MakeAttribute("job-originating-user-name", goipp.TagName, goipp.String(firstNonEmpty(j.user, "anonymous")))) + a.Add(goipp.MakeAttribute("job-state", goipp.TagEnum, goipp.Integer(j.state))) + a.Add(goipp.MakeAttribute("job-state-reasons", goipp.TagKeyword, goipp.String(firstNonEmpty(j.reasons, "none")))) + if j.message != "" { + a.Add(goipp.MakeAttribute("job-state-message", goipp.TagText, goipp.String(j.message))) + } + return a +} + +func (p *Printer) printerURI(req *goipp.Message, r *http.Request) string { + if u := attrString(req.Operation, "printer-uri"); u != "" { + return u + } + host := r.Host + if host == "" { + host = "localhost" + } + return "ipp://" + host + "/ipp/print" +} + +func (p *Printer) uuid() string { + if strings.TrimSpace(p.UUID) != "" { + u := strings.TrimPrefix(strings.TrimSpace(p.UUID), "urn:uuid:") + return "urn:uuid:" + u + } + h := sha1.Sum([]byte("verae-netprint|" + p.Name)) + return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", h[0:4], h[4:6], h[6:8], h[8:10], h[10:16]) +} + +func (p *Printer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attributes { + uri := p.printerURI(req, r) + p.mu.Lock() + queued := 0 + busy := false + started := p.started + if started.IsZero() { + started = time.Now() + } + for _, j := range p.jobs { + if j.state < jobCanceled { + queued++ + busy = busy || j.state == jobProcessing + } + } + p.mu.Unlock() + state := 3 + if busy { + state = 4 + } + name := firstNonEmpty(p.Name, "Verae Timestamp") + admin := p.AdminURL + if admin == "" { + admin = "http://" + r.Host + "/" + } + var a goipp.Attributes + add := func(x goipp.Attribute) { a.Add(x) } + add(goipp.MakeAttribute("printer-uri-supported", goipp.TagURI, goipp.String(uri))) + add(kw("uri-security-supported", "none")) + add(kw("uri-authentication-supported", "none")) + add(goipp.MakeAttribute("printer-name", goipp.TagName, goipp.String(name))) + add(goipp.MakeAttribute("printer-info", goipp.TagText, goipp.String(name))) + add(goipp.MakeAttribute("printer-make-and-model", goipp.TagText, goipp.String("Verae Timestamp"))) + add(goipp.MakeAttribute("printer-location", goipp.TagText, goipp.String("LAN"))) + add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String(admin))) + add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(p.uuid()))) + add(goipp.MakeAttribute("printer-dns-sd-name", goipp.TagName, goipp.String(name))) + add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:Timestamp;CMD:PDF,URF,PWG,JPEG;CLS:PRINTER;DES:Verae Timestamp;"))) + add(ints("printer-state", goipp.TagEnum, state)) + add(kw("printer-state-reasons", "none")) + add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true))) + add(ints("queued-job-count", goipp.TagInteger, queued)) + add(ints("printer-up-time", goipp.TagInteger, int(time.Since(started).Seconds())+1)) + add(kw("ipp-versions-supported", "1.1", "2.0")) + add(kw("ipp-features-supported", "ipp-everywhere")) + add(ints("operations-supported", goipp.TagEnum, + int(goipp.OpPrintJob), int(goipp.OpValidateJob), int(goipp.OpCreateJob), int(goipp.OpSendDocument), + int(goipp.OpCancelJob), int(goipp.OpGetJobAttributes), int(goipp.OpGetJobs), int(goipp.OpGetPrinterAttributes), + int(goipp.OpCloseJob), int(goipp.OpCancelMyJobs), int(goipp.OpIdentifyPrinter))) + add(multi("charset-configured", goipp.TagCharset, "utf-8")) + add(multi("charset-supported", goipp.TagCharset, "utf-8")) + add(multi("natural-language-configured", goipp.TagLanguage, "en")) + add(multi("generated-natural-language-supported", goipp.TagLanguage, "en")) + add(multi("document-format-supported", goipp.TagMimeType, acceptedFormats...)) + add(multi("document-format-default", goipp.TagMimeType, "application/pdf")) + add(kw("urf-supported", "V1.4", "W8", "SRGB24", "RS300", "RS600", "DM1")) + add(kw("pdl-override-supported", "attempted")) + add(kw("compression-supported", "none")) + add(goipp.MakeAttribute("color-supported", goipp.TagBoolean, goipp.Boolean(true))) + add(kw("print-color-mode-default", "color")) + add(kw("print-color-mode-supported", "auto", "color", "monochrome")) + add(kw("sides-default", "one-sided")) + add(kw("sides-supported", "one-sided")) + add(goipp.MakeAttribute("copies-default", goipp.TagInteger, goipp.Integer(1))) + add(goipp.MakeAttribute("copies-supported", goipp.TagRange, goipp.Range{Lower: 1, Upper: 99})) + add(kw("media-default", "na_letter_8.5x11in")) + add(kw("media-supported", "na_letter_8.5x11in", "iso_a4_210x297mm", "na_legal_8.5x14in")) + add(kw("media-ready", "na_letter_8.5x11in", "iso_a4_210x297mm")) + letter := mediaCol(21590, 27940, 0) // na_letter_8.5x11in, hundredths of a mm + a4 := mediaCol(21000, 29700, 0) // iso_a4_210x297mm + add(goipp.MakeAttribute("media-col-default", goipp.TagBeginCollection, letter)) + readyCol := goipp.Attribute{Name: "media-col-ready"} + readyCol.Values.Add(goipp.TagBeginCollection, letter) + readyCol.Values.Add(goipp.TagBeginCollection, a4) + add(readyCol) + add(kw("media-col-supported", "media-size", "media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin")) + sizes := goipp.Attribute{Name: "media-size-supported"} + sizes.Values.Add(goipp.TagBeginCollection, mediaSize(21590, 27940)) + sizes.Values.Add(goipp.TagBeginCollection, mediaSize(21000, 29700)) + add(sizes) + add(ints("media-bottom-margin-supported", goipp.TagInteger, 0)) + add(ints("media-left-margin-supported", goipp.TagInteger, 0)) + add(ints("media-right-margin-supported", goipp.TagInteger, 0)) + add(ints("media-top-margin-supported", goipp.TagInteger, 0)) + add(kw("output-bin-default", "face-down")) + add(kw("output-bin-supported", "face-down")) + add(ints("orientation-requested-default", goipp.TagEnum, 3)) + add(ints("orientation-requested-supported", goipp.TagEnum, 3, 4, 5, 6)) + add(ints("finishings-default", goipp.TagEnum, 3)) + add(ints("finishings-supported", goipp.TagEnum, 3)) + add(ints("print-quality-default", goipp.TagEnum, 4)) + add(ints("print-quality-supported", goipp.TagEnum, 3, 4, 5)) + res300 := goipp.Resolution{Xres: 300, Yres: 300, Units: goipp.UnitsDpi} + res600 := goipp.Resolution{Xres: 600, Yres: 600, Units: goipp.UnitsDpi} + add(goipp.MakeAttribute("printer-resolution-default", goipp.TagResolution, res600)) + pr := goipp.Attribute{Name: "printer-resolution-supported"} + pr.Values.Add(goipp.TagResolution, res300) + pr.Values.Add(goipp.TagResolution, res600) + add(pr) + add(kw("pwg-raster-document-type-supported", "sgray_8", "srgb_8", "srgb_16")) + add(kw("which-jobs-supported", "completed", "not-completed")) + add(goipp.MakeAttribute("multiple-document-jobs-supported", goipp.TagBoolean, goipp.Boolean(false))) + add(kw("multiple-operation-time-out-action", "process-job")) + add(ints("multiple-operation-time-out", goipp.TagInteger, 120)) + add(kw("identify-actions-default", "display")) + add(kw("identify-actions-supported", "display")) + add(kw("pdf-versions-supported", "adobe-1.3", "adobe-1.4", "adobe-1.5", "adobe-1.6", "adobe-1.7")) + add(kw("printer-kind", "document")) + return filterAttrs(a, req) +} + +func filterAttrs(a goipp.Attributes, req *goipp.Message) goipp.Attributes { + var names []string + for _, at := range req.Operation { + if at.Name != "requested-attributes" { + continue + } + for _, v := range at.Values { + names = append(names, v.V.String()) + } + } + if len(names) == 0 { + return a + } + for _, n := range names { + if n == "all" { + return a + } + } + want := map[string]bool{} + for _, n := range names { + want[n] = true + } + var out goipp.Attributes + for _, at := range a { + if want[at.Name] { + out.Add(at) + } + } + return out +} + +func mediaSize(w, h int) goipp.Collection { + c := goipp.Collection{} + c.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(w))) + c.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(h))) + return c +} + +func mediaCol(w, h, margin int) goipp.Collection { + c := goipp.Collection{} + c.Add(goipp.MakeAttribute("media-size", goipp.TagBeginCollection, mediaSize(w, h))) + for _, name := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} { + c.Add(goipp.MakeAttribute(name, goipp.TagInteger, goipp.Integer(margin))) + } + return c +} + +func kw(name string, vals ...string) goipp.Attribute { return multi(name, goipp.TagKeyword, vals...) } + +func multi(name string, tag goipp.Tag, vals ...string) goipp.Attribute { + a := goipp.Attribute{Name: name} + for _, v := range vals { + a.Values.Add(tag, goipp.String(v)) + } + return a +} + +func ints(name string, tag goipp.Tag, vals ...int) goipp.Attribute { + a := goipp.Attribute{Name: name} + for _, v := range vals { + a.Values.Add(tag, goipp.Integer(v)) + } + return a +} + +func attrString(attrs goipp.Attributes, name string) string { + for _, a := range attrs { + if a.Name == name && len(a.Values) > 0 { + return a.Values[0].V.String() + } + } + return "" +} + +func attrInt(attrs goipp.Attributes, name string) int32 { + for _, a := range attrs { + if a.Name == name && len(a.Values) > 0 { + if v, ok := a.Values[0].V.(goipp.Integer); ok { + return int32(v) + } + } + } + return 0 +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} diff --git a/internal/netprint/launch_tray_other.go b/internal/netprint/launch_tray_other.go new file mode 100644 index 0000000..ca718df --- /dev/null +++ b/internal/netprint/launch_tray_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package netprint + +import "fmt" + +// LaunchTrayNow starts the tray for the logged-in user. Windows is the only +// platform where a silent elevated install leaves the icon unstarted. +func LaunchTrayNow() error { + return fmt.Errorf("launch-tray is implemented on Windows") +} diff --git a/internal/netprint/launch_tray_windows.go b/internal/netprint/launch_tray_windows.go new file mode 100644 index 0000000..8b05cc0 --- /dev/null +++ b/internal/netprint/launch_tray_windows.go @@ -0,0 +1,164 @@ +//go:build windows + +package netprint + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const trayTaskName = "VeraeNetPrintTrayLaunch" + +// LaunchTrayNow starts verae-netprint-tray.exe as the logged-in user. +// The tray is not a child of this process. Task Scheduler (and, if that is +// unavailable, Explorer) starts it, so Start-Process -Wait on setup returns +// when setup exits instead of waiting for the tray. +func LaunchTrayNow() error { + if trayAlreadyRunning() { + log.Printf("netprint: tray already running") + fmt.Println("tray started") + return nil + } + spec, err := trayLaunchSpec() + if err != nil { + return err + } + // The dedicated tray exe is started through Explorer, as the interactive + // user when Task Scheduler is available. The scheduled task's process is + // explorer.exe, which forwards to the running shell and exits. The tray + // is not in that task's job, so the task's time limit and WM_CLOSE do + // not stop it a few minutes later. A second copy hits the tray mutex. + var launchErr error + if user, uerr := activeConsoleUser(); uerr != nil { + launchErr = uerr + log.Printf("netprint: tray user: %v", uerr) + } else if err := launchTrayTask(spec, user); err != nil { + launchErr = err + log.Printf("netprint: tray scheduled task: %v", err) + } else { + log.Printf("netprint: tray launch requested via scheduled task") + fmt.Println("tray started") + return nil + } + // Explorer can only launch a program with no arguments. The dedicated tray + // binary needs none. verae-print.exe must be started as `tray` by the task. + if len(spec.args) == 0 && spec.exe != "" { + if err := launchViaExplorer(spec.exe); err != nil { + if launchErr != nil { + return fmt.Errorf("%v; %w", launchErr, err) + } + return err + } + fmt.Println("tray started") + return nil + } + if launchErr != nil { + return launchErr + } + return fmt.Errorf("tray was not started") +} + +func trayLaunchSpec() (trayLaunch, error) { + exe, err := os.Executable() + if err != nil { + return trayLaunch{}, err + } + exe, err = filepath.Abs(exe) + if err != nil { + return trayLaunch{}, err + } + sibling := filepath.Join(filepath.Dir(exe), "verae-netprint-tray.exe") + if st, err := os.Stat(sibling); err == nil && !st.IsDir() { + return trayLaunch{exe: sibling}, nil + } + return trayLaunch{exe: exe, args: []string{"tray"}}, nil +} + +func trayAlreadyRunning() bool { + out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq verae-netprint-tray.exe", "/FO", "CSV", "/NH").Output() + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(out)), "verae-netprint-tray.exe") +} + +func activeConsoleUser() (string, error) { + out, err := exec.Command("query", "user").CombinedOutput() + if err != nil || len(strings.TrimSpace(string(out))) == 0 { + if alt, err2 := exec.Command("quser").CombinedOutput(); err2 == nil { + out = alt + } + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(strings.ToUpper(line), "USERNAME") { + continue + } + fields := strings.Fields(line) + user := "" + active := false + for i, f := range fields { + if i == 0 { + user = strings.TrimLeft(f, ">") + } + if strings.EqualFold(f, "Active") { + active = true + } + } + if !active || user == "" { + continue + } + upper := strings.ToUpper(user) + if upper == "SYSTEM" || strings.Contains(upper, "NT AUTHORITY") { + continue + } + return user, nil + } + return "", fmt.Errorf("no interactive user") +} + +func launchTrayTask(spec trayLaunch, user string) error { + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() + out, err := exec.Command("schtasks", "/Create", + "/TN", trayTaskName, + "/TR", spec.taskCommand(), + "/SC", "ONCE", + "/ST", "00:00", + "/RL", "LIMITED", + "/IT", + "/RU", user, + "/F", + ).CombinedOutput() + if err != nil { + return fmt.Errorf("schtasks create: %w (%s)", err, strings.TrimSpace(string(out))) + } + out, err = exec.Command("schtasks", "/Run", "/TN", trayTaskName).CombinedOutput() + if err != nil { + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() + return fmt.Errorf("schtasks run: %w (%s)", err, strings.TrimSpace(string(out))) + } + // /Run returns once Task Scheduler has created the process, which is not a + // child of setup. Give tasklist a moment to see it, then drop the one-shot + // task so it cannot also fire at midnight. + deadline := time.Now().Add(1500 * time.Millisecond) + for time.Now().Before(deadline) && !trayAlreadyRunning() { + time.Sleep(100 * time.Millisecond) + } + _ = exec.Command("schtasks", "/Delete", "/TN", trayTaskName, "/F").Run() + return nil +} + +func launchViaExplorer(exe string) error { + // explorer.exe starts the program and returns. The tray's parent is + // Explorer, not setup, so the installer job object does not wait on it. + cmd := exec.Command("explorer.exe", exe) + if err := cmd.Start(); err != nil { + return fmt.Errorf("explorer launch: %w", err) + } + return nil +} diff --git a/internal/netprint/listen.go b/internal/netprint/listen.go new file mode 100644 index 0000000..bd112de --- /dev/null +++ b/internal/netprint/listen.go @@ -0,0 +1,306 @@ +package netprint + +import ( + "context" + "fmt" + "log" + "net" + "runtime" + "strconv" + "strings" + "sync" + "syscall" +) + +// ippBindHosts splits cfg.Bind into an IPv4 host and an IPv6 host. +// An empty bind listens on 0.0.0.0 and on [::] as two sockets. A single +// dual-stack socket (IPV6_V6ONLY off) accepts global IPv6 and IPv4, and on +// Windows a connect to the host's fe80 address never completes. The IPv6 +// socket is bound with IPV6_V6ONLY so it can take [::] beside the IPv4 socket +// and still accept link-local destinations. +func ippBindHosts(bind string) (v4, v6 string) { + bind = strings.Trim(strings.TrimSpace(bind), "[]") + switch bind { + case "", "0.0.0.0", "::": + return "0.0.0.0", "::" + } + ip := net.ParseIP(bind) + if ip == nil { + return bind, "" + } + if ip.To4() != nil { + return ip.String(), "" + } + return "", ip.String() +} + +func bindIsWildcard(bind string) bool { + bind = strings.Trim(strings.TrimSpace(bind), "[]") + switch bind { + case "", "0.0.0.0", "::": + return true + default: + return false + } +} + +// lanOnlyIPP makes listenIPP use the macOS CUPS rule on whatever port the +// test chose. Production turns that rule on only for Darwin port 631. +var lanOnlyIPP bool + +// shouldBindLANOnly is the macOS cupsd rule. cupsd owns 127.0.0.1:631 and +// [::1]:631. Binding 0.0.0.0:631 fails, or would take localhost with it. +// Binding each LAN address on 631 leaves those loopback sockets to CUPS. +func shouldBindLANOnly(cfg Config) bool { + if !bindIsWildcard(cfg.Bind) { + return false + } + if lanOnlyIPP { + return true + } + return runtime.GOOS == "darwin" && cfg.Port == 631 +} + +type lanAddr struct { + network string + host string +} + +func lanListenAddrs() ([]lanAddr, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + var out []lanAddr + seen := map[string]bool{} + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ip := addrIP(a) + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() { + continue + } + if v4 := ip.To4(); v4 != nil { + if v4.IsLinkLocalUnicast() { + continue + } + key := "4 " + v4.String() + if seen[key] { + continue + } + seen[key] = true + out = append(out, lanAddr{network: "tcp4", host: v4.String()}) + continue + } + ip = ip.To16() + zone := "" + if ip.IsLinkLocalUnicast() { + zone = ifi.Name + } + key := "6 " + ip.String() + "%" + zone + if seen[key] { + continue + } + seen[key] = true + host := ip.String() + if zone != "" { + host += "%" + zone + } + out = append(out, lanAddr{network: "tcp6", host: host}) + } + } + return out, nil +} + +// listenLAN binds port on each non-loopback address. Localhost is not bound. +func listenLAN(port int) (net.Listener, error) { + addrs, err := lanListenAddrs() + if err != nil { + return nil, err + } + if len(addrs) == 0 { + return nil, fmt.Errorf("no LAN address") + } + var lns []net.Listener + var first error + for _, a := range addrs { + address := net.JoinHostPort(a.host, strconv.Itoa(port)) + var ln net.Listener + var err error + if a.network == "tcp6" { + ln, err = listenTCP6(address) + } else { + ln, err = net.Listen("tcp4", address) + } + if err != nil { + if first == nil { + first = err + } + log.Printf("netprint: %s: %v", address, err) + continue + } + lns = append(lns, ln) + } + if len(lns) == 0 { + if first == nil { + first = fmt.Errorf("no LAN address") + } + return nil, first + } + if len(lns) == 1 { + return lns[0], nil + } + return newMultiListener(lns), nil +} + +func listenPort(v4host, v6host string, port int) (net.Listener, error) { + var lns []net.Listener + if v4host != "" { + ln, err := net.Listen("tcp4", net.JoinHostPort(v4host, fmt.Sprint(port))) + if err != nil { + return nil, err + } + lns = append(lns, ln) + } + if v6host != "" { + ln, err := listenTCP6(net.JoinHostPort(v6host, fmt.Sprint(port))) + if err != nil { + if len(lns) == 0 { + return nil, err + } + // IPv4 is up. Keep the port that was requested instead of moving + // the printer, and say why link-local clients will time out. + log.Printf("netprint: IPv6 [%s]:%d (IPV6_V6ONLY): %v", v6host, port, err) + } else { + lns = append(lns, ln) + } + } + if len(lns) == 0 { + return nil, fmt.Errorf("no listen address") + } + if len(lns) == 1 { + return lns[0], nil + } + return newMultiListener(lns), nil +} + +func listenTCP6(address string) (net.Listener, error) { + var lc net.ListenConfig + lc.Control = func(network, address string, c syscall.RawConn) error { + var sockErr error + if err := c.Control(func(fd uintptr) { + sockErr = setIPv6Only(fd) + }); err != nil { + return err + } + return sockErr + } + return lc.Listen(context.Background(), "tcp6", address) +} + +func listenerAddrs(ln net.Listener) string { + m, ok := ln.(*multiListener) + if !ok { + return ln.Addr().String() + } + parts := make([]string, 0, len(m.lns)) + for _, l := range m.lns { + parts = append(parts, l.Addr().String()) + } + return strings.Join(parts, " and ") +} + +type acceptOne struct { + c net.Conn + err error +} + +// multiListener is several listeners served as one. http.Server.Shutdown +// closes it, which closes every socket. +type multiListener struct { + lns []net.Listener + ch chan acceptOne + done chan struct{} + start sync.Once + stop sync.Once +} + +func newMultiListener(lns []net.Listener) *multiListener { + return &multiListener{ + lns: lns, + ch: make(chan acceptOne), + done: make(chan struct{}), + } +} + +func (m *multiListener) Addr() net.Addr { return m.lns[0].Addr() } + +func (m *multiListener) Accept() (net.Conn, error) { + m.start.Do(func() { + for _, ln := range m.lns { + go m.loop(ln) + } + }) + select { + case <-m.done: + return nil, net.ErrClosed + case r := <-m.ch: + return r.c, r.err + } +} + +func (m *multiListener) loop(ln net.Listener) { + for { + c, err := ln.Accept() + select { + case <-m.done: + if c != nil { + _ = c.Close() + } + return + case m.ch <- acceptOne{c: c, err: err}: + } + if err != nil { + return + } + } +} + +func (m *multiListener) Close() error { + var err error + m.stop.Do(func() { + close(m.done) + for _, ln := range m.lns { + if e := ln.Close(); err == nil { + err = e + } + } + }) + return err +} + +func socketIPv6Only(ln net.Listener) (int, error) { + tcp, ok := ln.(*net.TCPListener) + if !ok { + return 0, fmt.Errorf("%T", ln) + } + raw, err := tcp.SyscallConn() + if err != nil { + return 0, err + } + var v int + var sockErr error + err = raw.Control(func(fd uintptr) { + v, sockErr = getIPv6Only(fd) + }) + if err != nil { + return 0, err + } + return v, sockErr +} diff --git a/internal/netprint/listen_ipv6_unix.go b/internal/netprint/listen_ipv6_unix.go new file mode 100644 index 0000000..10a6fe6 --- /dev/null +++ b/internal/netprint/listen_ipv6_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package netprint + +import "syscall" + +func setIPv6Only(fd uintptr) error { + return syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, 1) +} + +func getIPv6Only(fd uintptr) (int, error) { + return syscall.GetsockoptInt(int(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY) +} diff --git a/internal/netprint/listen_ipv6_windows.go b/internal/netprint/listen_ipv6_windows.go new file mode 100644 index 0000000..bd6f6d4 --- /dev/null +++ b/internal/netprint/listen_ipv6_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package netprint + +import "syscall" + +func setIPv6Only(fd uintptr) error { + return syscall.SetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY, 1) +} + +func getIPv6Only(fd uintptr) (int, error) { + return syscall.GetsockoptInt(syscall.Handle(fd), syscall.IPPROTO_IPV6, syscall.IPV6_V6ONLY) +} diff --git a/internal/netprint/listen_test.go b/internal/netprint/listen_test.go new file mode 100644 index 0000000..2988879 --- /dev/null +++ b/internal/netprint/listen_test.go @@ -0,0 +1,183 @@ +package netprint + +import ( + "net" + "os" + "strconv" + "strings" + "testing" + "time" +) + +func TestListenIPPAcceptsIPv4AndLinkLocalIPv6(t *testing.T) { + port := freePort(t) + ln, got, mode, err := listenIPP(Config{Port: port}) + if err != nil { + t.Fatal(err) + } + if mode != "all" { + t.Fatalf("mode %s", mode) + } + t.Cleanup(func() { _ = ln.Close() }) + if got != port { + t.Fatalf("port %d", got) + } + ml, ok := ln.(*multiListener) + if !ok || len(ml.lns) != 2 { + t.Fatalf("listener %T (%s)", ln, listenerAddrs(ln)) + } + v6only, err := socketIPv6Only(ml.lns[1]) + if err != nil { + t.Fatal(err) + } + if v6only != 1 { + t.Fatalf("IPV6_V6ONLY %d", v6only) + } + dial := func(network, address string) { + t.Helper() + conn, err := net.DialTimeout(network, address, 2*time.Second) + if err != nil { + t.Fatalf("dial %s %s: %v", network, address, err) + } + _ = conn.Close() + } + dial("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + dial("tcp6", net.JoinHostPort("::1", strconv.Itoa(port))) + ip, zone := firstLinkLocal() + if ip == nil { + t.Log("no link-local address; [::] with IPV6_V6ONLY still accepts ::1") + return + } + dial("tcp6", net.JoinHostPort(ip.String()+"%"+zone, strconv.Itoa(port))) +} + +func TestListenIPPSpecificIPv4StaysIPv4(t *testing.T) { + port := freePort(t) + ln, _, mode, err := listenIPP(Config{Bind: "127.0.0.1", Port: port}) + if err != nil { + t.Fatal(err) + } + if mode != "all" { + t.Fatalf("mode %s", mode) + } + t.Cleanup(func() { _ = ln.Close() }) + if _, ok := ln.(*net.TCPListener); !ok { + t.Fatalf("specific bind opened %T", ln) + } + conn, err := net.DialTimeout("tcp6", net.JoinHostPort("::1", strconv.Itoa(port)), 200*time.Millisecond) + if err == nil { + _ = conn.Close() + t.Fatal("127.0.0.1 bind accepted IPv6") + } +} + +func TestFirewallRuleAllowsLinkLocal(t *testing.T) { + if firewallRemoteIP != "any" { + t.Fatalf("remoteip %q", firewallRemoteIP) + } + src, err := os.ReadFile("firewall.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "fe80::/10") { + t.Fatal("firewall.go does not document link-local") + } + win, err := os.ReadFile("service_windows.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(win), "remoteip="+`firewallRemoteIP`) && !strings.Contains(string(win), `remoteip="+firewallRemoteIP`) { + t.Fatal("service firewall rule does not set remoteip") + } + for _, path := range []string{"../../platform/windows/installer-netprint.nsi", "../../platform/windows/installer-windows.nsi"} { + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(b) + if strings.Count(text, "remoteip=any") < 3 || !strings.Contains(text, "fe80::/10") { + t.Fatalf("%s missing link-local firewall coverage", path) + } + } +} + +func TestListenLeavesLocalhostForCUPS(t *testing.T) { + lanOnlyIPP = true + t.Cleanup(func() { lanOnlyIPP = false }) + primary := freePort(t) + fallback := freePort(t) + for fallback == primary { + fallback = freePort(t) + } + cups, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(primary))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cups.Close() }) + if cups6, err := net.Listen("tcp6", net.JoinHostPort("::1", strconv.Itoa(primary))); err == nil { + t.Cleanup(func() { _ = cups6.Close() }) + } + ln, got, mode, err := listenIPP(Config{Port: primary, FallbackPort: fallback}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + probe, err := net.DialTimeout("tcp4", cups.Addr().String(), time.Second) + if err != nil { + t.Fatalf("localhost:%d no longer belongs to the stand-in CUPS listener: %v", primary, err) + } + _ = probe.Close() + if got == primary { + if mode != "lan" { + t.Fatalf("mode %s", mode) + } + for _, part := range strings.Split(listenerAddrs(ln), " and ") { + host, _, err := net.SplitHostPort(part) + if err != nil { + t.Fatal(err) + } + ip := net.ParseIP(host) + if ip == nil || ip.IsLoopback() { + t.Fatalf("bound loopback %s", part) + } + } + conn, err := net.DialTimeout("tcp", strings.Split(listenerAddrs(ln), " and ")[0], time.Second) + if err != nil { + t.Fatal(err) + } + _ = conn.Close() + return + } + if got != fallback || mode != "all" { + t.Fatalf("port %d mode %s", got, mode) + } + conn, err := net.DialTimeout("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(fallback)), time.Second) + if err != nil { + t.Fatal(err) + } + _ = conn.Close() +} + +func firstLinkLocal() (net.IP, string) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, "" + } + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ip := addrIP(a) + if ip == nil || ip.To4() != nil || !ip.IsLinkLocalUnicast() { + continue + } + return ip, ifi.Name + } + } + return nil, "" +} diff --git a/internal/netprint/mail_job.go b/internal/netprint/mail_job.go new file mode 100644 index 0000000..a2f6542 --- /dev/null +++ b/internal/netprint/mail_job.go @@ -0,0 +1,263 @@ +package netprint + +import ( + "errors" + "fmt" + "html" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +var ( + maxReceiptJSON = 64 << 10 + maxAttachBytes int64 = 20 << 20 + maxAttachTotal int64 = 32 << 20 +) + +// mailRetryDelays is the wait before each attempt. The first attempt is immediate. +var mailRetryDelays = []time.Duration{0, 30 * time.Second, 2 * time.Minute, 10 * time.Minute} + +// sendMail is the SMTP call. Tests replace it. +var sendMail = func(cfg mailer.Config, m mailer.Message) error { + return (&mailer.SMTPSender{Cfg: cfg}).Send(m) +} + +func (s *Server) queueReceiptMail(rec Record) { + if rec.State != "completed" || !s.snapshot().EmailOn() { + return + } + go s.deliverWithRetry(rec) +} + +func (s *Server) deliverWithRetry(rec Record) { + delays := append([]time.Duration(nil), mailRetryDelays...) + var last error + for i, d := range delays { + if d > 0 { + time.Sleep(d) + } + cfg := s.snapshot() + if !cfg.EmailOn() { + return + } + sec, err := loadSecrets(s.secretsFile()) + if err != nil { + last = errors.New("could not read mail settings") + log.Printf("netprint: job %d email attempt %d: %v", rec.ID, i+1, last) + continue + } + err = sendReceiptMail(cfg, sec.SMTPPassword, rec) + if err == nil { + log.Printf("netprint: job %d emailed receipt=%s", rec.ID, rec.ReceiptID) + s.noteMail(rec.ID, "emailed", "") + return + } + last = scrubSecret(err, sec.SMTPPassword) + log.Printf("netprint: job %d email attempt %d: %v", rec.ID, i+1, last) + } + msg := "email failed" + if last != nil { + msg = last.Error() + } + log.Printf("netprint: job %d email failed: %s", rec.ID, msg) + s.noteMail(rec.ID, "email failed", msg) +} + +func (s *Server) noteMail(id int, notified, emailErr string) { + rec, ok := s.store.Get(id) + if !ok { + return + } + rec.Notified = notified + rec.EmailError = emailErr + if err := s.store.Put(rec); err != nil { + log.Printf("netprint: job %d mail index: %v", id, err) + } + s.mu.Lock() + s.emailErr = emailErr + s.mu.Unlock() +} + +func (s *Server) seedEmailError() { + list := s.store.List() + for i := len(list) - 1; i >= 0; i-- { + if list[i].EmailError != "" { + s.emailErr = list[i].EmailError + return + } + if list[i].Notified != "" || list[i].State == "completed" || list[i].State == "aborted" { + return + } + } +} + +func scrubSecret(err error, secret string) error { + if err == nil { + return nil + } + msg := err.Error() + if secret != "" { + msg = strings.ReplaceAll(msg, secret, "[redacted]") + } + return errors.New(msg) +} + +func mailSubject(title string) string { + title = strings.Join(strings.Fields(title), " ") + if title == "" { + title = "document" + } + return "Verae timestamp: " + title +} + +func smtpConfig(cfg Config, pass string) (mailer.Config, error) { + user := strings.TrimSpace(cfg.SMTPUser) + if user == "" { + user = DefaultEmailTo + } + host := strings.TrimSpace(cfg.SMTPHost) + if host == "" { + host = DefaultSMTPHost + } + port := cfg.SMTPPort + if port <= 0 { + port = 587 + } + from := "Verae Timestamp <" + user + ">" + if !strings.Contains(user, "@") { + return mailer.Config{}, errors.New("SMTP user is not an email address") + } + return mailer.Config{ + Host: host, + Port: port, + User: user, + Pass: pass, + From: from, + ImplicitTLS: strings.EqualFold(cfg.SMTPTLS, "tls"), + Timeout: 30 * time.Second, + }, nil +} + +// sendReceiptMail sends the stamped PDF, the original, and the receipt summary. +// It never includes the SMTP password in an error string. +func sendReceiptMail(cfg Config, pass string, rec Record) error { + if strings.TrimSpace(pass) == "" { + return errors.New("SMTP password is not set") + } + mc, err := smtpConfig(cfg, pass) + if err != nil { + return err + } + recipients := cfg.MailRecipients() + if len(recipients) == 0 { + return errors.New("no recipients") + } + msg, err := receiptMessage(rec) + if err != nil { + return err + } + var errs []string + for _, to := range recipients { + msg.To = to + if err := sendMail(mc, msg); err != nil { + errs = append(errs, scrubSecret(err, pass).Error()) + } + } + if len(errs) > 0 { + return errors.New(strings.Join(errs, "; ")) + } + return nil +} + +func receiptMessage(rec Record) (mailer.Message, error) { + var notes []string + var attachments []mailer.Attachment + used := 0 + add := func(path, name string) { + if strings.TrimSpace(path) == "" { + return + } + fi, err := os.Stat(path) + if err != nil || fi.IsDir() { + notes = append(notes, name+" was not attached") + return + } + if fi.Size() > maxAttachBytes || int64(used)+fi.Size() > maxAttachTotal { + notes = append(notes, fmt.Sprintf("%s was not attached (%d bytes)", name, fi.Size())) + return + } + b, err := os.ReadFile(path) + if err != nil { + notes = append(notes, name+" was not attached") + return + } + used += len(b) + attachments = append(attachments, mailer.Attachment{Name: name, Type: "application/pdf", Data: b}) + } + add(rec.CertificatePDF, "certificate.pdf") + if rec.OriginalPDF != "" && !samePath(rec.OriginalPDF, rec.CertificatePDF) { + add(rec.OriginalPDF, "document.pdf") + } + summary := receiptSummary(rec.ReceiptJSON) + title := strings.TrimSpace(rec.Title) + if title == "" { + title = "document" + } + var text strings.Builder + fmt.Fprintf(&text, "Verae timestamp: %s\n\nReceipt: %s\nSHA-256: %s\n", title, rec.ReceiptID, rec.SHA256) + if rec.VerifyURL != "" { + fmt.Fprintf(&text, "Verify: %s\n", rec.VerifyURL) + } + if len(notes) > 0 { + text.WriteByte('\n') + for _, n := range notes { + text.WriteString(n) + text.WriteByte('\n') + } + } + text.WriteByte('\n') + text.WriteString(summary) + plain := text.String() + page := "

" + html.EscapeString(plain) + "

" + page = strings.ReplaceAll(page, "\n", "
\n") + return mailer.Message{ + Subject: mailSubject(rec.Title), + Text: plain, + HTML: page, + Attachments: attachments, + }, nil +} + +func receiptSummary(path string) string { + if strings.TrimSpace(path) == "" { + return "(receipt summary is not on disk)\n" + } + b, err := os.ReadFile(path) + name := filepath.Base(path) + if err != nil { + return "(receipt summary is not on disk)\n" + } + clipped := "" + if len(b) > maxReceiptJSON { + b = b[:maxReceiptJSON] + clipped = "\n[truncated]\n" + } + return "--- " + name + " ---\n" + string(b) + clipped +} + +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + aa, errA := filepath.Abs(a) + bb, errB := filepath.Abs(b) + if errA != nil || errB != nil { + return a == b + } + return aa == bb +} diff --git a/internal/netprint/mdns.go b/internal/netprint/mdns.go new file mode 100644 index 0000000..ab5202e --- /dev/null +++ b/internal/netprint/mdns.go @@ -0,0 +1,738 @@ +package netprint + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "fmt" + "net" + "os" + "strings" + "time" +) + +const ( + dnsTypeA = 1 + dnsTypePTR = 12 + dnsTypeTXT = 16 + dnsTypeAAAA = 28 + dnsTypeSRV = 33 + dnsTypeANY = 255 + dnsClassIN = 1 + dnsCacheFlush = 0x8000 + mdnsTTL = 120 +) + +// Service is the DNS-SD record set for one IPP Everywhere printer. +type Service struct { + Instance string + Host string // label only, without .local + Port int + IPs []net.IP + TXT []string + // StampURL is the LAN stamp-and-return address advertised on _verae._tcp. + StampURL string + // SubtypesOnly answers _universal._sub and _print._sub PTRs and nothing + // else. Windows uses that mode while DnsServiceRegister owns the primary + // _ipp._tcp name: a second SRV or TXT for the same instance is a conflict, + // and mDNS renames one of them to "Name(1)". + SubtypesOnly bool +} + +// AirPrintTXT returns the Bonjour Printing / AirPrint text records. +// adminURL should be reachable on the LAN (http://ip:port/). +func AirPrintTXT(name, uuid, adminURL, pdl, urf string) []string { + if pdl == "" { + pdl = pdlValue() + } + if urf == "" { + urf = "V1.4,W8,SRGB24,RS300,RS600,DM1" + } + uuid = strings.TrimPrefix(strings.TrimSpace(uuid), "urn:uuid:") + return []string{ + "txtvers=1", + "qtotal=1", + "rp=ipp/print", + "ty=" + trimTXT(name, 100), + "product=(" + trimTXT(name, 80) + ")", + "pdl=" + pdl, + "URF=" + urf, + "Color=T", + "Duplex=F", + "Copies=T", + "Bind=F", + "Collate=F", + "Sort=F", + "Staple=F", + "Punch=F", + "PaperMax=legal-A4", + "PaperCustom=F", + "UUID=" + uuid, + "adminurl=" + adminURL, + "note=Digests-only Verae timestamp", + "priority=0", + "kind=document", + "Fax=F", + "Scan=F", + "Transparent=T", + "Binary=T", + "TBCP=F", + "usb_MFG=Verae", + "usb_MDL=Timestamp", + "usb_CMD=PDF,URF,PWG,JPEG", + } +} + +func trimTXT(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > n { + return s[:n] + } + return s +} + +func (s Service) instanceLabels() []string { + return []string{sanitizeLabel(s.Instance), "_ipp", "_tcp", "local"} +} + +func (s Service) hostLabels() []string { + return []string{sanitizeLabel(s.Host), "local"} +} + +func sanitizeLabel(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, ".") + if s == "" { + s = "Verae-Timestamp" + } + if len(s) > 63 { + s = s[:63] + } + return s +} + +type dnsQuestion struct { + name []string + typ uint16 + class uint16 +} + +// Reply builds an mDNS response for query, or nil when the query is not for +// this service (or is itself a response). +func (s Service) Reply(query []byte) []byte { + if len(query) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(query[2:4]) + if flags&0x8000 != 0 { // QR + return nil + } + qs, err := parseQuestions(query) + if err != nil || len(qs) == 0 { + return nil + } + id := binary.BigEndian.Uint16(query[0:2]) + var matched []dnsQuestion + for _, q := range qs { + if s.matches(q) { + matched = append(matched, q) + } + } + if len(matched) == 0 { + return nil + } + return s.encodeResponse(id, matched, mdnsTTL) +} + +func (s Service) matches(q dnsQuestion) bool { + n := lowerJoin(q.name) + inst := lowerJoin(s.instanceLabels()) + host := lowerJoin(s.hostLabels()) + ptr := "_ipp._tcp.local" + univ := "_universal._sub._ipp._tcp.local" + print := "_print._sub._ipp._tcp.local" + enum := "_services._dns-sd._udp.local" + if s.SubtypesOnly { + return (n == univ || n == print) && (q.typ == dnsTypePTR || q.typ == dnsTypeANY) + } + switch { + case n == ptr || n == univ || n == print || n == enum: + return q.typ == dnsTypePTR || q.typ == dnsTypeANY + case n == inst: + return q.typ == dnsTypeSRV || q.typ == dnsTypeTXT || q.typ == dnsTypeANY || q.typ == dnsTypePTR + case n == host: + return q.typ == dnsTypeA || q.typ == dnsTypeAAAA || q.typ == dnsTypeANY + default: + return false + } +} + +func lowerJoin(labels []string) string { + var b strings.Builder + for i, l := range labels { + if i > 0 { + b.WriteByte('.') + } + b.WriteString(strings.ToLower(l)) + } + return b.String() +} + +// Announce is an unsolicited response (id 0) advertising the service. +func (s Service) Announce(goodbye bool) []byte { + ttl := uint32(mdnsTTL) + if goodbye { + ttl = 0 + } + qs := []dnsQuestion{ + {name: []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, + {name: []string{"_print", "_sub", "_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}, + } + if !s.SubtypesOnly { + qs = append([]dnsQuestion{{name: []string{"_ipp", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN}}, qs...) + } + return s.encodeResponse(0, qs, ttl) +} + +func (s Service) veraeLabels() []string { + return []string{sanitizeLabel(s.Instance), "_verae", "_tcp", "local"} +} + +func (s Service) veraeTXT() []string { + txt := []string{"txtvers=1", "path=/api/stamp", "ver=1"} + if u := strings.TrimSpace(s.StampURL); u != "" { + txt = append(txt, "url="+trimTXT(u, 200)) + } + return txt +} + +// VeraeAnnounce advertises _verae._tcp. It is a different service name from +// _ipp._tcp, so it does not create a second copy of the printer. While +// SubtypesOnly is set, address records stay with DnsServiceRegister. +func (s Service) VeraeAnnounce(goodbye bool) []byte { + ttl := uint32(mdnsTTL) + if goodbye { + ttl = 0 + } + inst := s.veraeLabels() + ans := []dnsRR{{ + name: []string{"_verae", "_tcp", "local"}, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: inst, + }} + extra := []dnsRR{ + {name: inst, typ: dnsTypeSRV, class: dnsClassIN | dnsCacheFlush, ttl: ttl, srvPort: s.Port, srvTarget: s.hostLabels()}, + {name: inst, typ: dnsTypeTXT, class: dnsClassIN | dnsCacheFlush, ttl: ttl, txt: s.veraeTXT()}, + } + if !s.SubtypesOnly { + extra = append(extra, s.addressRRs(ttl)...) + } + return encodeMessage(0, 0x8400, nil, ans, extra) +} + +func (s Service) addressRRs(ttl uint32) []dnsRR { + var out []dnsRR + for _, ip := range s.IPs { + if v4 := ip.To4(); v4 != nil { + out = append(out, dnsRR{name: s.hostLabels(), typ: dnsTypeA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v4}) + continue + } + if v6 := ip.To16(); v6 != nil { + out = append(out, dnsRR{name: s.hostLabels(), typ: dnsTypeAAAA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v6}) + } + } + return out +} + +// ReplyVerae answers a browse or resolve for _verae._tcp. +func (s Service) ReplyVerae(query []byte) []byte { + if len(query) < 12 { + return nil + } + flags := binary.BigEndian.Uint16(query[2:4]) + if flags&0x8000 != 0 { + return nil + } + qs, err := parseQuestions(query) + if err != nil || len(qs) == 0 { + return nil + } + var matched []dnsQuestion + for _, q := range qs { + n := lowerJoin(q.name) + switch { + case n == "_verae._tcp.local" && (q.typ == dnsTypePTR || q.typ == dnsTypeANY): + matched = append(matched, q) + case n == lowerJoin(s.veraeLabels()) && (q.typ == dnsTypePTR || q.typ == dnsTypeSRV || q.typ == dnsTypeTXT || q.typ == dnsTypeANY): + matched = append(matched, q) + } + } + if len(matched) == 0 { + return nil + } + return s.VeraeAnnounce(false) +} + +func (s Service) encodeResponse(id uint16, qs []dnsQuestion, ttl uint32) []byte { + var answers []byte + var add []byte + comp := map[string]int{} + put := func(dst *[]byte, name []string, typ, class uint16, flush bool, rdata []byte) { + c := class + if flush { + c |= dnsCacheFlush + } + *dst = append(*dst, encodeName(name, comp, 12+len(answers)+len(add))...) + // The offset passed to encodeName must be the absolute offset in the + // final message. Rebuilding with a second pass is simpler and correct. + _ = rdata + _ = typ + _ = c + } + _ = put + + // Two-pass: first compute without compression, then we encode names + // uncompressed (still valid DNS). Packets stay well under 9 KiB. + var an, ar []dnsRR + seenPTR := map[string]bool{} + needSRV, needTXT, needAddr := false, false, false + for _, q := range qs { + n := lowerJoin(q.name) + switch { + case n == "_services._dns-sd._udp.local": + if s.SubtypesOnly { + break + } + k := "_ipp._tcp.local" + if !seenPTR[k] { + an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: []string{"_ipp", "_tcp", "local"}}) + seenPTR[k] = true + } + case n == "_ipp._tcp.local" || n == "_universal._sub._ipp._tcp.local" || n == "_print._sub._ipp._tcp.local": + if s.SubtypesOnly && n == "_ipp._tcp.local" { + break + } + if !seenPTR[n] { + an = append(an, dnsRR{name: q.name, typ: dnsTypePTR, class: dnsClassIN, ttl: ttl, ptr: s.instanceLabels()}) + seenPTR[n] = true + } + // Subtype PTRs are not a second claim on the instance. SRV, TXT, + // and address records stay with whoever owns the base name. + if !s.SubtypesOnly { + needSRV, needTXT, needAddr = true, true, true + } + case n == lowerJoin(s.instanceLabels()): + if s.SubtypesOnly { + break + } + if q.typ == dnsTypeANY || q.typ == dnsTypeSRV { + needSRV = true + } + if q.typ == dnsTypeANY || q.typ == dnsTypeTXT { + needTXT = true + } + if q.typ == dnsTypeANY || q.typ == dnsTypePTR { + needSRV, needTXT, needAddr = true, true, true + } + case n == lowerJoin(s.hostLabels()): + if !s.SubtypesOnly { + needAddr = true + } + } + } + if needSRV { + an = append(an, dnsRR{name: s.instanceLabels(), typ: dnsTypeSRV, class: dnsClassIN | dnsCacheFlush, ttl: ttl, srvPort: s.Port, srvTarget: s.hostLabels()}) + } + if needTXT { + an = append(an, dnsRR{name: s.instanceLabels(), typ: dnsTypeTXT, class: dnsClassIN | dnsCacheFlush, ttl: ttl, txt: s.TXT}) + } + if needAddr { + for _, ip := range s.IPs { + if v4 := ip.To4(); v4 != nil { + ar = append(ar, dnsRR{name: s.hostLabels(), typ: dnsTypeA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v4}) + } else if v6 := ip.To16(); v6 != nil { + ar = append(ar, dnsRR{name: s.hostLabels(), typ: dnsTypeAAAA, class: dnsClassIN | dnsCacheFlush, ttl: ttl, ip: v6}) + } + } + } + // SRV/TXT that were requested directly belong in the answer section. + // Additional address records stay additional. When the question was a + // PTR, SRV and TXT are additional too (RFC 6763). + var ans, extra []dnsRR + ptrOnly := true + for _, q := range qs { + if q.typ != dnsTypePTR && lowerJoin(q.name) != "_services._dns-sd._udp.local" { + ptrOnly = false + } + if q.typ == dnsTypePTR && (lowerJoin(q.name) == lowerJoin(s.instanceLabels())) { + ptrOnly = false + } + } + directHost := false + directInst := false + for _, q := range qs { + if lowerJoin(q.name) == lowerJoin(s.hostLabels()) { + directHost = true + } + if lowerJoin(q.name) == lowerJoin(s.instanceLabels()) { + directInst = true + } + } + for _, rr := range an { + if rr.typ == dnsTypePTR { + ans = append(ans, rr) + continue + } + if directInst || !ptrOnly { + ans = append(ans, rr) + } else { + extra = append(extra, rr) + } + } + if directHost { + ans = append(ans, ar...) + } else { + extra = append(extra, ar...) + } + _ = answers + _ = add + return encodeMessage(id, 0x8400, nil, ans, extra) +} + +type dnsRR struct { + name []string + typ uint16 + class uint16 + ttl uint32 + ptr []string + txt []string + srvPort int + srvTarget []string + ip net.IP +} + +func encodeMessage(id, flags uint16, qs []dnsQuestion, ans, extra []dnsRR) []byte { + var body bytes.Buffer + comp := map[string]int{} + // header is 12 bytes; compression offsets are absolute. + base := 12 + writeName := func(labels []string) { + body.Write(encodeNameAt(labels, comp, base+body.Len())) + } + for _, q := range qs { + writeName(q.name) + _ = binary.Write(&body, binary.BigEndian, q.typ) + _ = binary.Write(&body, binary.BigEndian, q.class) + } + writeRR := func(rr dnsRR) { + writeName(rr.name) + _ = binary.Write(&body, binary.BigEndian, rr.typ) + _ = binary.Write(&body, binary.BigEndian, rr.class) + _ = binary.Write(&body, binary.BigEndian, rr.ttl) + rdata := rr.rdata(comp, base+body.Len()+2) // +2 for the rdlength we haven't written + // rdata name compression offsets must account for rdlength. Recompute + // with a placeholder by encoding rdata names relative to the position + // after rdlength. encodeNameAt uses the map which may already have + // entries. Passing the future offset is correct if rdata() uses it. + _ = binary.Write(&body, binary.BigEndian, uint16(len(rdata))) + body.Write(rdata) + } + // The offset passed into rdata is wrong by construction if rdata encodes + // names, because comp offsets must match the final position. Do a simpler + // uncompressed rdata for SRV/PTR targets: still valid, and tests check names. + for _, rr := range ans { + writeRR(rr) + } + anCount := len(ans) + for _, rr := range extra { + writeRR(rr) + } + var hdr [12]byte + binary.BigEndian.PutUint16(hdr[0:2], id) + binary.BigEndian.PutUint16(hdr[2:4], flags) + binary.BigEndian.PutUint16(hdr[4:6], uint16(len(qs))) + binary.BigEndian.PutUint16(hdr[6:8], uint16(anCount)) + binary.BigEndian.PutUint16(hdr[8:10], 0) + binary.BigEndian.PutUint16(hdr[10:12], uint16(len(extra))) + return append(hdr[:], body.Bytes()...) +} + +func (rr dnsRR) rdata(comp map[string]int, off int) []byte { + var b bytes.Buffer + switch rr.typ { + case dnsTypePTR: + b.Write(encodeNameAt(rr.ptr, comp, off)) + case dnsTypeSRV: + _ = binary.Write(&b, binary.BigEndian, uint16(0)) + _ = binary.Write(&b, binary.BigEndian, uint16(0)) + _ = binary.Write(&b, binary.BigEndian, uint16(rr.srvPort)) + b.Write(encodeNameAt(rr.srvTarget, comp, off+6)) + case dnsTypeTXT: + if len(rr.txt) == 0 { + b.WriteByte(0) + break + } + for _, t := range rr.txt { + if len(t) > 255 { + t = t[:255] + } + b.WriteByte(byte(len(t))) + b.WriteString(t) + } + case dnsTypeA: + ip := rr.ip.To4() + b.Write(ip) + case dnsTypeAAAA: + b.Write(rr.ip.To16()) + } + return b.Bytes() +} + +func encodeNameRaw(labels []string) []byte { + var b bytes.Buffer + for _, l := range labels { + if len(l) > 63 { + l = l[:63] + } + b.WriteByte(byte(len(l))) + b.WriteString(l) + } + b.WriteByte(0) + return b.Bytes() +} + +func encodeNameAt(labels []string, comp map[string]int, off int) []byte { + // Compression: if the remaining name was already written, emit a pointer. + key := lowerJoin(labels) + if prev, ok := comp[key]; ok && prev < 0x3FFF { + return []byte{0xC0 | byte(prev>>8), byte(prev)} + } + if len(labels) == 0 { + return []byte{0} + } + comp[key] = off + lab := labels[0] + if len(lab) > 63 { + lab = lab[:63] + } + rest := encodeNameAt(labels[1:], comp, off+1+len(lab)) + out := make([]byte, 0, 1+len(lab)+len(rest)) + out = append(out, byte(len(lab))) + out = append(out, lab...) + out = append(out, rest...) + return out +} + +func encodeName(labels []string, comp map[string]int, off int) []byte { + return encodeNameAt(labels, comp, off) +} + +func parseQuestions(msg []byte) ([]dnsQuestion, error) { + if len(msg) < 12 { + return nil, fmt.Errorf("short") + } + n := int(binary.BigEndian.Uint16(msg[4:6])) + off := 12 + var qs []dnsQuestion + for i := 0; i < n; i++ { + labels, next, err := parseName(msg, off) + if err != nil { + return nil, err + } + off = next + if off+4 > len(msg) { + return nil, fmt.Errorf("truncated question") + } + typ := binary.BigEndian.Uint16(msg[off : off+2]) + class := binary.BigEndian.Uint16(msg[off+2:off+4]) & 0x7FFF + off += 4 + qs = append(qs, dnsQuestion{name: labels, typ: typ, class: class}) + } + return qs, nil +} + +func parseName(msg []byte, off int) ([]string, int, error) { + var labels []string + jumped := false + end := off + hops := 0 + for { + if off >= len(msg) { + return nil, 0, fmt.Errorf("name overflow") + } + l := int(msg[off]) + if l == 0 { + if !jumped { + end = off + 1 + } + return labels, end, nil + } + if l&0xC0 == 0xC0 { + if off+1 >= len(msg) { + return nil, 0, fmt.Errorf("bad pointer") + } + ptr := int(l&0x3F)<<8 | int(msg[off+1]) + if !jumped { + end = off + 2 + } + off = ptr + jumped = true + hops++ + if hops > 16 { + return nil, 0, fmt.Errorf("pointer loop") + } + continue + } + if l&0xC0 != 0 { + return nil, 0, fmt.Errorf("bad label") + } + off++ + if off+l > len(msg) { + return nil, 0, fmt.Errorf("label overflow") + } + labels = append(labels, string(msg[off:off+l])) + off += l + if !jumped { + end = off + } + } +} + +// Query builds a single-question mDNS query (for probes and tests). +func Query(id uint16, labels []string, typ uint16) []byte { + return encodeMessage(id, 0, []dnsQuestion{{name: labels, typ: typ, class: dnsClassIN}}, nil, nil) +} + +// QueryUnicast is Query with the QU bit set (unicast response requested). +func QueryUnicast(id uint16, labels []string, typ uint16) []byte { + return encodeMessage(id, 0, []dnsQuestion{{name: labels, typ: typ, class: dnsClassIN | 0x8000}}, nil, nil) +} + +// queryUnicast reports whether any question asked for a unicast reply. +func queryUnicast(msg []byte) bool { + if len(msg) < 12 { + return false + } + n := int(binary.BigEndian.Uint16(msg[4:6])) + off := 12 + for i := 0; i < n; i++ { + _, next, err := parseName(msg, off) + if err != nil || next+4 > len(msg) { + return false + } + if binary.BigEndian.Uint16(msg[next+2:next+4])&0x8000 != 0 { + return true + } + off = next + 4 + } + return false +} + +func lanIPs() []net.IP { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var out []net.IP + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + ip := addrIP(a) + if !keepLAN(ip) { + continue + } + if v4 := ip.To4(); v4 != nil { + out = append(out, v4) + continue + } + if v6 := ip.To16(); v6 != nil { + out = append(out, v6) + } + } + } + return out +} + +// keepLAN reports whether ip may be published in our own A/AAAA records. +// Link-local addresses (fe80::/10 and 169.254.0.0/16) are omitted. Windows +// still publishes fe80 for the computer name that the SRV target uses; the +// IPP listener accepts that address instead of leaving the connect unanswered. +func keepLAN(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + return ip.To4() != nil || ip.To16() != nil +} + +func addrIP(a net.Addr) net.IP { + switch v := a.(type) { + case *net.IPNet: + return v.IP + case *net.IPAddr: + return v.IP + default: + return nil + } +} + +func lanIPv4() []net.IP { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var out []net.IP + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + var ip net.IP + switch v := a.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip == nil || ip.IsLoopback() { + continue + } + if v4 := ip.To4(); v4 != nil { + out = append(out, v4) + } + } + } + return out +} + +func hostLabel(override string) string { + if override != "" { + return sanitizeLabel(override) + } + h, err := os.Hostname() + if err != nil || h == "" { + return "verae-timestamp" + } + if i := strings.IndexByte(h, '.'); i > 0 { + h = h[:i] + } + return sanitizeLabel(h) +} + +// newUUID returns a random UUID string (version 4 layout). +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("verae-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/netprint/mdns_dnssd_test.go b/internal/netprint/mdns_dnssd_test.go new file mode 100644 index 0000000..9c93b8b --- /dev/null +++ b/internal/netprint/mdns_dnssd_test.go @@ -0,0 +1,499 @@ +package netprint + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" +) + +// fakeDNS is a DnsServiceRegister stand-in. CI runs on Linux, so the Windows +// strategy is invoked through advertiseForOS("windows", ..., fakeDNS). +type fakeDNS struct { + mu sync.Mutex + registered []dnsSDInstance + deregistered []dnsSDInstance + err error + failSubtypes bool + conflicts chan struct{} + conflictOn string +} + +func (f *fakeDNS) Register(inst dnsSDInstance) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failSubtypes && len(f.registered) > 0 { + return f.err + } + if f.err != nil && !f.failSubtypes { + return f.err + } + f.registered = append(f.registered, inst) + if f.conflictOn != "" && inst.Name == f.conflictOn && f.conflicts != nil { + select { + case f.conflicts <- struct{}{}: + default: + } + } + return nil +} + +func (f *fakeDNS) Conflicts() <-chan struct{} { return f.conflicts } + +func (f *fakeDNS) Deregister(inst dnsSDInstance) error { + f.mu.Lock() + defer f.mu.Unlock() + f.deregistered = append(f.deregistered, inst) + return nil +} + +func (f *fakeDNS) names() []string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]string, len(f.registered)) + for i, inst := range f.registered { + out[i] = inst.InstanceName + } + return out +} + +func (f *fakeDNS) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.registered) +} + +func sampleService() Service { + return Service{ + Instance: "Verae Timestamp", + Host: "host", + Port: 631, + IPs: []net.IP{net.ParseIP("192.168.1.184")}, + TXT: []string{"ty=Verae Timestamp", "rp=ipp/print"}, + } +} + +func TestWindowsMDNSUsesDnsServiceRegister(t *testing.T) { + pkt := &fakePkt{} + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { + return pkt, nil + } + t.Cleanup(func() { listenMDNSFn = prev }) + + api := &fakeDNS{} + var mu sync.Mutex + var got mdnsReport + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, func(r mdnsReport) { + mu.Lock() + got = r + mu.Unlock() + }) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + detail := got.Detail + mu.Unlock() + if api.count() >= 1 && pkt.count() >= 1 && strings.Contains(detail, "_universal._sub") { + break + } + time.Sleep(10 * time.Millisecond) + } + if api.count() != 1 || pkt.count() < 1 { + t.Fatalf("registered %d announcements %d", api.count(), pkt.count()) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("advertiseForOS did not return after cancel") + } + mu.Lock() + report := got + mu.Unlock() + if report.Mode != "dns-sd" || !report.Advertising || report.Err != "" { + t.Fatalf("status report %+v", report) + } + if !strings.Contains(report.Detail, "DnsServiceRegister") || !strings.Contains(report.Detail, "631") || !strings.Contains(report.Detail, "_universal._sub") { + t.Fatalf("detail %q", report.Detail) + } + if strings.Contains(report.Detail, "PTR _ipp._tcp") { + t.Fatalf("UDP responder still claims the base PTR: %q", report.Detail) + } + gotNames := api.names() + if len(gotNames) != 1 || gotNames[0] != "Verae Timestamp._ipp._tcp.local" { + t.Fatalf("registered %v", gotNames) + } + for _, name := range gotNames { + if strings.Contains(name, "_sub") || strings.Contains(name, ",") { + t.Fatalf("subtype service was registered: %s", name) + } + } + api.mu.Lock() + defer api.mu.Unlock() + if api.registered[0].Port != 631 || api.registered[0].Host != "host.local" { + t.Fatalf("instance %+v", api.registered) + } + if len(api.deregistered) != 1 { + t.Fatalf("deregistered %d", len(api.deregistered)) + } + ann := pkt.packet(0) + if err := assertSubtypePTRsOnly(ann, "Verae Timestamp"); err != nil { + t.Fatal(err) + } +} + +func TestWindowsDNSFailureAdvertisesFullService(t *testing.T) { + pkt := &fakePkt{} + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { return pkt, nil } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{err: errors.New("dnsapi unavailable")} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && pkt.count() < 1 { + time.Sleep(10 * time.Millisecond) + } + if pkt.count() < 1 { + t.Fatal("no announcement after DnsServiceRegister failed") + } + if err := assertPrimaryPTRs(pkt.packet(0), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("advertiseForOS did not return") + } +} + +func TestWindowsDNSFailureFallsBackToSharedUDP(t *testing.T) { + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { + return nil, errors.New("Only one usage of each socket address (protocol/network address/port) is normally permitted") + } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{err: errors.New("dnsapi unavailable")} + err := advertiseForOS("windows", context.Background(), func() Service { return sampleService() }, api, nil) + if err == nil { + t.Fatal("expected a combined error") + } + msg := err.Error() + if !strings.Contains(msg, "DnsServiceRegister") || !strings.Contains(msg, "5353") { + t.Fatalf("error %q", msg) + } + if api.count() != 0 { + t.Fatal("primary registration should not be recorded after failure") + } +} + +func TestWindowsSubtypeFailureStillAdvertises(t *testing.T) { + // Subtype names are not registered. The shared socket carries them. + // A second DnsServiceRegister must not be attempted. + prev := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { + return &fakePkt{}, nil + } + t.Cleanup(func() { listenMDNSFn = prev }) + api := &fakeDNS{err: errors.New("subtype rejected"), failSubtypes: true} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- advertiseForOS("windows", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && api.count() < 1 { + time.Sleep(10 * time.Millisecond) + } + if api.count() != 1 { + t.Fatalf("primary count %d names %v", api.count(), api.names()) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestWindowsAdvertiseEntrypoint(t *testing.T) { + b, err := os.ReadFile("advertise_windows.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + if !strings.Contains(src, `advertiseForOS("windows"`) || !strings.Contains(src, "windowsDNSService{}") { + t.Fatal("Windows advertisePlatform must call DnsServiceRegister via advertiseForOS") + } +} + +func TestStatusShowsWindowsDNSDSuccess(t *testing.T) { + api := &fakeDNS{} + restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("windows", ctx, current, api, sink) + }) + defer restore() + prevListen := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { + return &fakePkt{}, nil + } + defer func() { listenMDNSFn = prevListen }() + + cfg, path := testConfig(t) + on := true + cfg.Advertise = &on + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + st := waitMDNS(t, s, "dns-sd") + if !st.MDNSAdvertising || st.MDNSError != "" { + t.Fatalf("%+v", st) + } + if api.count() < 1 || !strings.Contains(api.names()[0], "._ipp._tcp.local") { + t.Fatalf("Windows path was not invoked: %v", api.names()) + } + body := getURL(t, s.ControlURL()+"api/v1/status") + for _, needle := range []string{`"mdnsAdvertising": true`, `"mdnsMode": "dns-sd"`} { + if !strings.Contains(body, needle) { + t.Fatalf("status missing %s\n%s", needle, body) + } + } +} + +func TestStatusShowsMDNSFailure(t *testing.T) { + restore := swapAdvertiser(t, func(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("windows", ctx, current, &fakeDNS{err: errors.New("dnsapi unavailable")}, sink) + }) + defer restore() + prevListen := listenMDNSFn + listenMDNSFn = func() (mdnsConn, error) { + return nil, errors.New("Only one usage of each socket address") + } + defer func() { listenMDNSFn = prevListen }() + + cfg, path := testConfig(t) + on := true + cfg.Advertise = &on + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + st := waitMDNS(t, s, "error") + if st.MDNSAdvertising { + t.Fatal("advertising should be false") + } + if !strings.Contains(st.MDNSError, "DnsServiceRegister") || !strings.Contains(st.MDNSError, "5353") { + t.Fatalf("mdnsError %q", st.MDNSError) + } + body := getURL(t, s.ControlURL()+"api/v1/status") + if !strings.Contains(body, `"mdnsMode": "error"`) || !strings.Contains(body, "DnsServiceRegister") { + t.Fatalf("status API hid the mDNS error\n%s", body) + } +} + +func TestServiceLogWritesStartupAndJobs(t *testing.T) { + dir := t.TempDir() + t.Setenv("VERAE_LOG_DIR", dir) + prevW := log.Writer() + prevFlags := log.Flags() + t.Cleanup(func() { + log.SetOutput(prevW) + log.SetFlags(prevFlags) + }) + setupServiceLog() + log.Printf("netprint: starting") + st, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + ProcessJob(st, Config{}, nil, 7, "application/pdf", "Hello", "ada", []byte("%PDF-1.4"), func(string, string, string) (job.Result, error) { + return job.Result{}, fmt.Errorf("boom") + }, nil, nil) + b, err := os.ReadFile(filepath.Join(dir, "netprint.log")) + if err != nil { + t.Fatal(err) + } + text := string(b) + for _, needle := range []string{"netprint: log file", "netprint: starting", "job 7 started", "job 7 failed"} { + if !strings.Contains(text, needle) { + t.Fatalf("log missing %q\n%s", needle, text) + } + } +} + +func TestTrayDiscoveryAndPublicWarning(t *testing.T) { + st := Status{ + Running: true, Name: "Verae Timestamp", Port: 631, + MDNSMode: "error", MDNSError: "DnsServiceRegister: dnsapi unavailable; shared UDP 5353: bind failed", + NetworkPublic: true, SupportsPublic: true, + } + var sawDiscovery, sawWarn, sawPublic bool + for _, e := range BuildMenu(st) { + if strings.HasPrefix(e.Label, "Discovery — not advertising:") { + sawDiscovery = true + if !strings.Contains(e.Label, "DnsServiceRegister") { + t.Fatal(e.Label) + } + } + if strings.Contains(e.Label, "This network is Public") { + sawWarn = true + } + if e.Kind == "public" { + sawPublic = true + if !e.Enabled || e.Checked { + t.Fatalf("public entry %+v", e) + } + } + } + if !sawDiscovery || !sawWarn || !sawPublic { + t.Fatalf("discovery %v warn %v public %v", sawDiscovery, sawWarn, sawPublic) + } + if firewallProfile(false) != "private,domain" || firewallProfile(true) != "private,domain,public" { + t.Fatalf("profiles %q %q", firewallProfile(false), firewallProfile(true)) + } +} + +func TestAllowPublicRoundTrip(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + res, err := httpPost(t, s.ControlURL()+"api/v1/firewall", `{"allowPublic":true}`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(res, `"allowPublic": true`) { + t.Fatal(res) + } + body := getURL(t, s.ControlURL()+"api/v1/status") + if !strings.Contains(body, `"allowPublic": true`) { + t.Fatal(body) + } +} + +func TestConflictNameAndDarwinRename(t *testing.T) { + base := "Verae Timestamp (Georges-MacBook-Pro)" + if conflictName(base, 1) != base || conflictName(base, 2) != base+" (2)" { + t.Fatalf("%q %q", conflictName(base, 1), conflictName(base, 2)) + } + api := &fakeDNS{conflicts: make(chan struct{}, 1), conflictOn: "Verae Timestamp"} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- advertiseForOS("darwin", ctx, func() Service { return sampleService() }, api, nil) + }() + deadline := time.Now().Add(2 * time.Second) + var names []string + for time.Now().Before(deadline) { + names = api.names() + if strings.Contains(strings.Join(names, " "), "(2)") { + break + } + time.Sleep(10 * time.Millisecond) + } + if !strings.Contains(strings.Join(names, " "), "Verae Timestamp (2)._ipp._tcp.local") { + t.Fatalf("names %v", names) + } + cancel() + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func swapAdvertiser(t *testing.T, fn func(context.Context, func() Service, mdnsSink) error) func() { + t.Helper() + prev := startAdvertiser + startAdvertiser = fn + return func() { startAdvertiser = prev } +} + +func waitMDNS(t *testing.T, s *Server, mode string) Status { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + var st Status + for time.Now().Before(deadline) { + st = s.status() + if st.MDNSMode == mode { + return st + } + time.Sleep(15 * time.Millisecond) + } + t.Fatalf("mdns mode %q, want %q (%+v)", st.MDNSMode, mode, st) + return st +} + +func getURL(t *testing.T, raw string) string { + t.Helper() + res, err := http.Get(raw) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %d %s", raw, res.StatusCode, b) + } + return string(b) +} + +func httpPost(t *testing.T, raw, body string) (string, error) { + t.Helper() + res, err := http.Post(raw, "application/json", strings.NewReader(body)) + if err != nil { + return "", err + } + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + if res.StatusCode >= 300 { + return "", fmt.Errorf("POST %s: %d %s", raw, res.StatusCode, b) + } + return string(b), nil +} diff --git a/internal/netprint/mdns_packet_test.go b/internal/netprint/mdns_packet_test.go new file mode 100644 index 0000000..e6524a1 --- /dev/null +++ b/internal/netprint/mdns_packet_test.go @@ -0,0 +1,507 @@ +package netprint + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "strings" + "sync" + "testing" + "time" +) + +type fakePkt struct { + mu sync.Mutex + writes [][]byte + once sync.Once + closed chan struct{} + closeMu sync.Once +} + +func (f *fakePkt) ensure() { + f.once.Do(func() { f.closed = make(chan struct{}) }) +} + +func (f *fakePkt) ReadFromUDP([]byte) (int, *net.UDPAddr, error) { + f.ensure() + <-f.closed + return 0, nil, net.ErrClosed +} + +func (f *fakePkt) WriteToUDP(b []byte, _ *net.UDPAddr) (int, error) { + f.ensure() + f.mu.Lock() + f.writes = append(f.writes, append([]byte(nil), b...)) + f.mu.Unlock() + return len(b), nil +} + +func (f *fakePkt) SetReadDeadline(time.Time) error { return nil } + +func (f *fakePkt) Close() error { + f.ensure() + f.closeMu.Do(func() { close(f.closed) }) + return nil +} + +func (f *fakePkt) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.writes) +} + +func (f *fakePkt) packet(i int) []byte { + f.mu.Lock() + defer f.mu.Unlock() + return f.writes[i] +} + +type rrView struct { + name string + typ uint16 + ttl uint32 + ptr string + port int + target string + txt []string + ip net.IP +} + +func parsePacket(msg []byte) (ans, extra []rrView, compressed bool, err error) { + if len(msg) < 12 { + return nil, nil, false, fmt.Errorf("short") + } + qd := int(binary.BigEndian.Uint16(msg[4:6])) + an := int(binary.BigEndian.Uint16(msg[6:8])) + ns := int(binary.BigEndian.Uint16(msg[8:10])) + ar := int(binary.BigEndian.Uint16(msg[10:12])) + off := 12 + for i := 0; i < qd; i++ { + _, off, err = skipQuestion(msg, off) + if err != nil { + return nil, nil, false, err + } + } + read := func(n int) ([]rrView, error) { + var out []rrView + for i := 0; i < n; i++ { + var rr rrView + var jumped bool + rr, off, jumped, err = parseRR(msg, off) + if err != nil { + return nil, err + } + if jumped { + compressed = true + } + out = append(out, rr) + } + return out, nil + } + if ans, err = read(an); err != nil { + return nil, nil, false, err + } + if _, err = read(ns); err != nil { + return nil, nil, false, err + } + if extra, err = read(ar); err != nil { + return nil, nil, false, err + } + return ans, extra, compressed, nil +} + +func skipQuestion(msg []byte, off int) (int, int, error) { + _, next, err := parseName(msg, off) + if err != nil { + return 0, 0, err + } + if next+4 > len(msg) { + return 0, 0, fmt.Errorf("truncated question") + } + return 0, next + 4, nil +} + +func parseRR(msg []byte, off int) (rrView, int, bool, error) { + labels, next, err := parseName(msg, off) + jumped := nameUsesPointer(msg, off) + if err != nil { + return rrView{}, 0, false, err + } + off = next + if off+10 > len(msg) { + return rrView{}, 0, false, fmt.Errorf("truncated rr") + } + rr := rrView{ + name: strings.ToLower(strings.Join(labels, ".")), + typ: binary.BigEndian.Uint16(msg[off : off+2]), + ttl: binary.BigEndian.Uint32(msg[off+4 : off+8]), + } + rdlen := int(binary.BigEndian.Uint16(msg[off+8 : off+10])) + off += 10 + if off+rdlen > len(msg) { + return rrView{}, 0, false, fmt.Errorf("truncated rdata") + } + rdata := msg[off : off+rdlen] + off += rdlen + switch rr.typ { + case dnsTypePTR: + pl, _, err := parseName(msg, off-rdlen) + if err != nil { + return rrView{}, 0, false, err + } + if nameUsesPointer(msg, off-rdlen) { + jumped = true + } + rr.ptr = strings.ToLower(strings.Join(pl, ".")) + case dnsTypeSRV: + if len(rdata) < 6 { + return rrView{}, 0, false, fmt.Errorf("short srv") + } + rr.port = int(binary.BigEndian.Uint16(rdata[4:6])) + tl, _, err := parseName(msg, off-rdlen+6) + if err != nil { + return rrView{}, 0, false, err + } + if nameUsesPointer(msg, off-rdlen+6) { + jumped = true + } + rr.target = strings.ToLower(strings.Join(tl, ".")) + case dnsTypeTXT: + for i := 0; i < len(rdata); { + n := int(rdata[i]) + i++ + if i+n > len(rdata) { + return rrView{}, 0, false, fmt.Errorf("bad txt") + } + rr.txt = append(rr.txt, string(rdata[i:i+n])) + i += n + } + case dnsTypeA: + if len(rdata) == 4 { + rr.ip = net.IP(rdata) + } + case dnsTypeAAAA: + if len(rdata) == 16 { + rr.ip = net.IP(rdata) + } + } + return rr, off, jumped, nil +} + +func nameUsesPointer(msg []byte, off int) bool { + for hops := 0; hops < 16 && off < len(msg); hops++ { + l := int(msg[off]) + if l == 0 { + return false + } + if l&0xC0 == 0xC0 { + return true + } + if l&0xC0 != 0 || off+1+l > len(msg) { + return false + } + off += 1 + l + } + return false +} + +func assertPrimaryPTRs(msg []byte, instance string) error { + ans, extra, compressed, err := parsePacket(msg) + if err != nil { + return err + } + want := strings.ToLower(instance) + "._ipp._tcp.local" + need := map[string]bool{ + "_ipp._tcp.local": false, + "_universal._sub._ipp._tcp.local": false, + "_print._sub._ipp._tcp.local": false, + } + var sawSRV, sawTXT, sawA bool + for _, rr := range append(ans, extra...) { + if rr.typ == dnsTypePTR { + if _, ok := need[rr.name]; ok { + if rr.ptr != want { + return fmt.Errorf("PTR %s target %q, want %q", rr.name, rr.ptr, want) + } + need[rr.name] = true + } + } + if rr.typ == dnsTypeSRV && rr.port == 631 && rr.target == "host.local" && rr.name == want { + sawSRV = true + } + if rr.typ == dnsTypeTXT && rr.name == want { + joined := strings.Join(rr.txt, "\n") + if strings.Contains(joined, "rp=ipp/print") { + sawTXT = true + } + } + if rr.typ == dnsTypeA && rr.ip.Equal(net.ParseIP("192.168.1.184")) && rr.name == "host.local" { + sawA = true + } + } + for name, ok := range need { + if !ok { + return fmt.Errorf("missing PTR %s", name) + } + } + if !sawSRV || !sawTXT || !sawA { + return fmt.Errorf("srv %v txt %v a %v", sawSRV, sawTXT, sawA) + } + if !compressed { + return fmt.Errorf("announcement did not compress a repeated name") + } + return nil +} + +func assertSubtypePTRsOnly(msg []byte, instance string) error { + ans, extra, _, err := parsePacket(msg) + if err != nil { + return err + } + want := strings.ToLower(instance) + "._ipp._tcp.local" + need := map[string]bool{ + "_universal._sub._ipp._tcp.local": false, + "_print._sub._ipp._tcp.local": false, + } + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_ipp._tcp.local" { + return fmt.Errorf("base _ipp._tcp PTR was advertised") + } + if _, ok := need[rr.name]; ok { + if rr.ptr != want { + return fmt.Errorf("PTR %s target %q, want %q", rr.name, rr.ptr, want) + } + need[rr.name] = true + } + case dnsTypeSRV, dnsTypeTXT, dnsTypeA, dnsTypeAAAA: + return fmt.Errorf("unique record type %d name %s", rr.typ, rr.name) + } + } + for name, ok := range need { + if !ok { + return fmt.Errorf("missing PTR %s", name) + } + } + return nil +} + +func TestSubtypeOnlyResponderSkipsPrimaryRecords(t *testing.T) { + svc := sampleService() + svc.SubtypesOnly = true + if err := assertSubtypePTRsOnly(svc.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + bye := svc.Announce(true) + if err := assertSubtypePTRsOnly(bye, "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, _, _, err := parsePacket(bye) + if err != nil { + t.Fatal(err) + } + var ptrs int + for _, rr := range ans { + if rr.typ == dnsTypePTR { + ptrs++ + if rr.ttl != 0 { + t.Fatalf("goodbye ttl %d", rr.ttl) + } + } + } + if ptrs != 2 { + t.Fatalf("goodbye ptrs %d", ptrs) + } + if svc.Reply(Query(1, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) != nil { + t.Fatal("answered base _ipp._tcp PTR") + } + if svc.Reply(Query(1, svc.instanceLabels(), dnsTypeSRV)) != nil { + t.Fatal("answered SRV") + } + if svc.Reply(Query(1, svc.instanceLabels(), dnsTypeTXT)) != nil { + t.Fatal("answered TXT") + } + if svc.Reply(Query(1, svc.hostLabels(), dnsTypeA)) != nil { + t.Fatal("answered A") + } + if svc.Reply(Query(1, svc.hostLabels(), dnsTypeAAAA)) != nil { + t.Fatal("answered AAAA") + } + resp := svc.Reply(Query(1, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR)) + ans, extra, _, err := parsePacket(resp) + if err != nil { + t.Fatal(err) + } + var sawUniv bool + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_universal._sub._ipp._tcp.local" && rr.ptr == "verae timestamp._ipp._tcp.local" { + sawUniv = true + } + if rr.name == "_ipp._tcp.local" { + t.Fatal("subtype reply included the base PTR") + } + case dnsTypeSRV, dnsTypeTXT, dnsTypeA, dnsTypeAAAA: + t.Fatalf("subtype reply included type %d", rr.typ) + } + } + if !sawUniv { + t.Fatal("subtype reply missing the _universal PTR") + } + full := sampleService() + if full.Reply(Query(1, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) == nil { + t.Fatal("full responder dropped the base PTR") + } + if err := assertPrimaryPTRs(full.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } +} + +func TestLANIPsSkipLinkLocal(t *testing.T) { + if keepLAN(net.ParseIP("fe80::d5bb:1")) || keepLAN(net.ParseIP("169.254.1.1")) { + t.Fatal("link-local address was kept") + } + if keepLAN(net.ParseIP("127.0.0.1")) || keepLAN(net.ParseIP("::1")) || keepLAN(nil) { + t.Fatal("loopback or nil was kept") + } + if !keepLAN(net.ParseIP("192.168.1.184")) || !keepLAN(net.ParseIP("2001:db8::184")) { + t.Fatal("global address was dropped") + } + for _, ip := range lanIPs() { + if ip.IsLinkLocalUnicast() { + t.Fatalf("lanIPs returned %s", ip) + } + } +} + +func TestMDNSPacketLayout(t *testing.T) { + svc := sampleService() + pkt := svc.Announce(false) + if err := assertPrimaryPTRs(pkt, "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, _, _, err := parsePacket(pkt) + if err != nil { + t.Fatal(err) + } + for _, rr := range ans { + if rr.typ == dnsTypePTR && rr.ttl != mdnsTTL { + t.Fatalf("PTR ttl %d", rr.ttl) + } + } + bye := svc.Announce(true) + ans, _, _, err = parsePacket(bye) + if err != nil { + t.Fatal(err) + } + var ptrs int + for _, rr := range ans { + if rr.typ == dnsTypePTR { + ptrs++ + if rr.ttl != 0 { + t.Fatalf("goodbye ttl %d", rr.ttl) + } + if rr.ptr != "verae timestamp._ipp._tcp.local" { + t.Fatalf("goodbye target %s", rr.ptr) + } + } + } + if ptrs != 3 { + t.Fatalf("goodbye ptrs %d", ptrs) + } + + q := QueryUnicast(9, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR) + if !queryUnicast(q) { + t.Fatal("QU bit not set") + } + if queryUnicast(Query(9, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) { + t.Fatal("plain query reported QU") + } + resp := svc.Reply(q) + if resp == nil { + t.Fatal("no QU reply") + } + ans, extra, _, err := parsePacket(resp) + if err != nil { + t.Fatal(err) + } + found := false + for _, rr := range append(ans, extra...) { + if rr.typ == dnsTypePTR && rr.name == "_universal._sub._ipp._tcp.local" { + found = rr.ptr == "verae timestamp._ipp._tcp.local" + } + } + if !found { + t.Fatal("QU subtype PTR does not point at the primary instance") + } + plain := svc.Reply(Query(3, []string{"_ipp", "_tcp", "local"}, dnsTypePTR)) + if plain == nil { + t.Fatal("plain browse reply missing") + } + if err := func() error { + a, e, _, err := parsePacket(plain) + if err != nil { + return err + } + for _, rr := range append(a, e...) { + if rr.typ == dnsTypePTR && rr.name == "_ipp._tcp.local" && rr.ptr == "verae timestamp._ipp._tcp.local" { + return nil + } + } + return fmt.Errorf("plain PTR target") + }(); err != nil { + t.Fatal(err) + } +} + +func TestIPv6MulticastRequest(t *testing.T) { + b := ipv6GroupReq(7) + if got := net.IP(b[:16]).String(); got != "ff02::fb" { + t.Fatalf("group %s", got) + } + if binary.LittleEndian.Uint32(b[16:]) != 7 { + t.Fatalf("ifindex %d", binary.LittleEndian.Uint32(b[16:])) + } +} + +func TestWindowsListenJoinsBothGroups(t *testing.T) { + src, err := readSource("advertise_windows.go") + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"SO_REUSEADDR", "224.0.0.251", "ff02::fb", "SO_EXCLUSIVEADDRUSE", "ipv6GroupReq"} { + if !strings.Contains(src, needle) { + t.Fatalf("advertise_windows.go missing %s", needle) + } + } +} + +func TestTraySingleInstanceAndLog(t *testing.T) { + if trayLoopExit(0) != "WM_QUIT" || trayLoopExit(-1) != "GetMessage error" { + t.Fatal(trayLoopExit(0)) + } + src, err := readSource("tray_windows.go") + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"CreateMutexW", `Local\VeraeNetPrintTray`, "setupServiceLog", "tray message loop ended", "WM_CLOSE", "tray already running"} { + if !strings.Contains(src, needle) { + t.Fatalf("tray_windows.go missing %s", needle) + } + } + launch, err := readSource("launch_tray_windows.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(launch, "explorer.exe") { + t.Fatal("tray launch must go through Explorer so Task Scheduler does not own the process") + } +} + +func readSource(name string) (string, error) { + raw, err := os.ReadFile(name) + return string(raw), err +} diff --git a/internal/netprint/name_test.go b/internal/netprint/name_test.go new file mode 100644 index 0000000..0a24bb9 --- /dev/null +++ b/internal/netprint/name_test.go @@ -0,0 +1,54 @@ +package netprint + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDefaultServiceNameIsUniquePerHost(t *testing.T) { + want := legacyServiceName + " (" + hostLabel("") + ")" + if got := defaultServiceName(); got != want || got == legacyServiceName || !strings.Contains(got, "(") { + t.Fatalf("default %q", got) + } + d := Defaults() + if d.Name != want { + t.Fatalf("Defaults name %q", d.Name) + } + sample := string(ExampleJSON(t.TempDir())) + if !strings.Contains(sample, `"name": "`+want+`"`) { + t.Fatalf("example config:\n%s", sample) + } + kept := Config{Name: "Kitchen", Port: 631} + if err := kept.normalize(); err != nil || kept.Name != "Kitchen" { + t.Fatalf("%+v %v", kept, err) + } + dir := t.TempDir() + path := filepath.Join(dir, "netprint.json") + if err := os.WriteFile(path, []byte(`{"name":"Verae Timestamp","port":631}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + loaded, err := Load(path, Config{}) + if err != nil { + t.Fatal(err) + } + if loaded.Name != want { + t.Fatalf("legacy name stayed %q", loaded.Name) + } + if err := persistQualifiedName(path, loaded); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(b), want) { + t.Fatalf("not saved: %s %v", b, err) + } + custom := Config{Name: "Kitchen", Port: 631, DataDir: dir} + if err := Save(path, custom); err != nil { + t.Fatal(err) + } + again, err := Load(path, Config{}) + if err != nil || again.Name != "Kitchen" { + t.Fatalf("%+v %v", again, err) + } +} diff --git a/internal/netprint/netprint_test.go b/internal/netprint/netprint_test.go new file mode 100644 index 0000000..063d440 --- /dev/null +++ b/internal/netprint/netprint_test.go @@ -0,0 +1,564 @@ +package netprint + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "image" + "image/jpeg" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/OpenPrinting/goipp" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/hash" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/veraeapi" +) + +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return p +} + +type fakeCtrl struct { + mu sync.Mutex + actions []string + launch bool + tray bool +} + +func (f *fakeCtrl) Start() error { + f.mu.Lock() + f.actions = append(f.actions, "start") + f.mu.Unlock() + return nil +} +func (f *fakeCtrl) Stop() error { + f.mu.Lock() + f.actions = append(f.actions, "stop") + f.mu.Unlock() + return nil +} +func (f *fakeCtrl) Restart() error { + f.mu.Lock() + f.actions = append(f.actions, "restart") + f.mu.Unlock() + return nil +} +func (f *fakeCtrl) LaunchAtLogin() (bool, error) { return f.launch, nil } +func (f *fakeCtrl) SetLaunchAtLogin(on bool) error { + f.launch = on + return nil +} +func (f *fakeCtrl) TrayAtLogin() (bool, error) { return f.tray, nil } +func (f *fakeCtrl) SetTrayAtLogin(on bool) error { + f.tray = on + return nil +} + +func testConfig(t *testing.T) (Config, string) { + t.Helper() + off := false + dir := t.TempDir() + cfg := Defaults() + cfg.Bind = "127.0.0.1" + cfg.Port = freePort(t) + cfg.FallbackPort = 0 + cfg.ControlPort = freePort(t) + cfg.DataDir = dir + cfg.Advertise = &off + cfg.Name = "Verae Timestamp" + path := filepath.Join(dir, "netprint.json") + return cfg, path +} + +func stampMock(t *testing.T) StampFunc { + t.Helper() + return func(pdf, title, user string) (job.Result, error) { + pdfBytes := []byte("%PDF-1.4 certificate") + client := &veraeapi.Client{ + Channel: auth.ChannelZapier, + ZapierBase: "https://zap.test", + XVeraeToken: "xVerae-test", + HTTPDo: func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + if !bytes.Contains(body, []byte("sha256")) && !bytes.Contains(bytes.ToLower(body), []byte("digest")) && len(body) == 0 { + t.Errorf("stamp request had an empty body") + } + b64 := "JVBERi0xLjQgY2VydGlmaWNhdGU=" + raw := `{"receipt_id":"bafyReceipt99","pdfBase64":"` + b64 + `"}` + _ = pdfBytes + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(raw)), Header: make(http.Header)}, nil + }, + } + return job.Run(job.Options{ + SourcePDF: pdf, OutDir: filepath.Dir(pdf), DocumentName: "document.pdf", + Channel: auth.ChannelZapier, Client: client, + }) + } +} + +func ippReq(op goipp.Op, host string) *goipp.Message { + m := goipp.NewRequest(goipp.DefaultVersion, op, 1) + m.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8"))) + m.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en"))) + m.Operation.Add(goipp.MakeAttribute("printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print"))) + return m +} + +func ippCall(t *testing.T, addr string, m *goipp.Message, doc []byte) *goipp.Message { + t.Helper() + b, err := m.EncodeBytes() + if err != nil { + t.Fatal(err) + } + b = append(b, doc...) + res, err := http.Post("http://"+addr+"/ipp/print", goipp.ContentType, bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var out goipp.Message + if err := out.Decode(res.Body); err != nil { + t.Fatal(err) + } + return &out +} + +func hasKeyword(attrs goipp.Attributes, name, want string) bool { + for _, a := range attrs { + if a.Name != name { + continue + } + for _, v := range a.Values { + if v.V.String() == want { + return true + } + } + } + return false +} + +func hasOp(attrs goipp.Attributes, op goipp.Op) bool { + for _, a := range attrs { + if a.Name != "operations-supported" { + continue + } + for _, v := range a.Values { + if n, ok := v.V.(goipp.Integer); ok && int(n) == int(op) { + return true + } + } + } + return false +} + +func TestIPPEverywhereAttributesAndPrint(t *testing.T) { + cfg, path := testConfig(t) + ctrl := &fakeCtrl{launch: true, tray: true} + s, err := Start(cfg, path, stampMock(t), ctrl) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(s.Port())) + + gpa := ippCall(t, addr, ippReq(goipp.OpGetPrinterAttributes, addr), nil) + if goipp.Status(gpa.Code) != goipp.StatusOk { + t.Fatalf("get-printer-attributes %v", goipp.Status(gpa.Code)) + } + var formats []string + attrs := 0 + for _, a := range gpa.Printer { + if a.Name != "document-format-supported" { + continue + } + attrs++ + for _, v := range a.Values { + formats = append(formats, v.V.String()) + } + } + if attrs != 1 || strings.Join(formats, ",") != pdlValue() { + t.Fatalf("document-format-supported attrs=%d values=%v", attrs, formats) + } + for _, kw := range acceptedFormats { + if !hasKeyword(gpa.Printer, "document-format-supported", kw) { + t.Fatalf("missing document-format %s", kw) + } + } + if !hasKeyword(gpa.Printer, "ipp-features-supported", "ipp-everywhere") { + t.Fatal("missing ipp-everywhere") + } + if !hasKeyword(gpa.Printer, "ipp-versions-supported", "2.0") { + t.Fatal("missing ipp 2.0") + } + if !hasKeyword(gpa.Printer, "urf-supported", "V1.4") { + t.Fatal("missing urf-supported") + } + for _, op := range []goipp.Op{goipp.OpPrintJob, goipp.OpValidateJob, goipp.OpGetPrinterAttributes, goipp.OpGetJobs, goipp.OpGetJobAttributes, goipp.OpCancelJob} { + if !hasOp(gpa.Printer, op) { + t.Fatalf("missing operation %s", op) + } + } + bad := ippReq(goipp.OpValidateJob, addr) + bad.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/postscript"))) + if st := goipp.Status(ippCall(t, addr, bad, nil).Code); st == goipp.StatusOk { + t.Fatal("postscript should be rejected") + } + + doc := []byte("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + pj := ippReq(goipp.OpPrintJob, addr) + pj.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("ada@example.com"))) + pj.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("Contract"))) + pj.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + res := ippCall(t, addr, pj, doc) + if goipp.Status(res.Code) != goipp.StatusOk { + t.Fatalf("print-job %v", goipp.Status(res.Code)) + } + + var rec Record + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + list := s.store.List() + if len(list) > 0 && list[0].State == "completed" { + rec = list[0] + break + } + if len(list) > 0 && list[0].State == "aborted" { + t.Fatal(list[0].Error) + } + time.Sleep(20 * time.Millisecond) + } + if rec.State != "completed" { + t.Fatalf("job not completed: %+v", rec) + } + if rec.ReceiptID != "bafyReceipt99" { + t.Fatalf("receipt %q", rec.ReceiptID) + } + got, err := hash.FileSHA256(rec.OriginalPDF) + if err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(rec.OriginalPDF) + if err != nil { + t.Fatal(err) + } + if rec.SHA256 == "" || rec.SHA256 != got || !bytes.Equal(body, doc) { + t.Fatalf("sha %q file %q bytes match %v", rec.SHA256, got, bytes.Equal(body, doc)) + } + if !strings.Contains(rec.VerifyURL, "explorer.verae.ai") || !strings.Contains(rec.VerifyURL, rec.ReceiptID) { + t.Fatalf("verify url %s", rec.VerifyURL) + } + side, _ := os.ReadFile(rec.ReceiptJSON) + if !strings.Contains(string(side), "ada@example.com") { + t.Fatalf("sidecar missing user: %s", side) + } + + page, err := http.Get("http://" + addr + "/") + if err != nil { + t.Fatal(err) + } + htmlBytes, _ := io.ReadAll(page.Body) + page.Body.Close() + html := string(htmlBytes) + if !strings.Contains(html, "bafyReceipt99") || !strings.Contains(html, "explorer.verae.ai") { + t.Fatalf("admin page missing receipt: %s", html) + } + + c := &Client{BaseURL: strings.TrimRight(s.ControlURL(), "/")} + if err := c.Settings("Lobby Printer", "office", 6310); err != nil { + t.Fatal(err) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved), "Lobby Printer") || !strings.Contains(string(saved), "office") { + t.Fatalf("config not written by service: %s", saved) + } + st, err := c.Status() + if err != nil || st.Name != "Lobby Printer" || st.JobsToday < 1 || !st.Running { + t.Fatalf("status %+v err %v", st, err) + } + if err := c.Service("stop"); err != nil { + t.Fatal(err) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if len(ctrl.actions) != 1 || ctrl.actions[0] != "stop" { + t.Fatalf("actions %v", ctrl.actions) + } +} + +func TestRasterRoundTripAndMenu(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("UNIRAST\x00") + _ = binary.Write(&buf, binary.BigEndian, uint32(1)) + hdr := make([]byte, 32) + hdr[0] = 8 + hdr[1] = 4 // W + binary.BigEndian.PutUint32(hdr[12:16], 1) + binary.BigEndian.PutUint32(hdr[16:20], 1) + binary.BigEndian.PutUint32(hdr[20:24], 300) + buf.Write(hdr) + buf.Write([]byte{0x00, 0x00, 0xff}) // one white pixel + pages, err := RasterToPages(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + pdf, err := PagesToPDF(pages) + if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) { + t.Fatalf("pdf %v %q", err, pdf[:min(20, len(pdf))]) + } + + pwg := make([]byte, 4+pwgHeaderSize) + copy(pwg, []byte("RaS2")) + h := pwg[4:] + binary.BigEndian.PutUint32(h[372:], 1) + binary.BigEndian.PutUint32(h[376:], 1) + binary.BigEndian.PutUint32(h[384:], 8) + binary.BigEndian.PutUint32(h[388:], 24) + binary.BigEndian.PutUint32(h[392:], 3) + binary.BigEndian.PutUint32(h[400:], uint32(cspaceSRGB)) + binary.BigEndian.PutUint32(h[276:], 300) + pwg = append(pwg, 0x00, 0x00, 0xff, 0x00, 0x00) // repeat pixel once of 3 bytes? bpp=3, command 0 means 1 pixel + // command 0 + 3 bytes + pages, err = RasterToPages(pwg) + if err != nil { + t.Fatal(err) + } + pdf, err = PagesToPDF(pages) + if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) { + t.Fatal(err) + } + + stopped := BuildMenu(StoppedStatus()) + var startOn, stopOn bool + for _, e := range stopped { + if e.Kind == "start" { + startOn = e.Enabled + } + if e.Kind == "stop" { + stopOn = e.Enabled + } + if e.Kind == "launch" && e.Enabled { + t.Fatal("login toggle should wait until the service is up") + } + if e.Kind == "public" && e.Enabled { + t.Fatal("public toggle should wait until the service is up") + } + } + if !startOn || stopOn { + t.Fatalf("start %v stop %v", startOn, stopOn) + } +} + +func TestMDNSAirPrintReply(t *testing.T) { + txt := AirPrintTXT("Verae Timestamp", "abc-def", "http://192.0.2.10:631/", "", "") + joined := strings.Join(txt, "\n") + for _, k := range []string{"txtvers=1", "rp=ipp/print", "ty=Verae Timestamp", "pdl=" + pdlValue(), "URF=", "UUID=abc-def", "adminurl=http://192.0.2.10:631/", "usb_CMD=PDF,URF,PWG,JPEG"} { + if !strings.Contains(joined, k) { + t.Fatalf("txt missing %s\n%s", k, joined) + } + } + svc := Service{ + Instance: "Verae Timestamp", + Host: "host", + Port: 631, + IPs: []net.IP{net.ParseIP("192.0.2.10")}, + TXT: txt, + } + q := Query(7, []string{"_universal", "_sub", "_ipp", "_tcp", "local"}, dnsTypePTR) + resp := svc.Reply(q) + if resp == nil { + t.Fatal("no reply for universal subtype") + } + if !bytes.Contains(resp, []byte("Verae Timestamp")) { + t.Fatal("reply missing instance") + } + if svc.Reply(svc.Announce(false)) != nil { + t.Fatal("responses must be ignored") + } +} + +func TestMachineToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Setenv("XVERAE_TOKEN", "") + t.Setenv("VERAE_SKIP_KEYCHAIN", "1") + t.Setenv("VERAE_TOKEN_FILE", filepath.Join(dir, "machine")) + if err := auth.SaveMachineToken("xVerae-machine"); err != nil { + t.Fatal(err) + } + got, err := auth.LoadXVeraeToken() + if err != nil || got != "xVerae-machine" { + t.Fatalf("%q %v", got, err) + } +} + +func TestJobIDsPersistAndDoNotReuseFolders(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "job-1"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "job-1", "document-3.pdf"), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "jobs.json"), []byte("[{\"id\":3,\"state\":\"completed\"}]\n"), 0o600); err != nil { + t.Fatal(err) + } + st, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + if st.NextID() != 4 { + t.Fatalf("next %d", st.NextID()) + } + id, err := st.AllocateID() + if err != nil || id != 4 { + t.Fatalf("id %d err %v", id, err) + } + st2, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + id2, err := st2.AllocateID() + if err != nil || id2 != 5 { + t.Fatalf("restarted id %d err %v", id2, err) + } + req := goipp.NewRequest(goipp.DefaultVersion, goipp.OpCreateJob, 1) + req.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("third"))) + p := &Printer{AllocateID: st2.AllocateID} + j, err := p.createJob(req) + if err != nil || j == nil || j.id != 6 { + t.Fatalf("job %+v err %v", j, err) + } + old, err := os.ReadFile(filepath.Join(dir, "job-1", "document-3.pdf")) + if err != nil || string(old) != "old" { + t.Fatalf("job-1 was reused: %q %v", old, err) + } + entries, err := os.ReadDir(filepath.Join(dir, "job-1")) + if err != nil || len(entries) != 1 || entries[0].Name() != "document-3.pdf" { + t.Fatalf("job-1 entries %v %v", entries, err) + } +} + +func TestJPEGAcceptedAndConverted(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatal(err) + } + if got := sniffFormat("application/octet-stream", buf.Bytes()); got != "image/jpeg" { + t.Fatalf("sniff %s", got) + } + pdf, err := documentPDF("image/jpeg", buf.Bytes()) + if err != nil || !bytes.HasPrefix(pdf, []byte("%PDF-")) { + t.Fatalf("pdf %v", err) + } + req := goipp.NewRequest(goipp.DefaultVersion, goipp.OpValidateJob, 1) + req.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("image/jpeg"))) + if st, msg := (&Printer{}).checkFormat(req); st != goipp.StatusOk { + t.Fatalf("%v %s", st, msg) + } + if joined := strings.Join(acceptedFormats, ","); !strings.Contains(pdlValue(), "image/jpeg") || pdlValue() != joined { + t.Fatalf("pdl %s", pdlValue()) + } +} + +type failWriter struct{} + +func (failWriter) Write([]byte) (int, error) { return 0, errors.New("stderr closed") } + +func TestServiceLogWriteSurvivesBrokenStderr(t *testing.T) { + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, "netprint.log")) + if err != nil { + t.Fatal(err) + } + w := &serviceLogWriter{file: f, extra: failWriter{}} + if _, err := w.Write([]byte("netprint: starting\n")); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(filepath.Join(dir, "netprint.log")) + if err != nil { + t.Fatal(err) + } + if string(b) != "netprint: starting\n" { + t.Fatalf("log %q", b) + } +} + +func TestTrayLaunchIsDetachedFromSetup(t *testing.T) { + b, err := os.ReadFile("launch_tray_windows.go") + if err != nil { + t.Fatal(err) + } + src := string(b) + if strings.Contains(src, "CreateProcessWithTokenW") { + t.Fatal("tray launch must not be a child of setup") + } + if !strings.Contains(src, "schtasks") || !strings.Contains(src, "explorer.exe") { + t.Fatal("tray launch should use a scheduled task or Explorer") + } + spec := trayLaunch{exe: `C:\Program Files\Verae\NetPrint\bin\verae-netprint-tray.exe`} + if spec.taskCommand() != `explorer.exe "`+spec.exe+`"` { + t.Fatalf("task command %q", spec.taskCommand()) + } + withArgs := trayLaunch{exe: `C:\Program Files\Verae\PrintStamp\bin\verae-print.exe`, args: []string{"tray"}} + if withArgs.taskCommand() != `"C:\Program Files\Verae\PrintStamp\bin\verae-print.exe" tray` { + t.Fatalf("task command %q", withArgs.taskCommand()) + } +} + +func TestOwnersRefcount(t *testing.T) { + dir := t.TempDir() + // ownersPath is platform-fixed; exercise the JSON helpers by writing the file they read + // only when we can point them at a temp dir. The helpers use a fixed path, so this + // test covers the empty/add/remove logic through a temp copy of the same format. + o := ownerSet{} + if !o.empty() { + t.Fatal("empty") + } + o.Driver = true + o.Standalone = true + b, _ := json.Marshal(o) + var back ownerSet + if err := json.Unmarshal(b, &back); err != nil || back.empty() { + t.Fatal(err) + } + back.Driver = false + if back.empty() { + t.Fatal("standalone should remain") + } + back.Standalone = false + if !back.empty() { + t.Fatal("both removed") + } + _ = dir +} diff --git a/internal/netprint/network_other.go b/internal/netprint/network_other.go new file mode 100644 index 0000000..b43a3cf --- /dev/null +++ b/internal/netprint/network_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package netprint + +func supportsPublicFirewall() bool { return false } + +func cachedNetworkPublic() bool { return false } diff --git a/internal/netprint/network_windows.go b/internal/netprint/network_windows.go new file mode 100644 index 0000000..26b5401 --- /dev/null +++ b/internal/netprint/network_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package netprint + +import ( + "os/exec" + "strings" + "sync" + "time" +) + +func supportsPublicFirewall() bool { return true } + +func networkIsPublic() bool { + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", + `(Get-NetConnectionProfile | ForEach-Object { $_.NetworkCategory }) -join ','`).Output() + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(out)), "public") +} + +var ( + netPublicMu sync.Mutex + netPublicAt time.Time + netPublicVal bool +) + +// cachedNetworkPublic avoids a PowerShell call on every tray poll. +func cachedNetworkPublic() bool { + netPublicMu.Lock() + defer netPublicMu.Unlock() + if !netPublicAt.IsZero() && time.Since(netPublicAt) < 20*time.Second { + return netPublicVal + } + netPublicVal = networkIsPublic() + netPublicAt = time.Now() + return netPublicVal +} diff --git a/internal/netprint/open_darwin.go b/internal/netprint/open_darwin.go new file mode 100644 index 0000000..4ba98e3 --- /dev/null +++ b/internal/netprint/open_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package netprint + +import "os/exec" + +func openURL(raw string) error { + return exec.Command("open", raw).Start() +} diff --git a/internal/netprint/open_other.go b/internal/netprint/open_other.go new file mode 100644 index 0000000..8a889d1 --- /dev/null +++ b/internal/netprint/open_other.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package netprint + +import "os/exec" + +func openURL(raw string) error { + return exec.Command("xdg-open", raw).Start() +} diff --git a/internal/netprint/open_windows.go b/internal/netprint/open_windows.go new file mode 100644 index 0000000..0139266 --- /dev/null +++ b/internal/netprint/open_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package netprint + +import "os/exec" + +func openURL(raw string) error { + return exec.Command("rundll32", "url.dll,FileProtocolHandler", raw).Start() +} diff --git a/internal/netprint/pdf.go b/internal/netprint/pdf.go new file mode 100644 index 0000000..1832926 --- /dev/null +++ b/internal/netprint/pdf.go @@ -0,0 +1,94 @@ +package netprint + +import ( + "bytes" + "compress/flate" + "fmt" +) + +// PagesToPDF wraps decoded RGB pages in a PDF 1.4 document. Each page is an +// Image XObject with Flate compression. Page size is width/height at the +// raster DPI (72 points per inch). +func PagesToPDF(pages []Page) ([]byte, error) { + if len(pages) == 0 { + return nil, fmt.Errorf("no pages") + } + type blob struct { + data []byte + } + var objs []blob + add := func(s string) int { + objs = append(objs, blob{data: []byte(s)}) + return len(objs) + } + addBin := func(dict string, stream []byte) int { + var b bytes.Buffer + fmt.Fprintf(&b, "%s\nstream\n", dict) + b.Write(stream) + if len(stream) == 0 || stream[len(stream)-1] != '\n' { + b.WriteByte('\n') + } + b.WriteString("endstream") + objs = append(objs, blob{data: b.Bytes()}) + return len(objs) + } + + // Object 1 is the catalog, object 2 the pages tree. Children are filled later. + objs = append(objs, blob{}, blob{}) + var kids []int + for i, p := range pages { + if p.Width <= 0 || p.Height <= 0 || len(p.RGB) != p.Width*p.Height*3 { + return nil, fmt.Errorf("page %d: rgb length %d want %d", i+1, len(p.RGB), p.Width*p.Height*3) + } + dpi := p.DPI + if dpi <= 0 { + dpi = 300 + } + w := float64(p.Width) * 72 / float64(dpi) + h := float64(p.Height) * 72 / float64(dpi) + var comp bytes.Buffer + zw, err := flate.NewWriter(&comp, flate.BestSpeed) + if err != nil { + return nil, err + } + if _, err := zw.Write(p.RGB); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + img := addBin(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>", + p.Width, p.Height, comp.Len()), comp.Bytes()) + content := fmt.Sprintf("q\n%.4f 0 0 %.4f 0 0 cm\n/Im0 Do\nQ\n", w, h) + cs := addBin(fmt.Sprintf("<< /Length %d >>", len(content)), []byte(content)) + page := add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.4f %.4f] /Contents %d 0 R /Resources << /XObject << /Im0 %d 0 R >> >> >>", + w, h, cs, img)) + kids = append(kids, page) + } + var kb bytes.Buffer + for i, id := range kids { + if i > 0 { + kb.WriteByte(' ') + } + fmt.Fprintf(&kb, "%d 0 R", id) + } + objs[0] = blob{data: []byte("<< /Type /Catalog /Pages 2 0 R >>")} + objs[1] = blob{data: []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(kids), kb.String()))} + + var out bytes.Buffer + out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") + offs := make([]int, len(objs)) + for i, o := range objs { + offs[i] = out.Len() + fmt.Fprintf(&out, "%d 0 obj\n", i+1) + out.Write(o.data) + out.WriteString("\nendobj\n") + } + xref := out.Len() + fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1) + for _, off := range offs { + fmt.Fprintf(&out, "%010d 00000 n \n", off) + } + fmt.Fprintf(&out, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref) + return out.Bytes(), nil +} diff --git a/internal/netprint/pipeline.go b/internal/netprint/pipeline.go new file mode 100644 index 0000000..e042cea --- /dev/null +++ b/internal/netprint/pipeline.go @@ -0,0 +1,208 @@ +package netprint + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "image/draw" + "image/jpeg" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/pdfcert" +) + +// StampFunc timestamps one PDF with the same client path as the desktop drivers. +// Implementations call job.Run (which hashes the file). They must not prompt. +type StampFunc func(pdfPath, title, user string) (job.Result, error) + +// ProcessJob turns an IPP document into a Verae receipt and optional forward. +// A forward failure does not fail the timestamp. notify runs only after the +// job is stored as completed, and its work must not change that outcome. +func ProcessJob(st *Store, cfg Config, p *Printer, id int, format, title, user string, doc []byte, stamp StampFunc, forward ForwardFunc, notify func(Record)) { + if stamp == nil { + stamp = func(string, string, string) (job.Result, error) { + return job.Result{}, fmt.Errorf("no stamp function") + } + } + if forward == nil { + forward = Forward + } + rec := Record{ + ID: id, Received: time.Now(), User: user, Title: title, Format: format, State: "processing", + } + if err := st.Put(rec); err != nil { + log.Printf("netprint: job %d index: %v", id, err) + } + if p != nil { + p.SetState(id, jobProcessing, "job-printing", "") + } + log.Printf("netprint: job %d started user=%q title=%q format=%s bytes=%d", id, user, title, format, len(doc)) + fail := func(err error) { + log.Printf("netprint: job %d failed: %v", id, err) + rec.State = "aborted" + rec.Error = err.Error() + rec.Completed = time.Now() + if putErr := st.Put(rec); putErr != nil { + log.Printf("netprint: job %d index: %v", id, putErr) + } + if p != nil { + p.SetState(id, jobAborted, "aborted-by-system", err.Error()) + } + } + if p != nil && p.Canceled(id) { + rec.State = "canceled" + rec.Completed = time.Now() + if putErr := st.Put(rec); putErr != nil { + log.Printf("netprint: job %d index: %v", id, putErr) + } + return + } + pdf, err := documentPDF(format, doc) + if err != nil { + fail(err) + return + } + dir := filepath.Join(st.Dir(), fmt.Sprintf("job-%d", id)) + if err := mkdirReceipts(dir); err != nil { + fail(err) + return + } + src := filepath.Join(dir, "document.pdf") + if err := writeReceiptFile(src, pdf); err != nil { + fail(err) + return + } + res, err := stamp(src, title, user) + if err != nil { + fail(err) + return + } + if err := job.RefuseMockReceipt(res.ReceiptID, receiptMerkle(res.ReceiptJSON)); err != nil { + fail(err) + return + } + if user != "" && res.ReceiptJSON != "" { + annotateUser(res.ReceiptJSON, user) + } + rec.SHA256 = res.SHA256 + rec.ReceiptID = res.ReceiptID + rec.VerifyURL = pdfcert.LookupURL(res.SHA256, res.ReceiptID) + rec.CertificatePDF = res.CertificatePDF + rec.ReceiptJSON = res.ReceiptJSON + if cfg.Keep() { + rec.OriginalPDF = res.OriginalPDF + } else if res.OriginalPDF != "" { + _ = os.Remove(res.OriginalPDF) + } + if fp := strings.TrimSpace(cfg.ForwardPrinter); fp != "" && res.CertificatePDF != "" { + // Forward the original document when we still have it, otherwise the certificate. + path := res.OriginalPDF + if !cfg.Keep() || path == "" { + path = res.CertificatePDF + } + if err := forward(fp, path, title); err != nil { + rec.ForwardError = err.Error() + } else { + rec.ForwardedTo = fp + } + } + rec.State = "completed" + rec.Completed = time.Now() + publishReceiptPerms(dir) + if err := st.Put(rec); err != nil { + log.Printf("netprint: job %d index: %v", id, err) + } + log.Printf("netprint: job %d completed receipt=%s sha256=%s", id, rec.ReceiptID, rec.SHA256) + if p != nil { + p.SetState(id, jobCompleted, "job-completed-successfully", "") + } + if notify != nil { + notify(rec) + } +} + +func documentPDF(format string, doc []byte) ([]byte, error) { + f := sniffFormat(format, doc) + switch f { + case "application/pdf": + return doc, nil + case "image/urf", "image/pwg-raster": + pages, err := RasterToPages(doc) + if err != nil { + return nil, err + } + return PagesToPDF(pages) + case "image/jpeg": + pages, err := jpegToPages(doc) + if err != nil { + return nil, err + } + return PagesToPDF(pages) + default: + return nil, fmt.Errorf("unsupported document format %s", f) + } +} + +func jpegToPages(doc []byte) ([]Page, error) { + img, err := jpeg.Decode(bytes.NewReader(doc)) + if err != nil { + return nil, fmt.Errorf("jpeg: %w", err) + } + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w < 1 || h < 1 || w > 20000 || h > 20000 || w*h > maxPagePixels { + return nil, fmt.Errorf("jpeg: dimensions %dx%d", w, h) + } + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + rgb := make([]byte, w*h*3) + for i, j := 0, 0; i < len(dst.Pix); i, j = i+4, j+3 { + rgb[j] = dst.Pix[i] + rgb[j+1] = dst.Pix[i+1] + rgb[j+2] = dst.Pix[i+2] + } + return []Page{{Width: w, Height: h, DPI: 300, RGB: rgb}}, nil +} + +func receiptMerkle(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + b, err := os.ReadFile(path) + if err != nil { + return "" + } + var m map[string]any + if json.Unmarshal(b, &m) != nil || m == nil { + return "" + } + switch v := m["merkle"].(type) { + case string: + return v + default: + return "" + } +} + +func annotateUser(path, user string) { + b, err := os.ReadFile(path) + if err != nil { + return + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil || m == nil { + return + } + m["requesting_user_name"] = user + out, err := json.MarshalIndent(m, "", " ") + if err != nil { + return + } + _ = writeReceiptFile(path, append(out, '\n')) +} diff --git a/internal/netprint/printjob_test.go b/internal/netprint/printjob_test.go new file mode 100644 index 0000000..9d85495 --- /dev/null +++ b/internal/netprint/printjob_test.go @@ -0,0 +1,226 @@ +package netprint + +import ( + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/OpenPrinting/goipp" +) + +func TestMediaColLetterAndA4(t *testing.T) { + p := &Printer{Name: "Verae Timestamp"} + req := ippReq(goipp.OpGetPrinterAttributes, "nimo.local:631") + r, _ := http.NewRequest(http.MethodPost, "http://nimo.local:631/ipp/print", nil) + attrs := p.printerAttrs(req, r) + def := attrByName(attrs, "media-col-default") + if def.Name == "" || len(def.Values) != 1 { + t.Fatal("missing media-col-default") + } + col, ok := def.Values[0].V.(goipp.Collection) + if !ok { + t.Fatalf("media-col-default type %T", def.Values[0].V) + } + if x, y := collectionSize(col); x != 21590 || y != 27940 { + t.Fatalf("letter size %d x %d", x, y) + } + for _, m := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} { + if _, found := collectionInt(col, m); !found { + t.Fatalf("missing %s", m) + } + } + ready := attrByName(attrs, "media-col-ready") + if len(ready.Values) != 2 { + t.Fatalf("media-col-ready %d", len(ready.Values)) + } + a4, ok := ready.Values[1].V.(goipp.Collection) + if !ok { + t.Fatalf("a4 %T", ready.Values[1].V) + } + if x, y := collectionSize(a4); x != 21000 || y != 29700 { + t.Fatalf("a4 size %d x %d", x, y) + } + sizes := attrByName(attrs, "media-size-supported") + if len(sizes.Values) != 2 { + t.Fatalf("media-size-supported %d", len(sizes.Values)) + } + if !hasKeyword(attrs, "media-default", "na_letter_8.5x11in") || !hasKeyword(attrs, "media-ready", "iso_a4_210x297mm") || !hasKeyword(attrs, "media-supported", "na_letter_8.5x11in") { + t.Fatal("media keywords") + } + if !hasKeyword(attrs, "media-col-supported", "media-size") || !hasKeyword(attrs, "media-col-supported", "media-top-margin") { + t.Fatal("media-col-supported") + } + msg := goipp.NewResponse(goipp.DefaultVersion, goipp.StatusOk, 1) + msg.Printer = attrs + raw, err := msg.EncodeBytes() + if err != nil { + t.Fatal(err) + } + var back goipp.Message + if err := back.DecodeBytes(raw); err != nil { + t.Fatal(err) + } + again := attrByName(back.Printer, "media-col-default") + col, ok = again.Values[0].V.(goipp.Collection) + if !ok { + t.Fatalf("round trip %T", again.Values[0].V) + } + if x, y := collectionSize(col); x != 21590 || y != 27940 { + t.Fatalf("round trip size %d x %d", x, y) + } +} + +func attrByName(attrs goipp.Attributes, name string) goipp.Attribute { + for _, a := range attrs { + if a.Name == name { + return a + } + } + return goipp.Attribute{} +} + +func collectionSize(c goipp.Collection) (int, int) { + for _, a := range c { + if a.Name != "media-size" || len(a.Values) == 0 { + continue + } + inner, ok := a.Values[0].V.(goipp.Collection) + if !ok { + return 0, 0 + } + x, _ := collectionInt(inner, "x-dimension") + y, _ := collectionInt(inner, "y-dimension") + return x, y + } + return 0, 0 +} + +func collectionInt(c goipp.Collection, name string) (int, bool) { + for _, a := range c { + if a.Name == name && len(a.Values) > 0 { + if v, ok := a.Values[0].V.(goipp.Integer); ok { + return int(v), true + } + } + } + return 0, false +} + +func TestPrintJobReadsContentLengthWithoutEOF(t *testing.T) { + doc := []byte("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + body := printJobBytes(t, "127.0.0.1", doc) + ln := servePrinter(t, doc) + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + deadline := time.Now().Add(2 * time.Second) + _ = conn.SetDeadline(deadline) + host := ln.Addr().String() + fmt.Fprintf(conn, "POST /ipp/print HTTP/1.1\r\nHost: %s\r\nContent-Type: application/ipp\r\nContent-Length: %d\r\nConnection: keep-alive\r\n\r\n", host, len(body)) + if _, err := conn.Write(body); err != nil { + t.Fatal(err) + } + // The write side stays open. A reader that waits for EOF blocks here. + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil && n == 0 { + t.Fatal(err) + } + if !strings.Contains(string(buf[:n]), "200") { + t.Fatalf("response %q", buf[:n]) + } + if time.Now().After(deadline) { + t.Fatal("print-job exceeded 2s") + } +} + +func TestPrintJobExpect100Continue(t *testing.T) { + doc := []byte("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n") + body := printJobBytes(t, "127.0.0.1", doc) + ln := servePrinter(t, doc) + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + fmt.Fprintf(conn, "POST /ipp/print HTTP/1.1\r\nHost: %s\r\nContent-Type: application/ipp\r\nContent-Length: %d\r\nExpect: 100-continue\r\n\r\n", ln.Addr().String(), len(body)) + hdr := readHTTPHead(t, conn) + if !strings.Contains(hdr, "100") { + t.Fatalf("expected 100 Continue, got %q", hdr) + } + if _, err := conn.Write(body); err != nil { + t.Fatal(err) + } + rest := readHTTPHead(t, conn) + if !strings.Contains(rest, "200") { + t.Fatalf("response %q", rest) + } +} + +func servePrinter(t *testing.T, want []byte) net.Listener { + t.Helper() + started := make(chan []byte, 1) + p := &Printer{ + Name: "Verae Timestamp", + OnDocument: func(id int, format, title, user string, doc []byte) { + started <- append([]byte(nil), doc...) + }, + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: p} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + t.Cleanup(func() { + select { + case got := <-started: + if string(got) != string(want) { + t.Fatalf("document %q", got) + } + case <-time.After(2 * time.Second): + t.Fatal("job did not start") + } + }) + return ln +} + +func printJobBytes(t *testing.T, host string, doc []byte) []byte { + t.Helper() + m := ippReq(goipp.OpPrintJob, host+"/ipp/print") + m.Operation.Add(goipp.MakeAttribute("requesting-user-name", goipp.TagName, goipp.String("ada"))) + m.Operation.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String("note"))) + m.Operation.Add(goipp.MakeAttribute("document-format", goipp.TagMimeType, goipp.String("application/pdf"))) + b, err := m.EncodeBytes() + if err != nil { + t.Fatal(err) + } + return append(b, doc...) +} + +func readHTTPHead(t *testing.T, r io.Reader) string { + t.Helper() + var buf []byte + tmp := make([]byte, 1) + for !strings.Contains(string(buf), "\r\n\r\n") { + n, err := r.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + } + if err != nil { + t.Fatalf("read headers: %v %q", err, buf) + } + if len(buf) > 8192 { + t.Fatalf("headers too long %q", buf) + } + } + return string(buf) +} diff --git a/internal/netprint/pwg_test.go b/internal/netprint/pwg_test.go new file mode 100644 index 0000000..90e9fbd --- /dev/null +++ b/internal/netprint/pwg_test.go @@ -0,0 +1,88 @@ +package netprint + +import ( + "encoding/binary" + "strings" + "testing" +) + +func TestPWGHeaderBigEndian(t *testing.T) { + h := make([]byte, pwgHeaderSize) + put := binary.BigEndian.PutUint32 + put(h[276:], 600) + put(h[372:], 5100) + put(h[376:], 6600) + put(h[384:], 8) + put(h[388:], 24) + put(h[392:], 5100*3) + put(h[396:], 0) + put(h[400:], uint32(cspaceSRGB)) + // 5100 is 00 00 13 EC. A little-endian read is the field failure 0xEC130000. + if binary.LittleEndian.Uint32(h[372:376]) != 0xEC130000 { + t.Fatalf("width bytes %x", h[372:376]) + } + w, ht, dpi, bpc, bpp, bpl, order, cs := pwgFields(h, true) + if w != 5100 || ht != 6600 || dpi != 600 || bpc != 8 || bpp != 24 || bpl != 5100*3 || order != 0 || cs != cspaceSRGB { + t.Fatalf("fields %d %d dpi %d bpc %d bpp %d bpl %d order %d cs %d", w, ht, dpi, bpc, bpp, bpl, order, cs) + } + + small := make([]byte, pwgHeaderSize) + put(small[276:], 600) + put(small[372:], 1) + put(small[376:], 1) + put(small[384:], 8) + put(small[388:], 24) + put(small[392:], 3) + put(small[400:], uint32(cspaceSRGB)) + doc := append([]byte("RaS2"), small...) + doc = append(doc, 0x00, 0x00, 0xff, 0x00, 0x00) // one RGB pixel + pages, err := RasterToPages(doc) + if err != nil { + t.Fatal(err) + } + if pages[0].Width != 1 || pages[0].Height != 1 || pages[0].DPI != 600 { + t.Fatalf("page %+v", pages[0]) + } + + native := make([]byte, pwgHeaderSize) + le := binary.LittleEndian.PutUint32 + le(native[276:], 300) + le(native[372:], 2) + le(native[376:], 1) + le(native[384:], 8) + le(native[388:], 24) + le(native[392:], 6) + le(native[400:], uint32(cspaceSRGB)) + doc = append([]byte("2SaR"), native...) + doc = append(doc, 0x00, 0x01, 0x11, 0x22, 0x33) // one row, two pixels + pages, err = RasterToPages(doc) + if err != nil { + t.Fatal(err) + } + if pages[0].Width != 2 || pages[0].Height != 1 || pages[0].DPI != 300 { + t.Fatalf("native page %+v", pages[0]) + } +} + +func TestPWGTruncatedHeaderRejected(t *testing.T) { + doc := append([]byte("RaS2"), make([]byte, 100)...) + _, err := RasterToPages(doc) + if err == nil || !strings.Contains(err.Error(), "truncated") { + t.Fatalf("err %v", err) + } + h := make([]byte, pwgHeaderSize) + binary.BigEndian.PutUint32(h[372:], 5100) + binary.BigEndian.PutUint32(h[376:], 6600) + binary.BigEndian.PutUint32(h[384:], 8) + binary.BigEndian.PutUint32(h[388:], 24) + binary.BigEndian.PutUint32(h[392:], 5100*3) + binary.BigEndian.PutUint32(h[400:], uint32(cspaceSRGB)) + binary.BigEndian.PutUint32(h[276:], 600) + _, err = RasterToPages(append([]byte("RaS2"), h...)) + if err == nil { + t.Fatal("header without pixels decoded") + } + if strings.Contains(err.Error(), "3960668160") || strings.Contains(err.Error(), "3357081600") { + t.Fatal(err) + } +} diff --git a/internal/netprint/queue_windows.go b/internal/netprint/queue_windows.go new file mode 100644 index 0000000..78cde94 --- /dev/null +++ b/internal/netprint/queue_windows.go @@ -0,0 +1,56 @@ +//go:build windows + +package netprint + +import ( + "fmt" + "log" + "os/exec" + "strings" + "time" +) + +func runPowerShell(script string) error { + out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + msg := strings.TrimSpace(string(out)) + if err != nil { + if msg != "" { + return fmt.Errorf("%w (%s)", err, msg) + } + return err + } + if msg != "" { + log.Printf("netprint: %s", msg) + } + return nil +} + +// waitBoundPort polls the control API until the service reports the IPP port +// it actually bound (631, or 8632 when 631 was taken). +func waitBoundPort(controlPort int) int { + c := NewClient(controlPort) + for i := 0; i < 40; i++ { + st, err := c.Status() + if err == nil && st.Port > 0 { + log.Printf("netprint: local queue will use port %d", st.Port) + return st.Port + } + time.Sleep(250 * time.Millisecond) + } + log.Printf("netprint: status port unavailable; local queue uses 631") + return 631 +} + +// addLocalQueue adds the Windows printer "Verae Timestamp" pointed at this +// machine's IPP listener. An existing queue with that name is left as-is. +func addLocalQueue(port int) error { + if port <= 0 { + port = 631 + } + script := fmt.Sprintf(`$name = 'Verae Timestamp'; if (Get-Printer -Name $name -ErrorAction SilentlyContinue) { 'local queue exists' } else { Add-Printer -IppURL 'http://127.0.0.1:%d/ipp/print' -Name $name; 'local queue added' }`, port) + return runPowerShell(script) +} + +func removeLocalQueue() error { + return runPowerShell(`Remove-Printer -Name 'Verae Timestamp' -ErrorAction SilentlyContinue; 'local queue removed'`) +} diff --git a/internal/netprint/raster.go b/internal/netprint/raster.go new file mode 100644 index 0000000..f9cac72 --- /dev/null +++ b/internal/netprint/raster.go @@ -0,0 +1,350 @@ +package netprint + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" +) + +// Page is one decoded raster page in 8-bit RGB, top to bottom. +type Page struct { + Width int + Height int + DPI int + RGB []byte // width*height*3 +} + +const ( + maxPagePixels = 40_000_000 + maxPages = 100 + pwgHeaderSize = 1796 +) + +// RasterToPages decodes an Apple Raster (image/urf) or PWG Raster +// (image/pwg-raster) document. The line encoding is the modified PackBits +// CUPS uses for both (cups/raster-stream.c): a leading repeat count, then +// pixel runs. +func RasterToPages(doc []byte) ([]Page, error) { + if len(doc) >= 8 && bytes.HasPrefix(doc, []byte("UNIRAST\x00")) { + return decodeURF(doc) + } + if len(doc) >= 4 && (bytes.HasPrefix(doc, []byte("RaS2")) || bytes.HasPrefix(doc, []byte("2SaR"))) { + return decodePWG(doc) + } + return nil, fmt.Errorf("not URF or PWG raster") +} + +func decodeURF(doc []byte) ([]Page, error) { + if len(doc) < 12 { + return nil, fmt.Errorf("urf: short header") + } + nPages := int(binary.BigEndian.Uint32(doc[8:12])) + unknownCount := nPages == 0 || nPages == int(^uint32(0)>>0) || nPages < 0 + if unknownCount { + nPages = maxPages + } + off := 12 + var pages []Page + for i := 0; i < nPages; i++ { + if off >= len(doc) { + break + } + if off+32 > len(doc) { + return nil, fmt.Errorf("urf: truncated page header") + } + h := doc[off : off+32] + off += 32 + bppBits := int(h[0]) + cs := int(h[1]) + width := int(binary.BigEndian.Uint32(h[12:16])) + height := int(binary.BigEndian.Uint32(h[16:20])) + dpi := int(binary.BigEndian.Uint32(h[20:24])) + colors := urfColors(cs) + if colors <= 0 { + return nil, fmt.Errorf("urf: unsupported color space %d", cs) + } + if bppBits <= 0 || bppBits > 32 { + return nil, fmt.Errorf("urf: unsupported bits per pixel %d", bppBits) + } + bpl := width * bppBits / 8 + page, n, err := decodeCompressedPage(doc[off:], width, height, bpl, (bppBits+7)/8, dpi, colors, urfColorSpace(cs)) + if err != nil { + return nil, fmt.Errorf("urf page %d: %w", i+1, err) + } + pages = append(pages, page) + off += n + if unknownCount && off >= len(doc) { + break + } + } + if len(pages) == 0 { + return nil, fmt.Errorf("urf: no pages") + } + return pages, nil +} + +// urf color-space byte → CUPS colorspace roughly matching cupsRasterReadHeader. +func urfColorSpace(cs int) int { + switch cs { + case 0: + return cspaceSW + case 1: + return cspaceSRGB + case 2: + return cspaceCIELab + case 3: + return cspaceAdobe + case 4: + return cspaceW + case 5: + return cspaceRGB + case 6: + return cspaceCMYK + default: + return -1 + } +} + +func urfColors(cs int) int { + switch cs { + case 0, 4: + return 1 + case 1, 2, 3, 5: + return 3 + case 6: + return 4 + default: + return 0 + } +} + +const ( + cspaceW = iota + cspaceRGB + cspaceRGBA + cspaceK + cspaceCMY + cspaceYMC + cspaceCMYK + _ + _ + _ + _ + _ + _ + _ + _ + cspaceCIEXYZ + cspaceCIELab + cspaceRGBW + cspaceSW + cspaceSRGB + cspaceAdobe +) + +func fillByte(cs int) byte { + switch cs { + case cspaceW, cspaceRGB, cspaceSW, cspaceSRGB, cspaceRGBW, cspaceAdobe, cspaceRGBA: + return 0xff + default: + return 0x00 + } +} + +func decodePWG(doc []byte) ([]Page, error) { + // PWG 5102.4 / CUPS: the sync word "RaS2" means the header integers are + // big-endian (network byte order). "2SaR" is the little-endian native + // form. cupsfilter writes "RaS2"; reading those fields little-endian + // turns 5100 (00 00 13 EC) into 0xEC130000. + big := bytes.HasPrefix(doc, []byte("RaS2")) + off := 4 + var pages []Page + for len(pages) < maxPages { + if off == len(doc) || (off > 4 && off+pwgHeaderSize > len(doc) && off+32 > len(doc)) { + break + } + if off+pwgHeaderSize > len(doc) { + if len(pages) > 0 { + break + } + return nil, fmt.Errorf("pwg: truncated page header") + } + h := doc[off : off+pwgHeaderSize] + off += pwgHeaderSize + width, height, dpi, bpc, bppBits, bpl, order, cs := pwgFields(h, big) + if width <= 0 || height <= 0 || bpl <= 0 || bppBits <= 0 { + return nil, fmt.Errorf("pwg: bad geometry %dx%d bpl=%d bpp=%d", width, height, bpl, bppBits) + } + bpp := (bppBits + 7) / 8 + if order != 0 { // not chunked + bpp = (bpc + 7) / 8 + } + if bpp <= 0 { + bpp = 1 + } + colors := pwgColors(cs, bppBits, bpp) + page, n, err := decodeCompressedPage(doc[off:], width, height, bpl, bpp, dpi, colors, cs) + if err != nil { + return nil, fmt.Errorf("pwg page %d: %w", len(pages)+1, err) + } + pages = append(pages, page) + off += n + } + if len(pages) == 0 { + return nil, fmt.Errorf("pwg: no pages") + } + return pages, nil +} + +func pwgColors(cs, bppBits, bpp int) int { + switch cs { + case cspaceW, cspaceK, cspaceSW: + return 1 + case cspaceRGB, cspaceSRGB, cspaceAdobe, cspaceCIELab, cspaceCIEXYZ, cspaceCMY, cspaceYMC: + return 3 + case cspaceRGBA, cspaceRGBW, cspaceCMYK: + return 4 + default: + if bpp > 0 && bppBits/8 > 0 { + return bppBits / 8 + } + return bpp + } +} + +// pwgFields reads every header integer the decoder uses. big is true for a +// "RaS2" page (network byte order) and false for "2SaR". +func pwgFields(h []byte, big bool) (width, height, dpi, bpc, bppBits, bpl, order, cs int) { + return int(u32at(h, 372, big)), int(u32at(h, 376, big)), int(u32at(h, 276, big)), + int(u32at(h, 384, big)), int(u32at(h, 388, big)), int(u32at(h, 392, big)), + int(u32at(h, 396, big)), int(u32at(h, 400, big)) +} + +func u32at(h []byte, off int, big bool) uint32 { + if off+4 > len(h) { + return 0 + } + if big { + return binary.BigEndian.Uint32(h[off : off+4]) + } + return binary.LittleEndian.Uint32(h[off : off+4]) +} + +// decodeCompressedPage reads CUPS modified PackBits and returns the page plus +// how many bytes of src were consumed. +func decodeCompressedPage(src []byte, width, height, bytesPerLine, bpp, dpi, colors, cs int) (Page, int, error) { + if width <= 0 || height <= 0 || bytesPerLine <= 0 || bpp <= 0 { + return Page{}, 0, fmt.Errorf("bad geometry") + } + if width > 20000 || height > 20000 || int64(width)*int64(height) > maxPagePixels { + return Page{}, 0, fmt.Errorf("page too large (%dx%d)", width, height) + } + if bytesPerLine%bpp != 0 { + return Page{}, 0, fmt.Errorf("bytes-per-line %d not divisible by bpp %d", bytesPerLine, bpp) + } + r := bytes.NewReader(src) + raw := make([]byte, height*bytesPerLine) + row := 0 + for row < height { + repb, err := r.ReadByte() + if err != nil { + return Page{}, 0, fmt.Errorf("row %d repeat: %w", row, err) + } + repeats := int(repb) + 1 + line := make([]byte, bytesPerLine) + if err := decodeLine(r, line, bpp, fillByte(cs)); err != nil { + return Page{}, 0, fmt.Errorf("row %d: %w", row, err) + } + for i := 0; i < repeats && row < height; i++ { + copy(raw[row*bytesPerLine:(row+1)*bytesPerLine], line) + row++ + } + } + rgb, err := toRGB(raw, width, height, bytesPerLine, colors, cs) + if err != nil { + return Page{}, 0, err + } + if dpi <= 0 { + dpi = 300 + } + consumed := len(src) - r.Len() + return Page{Width: width, Height: height, DPI: dpi, RGB: rgb}, consumed, nil +} + +func decodeLine(r *bytes.Reader, line []byte, bpp int, fill byte) error { + pos := 0 + for pos < len(line) { + b, err := r.ReadByte() + if err != nil { + return err + } + switch { + case b == 128: + for i := pos; i < len(line); i++ { + line[i] = fill + } + return nil + case b&128 != 0: + n := (257 - int(b)) * bpp + if pos+n > len(line) { + n = len(line) - pos + } + if _, err := io.ReadFull(r, line[pos:pos+n]); err != nil { + return err + } + pos += n + default: + n := (int(b) + 1) * bpp + if pos+n > len(line) { + n = len(line) - pos + } + if n < bpp { + return fmt.Errorf("short pixel run") + } + pix := make([]byte, bpp) + if _, err := io.ReadFull(r, pix); err != nil { + return err + } + for wrote := 0; wrote < n; wrote += bpp { + copy(line[pos+wrote:pos+wrote+bpp], pix) + } + pos += n + } + } + return nil +} + +func toRGB(raw []byte, width, height, bpl, colors, cs int) ([]byte, error) { + if cs == cspaceCIEXYZ || cs == cspaceCIELab { + return nil, fmt.Errorf("color space %d is not converted", cs) + } + if colors != 1 && colors != 3 && colors != 4 { + return nil, fmt.Errorf("unsupported %d color components", colors) + } + rgb := make([]byte, width*height*3) + for y := 0; y < height; y++ { + row := raw[y*bpl : y*bpl+bpl] + for x := 0; x < width; x++ { + px := x * colors + if px+colors > len(row) { + return nil, fmt.Errorf("row shorter than width") + } + o := (y*width + x) * 3 + switch { + case colors == 1: + rgb[o], rgb[o+1], rgb[o+2] = row[px], row[px], row[px] + case colors == 3: + copy(rgb[o:o+3], row[px:px+3]) + case cs == cspaceCMYK: + c, m, yy, k := float64(row[px])/255, float64(row[px+1])/255, float64(row[px+2])/255, float64(row[px+3])/255 + rgb[o] = byte(255 * (1 - c) * (1 - k)) + rgb[o+1] = byte(255 * (1 - m) * (1 - k)) + rgb[o+2] = byte(255 * (1 - yy) * (1 - k)) + default: + copy(rgb[o:o+3], row[px:px+3]) + } + } + } + return rgb, nil +} diff --git a/internal/netprint/receipts.go b/internal/netprint/receipts.go new file mode 100644 index 0000000..5ef2d9b --- /dev/null +++ b/internal/netprint/receipts.go @@ -0,0 +1,204 @@ +package netprint + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "html" + "log" + "net" + "net/http" + "strconv" + "strings" + + "github.com/skip2/go-qrcode" +) + +// currentNetworkPublic is the Windows network category, replaced in tests. +var currentNetworkPublic = cachedNetworkPublic + +// receiptsNetworkAllowed is the HTTP check in front of the LAN receipts pages. +// The firewall already limits the port to Private and Domain unless AllowPublic +// is on. On Windows this also refuses the pages when the active network is Public. +func receiptsNetworkAllowed(networkPublic, allowPublic bool) bool { + if networkPublic && !allowPublic { + return false + } + return true +} + +func (s *Server) receiptsURL() string { + host := "" + cfg := s.snapshot() + if ip := strings.TrimSpace(cfg.Bind); ip != "" && ip != "0.0.0.0" && ip != "::" && ip != "[::]" { + host = ip + } + if host == "" { + hosts := lanHosts() + if len(hosts) == 0 { + return "" + } + host = hosts[0] + } + return fmt.Sprintf("http://%s/receipts", net.JoinHostPort(host, strconv.Itoa(s.port))) +} + +func (s *Server) secretsFile() string { + s.mu.Lock() + defer s.mu.Unlock() + return secretsPath(s.configPath, s.cfg.DataDir) +} + +func (s *Server) receiptsPIN() (string, error) { + sec, err := loadSecrets(s.secretsFile()) + if err != nil { + return "", err + } + return strings.TrimSpace(sec.ReceiptsPIN), nil +} + +func (s *Server) allowReceipts(w http.ResponseWriter, r *http.Request) bool { + pin, err := s.receiptsPIN() + if err != nil { + log.Printf("netprint: receipts lock: %v", err) + http.Error(w, "receipts unavailable", http.StatusForbidden) + return false + } + if pin == "" { + return true + } + if r.URL.Path == "/receipts/unlock" && r.Method == http.MethodPost { + s.unlockReceipts(w, r, pin) + return false + } + if c, err := r.Cookie(receiptsCookie); err == nil && cookieMatches(c.Value, pin) { + return true + } + if access := r.URL.Query().Get("access"); access != "" && pinMatch(access, pin) { + setReceiptsCookie(w, pin) + return true + } + writePINForm(w, false) + return false +} + +func (s *Server) unlockReceipts(w http.ResponseWriter, r *http.Request, pin string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + if !pinMatch(r.FormValue("pin"), pin) { + writePINForm(w, true) + return + } + setReceiptsCookie(w, pin) + http.Redirect(w, r, "/receipts", http.StatusSeeOther) +} + +const receiptsCookie = "verae_receipts" + +func pinMAC(pin string) []byte { + m := hmac.New(sha256.New, []byte("verae-netprint-receipts")) + _, _ = m.Write([]byte(pin)) + return m.Sum(nil) +} + +func pinMatch(got, want string) bool { + return subtle.ConstantTimeCompare(pinMAC(got), pinMAC(want)) == 1 +} + +func cookieMatches(cookie, pin string) bool { + want := hex.EncodeToString(pinMAC(pin)) + if len(cookie) != len(want) { + return false + } + return subtle.ConstantTimeCompare([]byte(cookie), []byte(want)) == 1 +} + +func setReceiptsCookie(w http.ResponseWriter, pin string) { + http.SetCookie(w, &http.Cookie{ + Name: receiptsCookie, + Value: hex.EncodeToString(pinMAC(pin)), + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 30 * 24 * 3600, + }) +} + +func writePINForm(w http.ResponseWriter, bad bool) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + msg := "" + if bad { + msg = "

That PIN is not right.

" + } + _, _ = w.Write([]byte(`Receipts + +

Receipts

` + msg + `
`)) +} + +func (a *Admin) receipts(w http.ResponseWriter, r *http.Request) { + name := "Verae Timestamp" + if a.Name != nil && a.Name() != "" { + name = a.Name() + } + var b strings.Builder + b.WriteString("") + b.WriteString("") + fmt.Fprintf(&b, "%s receipts", html.EscapeString(name)) + b.WriteString(``) + fmt.Fprintf(&b, "

%s

Recent timestamped jobs, newest first.

", html.EscapeString(name)) + if a.SMBURL != nil { + if u := strings.TrimSpace(a.SMBURL()); u != "" { + fmt.Fprintf(&b, `

Open these files from another device: %s

On iPhone, open the Files app and connect to that server. The first connection needs a username and password from this Mac. Guest access is off.

`, html.EscapeString(u), html.EscapeString(u)) + } + } + if r.Host != "" { + shortcut := "http://" + r.Host + "/ios-shortcut" + fmt.Fprintf(&b, `

iPhone Shortcut

`) + if png, err := qrcode.Encode(shortcut, qrcode.Medium, 256); err == nil { + fmt.Fprintf(&b, `

Scan to build the Share sheet shortcut.

QR code for the iPhone shortcut

`, base64.StdEncoding.EncodeToString(png)) + } + } + list := a.Store.List() + shown := 0 + for i := len(list) - 1; i >= 0 && shown < 100; i-- { + rec := list[i] + shown++ + title := firstNonEmpty(rec.Title, fmt.Sprintf("Job %d", rec.ID)) + when := rec.Received.Local().Format("2006-01-02 15:04") + fmt.Fprintf(&b, "

%s

%s · %s · %s

", + html.EscapeString(title), html.EscapeString(when), html.EscapeString(rec.User), html.EscapeString(rec.State)) + if rec.ReceiptID != "" { + fmt.Fprintf(&b, "

Receipt %s

", html.EscapeString(rec.ReceiptID)) + } + if rec.SHA256 != "" { + fmt.Fprintf(&b, "

SHA-256 %s

", html.EscapeString(rec.SHA256)) + } + if rec.CertificatePDF != "" { + fmt.Fprintf(&b, `Stamped PDF`, rec.ID) + } + if rec.OriginalPDF != "" { + fmt.Fprintf(&b, `Original`, rec.ID) + } + b.WriteString("
") + } + if shown == 0 { + b.WriteString("

No jobs yet.

") + } + b.WriteString("") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(b.String())) +} diff --git a/internal/netprint/receipts_dir.go b/internal/netprint/receipts_dir.go new file mode 100644 index 0000000..ba95fc5 --- /dev/null +++ b/internal/netprint/receipts_dir.go @@ -0,0 +1,120 @@ +package netprint + +import ( + "log" + "os" + "path/filepath" + "runtime" + "strings" +) + +const ( + // legacyDarwinDataDir is the old receipts location, next to the program. + legacyDarwinDataDir = "/Library/Application Support/Verae/NetPrint" + // darwinSharedReceiptsDir is the receipts folder. It is not the folder + // that holds verae-print, so it can be shared read-only over SMB. + darwinSharedReceiptsDir = "/Users/Shared/Verae Receipts" +) + +func receiptDirMode() os.FileMode { + if runtime.GOOS == "darwin" { + return 0o755 + } + return 0o700 +} + +func receiptFileMode() os.FileMode { + if runtime.GOOS == "darwin" { + return 0o644 + } + return 0o600 +} + +func mkdirReceipts(dir string) error { + if err := os.MkdirAll(dir, receiptDirMode()); err != nil { + return err + } + return os.Chmod(dir, receiptDirMode()) +} + +func writeReceiptFile(path string, data []byte) error { + if err := os.WriteFile(path, data, receiptFileMode()); err != nil { + return err + } + return os.Chmod(path, receiptFileMode()) +} + +// publishReceiptPerms makes a finished job readable by the console user on +// macOS. Other systems keep the directory private. +func publishReceiptPerms(dir string) { + if receiptDirMode() == 0o700 { + return + } + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + mode := receiptFileMode() + if info.IsDir() { + mode = receiptDirMode() + } + return os.Chmod(path, mode) + }) +} + +func receiptDataName(name string) bool { + switch name { + case "jobs.json", "next-job", "uuid": + return true + default: + return strings.HasPrefix(name, "job-") + } +} + +// migrateReceiptTree moves job files from oldDir to newDir. Program files, +// including bin/, stay where they are. +func migrateReceiptTree(oldDir, newDir string) error { + if err := mkdirReceipts(newDir); err != nil { + return err + } + entries, err := os.ReadDir(oldDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, e := range entries { + if !receiptDataName(e.Name()) { + continue + } + src := filepath.Join(oldDir, e.Name()) + dst := filepath.Join(newDir, e.Name()) + if _, err := os.Stat(dst); err == nil { + continue + } + if err := os.Rename(src, dst); err != nil { + return err + } + } + return nil +} + +// useSharedReceipts moves a macOS install off the program folder and remembers +// the new path in the config file. +func useSharedReceipts(cfg *Config, configPath string) error { + if runtime.GOOS != "darwin" || filepath.Clean(cfg.DataDir) != filepath.Clean(legacyDarwinDataDir) { + return nil + } + if err := migrateReceiptTree(legacyDarwinDataDir, darwinSharedReceiptsDir); err != nil { + return err + } + cfg.DataDir = darwinSharedReceiptsDir + if strings.TrimSpace(configPath) == "" { + return nil + } + if err := Save(configPath, *cfg); err != nil { + log.Printf("netprint: save receipts dir: %v", err) + } + return nil +} diff --git a/internal/netprint/receipts_dir_test.go b/internal/netprint/receipts_dir_test.go new file mode 100644 index 0000000..cd32dae --- /dev/null +++ b/internal/netprint/receipts_dir_test.go @@ -0,0 +1,104 @@ +package netprint + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSharedReceiptsDirIsNotTheProgramFolder(t *testing.T) { + if darwinSharedReceiptsDir != "/Users/Shared/Verae Receipts" { + t.Fatal(darwinSharedReceiptsDir) + } + if strings.Contains(darwinSharedReceiptsDir, "NetPrint") || strings.Contains(darwinSharedReceiptsDir, "/bin") { + t.Fatal(darwinSharedReceiptsDir) + } + src, err := os.ReadFile("service_darwin.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "darwinSharedReceiptsDir") || strings.Contains(string(src), `ExampleJSON("/Library/Application Support/Verae/NetPrint")`) { + t.Fatal("install still writes receipts beside the binary") + } +} + +func TestMigrateReceiptsLeavesBinaries(t *testing.T) { + old := t.TempDir() + neu := filepath.Join(t.TempDir(), "Verae Receipts") + if err := os.Mkdir(filepath.Join(old, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "bin", "verae-print"), []byte("bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "com.verae.netprint.plist"), []byte("plist"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(old, "job-1"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "job-1", "certificate.pdf"), []byte("%PDF"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "jobs.json"), []byte("[]\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "next-job"), []byte("2\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := migrateReceiptTree(old, neu); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(old, "bin", "verae-print")); err != nil || string(b) != "bin" { + t.Fatalf("binary moved: %q %v", b, err) + } + if _, err := os.Stat(filepath.Join(old, "com.verae.netprint.plist")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(neu, "bin")); !os.IsNotExist(err) { + t.Fatal("bin was copied into the receipts folder") + } + if b, err := os.ReadFile(filepath.Join(neu, "job-1", "certificate.pdf")); err != nil || string(b) != "%PDF" { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(neu, "jobs.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(neu, "next-job")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(old, "jobs.json")); !os.IsNotExist(err) { + t.Fatal("jobs.json still in the program folder") + } +} + +func TestStatusExposesReceiptsDir(t *testing.T) { + dir := t.TempDir() + st, err := OpenStore(dir) + if err != nil { + t.Fatal(err) + } + s := &Server{ + cfg: Config{DataDir: dir, Name: "Verae Timestamp"}, + store: st, + ctrl: &fakeCtrl{}, + port: 631, + ippBind: "lan", + } + got := s.status() + if got.ReceiptsDir != dir { + t.Fatal(got.ReceiptsDir) + } + if got.IPPBind != "lan" { + t.Fatal(got.IPPBind) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"receiptsDir"`) || strings.Contains(string(b), "netprint-secrets") { + t.Fatal(string(b)) + } +} diff --git a/internal/netprint/secret.go b/internal/netprint/secret.go new file mode 100644 index 0000000..bc4e744 --- /dev/null +++ b/internal/netprint/secret.go @@ -0,0 +1,86 @@ +package netprint + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +// Secrets holds values that must not be written to netprint.json or the log. +type Secrets struct { + SMTPPassword string `json:"smtpPassword,omitempty"` + ReceiptsPIN string `json:"receiptsPin,omitempty"` +} + +func (s Secrets) empty() bool { + return strings.TrimSpace(s.SMTPPassword) == "" && strings.TrimSpace(s.ReceiptsPIN) == "" +} + +// secretsPath is the protected file next to the config, or inside dataDir. +func secretsPath(configPath, dataDir string) string { + if strings.TrimSpace(configPath) != "" { + return filepath.Join(filepath.Dir(configPath), "netprint-secrets.bin") + } + return filepath.Join(dataDir, "netprint-secrets.bin") +} + +func loadSecrets(path string) (Secrets, error) { + b, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Secrets{}, nil + } + return Secrets{}, err + } + if len(b) == 0 { + return Secrets{}, nil + } + plain, err := unprotect(b) + if err != nil { + return Secrets{}, err + } + var s Secrets + if err := json.Unmarshal(plain, &s); err != nil { + return Secrets{}, err + } + return s, nil +} + +func saveSecrets(path string, s Secrets) error { + if strings.TrimSpace(path) == "" { + return errors.New("secrets path required") + } + if s.empty() { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + b, err := json.Marshal(s) + if err != nil { + return err + } + sealed, err := protect(b) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, sealed, 0o600); err != nil { + return err + } + if err := os.Chmod(tmp, 0o600); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Chmod(path, 0o600) +} diff --git a/internal/netprint/secret_unix.go b/internal/netprint/secret_unix.go new file mode 100644 index 0000000..3ec5189 --- /dev/null +++ b/internal/netprint/secret_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package netprint + +func protect(plain []byte) ([]byte, error) { return plain, nil } + +func unprotect(b []byte) ([]byte, error) { return b, nil } diff --git a/internal/netprint/secret_windows.go b/internal/netprint/secret_windows.go new file mode 100644 index 0000000..21cb2a7 --- /dev/null +++ b/internal/netprint/secret_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package netprint + +import ( + "unsafe" + + "golang.org/x/sys/windows" +) + +// protect seals b with DPAPI for the service account. The blob is useless on +// another machine or to another Windows user. +func protect(plain []byte) ([]byte, error) { + in := dataBlob(plain) + var out windows.DataBlob + err := windows.CryptProtectData(in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(out.Data)))) + return copyBlob(out), nil +} + +func unprotect(b []byte) ([]byte, error) { + in := dataBlob(b) + var out windows.DataBlob + err := windows.CryptUnprotectData(in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(out.Data)))) + return copyBlob(out), nil +} + +func dataBlob(b []byte) *windows.DataBlob { + if len(b) == 0 { + return &windows.DataBlob{} + } + return &windows.DataBlob{Size: uint32(len(b)), Data: &b[0]} +} + +func copyBlob(b windows.DataBlob) []byte { + if b.Data == nil || b.Size == 0 { + return nil + } + return append([]byte(nil), unsafe.Slice(b.Data, b.Size)...) +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go new file mode 100644 index 0000000..4cc07d6 --- /dev/null +++ b/internal/netprint/server.go @@ -0,0 +1,434 @@ +package netprint + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Server is the LAN IPP printer plus the loopback control API. +type Server struct { + mu sync.Mutex + cfg Config + configPath string + store *Store + printer *Printer + ctrl ServiceControl + port int + controlURL string + stamp StampFunc + forward ForwardFunc + + ippSrv *http.Server + controlSrv *http.Server + mdnsCancel context.CancelFunc + mdnsGen int + mdns mdnsReport + emailErr string + smbErr string + uuid string + jobs jobBus + ippBind string +} + +// Run loads config, listens, and blocks until ctx is canceled. +func Run(ctx context.Context, explicitPath string, flags Config, stamp StampFunc) error { + setupServiceLog() + log.Printf("netprint: starting") + cfg, err := Load(explicitPath, flags) + if err != nil { + return err + } + path := explicitPath + if strings.TrimSpace(path) == "" { + path = SystemConfigPath() + } + if err := persistQualifiedName(path, cfg); err != nil { + log.Printf("netprint: default name: %v", err) + } + s, err := Start(cfg, path, stamp, nil) + if err != nil { + return err + } + <-ctx.Done() + shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.Shutdown(shut) +} + +// Start listens and serves. ctrl may be nil (the OS service manager is used). +func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) (*Server, error) { + if err := cfg.normalize(); err != nil { + return nil, err + } + if err := useSharedReceipts(&cfg, configPath); err != nil { + return nil, err + } + st, err := OpenStore(cfg.DataDir) + if err != nil { + return nil, err + } + if ctrl == nil { + ctrl = NewServiceControl() + } + uuid := loadUUID(cfg.DataDir) + s := &Server{ + cfg: cfg, + configPath: configPath, + store: st, + ctrl: ctrl, + stamp: stamp, + printer: &Printer{ + Name: cfg.Name, + UUID: uuid, + AllocateID: st.AllocateID, + }, + } + ippLn, port, bindMode, err := listenIPP(cfg) + if err != nil { + return nil, err + } + s.port = port + s.ippBind = bindMode + ctlLn, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.ControlPort)) + if err != nil { + _ = ippLn.Close() + return nil, fmt.Errorf("control listen: %w", err) + } + s.controlURL = "http://" + ctlLn.Addr().String() + "/" + s.uuid = uuid + s.seedEmailError() + s.printer.AdminURL = s.receiptsURL() + s.printer.OnDocument = func(id int, format, title, user string, doc []byte) { + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) + } + + admin := &Admin{ + Store: st, + Name: func() string { return s.snapshot().Name }, + Port: func() int { return s.port }, + SMBURL: func() string { return smbURLIfOn(s.snapshot()) }, + } + pub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && (strings.Contains(r.Header.Get("Content-Type"), "application/ipp") || strings.HasPrefix(r.URL.Path, "/ipp")) { + s.printer.ServeHTTP(w, r) + return + } + if r.URL.Path == "/api/stamp" || r.URL.Path == "/api/stamp/" { + if !receiptsNetworkAllowed(currentNetworkPublic(), s.snapshot().PublicNetworks()) { + http.Error(w, "the stamp API is available on Private and Domain networks", http.StatusForbidden) + return + } + if !s.authorizeStamp(w, r) { + return + } + s.handleStamp(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/settings" { + http.NotFound(w, r) + return + } + if !receiptsNetworkAllowed(currentNetworkPublic(), s.snapshot().PublicNetworks()) { + http.Error(w, "receipts are available on Private and Domain networks", http.StatusForbidden) + return + } + if !s.allowReceipts(w, r) { + return + } + if r.URL.Path == "/ios-shortcut" || r.URL.Path == "/ios-shortcut/" { + s.serveShortcutGuide(w, r) + return + } + admin.ServeHTTP(w, r) + }) + s.ippSrv = &http.Server{Handler: pub} + ctrlMux := http.NewServeMux() + ctrlMux.HandleFunc("/api/v1/status", s.handleStatus) + ctrlMux.HandleFunc("/api/v1/service", s.handleService) + ctrlMux.HandleFunc("/api/v1/launch", s.handleLaunch) + ctrlMux.HandleFunc("/api/v1/tray", s.handleTray) + ctrlMux.HandleFunc("/api/v1/firewall", s.handleFirewall) + ctrlMux.HandleFunc("/api/v1/settings", s.handleSettings) + ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) + ctrlMux.HandleFunc("/api/v1/events", s.handleEvents) + ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck) + ctrlMux.HandleFunc("/api/v1/receipts-share/ensure", s.handleEnsureShare) + ctrlMux.HandleFunc("/settings", s.handleSettingsPage) + ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !loopback(r) { + http.Error(w, "loopback only", http.StatusForbidden) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + admin.ServeHTTP(w, r) + })) + s.controlSrv = &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !loopback(r) { + http.Error(w, "loopback only", http.StatusForbidden) + return + } + ctrlMux.ServeHTTP(w, r) + })} + go func() { _ = s.ippSrv.Serve(ippLn) }() + go func() { _ = s.controlSrv.Serve(ctlLn) }() + s.syncAdvertiser() + go s.syncReceiptsShare() + log.Printf("netprint: IPP on %s (%s), receipts %s, control on %s", listenerAddrs(ippLn), s.ippBind, cfg.DataDir, ctlLn.Addr()) + return s, nil +} + +func (s *Server) noteShare(on bool) { + err := applyReceiptsShare(on) + s.mu.Lock() + if err != nil { + s.smbErr = err.Error() + } else { + s.smbErr = "" + } + s.mu.Unlock() + if err != nil { + log.Printf("netprint: receipts share: %v", err) + return + } + if on { + if u := smbURLIfOn(s.snapshot()); u != "" { + log.Printf("netprint: receipts share %s", u) + } + return + } + log.Printf("netprint: receipts share off") +} + +func (s *Server) syncReceiptsShare() { + if !s.snapshot().ShareReceiptsOn() { + return + } + s.noteShare(true) +} + +func (s *Server) handleEnsureShare(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.snapshot().ShareReceiptsOn() { + s.noteShare(true) + } + st := s.status() + writeJSON(w, map[string]any{ + "shareReceiptsSMB": st.ShareReceiptsSMB, + "smbUrl": st.SMBURL, + "smbError": st.SMBError, + }) +} + +func (s *Server) snapshot() Config { + s.mu.Lock() + defer s.mu.Unlock() + return s.cfg +} + +// syncAdvertiser starts or stops Bonjour to match the Advertise setting. +// IPP keeps running either way. A name or port change still needs a restart +// before mDNSResponder replaces a registration it already accepted. +func (s *Server) syncAdvertiser() { + if s.snapshot().MDNS() { + s.startMDNS() + return + } + s.stopMDNS() +} + +func (s *Server) startMDNS() { + s.mu.Lock() + if s.mdnsCancel != nil { + s.mu.Unlock() + return + } + s.mdnsGen++ + gen := s.mdnsGen + ctx, cancel := context.WithCancel(context.Background()) + s.mdnsCancel = cancel + s.mu.Unlock() + setMDNSRename(s.noteAdvertisedName) + s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) + go func() { + err := startAdvertiser(ctx, func() Service { + c := s.snapshot() + adminURL := "" + if s.printer != nil { + adminURL = s.printer.AdminURL + } + return Service{ + Instance: c.Name, + Host: hostLabel(c.Hostname), + Port: s.port, + IPs: lanIPs(), + TXT: AirPrintTXT(c.Name, s.uuid, adminURL, "", ""), + StampURL: strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp", + } + }, s.setMDNS) + s.mu.Lock() + current := s.mdnsGen + s.mu.Unlock() + if current != gen { + return + } + if err != nil && ctx.Err() == nil { + log.Printf("netprint: mDNS not advertised: %v", err) + s.setMDNS(mdnsReport{Mode: "error", Err: err.Error(), Detail: err.Error()}) + } + }() +} + +func (s *Server) stopMDNS() { + s.mu.Lock() + s.mdnsGen++ + cancel := s.mdnsCancel + s.mdnsCancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + log.Printf("netprint: mDNS disabled") + s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) +} + +func (s *Server) noteAdvertisedName(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + s.mu.Lock() + if s.cfg.Name == name { + s.mu.Unlock() + return + } + s.cfg.Name = name + cfg := s.cfg + path := s.configPath + if s.printer != nil { + s.printer.Name = name + } + s.mu.Unlock() + if strings.TrimSpace(path) != "" { + if err := Save(path, cfg); err != nil { + log.Printf("netprint: mDNS rename save: %v", err) + } + } + log.Printf("netprint: mDNS renamed the printer to %q", name) +} + +func (s *Server) setMDNS(r mdnsReport) { + s.mu.Lock() + s.mdns = r + s.mu.Unlock() + if r.Err != "" { + log.Printf("netprint: mDNS status: %s (%s)", r.Detail, r.Err) + } +} + +// Port is the IPP port actually bound. +func (s *Server) Port() int { return s.port } + +// ControlURL is the loopback base URL including a trailing slash. +func (s *Server) ControlURL() string { return s.controlURL } + +// Shutdown stops listeners and sends an mDNS goodbye. +func (s *Server) Shutdown(ctx context.Context) error { + s.mu.Lock() + cancel := s.mdnsCancel + s.mdnsCancel = nil + s.mdnsGen++ + s.mu.Unlock() + if cancel != nil { + cancel() + } + var err error + if s.ippSrv != nil { + err = s.ippSrv.Shutdown(ctx) + } + if s.controlSrv != nil { + if e := s.controlSrv.Shutdown(ctx); err == nil { + err = e + } + } + return err +} + +// ippListenPorts is the IPP port list for cfg. Port 8631 is the desktop +// driver's loopback helper and is never used, even when a saved config asks for it. +func ippListenPorts(cfg Config) []int { + primary := cfg.Port + fallback := cfg.FallbackPort + if primary == DriverHelperPort { + primary = DefaultFallbackPort + } + if fallback == DriverHelperPort { + fallback = DefaultFallbackPort + } + if primary <= 0 { + primary = 631 + } + out := []int{primary} + if fallback > 0 && fallback != primary { + out = append(out, fallback) + } + return out +} + +func listenIPP(cfg Config) (net.Listener, int, string, error) { + try := ippListenPorts(cfg) + if shouldBindLANOnly(cfg) && len(try) > 0 { + primary := try[0] + ln, err := listenLAN(primary) + if err == nil { + log.Printf("netprint: port %d on LAN interfaces; localhost:%d left for CUPS", primary, primary) + return ln, primary, "lan", nil + } + log.Printf("netprint: LAN port %d: %v", primary, err) + try = try[1:] + } + v4host, v6host := ippBindHosts(cfg.Bind) + var last error + for _, port := range try { + ln, err := listenPort(v4host, v6host, port) + if err == nil { + if shouldBindLANOnly(cfg) { + log.Printf("netprint: port %d (fallback); localhost CUPS kept %d", port, cfg.Port) + } + return ln, port, "all", nil + } + last = err + log.Printf("netprint: port %d: %v", port, err) + } + if last == nil { + last = errors.New("no port") + } + return nil, 0, "", fmt.Errorf("listen: %w", last) +} + +func loadUUID(dir string) string { + path := filepath.Join(dir, "uuid") + if b, err := os.ReadFile(path); err == nil { + if s := strings.TrimSpace(string(b)); s != "" { + return s + } + } + id := newUUID() + _ = mkdirReceipts(dir) + _ = writeReceiptFile(path, []byte(id+"\n")) + return id +} diff --git a/internal/netprint/service.go b/internal/netprint/service.go new file mode 100644 index 0000000..662e15c --- /dev/null +++ b/internal/netprint/service.go @@ -0,0 +1,179 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth" +) + +const ( + linuxUnit = "verae-netprint.service" + winService = "VeraeNetPrint" + launchdLabel = "com.verae.netprint" + trayLabel = "com.verae.netprint.tray" +) + +// NewServiceControl returns the OS service manager for this platform. +func NewServiceControl() ServiceControl { return osControl{} } + +type ownerSet struct { + Driver bool `json:"driver"` + Standalone bool `json:"standalone"` +} + +func ownersPath() string { + switch runtime.GOOS { + case "windows": + return filepath.Join(programData(), "Verae", "PrintStamp", "netprint.owners.json") + case "darwin": + return "/Library/Application Support/Verae/PrintStamp/netprint.owners.json" + default: + return "/etc/verae/netprint.owners.json" + } +} + +func programData() string { + pd := os.Getenv("ProgramData") + if pd == "" { + pd = `C:\ProgramData` + } + return pd +} + +func readOwners() ownerSet { + b, err := os.ReadFile(ownersPath()) + if err != nil { + return ownerSet{} + } + var o ownerSet + _ = json.Unmarshal(b, &o) + return o +} + +func writeOwners(o ownerSet) error { + path := ownersPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(o, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o644) +} + +func addOwner(owner string) (ownerSet, error) { + o := readOwners() + switch owner { + case "driver": + o.Driver = true + case "standalone": + o.Standalone = true + default: + return o, fmt.Errorf("owner must be driver or standalone") + } + return o, writeOwners(o) +} + +func removeOwner(owner string) (ownerSet, error) { + o := readOwners() + switch owner { + case "driver": + o.Driver = false + case "standalone": + o.Standalone = false + default: + return o, fmt.Errorf("owner must be driver or standalone") + } + return o, writeOwners(o) +} + +func (o ownerSet) empty() bool { return !o.Driver && !o.Standalone } + +func copyToken(explicit string) { + explicit = strings.TrimSpace(explicit) + if explicit != "" { + if b, err := os.ReadFile(explicit); err == nil { + _ = auth.SaveMachineToken(strings.TrimSpace(string(b))) + } + return + } + if t, err := auth.LoadXVeraeToken(); err == nil { + _ = auth.SaveMachineToken(t) + return + } + if u := os.Getenv("SUDO_USER"); u != "" { + if home := userHome(u); home != "" { + if b, err := os.ReadFile(filepath.Join(home, ".verae", "xverae-token")); err == nil { + _ = auth.SaveMachineToken(strings.TrimSpace(string(b))) + } + } + } +} + +func userHome(user string) string { + if runtime.GOOS == "windows" { + return "" + } + out, err := exec.Command("getent", "passwd", user).Output() + if err != nil { + return "" + } + parts := strings.Split(strings.TrimSpace(string(out)), ":") + if len(parts) < 6 { + return "" + } + return parts[5] +} + +// InstallOptions carries installer choices that are not the owner or token. +// AllowPublic nil leaves the saved firewall choice unchanged. +type InstallOptions struct { + AllowPublic *bool + LocalQueue bool +} + +func noteInstallOptions(cfgPath string, opts InstallOptions) { + cfg, err := Load(cfgPath, Config{}) + if err != nil { + log.Printf("netprint: install options: %v", err) + return + } + changed := false + if opts.AllowPublic != nil { + v := *opts.AllowPublic + cfg.AllowPublic = &v + changed = true + } + if consumeReceiptsShareStamp(&cfg) { + changed = true + } + if !changed { + return + } + if err := Save(cfgPath, cfg); err != nil { + log.Printf("netprint: save install options: %v", err) + return + } + if cfg.ShareReceiptsOn() { + if err := applyReceiptsShare(true); err != nil { + log.Printf("netprint: receipts share: %v", err) + } + } +} + +// Executable is the verae-print binary the service should launch. +func Executable() (string, error) { + p, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Abs(p) +} diff --git a/internal/netprint/service_darwin.go b/internal/netprint/service_darwin.go new file mode 100644 index 0000000..9855c50 --- /dev/null +++ b/internal/netprint/service_darwin.go @@ -0,0 +1,281 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "log" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" +) + +const ( + daemonPlist = "/Library/LaunchDaemons/com.verae.netprint.plist" + agentPlist = "/Library/LaunchAgents/com.verae.netprint.tray.plist" + trayAppExecutable = "/Applications/Verae NetPrint.app/Contents/MacOS/verae-print" +) + +// trayProgram is the menu-bar executable inside Verae NetPrint.app. +// The LaunchDaemon keeps using the binary under Application Support. +func trayProgram() string { + if st, err := os.Stat(trayAppExecutable); err == nil && !st.IsDir() { + return trayAppExecutable + } + return mustExe() +} + +func plistDaemon(exe, config string) string { + return fmt.Sprintf(` + + + Label%s + ProgramArguments + + %s + netprint + --config + %s + + RunAtLoad + KeepAlive + +`, launchdLabel, exe, config) +} + +func plistAgent(exe string) string { + return fmt.Sprintf(` + + + Label%s + ProgramArguments + + %s + tray + + RunAtLoad + LimitLoadToSessionTypeAqua + +`, trayLabel, exe) +} + +type osControl struct{} + +func (osControl) Start() error { return launchctl("bootstrap", "system", daemonPlist) } +func (osControl) Stop() error { + return delayed("launchctl", "bootout", "system/"+launchdLabel) +} +func (osControl) Restart() error { + return delayed("launchctl", "kickstart", "-k", "system/"+launchdLabel) +} +func (osControl) LaunchAtLogin() (bool, error) { + out, _ := exec.Command("launchctl", "print", "system/"+launchdLabel).CombinedOutput() + return strings.Contains(string(out), "state = running") || strings.Contains(string(out), "state = waiting"), nil +} +func (osControl) SetLaunchAtLogin(on bool) error { + if on { + _ = exec.Command("launchctl", "enable", "system/"+launchdLabel).Run() + return launchctl("bootstrap", "system", daemonPlist) + } + _ = exec.Command("launchctl", "disable", "system/"+launchdLabel).Run() + return exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() +} +func (osControl) TrayAtLogin() (bool, error) { + _, err := os.Stat(agentPlist) + if err != nil { + return false, nil + } + out, _ := exec.Command("launchctl", "print-disabled", "system").CombinedOutput() + return !strings.Contains(string(out), `"`+trayLabel+`" => disabled`), nil +} +func (osControl) SetTrayAtLogin(on bool) error { + uid := consoleUID() + target := "gui/" + uid + if on { + if err := os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644); err != nil { + return err + } + _ = exec.Command("launchctl", "enable", target+"/"+trayLabel).Run() + return exec.Command("launchctl", "bootstrap", target, agentPlist).Run() + } + _ = exec.Command("launchctl", "bootout", target+"/"+trayLabel).Run() + return exec.Command("launchctl", "disable", target+"/"+trayLabel).Run() +} + +func delayed(args ...string) error { + cmd := exec.Command("sh", append([]string{"-c", `sleep 0.4; exec "$@"`, "sh"}, args...)...) + return cmd.Start() +} + +func launchctl(args ...string) error { + out, err := exec.Command("launchctl", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("launchctl: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func mustExe() string { + p, err := Executable() + if err != nil { + if _, statErr := os.Stat("/Library/Application Support/Verae/PrintStamp/bin/verae-print"); statErr == nil { + return "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + } + return "/Library/Application Support/Verae/NetPrint/bin/verae-print" + } + return p +} + +func consoleUID() string { + if u := os.Getenv("SUDO_UID"); u != "" { + return u + } + out, err := exec.Command("stat", "-f", "%u", "/dev/console").Output() + if err == nil { + if s := strings.TrimSpace(string(out)); s != "" && s != "0" { + return s + } + } + if cu, err := user.Current(); err == nil && cu.Uid != "" { + return cu.Uid + } + return "501" +} + +// Install registers the LaunchDaemon and the menu-bar agent. +func Install(owner, tokenFile string, opts InstallOptions) error { + if _, err := addOwner(owner); err != nil { + return err + } + exe, err := Executable() + if err != nil { + return err + } + cfgPath := SystemConfigPath() + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + return err + } + if err := os.MkdirAll(darwinSharedReceiptsDir, 0o755); err != nil { + return err + } + _ = os.Chmod(darwinSharedReceiptsDir, 0o755) + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if err := os.WriteFile(cfgPath, ExampleJSON(darwinSharedReceiptsDir), 0o644); err != nil { + return err + } + } + noteInstallOptions(cfgPath, opts) + chosen := selectServiceBinary(exe, currentServiceExe()) + if err := os.MkdirAll("/Library/LaunchDaemons", 0o755); err != nil { + return err + } + if err := os.WriteFile(daemonPlist, []byte(plistDaemon(chosen, cfgPath)), 0o644); err != nil { + return err + } + if err := os.MkdirAll("/Library/LaunchAgents", 0o755); err != nil { + return err + } + if err := os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644); err != nil { + return err + } + linkCLI(exe) + copyToken(tokenFile) + allowAppFirewall(chosen) + if tray := trayProgram(); tray != chosen { + allowAppFirewall(tray) + } + _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() + if err := launchctl("bootstrap", "system", daemonPlist); err != nil { + return err + } + uid := consoleUID() + _ = exec.Command("launchctl", "bootstrap", "gui/"+uid, agentPlist).Run() + return nil +} + +// Uninstall removes the daemon and menu item only when no owner remains. +func Uninstall(owner string) error { + o, err := removeOwner(owner) + if err != nil { + return err + } + if !o.empty() { + if other := peerBinary(owner); other != "" { + cfgPath := SystemConfigPath() + _ = os.WriteFile(daemonPlist, []byte(plistDaemon(other, cfgPath)), 0o644) + _ = os.WriteFile(agentPlist, []byte(plistAgent(trayProgram())), 0o644) + _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() + _ = launchctl("bootstrap", "system", daemonPlist) + } + return nil + } + _ = exec.Command("launchctl", "bootout", "system/"+launchdLabel).Run() + if uid := consoleUID(); uid != "" { + _ = exec.Command("launchctl", "bootout", "gui/"+uid+"/"+trayLabel).Run() + } + removeReceiptsShare() + _ = os.Remove(daemonPlist) + _ = os.Remove(agentPlist) + return nil +} + +func currentServiceExe() string { + b, err := os.ReadFile(daemonPlist) + if err != nil { + return "" + } + return parseDarwinProgram(string(b)) +} + +func peerBinary(owner string) string { + p := "/Library/Application Support/Verae/NetPrint/bin/verae-print" + if owner == "standalone" { + p = "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// linkCLI keeps /usr/local/bin/verae-print on the driver binary when that +// install is present. A real file at the link path is left alone. +func linkCLI(exe string) { + const link = "/usr/local/bin/verae-print" + const driver = "/Library/Application Support/Verae/PrintStamp/bin/verae-print" + target := exe + if _, err := os.Stat(driver); err == nil { + target = driver + } + _ = os.MkdirAll("/usr/local/bin", 0o755) + st, err := os.Lstat(link) + if err == nil && st.Mode()&os.ModeSymlink == 0 { + return + } + if err == nil { + _ = os.Remove(link) + } + _ = os.Symlink(target, link) +} + +// allowAppFirewall asks the macOS application firewall to accept the service +// binary. System Settings → Network → Firewall still applies if this tool +// is unavailable or the user has blocked the app. +func allowAppFirewall(exe string) { + const fw = "/usr/libexec/ApplicationFirewall/socketfilterfw" + if _, err := os.Stat(fw); err != nil { + return + } + if out, err := exec.Command(fw, "--add", exe).CombinedOutput(); err != nil { + log.Printf("netprint: socketfilterfw --add: %v (%s)", err, strings.TrimSpace(string(out))) + } + if out, err := exec.Command(fw, "--unblockapp", exe).CombinedOutput(); err != nil { + log.Printf("netprint: socketfilterfw --unblockapp: %v (%s)", err, strings.TrimSpace(string(out))) + } +} + +// silence unused in case strconv is needed by future uid parsing +var _ = strconv.Itoa diff --git a/internal/netprint/service_linux.go b/internal/netprint/service_linux.go new file mode 100644 index 0000000..7ee7155 --- /dev/null +++ b/internal/netprint/service_linux.go @@ -0,0 +1,342 @@ +//go:build linux + +package netprint + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +const ( + unitPath = "/usr/lib/systemd/system/verae-netprint.service" + trayDesktop = "/etc/xdg/autostart/verae-netprint-tray.desktop" + sudoersPath = "/etc/sudoers.d/verae-netprint" + ctlPath = "/usr/lib/verae-netprint/ctl" + serviceUser = "verae-netprint" + serviceHome = "/var/lib/verae-netprint" +) + +func unitText(exe, config string) string { + return fmt.Sprintf(`[Unit] +Description=Verae network timestamp printer +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=%s +Group=%s +Environment=HOME=%s +ExecStart=%s netprint --config %s +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target +`, serviceUser, serviceUser, serviceHome, exe, config) +} + +func trayDesktopText(exe string, enabled bool) string { + hidden := "false" + gnome := "true" + if !enabled { + hidden = "true" + gnome = "false" + } + return fmt.Sprintf(`[Desktop Entry] +Type=Application +Name=Verae Network Printer +Comment=Status and controls for the Verae network timestamp printer +Exec=%s tray +Icon=printer +Terminal=false +Categories=Utility; +X-GNOME-Autostart-enabled=%s +Hidden=%s +`, exe, gnome, hidden) +} + +func ctlScript() string { + return `#!/bin/sh +set -e +f=/etc/xdg/autostart/verae-netprint-tray.desktop +case "$1" in + tray-on) sed -i 's/^Hidden=.*/Hidden=false/; s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=true/' "$f" ;; + tray-off) sed -i 's/^Hidden=.*/Hidden=true/; s/^X-GNOME-Autostart-enabled=.*/X-GNOME-Autostart-enabled=false/' "$f" ;; + *) echo "usage: ctl tray-on|tray-off" >&2; exit 2 ;; +esac +` +} + +func sudoersText() string { + return serviceUser + ` ALL=(root) NOPASSWD: /bin/systemctl start verae-netprint.service, /bin/systemctl stop verae-netprint.service, /bin/systemctl restart verae-netprint.service, /bin/systemctl enable verae-netprint.service, /bin/systemctl disable verae-netprint.service, /usr/lib/verae-netprint/ctl tray-on, /usr/lib/verae-netprint/ctl tray-off +` +} + +type osControl struct{} + +func (osControl) Start() error { return systemctlNow("start", linuxUnit) } +func (osControl) Stop() error { return systemctlDelayed("stop", linuxUnit) } +func (osControl) Restart() error { + return systemctlDelayed("restart", linuxUnit) +} + +func (osControl) LaunchAtLogin() (bool, error) { + out, _ := exec.Command("systemctl", "is-enabled", linuxUnit).CombinedOutput() + return strings.TrimSpace(string(out)) == "enabled", nil +} + +func (osControl) SetLaunchAtLogin(on bool) error { + if on { + return systemctlNow("enable", linuxUnit) + } + return systemctlNow("disable", linuxUnit) +} + +func (osControl) TrayAtLogin() (bool, error) { + b, err := os.ReadFile(trayDesktop) + if err != nil { + return false, nil + } + s := string(b) + if strings.Contains(s, "Hidden=true") || strings.Contains(s, "X-GNOME-Autostart-enabled=false") { + return false, nil + } + return true, nil +} + +func (osControl) SetTrayAtLogin(on bool) error { + arg := "tray-off" + if on { + arg = "tray-on" + } + if os.Geteuid() == 0 { + return exec.Command(ctlPath, arg).Run() + } + return exec.Command("sudo", "-n", ctlPath, arg).Run() +} + +func systemctlNow(args ...string) error { + if _, err := exec.LookPath("systemctl"); err != nil { + return fmt.Errorf("systemctl not available") + } + if os.Geteuid() != 0 { + args = append([]string{"-n", "systemctl"}, args...) + out, err := exec.Command("sudo", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("sudo systemctl: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil + } + out, err := exec.Command("systemctl", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +func systemctlDelayed(args ...string) error { + bin := "systemctl" + argv := args + if os.Geteuid() != 0 { + bin = "sudo" + argv = append([]string{"-n", "systemctl"}, args...) + } + cmd := exec.Command("sh", append([]string{"-c", `sleep 0.4; exec "$@"`, "sh", bin}, argv...)...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return cmd.Start() +} + +// Install registers this product as an owner and enables the shared service and tray. +func Install(owner, tokenFile string, opts InstallOptions) error { + if _, err := addOwner(owner); err != nil { + return err + } + exe, err := Executable() + if err != nil { + return err + } + if err := ensureServiceUser(); err != nil { + return err + } + if err := os.MkdirAll("/etc/verae", 0o750); err != nil { + return err + } + cfgPath := SystemConfigPath() + if _, err := os.Stat(cfgPath); err != nil { + if err := os.WriteFile(cfgPath, ExampleJSON(serviceHome), 0o644); err != nil { + return err + } + } + noteInstallOptions(cfgPath, opts) + uid := userID(serviceUser) + if uid >= 0 { + _ = os.Chown("/etc/verae", uid, uid) + _ = os.Chown(cfgPath, uid, uid) + } + chosen := selectServiceBinary(exe, currentServiceExe()) + if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(unitPath, []byte(unitText(chosen, cfgPath)), 0o644); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(ctlPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(ctlPath, []byte(ctlScript()), 0o755); err != nil { + return err + } + if err := os.WriteFile(sudoersPath, []byte(sudoersText()), 0o440); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(trayDesktop), 0o755); err != nil { + return err + } + if err := os.WriteFile(trayDesktop, []byte(trayDesktopText(chosen, true)), 0o644); err != nil { + return err + } + linkBin(exe) + copyToken(tokenFile) + ensureFirewall() + _ = exec.Command("systemctl", "daemon-reload").Run() + if err := systemctlNow("enable", "--now", linuxUnit); err != nil { + return err + } + return nil +} + +// Uninstall drops this owner. The service, unit, and tray icon are removed only +// when neither the driver option nor the standalone package still wants them. +func Uninstall(owner string) error { + o, err := removeOwner(owner) + if err != nil { + return err + } + if !o.empty() { + if other := peerBinary(owner); other != "" { + cfgPath := SystemConfigPath() + _ = os.WriteFile(unitPath, []byte(unitText(other, cfgPath)), 0o644) + _ = os.WriteFile(trayDesktop, []byte(trayDesktopText(other, true)), 0o644) + _ = exec.Command("systemctl", "daemon-reload").Run() + _ = systemctlNow("restart", linuxUnit) + } + return nil + } + removeFirewall() + _ = systemctlNow("disable", "--now", linuxUnit) + _ = os.Remove(unitPath) + _ = os.Remove(trayDesktop) + _ = os.Remove(sudoersPath) + _ = exec.Command("systemctl", "daemon-reload").Run() + return nil +} + +func currentServiceExe() string { + b, err := os.ReadFile(unitPath) + if err != nil { + return "" + } + return parseExecStart(string(b)) +} + +func peerBinary(owner string) string { + p := "/usr/lib/verae-netprint/verae-print" + if owner == "standalone" { + p = "/usr/lib/verae-print/verae-print" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} + +// linkBin points /usr/bin/verae-print at this binary only when the driver +// package is not installed. An installed driver keeps the CLI. A real +// (non-symlink) file is left alone. +func linkBin(exe string) { + const link = "/usr/bin/verae-print" + const driver = "/usr/lib/verae-print/verae-print" + target := exe + if _, err := os.Stat(driver); err == nil { + target = driver + } + if target == link { + return + } + st, err := os.Lstat(link) + if err == nil && st.Mode()&os.ModeSymlink == 0 { + return + } + if err == nil { + _ = os.Remove(link) + } + _ = os.Symlink(target, link) +} + +func ensureFirewall() { + if _, err := exec.LookPath("ufw"); err != nil { + return + } + out, err := exec.Command("ufw", "status").CombinedOutput() + if err != nil || !strings.Contains(string(out), "Status: active") { + return + } + status := string(out) + for _, rule := range []string{"631/tcp", "8632/tcp", "5353/udp"} { + if strings.Contains(status, rule) { + continue + } + if err := exec.Command("ufw", "--force", "allow", rule, "comment", "Verae NetPrint").Run(); err != nil { + log.Printf("netprint: ufw allow %s: %v", rule, err) + } + } +} + +func removeFirewall() { + if _, err := exec.LookPath("ufw"); err != nil { + return + } + for _, rule := range []string{"631/tcp", "8632/tcp", "5353/udp"} { + _ = exec.Command("ufw", "--force", "delete", "allow", rule).Run() + } +} + +func ensureServiceUser() error { + if exec.Command("id", serviceUser).Run() == nil { + _ = os.MkdirAll(serviceHome, 0o750) + return nil + } + if _, err := exec.LookPath("useradd"); err == nil { + out, err := exec.Command("useradd", "--system", "--home", serviceHome, "--create-home", "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() + if err != nil { + return fmt.Errorf("useradd %s: %w (%s)", serviceUser, err, strings.TrimSpace(string(out))) + } + return nil + } + if _, err := exec.LookPath("adduser"); err == nil { + out, err := exec.Command("adduser", "--system", "--home", serviceHome, "--shell", "/usr/sbin/nologin", serviceUser).CombinedOutput() + if err != nil { + return fmt.Errorf("adduser %s: %w (%s)", serviceUser, err, strings.TrimSpace(string(out))) + } + } + return nil +} + +func userID(name string) int { + out, err := exec.Command("id", "-u", name).Output() + if err != nil { + return -1 + } + var id int + fmt.Sscan(strings.TrimSpace(string(out)), &id) + return id +} diff --git a/internal/netprint/service_log.go b/internal/netprint/service_log.go new file mode 100644 index 0000000..753b8a6 --- /dev/null +++ b/internal/netprint/service_log.go @@ -0,0 +1,66 @@ +package netprint + +import ( + "io" + "log" + "os" + "path/filepath" +) + +// serviceLogWriter appends each line to the log file and flushes it. +// A Windows service has no console, so os.Stderr.Write fails. io.MultiWriter +// stops at that first error and never reaches the file, which left +// netprint.log at 0 bytes. The file is written first; stderr is best-effort. +type serviceLogWriter struct { + file *os.File + extra io.Writer +} + +func (w *serviceLogWriter) Write(p []byte) (int, error) { + if w == nil || w.file == nil { + return 0, os.ErrInvalid + } + n, err := w.file.Write(p) + if err != nil { + return n, err + } + if n != len(p) { + return n, io.ErrShortWrite + } + _ = w.file.Sync() + if w.extra != nil { + _, _ = w.extra.Write(p) + } + return n, nil +} + +// setupServiceLog appends the service log under %ProgramData%\Verae\logs on Windows. +// VERAE_LOG_DIR overrides the directory (used by tests). +func setupServiceLog() { + dir := serviceLogDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf("netprint: log directory: %v", err) + return + } + _ = osChmodUsers(dir) + f, err := os.OpenFile(filepath.Join(dir, "netprint.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.Printf("netprint: log file: %v", err) + return + } + log.SetFlags(log.Ldate | log.Ltime) + log.SetOutput(&serviceLogWriter{file: f, extra: os.Stderr}) + log.Printf("netprint: log file %s", f.Name()) +} + +func serviceLogDir() string { + if d := os.Getenv("VERAE_LOG_DIR"); d != "" { + return d + } + return defaultServiceLogDir() +} + +func osChmodUsers(dir string) error { + // Users (S-1-5-32-545) can read the service log. Ignore failure when not elevated. + return execGrantUsers(dir) +} diff --git a/internal/netprint/service_log_other.go b/internal/netprint/service_log_other.go new file mode 100644 index 0000000..be035ce --- /dev/null +++ b/internal/netprint/service_log_other.go @@ -0,0 +1,21 @@ +//go:build !windows + +package netprint + +import ( + "os" + "path/filepath" +) + +func defaultServiceLogDir() string { + if os.Geteuid() == 0 { + return "/var/log/verae" + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "verae-logs") + } + return filepath.Join(home, ".verae", "logs") +} + +func execGrantUsers(string) error { return nil } diff --git a/internal/netprint/service_log_windows.go b/internal/netprint/service_log_windows.go new file mode 100644 index 0000000..e893c5a --- /dev/null +++ b/internal/netprint/service_log_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package netprint + +import ( + "os/exec" + "path/filepath" +) + +func defaultServiceLogDir() string { + return filepath.Join(programData(), "Verae", "logs") +} + +func execGrantUsers(dir string) error { + return exec.Command("icacls", dir, "/grant", "*S-1-5-32-545:(OI)(CI)R", "/C").Run() +} diff --git a/internal/netprint/service_run_other.go b/internal/netprint/service_run_other.go new file mode 100644 index 0000000..cef787a --- /dev/null +++ b/internal/netprint/service_run_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package netprint + +import "context" + +// RunCommand runs the printer in the foreground. Windows uses the service control manager. +func RunCommand(ctx context.Context, explicit string, flags Config, stamp StampFunc) error { + return Run(ctx, explicit, flags, stamp) +} diff --git a/internal/netprint/service_run_windows.go b/internal/netprint/service_run_windows.go new file mode 100644 index 0000000..efc3509 --- /dev/null +++ b/internal/netprint/service_run_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package netprint + +import ( + "context" + + "golang.org/x/sys/windows/svc" +) + +type svcHandler struct { + explicit string + flags Config + stamp StampFunc +} + +func (h *svcHandler) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) { + const accept = svc.AcceptStop | svc.AcceptShutdown + changes <- svc.Status{State: svc.StartPending} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errc := make(chan error, 1) + go func() { errc <- Run(ctx, h.explicit, h.flags, h.stamp) }() + changes <- svc.Status{State: svc.Running, Accepts: accept} + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Stop, svc.Shutdown: + changes <- svc.Status{State: svc.StopPending} + cancel() + <-errc + return false, 0 + case svc.Interrogate: + changes <- svc.Status{State: svc.Running, Accepts: accept} + } + case <-errc: + return false, 0 + } + } +} + +// RunCommand runs under the Windows service control manager when started by SCM. +func RunCommand(ctx context.Context, explicit string, flags Config, stamp StampFunc) error { + isSvc, err := svc.IsWindowsService() + if err == nil && isSvc { + return svc.Run(winService, &svcHandler{explicit: explicit, flags: flags, stamp: StampFunc(stamp)}) + } + return Run(ctx, explicit, flags, stamp) +} diff --git a/internal/netprint/service_windows.go b/internal/netprint/service_windows.go new file mode 100644 index 0000000..a2fdb63 --- /dev/null +++ b/internal/netprint/service_windows.go @@ -0,0 +1,255 @@ +//go:build windows + +package netprint + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +const runKey = `HKLM\Software\Microsoft\Windows\CurrentVersion\Run` +const ownerKey = `HKLM\Software\Verae\NetPrint` + +type osControl struct{} + +func (osControl) Start() error { return sc("start", winService) } +func (osControl) Stop() error { + return delayed("cmd.exe", "/c", "ping -n 2 127.0.0.1 >NUL & sc stop "+winService) +} +func (osControl) Restart() error { + return delayed("cmd.exe", "/c", "sc stop "+winService+" & ping -n 2 127.0.0.1 >NUL & sc start "+winService) +} +func (osControl) LaunchAtLogin() (bool, error) { + out, err := exec.Command("sc.exe", "qc", winService).CombinedOutput() + if err != nil { + return false, nil + } + return strings.Contains(strings.ToUpper(string(out)), "AUTO_START"), nil +} +func (osControl) SetLaunchAtLogin(on bool) error { + mode := "demand" + if on { + mode = "auto" + } + return sc("config", winService, "start=", mode) +} +func (osControl) TrayAtLogin() (bool, error) { + out, err := exec.Command("reg.exe", "query", runKey, "/v", "VeraeNetPrintTray").CombinedOutput() + return err == nil && strings.Contains(string(out), "VeraeNetPrintTray"), nil +} +func (osControl) SetTrayAtLogin(on bool) error { + exe, _ := Executable() + return setTrayFor(exe, on) +} + +func sc(args ...string) error { + out, err := exec.Command("sc.exe", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("sc %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func delayed(args ...string) error { + cmd := exec.Command(args[0], args[1:]...) + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x00000008} // DETACHED_PROCESS + return cmd.Start() +} + +func trayNextTo(serviceExe string) string { + serviceExe = strings.Trim(serviceExe, `"`) + if serviceExe == "" { + return `verae-print.exe tray` + } + sibling := filepath.Join(filepath.Dir(serviceExe), "verae-netprint-tray.exe") + if _, err := os.Stat(sibling); err == nil { + return `"` + sibling + `"` + } + return `"` + serviceExe + `" tray` +} + +func setTrayFor(serviceExe string, on bool) error { + if !on { + return exec.Command("reg.exe", "delete", runKey, "/v", "VeraeNetPrintTray", "/f").Run() + } + return exec.Command("reg.exe", "add", runKey, "/v", "VeraeNetPrintTray", "/t", "REG_SZ", "/d", trayNextTo(serviceExe), "/f").Run() +} + +func serviceBinPath(exe string) string { + return fmt.Sprintf(`"%s" netprint`, exe) +} + +func currentServiceExe() string { + out, err := exec.Command("sc.exe", "qc", winService).CombinedOutput() + if err != nil { + return "" + } + return parseWindowsBinPath(string(out)) +} + +func peerBinary(owner string) string { + rel := filepath.Join("Verae", "NetPrint", "bin", "verae-print.exe") + if owner == "standalone" { + rel = filepath.Join("Verae", "PrintStamp", "bin", "verae-print.exe") + } + var roots []string + if p := os.Getenv("ProgramW6432"); p != "" { + roots = append(roots, p) + } + if p := os.Getenv("ProgramFiles"); p != "" { + roots = append(roots, p) + } + roots = append(roots, `C:\Program Files`) + seen := map[string]bool{} + for _, root := range roots { + if seen[root] { + continue + } + seen[root] = true + p := filepath.Join(root, rel) + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func ensureFirewall(public bool) { + removeFirewall() + profile := firewallProfile(public) + rules := []struct{ name, proto, port string }{ + {"Verae NetPrint IPP", "TCP", "631"}, + {"Verae NetPrint IPP Fallback", "TCP", "8632"}, + {"Verae NetPrint mDNS", "UDP", "5353"}, + } + for _, r := range rules { + out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule", + "name="+r.name, "dir=in", "action=allow", "protocol="+r.proto, + "localport="+r.port, "remoteip="+firewallRemoteIP, "profile="+profile).CombinedOutput() + if err != nil { + log.Printf("netprint: firewall %s profile %s: %v (%s)", r.name, profile, err, strings.TrimSpace(string(out))) + } else { + log.Printf("netprint: firewall %s profile %s", r.name, profile) + } + } +} + +func applyFirewall(public bool) { ensureFirewall(public) } + +func removeFirewall() { + for _, name := range []string{"Verae NetPrint IPP", "Verae NetPrint IPP Fallback", "Verae NetPrint mDNS"} { + _ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule", "name="+name).Run() + } +} + +func setOwnerReg(name string, on bool) { + v := "0" + if on { + v = "1" + } + _ = exec.Command("reg.exe", "add", ownerKey, "/v", name, "/t", "REG_DWORD", "/d", v, "/f").Run() +} + +func ownerReg(name string) bool { + out, err := exec.Command("reg.exe", "query", ownerKey, "/v", name).CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "0x1") +} + +// Install registers the VeraeNetPrint service and the tray Run key. +func Install(owner, tokenFile string, opts InstallOptions) error { + if _, err := addOwner(owner); err != nil { + return err + } + if owner == "driver" { + setOwnerReg("Driver", true) + } else { + setOwnerReg("Standalone", true) + } + exe, err := Executable() + if err != nil { + return err + } + cfgPath := SystemConfigPath() + _ = os.MkdirAll(filepath.Dir(cfgPath), 0o755) + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + if err := os.WriteFile(cfgPath, ExampleJSON(defaultDataDir()), 0o644); err != nil { + return err + } + } + current := currentServiceExe() + chosen := selectServiceBinary(exe, current) + bin := serviceBinPath(chosen) + if current == "" { + if err := sc("create", winService, "binPath=", bin, "start=", "auto", "DisplayName=", "Verae Network Printer"); err != nil { + return err + } + } else if chosen != current { + _ = exec.Command("sc.exe", "stop", winService).Run() + if err := sc("config", winService, "binPath=", bin); err != nil { + return err + } + } + _ = exec.Command("sc.exe", "description", winService, "Verae network timestamp printer").Run() + _ = exec.Command("sc.exe", "config", winService, "start=", "auto").Run() + copyToken(tokenFile) + noteInstallOptions(cfgPath, opts) + public := false + if opts.AllowPublic != nil { + public = *opts.AllowPublic + } else if c, err := Load(cfgPath, Config{}); err == nil { + public = c.PublicNetworks() + } + ensureFirewall(public) + startErr := sc("start", winService) + if opts.LocalQueue && startErr == nil { + port := waitBoundPort(DefaultControlPort) + if err := addLocalQueue(port); err != nil { + log.Printf("netprint: local printer queue: %v", err) + } + } + if err := setTrayFor(chosen, true); err != nil { + log.Printf("netprint: tray autostart: %v", err) + } + if err := LaunchTrayNow(); err != nil { + log.Printf("netprint: tray launch: %v", err) + } + return startErr +} + +// Uninstall removes the service only when neither installer still owns it. +func Uninstall(owner string) error { + o, err := removeOwner(owner) + if err != nil { + return err + } + if owner == "driver" { + setOwnerReg("Driver", false) + } else { + setOwnerReg("Standalone", false) + } + if !o.empty() || ownerReg("Driver") || ownerReg("Standalone") { + if other := peerBinary(owner); other != "" { + _ = exec.Command("sc.exe", "stop", winService).Run() + _ = sc("config", winService, "binPath=", serviceBinPath(other)) + _ = sc("start", winService) + _ = setTrayFor(other, true) + } + return nil + } + _ = exec.Command("sc.exe", "stop", winService).Run() + _ = exec.Command("sc.exe", "delete", winService).Run() + _ = setTrayFor("", false) + removeFirewall() + if err := removeLocalQueue(); err != nil { + log.Printf("netprint: remove local queue: %v", err) + } + return nil +} diff --git a/internal/netprint/share.go b/internal/netprint/share.go new file mode 100644 index 0000000..3dce608 --- /dev/null +++ b/internal/netprint/share.go @@ -0,0 +1,118 @@ +package netprint + +import ( + "log" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const receiptsShareName = "Verae Receipts" + +// receiptsShareStampPath is written by the optional installer package when +// the config file does not exist yet. Install consumes it once. +func receiptsShareStampPath() string { + return filepath.Join(filepath.Dir(SystemConfigPath()), "share-receipts-smb.on") +} + +// formatSMBURL is the address shown on the receipts page, for example +// smb://Georges-MacBook-Pro.local/Verae%20Receipts. An IP is used as-is. +func formatSMBURL(host string) string { + host = strings.TrimSpace(host) + host = strings.TrimSuffix(host, ".") + if host == "" || strings.EqualFold(host, "localhost") { + host = "localhost" + } else if net.ParseIP(host) == nil && !strings.HasSuffix(strings.ToLower(host), ".local") { + host += ".local" + } + return "smb://" + host + "/" + url.PathEscape(receiptsShareName) +} + +func smbHost() string { + h, err := os.Hostname() + if err == nil { + h = strings.TrimSpace(h) + h = strings.TrimSuffix(h, ".local") + if h != "" && !strings.EqualFold(h, "localhost") { + return h + } + } + if ips := lanIPv4(); len(ips) > 0 { + return ips[0].String() + } + return "localhost" +} + +func smbURLIfOn(cfg Config) string { + if !cfg.ShareReceiptsOn() || !supportsSMBShare() { + return "" + } + return formatSMBURL(smbHost()) +} + +// EnableReceiptsShare records shareReceiptsSMB and creates the share. +// It does not start the print server. The macOS installer calls it. +func EnableReceiptsShare() error { + path := SystemConfigPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + cfg, err := Load(path, Config{}) + if err != nil { + return err + } + on := true + cfg.ShareReceiptsSMB = &on + if err := cfg.normalize(); err != nil { + return err + } + if err := Save(path, cfg); err != nil { + return err + } + _ = os.Remove(receiptsShareStampPath()) + if err := applyReceiptsShare(true); err != nil { + return err + } + kickstartNetprint() + return nil +} + +// consumeReceiptsShareStamp turns the share on when the installer left a +// marker because the config file was not there yet. The marker is removed +// so a later upgrade does not turn the share back on after Settings saved off. +func consumeReceiptsShareStamp(cfg *Config) bool { + p := receiptsShareStampPath() + if _, err := os.Stat(p); err != nil { + return false + } + on := true + cfg.ShareReceiptsSMB = &on + _ = os.Remove(p) + return true +} + +// healReceiptsShare asks a running daemon to recreate a missing share. +// The menu-bar process is not root, so it does not call sharing(8) itself. +func healReceiptsShare() { + if os.Getenv("VERAE_TRAY_SMOKE") == "1" { + return + } + go func() { + c := NewClient(DefaultControlPort) + var last error + for i := 0; i < 5; i++ { + if err := c.EnsureReceiptsShare(); err == nil { + return + } else { + last = err + } + time.Sleep(time.Second) + } + if last != nil { + log.Printf("netprint tray: receipts share: %v", last) + } + }() +} diff --git a/internal/netprint/share_darwin.go b/internal/netprint/share_darwin.go new file mode 100644 index 0000000..0f5f861 --- /dev/null +++ b/internal/netprint/share_darwin.go @@ -0,0 +1,101 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func supportsSMBShare() bool { return true } + +// applyReceiptsShare creates or removes the read-only SMB point. +// The folder stays root-owned and not world-writable. Guests stay off. +// File Sharing is enabled so the share is still there after reboot. +func applyReceiptsShare(on bool) error { + if !on { + removeReceiptsShare() + return nil + } + if err := os.MkdirAll(darwinSharedReceiptsDir, 0o755); err != nil { + return err + } + _ = os.Chown(darwinSharedReceiptsDir, 0, 0) + if err := os.Chmod(darwinSharedReceiptsDir, 0o755); err != nil { + return err + } + if _, err := exec.LookPath("sharing"); err != nil { + return fmt.Errorf("sharing(8) is not available") + } + if !receiptsSharePresent() { + // -s 001 is SMB only. -g 000 keeps guest access off. + out, err := exec.Command("sharing", + "-a", darwinSharedReceiptsDir, + "-n", receiptsShareName, + "-S", receiptsShareName, + "-s", "001", + "-g", "000", + ).CombinedOutput() + if err != nil && !receiptsSharePresent() { + return fmt.Errorf("sharing: %w (%s)", err, strings.TrimSpace(string(out))) + } + } + setReceiptsShareReadOnly() + ensureFileSharing() + return nil +} + +func receiptsSharePresent() bool { + out, err := exec.Command("sharing", "-l").CombinedOutput() + if err != nil { + return false + } + text := string(out) + return strings.Contains(text, receiptsShareName) && strings.Contains(text, darwinSharedReceiptsDir) +} + +func setReceiptsShareReadOnly() { + if _, err := exec.LookPath("dscl"); err != nil { + return + } + rec := "/SharePoints/" + receiptsShareName + if err := exec.Command("dscl", ".", "-read", rec).Run(); err != nil { + return + } + for _, kv := range [][2]string{ + {"smb_shared", "1"}, + {"smb_guestaccess", "0"}, + {"smb_read_only", "1"}, + {"directory_path", darwinSharedReceiptsDir}, + } { + _ = exec.Command("dscl", ".", "-create", rec, kv[0], kv[1]).Run() + } +} + +// ensureFileSharing turns smbd on and leaves it enabled across reboots. +// A running smbd is not restarted. +func ensureFileSharing() { + _ = exec.Command("launchctl", "enable", "system/com.apple.smbd").Run() + out, err := exec.Command("launchctl", "print", "system/com.apple.smbd").CombinedOutput() + if err == nil && strings.Contains(string(out), "state = running") { + return + } + if exec.Command("launchctl", "bootstrap", "system", "/System/Library/LaunchDaemons/com.apple.smbd.plist").Run() == nil { + return + } + _ = exec.Command("launchctl", "kickstart", "system/com.apple.smbd").Run() +} + +// removeReceiptsShare drops the SMB point. The receipts folder stays. +func removeReceiptsShare() { + if _, err := exec.LookPath("sharing"); err != nil { + return + } + _ = exec.Command("sharing", "-r", receiptsShareName).Run() +} + +func kickstartNetprint() { + _ = exec.Command("launchctl", "kickstart", "-k", "system/com.verae.netprint").Run() +} diff --git a/internal/netprint/share_other.go b/internal/netprint/share_other.go new file mode 100644 index 0000000..a5d6a3f --- /dev/null +++ b/internal/netprint/share_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package netprint + +func supportsSMBShare() bool { return false } + +func applyReceiptsShare(bool) error { return nil } + +func removeReceiptsShare() {} + +func kickstartNetprint() {} diff --git a/internal/netprint/share_test.go b/internal/netprint/share_test.go new file mode 100644 index 0000000..5127c09 --- /dev/null +++ b/internal/netprint/share_test.go @@ -0,0 +1,111 @@ +package netprint + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func TestFormatSMBURL(t *testing.T) { + cases := map[string]string{ + "Georges-MacBook-Pro": "smb://Georges-MacBook-Pro.local/Verae%20Receipts", + "Georges-MacBook-Pro.local": "smb://Georges-MacBook-Pro.local/Verae%20Receipts", + "192.168.1.108": "smb://192.168.1.108/Verae%20Receipts", + "": "smb://localhost/Verae%20Receipts", + } + for in, want := range cases { + if got := formatSMBURL(in); got != want { + t.Fatalf("%q -> %s, want %s", in, got, want) + } + } + if Defaults().ShareReceiptsOn() { + t.Fatal("share defaults off") + } + sample := string(ExampleJSON("/tmp")) + if !strings.Contains(sample, `"shareReceiptsSMB": false`) { + t.Fatal(sample) + } +} + +func TestReceiptsPageShowsSMBURL(t *testing.T) { + st, err := OpenStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const link = "smb://Georges-MacBook-Pro.local/Verae%20Receipts" + a := &Admin{Store: st, SMBURL: func() string { return link }} + req := httptest.NewRequest(http.MethodGet, "/receipts", nil) + rr := httptest.NewRecorder() + a.ServeHTTP(rr, req) + body := rr.Body.String() + if !strings.Contains(body, link) || !strings.Contains(body, "username and password") { + t.Fatal(body) + } +} + +func TestMenuShareToggleUsesLiveURL(t *testing.T) { + st := Status{ + Running: true, SupportsSMBShare: true, ShareReceiptsSMB: true, + SMBURL: "smb://Georges-MacBook-Pro.local/Verae%20Receipts", + } + var sawToggle, sawURL bool + for _, e := range BuildMenu(st) { + if e.Kind == "share" && e.Checked && e.Label == "Share receipts folder over SMB" { + sawToggle = true + } + if strings.Contains(e.Label, st.SMBURL) { + sawURL = true + } + } + if !sawToggle || !sawURL { + t.Fatalf("toggle %v url %v", sawToggle, sawURL) + } + off := BuildMenu(Status{Running: true}) + for _, e := range off { + if e.Kind == "share" { + t.Fatal("share toggle is macOS only") + } + } +} + +func TestShareReceiptsSettingRoundTrip(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, stampMock(t), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + c := NewClient(cfg.ControlPort) + if err := c.SetShareReceiptsSMB(true); err != nil { + t.Fatal(err) + } + st, err := c.Status() + if err != nil || !st.ShareReceiptsSMB { + t.Fatalf("%+v %v", st, err) + } + if supportsSMBShare() { + if st.SMBURL == "" || !strings.Contains(st.SMBURL, "Verae%20Receipts") { + t.Fatal(st.SMBURL) + } + } else if st.SMBURL != "" { + t.Fatal(st.SMBURL) + } + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved), `"shareReceiptsSMB": true`) { + t.Fatal(string(saved)) + } + if err := c.EnsureReceiptsShare(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/netprint/shortcut_page.go b/internal/netprint/shortcut_page.go new file mode 100644 index 0000000..4494bd6 --- /dev/null +++ b/internal/netprint/shortcut_page.go @@ -0,0 +1,39 @@ +package netprint + +import ( + "fmt" + "html" + "net/http" + "strings" +) + +func (s *Server) serveShortcutGuide(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + stamp := "http://" + r.Host + "/api/stamp" + if r.Host == "" { + stamp = strings.TrimSuffix(s.receiptsURL(), "/receipts") + "/api/stamp" + } + page := fmt.Sprintf(`iPhone shortcut + + +

Receipts

+

Verae Timestamp shortcut

+

Build this in the Shortcuts app so the Share sheet sends a PDF or photo here and returns the stamped certificate. A signed shortcut file is not served: Apple signs those on a Mac or iPhone.

+
    +
  1. Open Shortcuts, tap +, and name it Verae Timestamp.
  2. +
  3. Turn on Show in Share Sheet. Keep PDFs and Images selected.
  4. +
  5. Add Get Contents of URL. URL %s. Method POST. Request Body: File. File: Shortcut Input. If a PIN is set, add ?access= and the PIN.
  6. +
  7. Add a header X-Filename when you want that name as the job title. PDF and JPEG are recognized from the file itself.
  8. +
  9. Add Quick Look on the result, then Save File, and leave Ask Where to Save on.
  10. +
  11. Add Get Dictionary Value. Dictionary: Headers of the URL result. Key X-Verae-Receipt-Id. Show Result. X-Verae-SHA256 is the hash and X-Verae-Receipt-JSON is the receipt summary.
  12. +
  13. For a zip of the original, the stamped PDF, and the receipt JSON, use %s?format=zip.
  14. +
+

From Files or Photos, tap Share and choose Verae Timestamp. The printer lists the job and emails it when email is on.

+`, html.EscapeString(stamp), html.EscapeString(stamp)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(page)) +} diff --git a/internal/netprint/stamp_api.go b/internal/netprint/stamp_api.go new file mode 100644 index 0000000..2fcf1f4 --- /dev/null +++ b/internal/netprint/stamp_api.go @@ -0,0 +1,259 @@ +package netprint + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "log" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" +) + +// handleStamp runs the print pipeline and returns the stamped PDF, or a zip +// of the original, the certificate, and the receipt JSON when format=zip. +func (s *Server) handleStamp(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + doc, title, format, err := readStampBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(doc) == 0 { + http.Error(w, "empty document", http.StatusBadRequest) + return + } + if len(doc) > maxDocument { + http.Error(w, "document is too large", http.StatusRequestEntityTooLarge) + return + } + id, err := s.store.AllocateID() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + user := stampUser(r.Header.Get("X-Verae-User")) + log.Printf("netprint: stamp api job %d title=%q bytes=%d", id, title, len(doc)) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) + rec, ok := s.store.Get(id) + if !ok || rec.State != "completed" || rec.CertificatePDF == "" { + msg := "stamp failed" + if rec.Error != "" { + msg = rec.Error + } + http.Error(w, msg, http.StatusUnprocessableEntity) + return + } + cert, err := readStampFile(rec.CertificatePDF) + if err != nil { + http.Error(w, "stamped PDF is missing", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Verae-Receipt-Id", rec.ReceiptID) + w.Header().Set("X-Verae-SHA256", rec.SHA256) + w.Header().Set("X-Verae-Receipt-JSON", s.receiptJSONURL(r, id)) + if strings.EqualFold(r.URL.Query().Get("format"), "zip") { + payload, err := stampZip(rec, cert) + if err != nil { + http.Error(w, "could not build zip", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="verae-stamp.zip"`) + _, _ = w.Write(payload) + return + } + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="certificate.pdf"`) + _, _ = w.Write(cert) +} + +func (s *Server) receiptJSONURL(r *http.Request, id int) string { + host := r.Host + if host == "" { + host = strings.TrimPrefix(strings.TrimSuffix(s.receiptsURL(), "/receipts"), "http://") + } + return fmt.Sprintf("http://%s/jobs/%d/receipt.json", host, id) +} + +func (s *Server) authorizeStamp(w http.ResponseWriter, r *http.Request) bool { + pin, err := s.receiptsPIN() + if err != nil { + log.Printf("netprint: stamp lock: %v", err) + http.Error(w, "stamp unavailable", http.StatusForbidden) + return false + } + if pin == "" || stampTokenOK(r, pin) { + return true + } + w.Header().Set("WWW-Authenticate", `Bearer realm="verae-stamp"`) + http.Error(w, "PIN required", http.StatusUnauthorized) + return false +} + +func stampTokenOK(r *http.Request, pin string) bool { + if c, err := r.Cookie(receiptsCookie); err == nil && cookieMatches(c.Value, pin) { + return true + } + if access := strings.TrimSpace(r.URL.Query().Get("access")); access != "" && pinMatch(access, pin) { + return true + } + if h := strings.TrimSpace(r.Header.Get("X-Verae-Access")); h != "" && pinMatch(h, pin) { + return true + } + authz := strings.TrimSpace(r.Header.Get("Authorization")) + if len(authz) > 7 && strings.EqualFold(authz[:7], "bearer ") && pinMatch(strings.TrimSpace(authz[7:]), pin) { + return true + } + return false +} + +func readStampBody(r *http.Request) (doc []byte, title, format string, err error) { + ct := r.Header.Get("Content-Type") + mediaType, _, _ := mime.ParseMediaType(ct) + if strings.HasPrefix(mediaType, "multipart/") { + r.Body = http.MaxBytesReader(nil, r.Body, maxDocument+1<<20) + mr, err := r.MultipartReader() + if err != nil { + return nil, "", "", fmt.Errorf("bad multipart body") + } + var part *multipart.Part + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, "", "", fmt.Errorf("bad multipart body") + } + name := p.FormName() + if part == nil || name == "file" || name == "document" || name == "pdf" { + if part != nil { + part.Close() + } + part = p + if name == "file" || name == "document" || name == "pdf" { + break + } + } else { + p.Close() + } + } + if part == nil { + return nil, "", "", fmt.Errorf("multipart body has no file") + } + defer part.Close() + doc, err = io.ReadAll(io.LimitReader(part, maxDocument+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read document") + } + title = stampTitle(part.FileName()) + format = part.Header.Get("Content-Type") + return doc, title, format, nil + } + doc, err = io.ReadAll(io.LimitReader(http.MaxBytesReader(nil, r.Body, maxDocument+1), maxDocument+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read document") + } + title = stampTitle(firstNonEmpty(r.Header.Get("X-Filename"), r.URL.Query().Get("filename"), filenameParam(r.Header.Get("Content-Disposition")))) + format = mediaType + return doc, title, format, nil +} + +func filenameParam(v string) string { + _, params, err := mime.ParseMediaType(v) + if err != nil { + return "" + } + return params["filename"] +} + +func stampTitle(name string) string { + name = strings.ReplaceAll(name, "\\", "/") + name = filepath.Base(name) + name = strings.Map(func(r rune) rune { + if r < 32 || r == '"' { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if name == "" || name == "." || name == "/" { + return "Stamp" + } + return name +} + +func stampUser(user string) string { + user = strings.TrimSpace(strings.Map(func(r rune) rune { + if r < 32 { + return -1 + } + return r + }, user)) + if user == "" { + return "stamp" + } + if len(user) > 80 { + user = user[:80] + } + return user +} + +func readStampFile(path string) ([]byte, error) { + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("missing") + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, maxDocument+1)) +} + +func stampZip(rec Record, cert []byte) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + add := func(name string, b []byte) error { + if len(b) == 0 { + return nil + } + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = w.Write(b) + return err + } + origPath := rec.OriginalPDF + if origPath == "" { + origPath = filepath.Join(filepath.Dir(rec.CertificatePDF), "document.pdf") + } + orig, _ := readStampFile(origPath) + if err := add("original.pdf", orig); err != nil { + _ = zw.Close() + return nil, err + } + if err := add("certificate.pdf", cert); err != nil { + _ = zw.Close() + return nil, err + } + js, _ := readStampFile(rec.ReceiptJSON) + if err := add("receipt.json", js); err != nil { + _ = zw.Close() + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/netprint/stamp_api_test.go b/internal/netprint/stamp_api_test.go new file mode 100644 index 0000000..790f357 --- /dev/null +++ b/internal/netprint/stamp_api_test.go @@ -0,0 +1,326 @@ +package netprint + +import ( + "archive/zip" + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job" + "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" +) + +func TestStampAPIReturnsCertificate(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, localStamp(), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + on := true + s.mu.Lock() + s.cfg.EmailEnabled = &on + s.cfg.EmailTo = DefaultEmailTo + s.mu.Unlock() + if err := saveSecrets(s.secretsFile(), Secrets{SMTPPassword: "pw"}); err != nil { + t.Fatal(err) + } + prev := sendMail + var mu sync.Mutex + var mailed mailer.Message + sendMail = func(c mailer.Config, m mailer.Message) error { + mu.Lock() + mailed = m + mu.Unlock() + return nil + } + t.Cleanup(func() { sendMail = prev }) + + req, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp"), strings.NewReader("%PDF-1.4 contract")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/pdf") + req.Header.Set("X-Filename", "Contract.pdf") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusOK || !bytes.HasPrefix(body, []byte("%PDF-cert")) { + t.Fatalf("%d %q", res.StatusCode, body) + } + if res.Header.Get("X-Verae-Receipt-Id") != "QmabStamp" || res.Header.Get("X-Verae-SHA256") == "" || !strings.Contains(res.Header.Get("X-Verae-Receipt-JSON"), "/receipt.json") { + t.Fatalf("headers %+v", res.Header) + } + page := getBody(t, ippURL(s, "/receipts")) + if !strings.Contains(page, "Contract.pdf") || !strings.Contains(page, "QmabStamp") || !strings.Contains(page, "iPhone Shortcut") || !strings.Contains(page, "data:image/png;base64,") { + t.Fatal(page) + } + guide := getBody(t, ippURL(s, "/ios-shortcut")) + if !strings.Contains(guide, "Share Sheet") || !strings.Contains(guide, "/api/stamp") || !strings.Contains(guide, "X-Verae-Receipt-Id") { + t.Fatal(guide) + } + other := httpStatus(t, ippURL(s, "/api/v1/status")) + if other != http.StatusNotFound { + t.Fatalf("control route on the LAN listener: %d", other) + } + deadline := time.Now().Add(2 * time.Second) + var subject, text string + for time.Now().Before(deadline) { + mu.Lock() + subject, text = mailed.Subject, mailed.Text + mu.Unlock() + if strings.Contains(text, "QmabStamp") { + break + } + time.Sleep(15 * time.Millisecond) + } + if !strings.Contains(subject, "Verae timestamp: Contract.pdf") || !strings.Contains(text, "QmabStamp") { + t.Fatalf("mail %q %q", subject, text) + } + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file", "Photo.jpg") + if err != nil { + t.Fatal(err) + } + _, _ = part.Write([]byte("%PDF-1.4 note")) + _ = mw.Close() + mreq, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp?format=zip"), &buf) + if err != nil { + t.Fatal(err) + } + mreq.Header.Set("Content-Type", mw.FormDataContentType()) + mres, err := http.DefaultClient.Do(mreq) + if err != nil { + t.Fatal(err) + } + zbody, _ := io.ReadAll(mres.Body) + mres.Body.Close() + if mres.StatusCode != http.StatusOK || mres.Header.Get("Content-Type") != "application/zip" || mres.Header.Get("X-Verae-Receipt-Id") != "QmabStamp" { + t.Fatalf("zip %d %s %q", mres.StatusCode, mres.Header.Get("Content-Type"), zbody) + } + zr, err := zip.NewReader(bytes.NewReader(zbody), int64(len(zbody))) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, f := range zr.File { + names[f.Name] = true + } + for _, name := range []string{"original.pdf", "certificate.pdf", "receipt.json"} { + if !names[name] { + t.Fatalf("zip names %+v", names) + } + } +} + +func TestStampAPIUsesPINAndNetwork(t *testing.T) { + cfg, path := testConfig(t) + s, err := Start(cfg, path, localStamp(), &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + if err := saveSecrets(s.secretsFile(), Secrets{ReceiptsPIN: "2468"}); err != nil { + t.Fatal(err) + } + res, err := http.Post(ippURL(s, "/api/stamp"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + locked, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusUnauthorized || bytes.Contains(locked, []byte("%PDF")) { + t.Fatalf("%d %q", res.StatusCode, locked) + } + ok, err := http.Post(ippURL(s, "/api/stamp?access=2468"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(ok.Body) + ok.Body.Close() + if ok.StatusCode != http.StatusOK || !bytes.HasPrefix(got, []byte("%PDF-cert")) { + t.Fatalf("access %d %q", ok.StatusCode, got) + } + req, err := http.NewRequest(http.MethodPost, ippURL(s, "/api/stamp"), strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/pdf") + req.Header.Set("X-Verae-Access", "2468") + hres, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + hbody, _ := io.ReadAll(hres.Body) + hres.Body.Close() + if hres.StatusCode != http.StatusOK || !bytes.HasPrefix(hbody, []byte("%PDF-cert")) { + t.Fatalf("header %d %q", hres.StatusCode, hbody) + } + + prev := currentNetworkPublic + currentNetworkPublic = func() bool { return true } + t.Cleanup(func() { currentNetworkPublic = prev }) + blocked, err := http.Post(ippURL(s, "/api/stamp?access=2468"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + bb, _ := io.ReadAll(blocked.Body) + blocked.Body.Close() + if blocked.StatusCode != http.StatusForbidden || bytes.Contains(bb, []byte("%PDF-cert")) { + t.Fatalf("public %d %q", blocked.StatusCode, bb) + } +} + +func TestVeraeAnnounceStaysOffIPPRecords(t *testing.T) { + svc := sampleService() + svc.SubtypesOnly = true + svc.StampURL = "http://192.0.2.10:631/api/stamp" + if err := assertSubtypePTRsOnly(svc.Announce(false), "Verae Timestamp"); err != nil { + t.Fatal(err) + } + ans, extra, _, err := parsePacket(svc.VeraeAnnounce(false)) + if err != nil { + t.Fatal(err) + } + var sawPTR, sawSRV, sawTXT, sawAddr bool + for _, rr := range append(ans, extra...) { + switch rr.typ { + case dnsTypePTR: + if rr.name == "_verae._tcp.local" && rr.ptr == "verae timestamp._verae._tcp.local" { + sawPTR = true + } + if rr.name == "_ipp._tcp.local" { + t.Fatal("verae announcement included _ipp._tcp") + } + case dnsTypeSRV: + sawSRV = rr.port == 631 + case dnsTypeTXT: + if strings.Contains(strings.Join(rr.txt, "\n"), "path=/api/stamp") && strings.Contains(strings.Join(rr.txt, "\n"), "http://192.0.2.10:631/api/stamp") { + sawTXT = true + } + case dnsTypeA, dnsTypeAAAA: + sawAddr = true + } + } + if !sawPTR || !sawSRV || !sawTXT || sawAddr { + t.Fatalf("ptr %v srv %v txt %v addr %v", sawPTR, sawSRV, sawTXT, sawAddr) + } + if svc.ReplyVerae(Query(1, []string{"_verae", "_tcp", "local"}, dnsTypePTR)) == nil { + t.Fatal("did not answer _verae._tcp") + } + full := sampleService() + full.StampURL = svc.StampURL + _, extra, _, err = parsePacket(full.VeraeAnnounce(false)) + if err != nil { + t.Fatal(err) + } + var sawA bool + for _, rr := range extra { + if rr.typ == dnsTypeA { + sawA = true + } + } + if !sawA { + t.Fatal("full _verae announcement omitted the address") + } +} + +func TestStampAPIRejectsMockReceipt(t *testing.T) { + cfg, path := testConfig(t) + var calls int + stamp := func(pdf, title, user string) (job.Result, error) { + calls++ + dir := filepath.Dir(pdf) + js := filepath.Join(dir, "mock.verae.json") + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"mock-cert-9\",\"merkle\":\"42\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{ReceiptID: "mock-cert-9", SHA256: strings.Repeat("ab", 32), ReceiptJSON: js, OriginalPDF: pdf}, nil + } + s, err := Start(cfg, path, stamp, &fakeCtrl{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = s.Shutdown(ctx) + }) + res, err := http.Post(ippURL(s, "/api/stamp"), "application/pdf", strings.NewReader("%PDF-1.4")) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(res.Body) + res.Body.Close() + if res.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status %d body %q", res.StatusCode, body) + } + if id := res.Header.Get("X-Verae-Receipt-Id"); id != "" || strings.Contains(string(body), "mock-cert") && res.StatusCode == http.StatusOK { + t.Fatalf("mock receipt returned: %s %q", id, body) + } + if !strings.Contains(string(body), "mock receipt") { + t.Fatalf("body %q", body) + } + if calls != 1 { + t.Fatalf("stamp calls %d", calls) + } + id, err := s.store.AllocateID() + if err != nil { + t.Fatal(err) + } + ProcessJob(s.store, s.snapshot(), nil, id, "application/pdf", "Printed", "ada", []byte("%PDF-1.4 print"), stamp, nil, nil) + rec, ok := s.store.Get(id) + if !ok || rec.State == "completed" || !strings.Contains(rec.Error, "mock receipt") { + t.Fatalf("%+v", rec) + } +} + +func localStamp() StampFunc { + return func(pdf, title, user string) (job.Result, error) { + dir := filepath.Dir(pdf) + cert := filepath.Join(dir, "certificate.pdf") + js := filepath.Join(dir, title+".verae.json") + if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil { + return job.Result{}, err + } + if err := os.WriteFile(js, []byte("{\"receipt_id\":\"QmabStamp\"}\n"), 0o600); err != nil { + return job.Result{}, err + } + return job.Result{ + SHA256: strings.Repeat("ab", 32), ReceiptID: "QmabStamp", + CertificatePDF: cert, OriginalPDF: pdf, ReceiptJSON: js, + }, nil + } +} + +func httpStatus(t *testing.T, url string) int { + t.Helper() + res, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + return res.StatusCode +} diff --git a/internal/netprint/store.go b/internal/netprint/store.go new file mode 100644 index 0000000..220279b --- /dev/null +++ b/internal/netprint/store.go @@ -0,0 +1,195 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Record is one network print job as shown on the admin page and kept across restarts. +type Record struct { + ID int `json:"id"` + Received time.Time `json:"received"` + Completed time.Time `json:"completed,omitempty"` + User string `json:"user,omitempty"` + Title string `json:"title,omitempty"` + Format string `json:"format,omitempty"` + State string `json:"state"` + Error string `json:"error,omitempty"` + SHA256 string `json:"sha256,omitempty"` + ReceiptID string `json:"receiptId,omitempty"` + VerifyURL string `json:"verifyUrl,omitempty"` + OriginalPDF string `json:"originalPdf,omitempty"` + CertificatePDF string `json:"certificatePdf,omitempty"` + ReceiptJSON string `json:"receiptJson,omitempty"` + ForwardedTo string `json:"forwardedTo,omitempty"` + ForwardError string `json:"forwardError,omitempty"` + Notified string `json:"notified,omitempty"` + EmailError string `json:"emailError,omitempty"` +} + +// Store persists the job index under DataDir/jobs.json. +type Store struct { + dir string + mu sync.Mutex + all []Record +} + +func OpenStore(dir string) (*Store, error) { + if err := mkdirReceipts(dir); err != nil { + return nil, err + } + s := &Store{dir: dir} + b, err := os.ReadFile(s.path()) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, err + } + if len(b) == 0 { + return s, nil + } + if err := json.Unmarshal(b, &s.all); err != nil { + return nil, fmt.Errorf("jobs.json: %w", err) + } + return s, nil +} + +func (s *Store) path() string { return filepath.Join(s.dir, "jobs.json") } + +func (s *Store) Dir() string { return s.dir } + +// List returns a copy, newest id last. +func (s *Store) List() []Record { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Record, len(s.all)) + copy(out, s.all) + return out +} + +func (s *Store) Get(id int) (Record, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range s.all { + if r.ID == id { + return r, true + } + } + return Record{}, false +} + +func (s *Store) seqPath() string { return filepath.Join(s.dir, "next-job") } + +// NextID is the next job id that AllocateID would return, without reserving it. +func (s *Store) NextID() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.highWaterLocked() +} + +// AllocateID reserves a job id and its job-N directory. The counter and the +// directory both survive a process restart, so a later job cannot land in an +// earlier job-N folder. +func (s *Store) AllocateID() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + next := s.highWaterLocked() + for { + dir := filepath.Join(s.dir, fmt.Sprintf("job-%d", next)) + err := os.Mkdir(dir, receiptDirMode()) + if err == nil { + if cerr := os.Chmod(dir, receiptDirMode()); cerr != nil { + return 0, cerr + } + if werr := s.writeSeq(next + 1); werr != nil { + return 0, werr + } + return next, nil + } + if !os.IsExist(err) { + return 0, err + } + next++ + if next > 1_000_000_000 { + return 0, fmt.Errorf("job id space exhausted") + } + } +} + +func (s *Store) highWaterLocked() int { + next := 1 + if n, err := s.readSeq(); err == nil && n > next { + next = n + } + for _, r := range s.all { + if r.ID+1 > next { + next = r.ID + 1 + } + } + entries, err := os.ReadDir(s.dir) + if err != nil { + return next + } + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, "job-") { + continue + } + n, err := strconv.Atoi(name[len("job-"):]) + if err != nil || n < 1 { + continue + } + if n+1 > next { + next = n + 1 + } + } + return next +} + +func (s *Store) readSeq() (int, error) { + b, err := os.ReadFile(s.seqPath()) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(b))) +} + +func (s *Store) writeSeq(n int) error { + tmp := s.seqPath() + ".tmp" + if err := writeReceiptFile(tmp, []byte(strconv.Itoa(n)+"\n")); err != nil { + return err + } + return os.Rename(tmp, s.seqPath()) +} + +func (s *Store) Put(r Record) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.all { + if s.all[i].ID == r.ID { + s.all[i] = r + return s.flush() + } + } + s.all = append(s.all, r) + return s.flush() +} + +func (s *Store) flush() error { + b, err := json.MarshalIndent(s.all, "", " ") + if err != nil { + return err + } + tmp := s.path() + ".tmp" + if err := writeReceiptFile(tmp, append(b, '\n')); err != nil { + return err + } + return os.Rename(tmp, s.path()) +} diff --git a/internal/netprint/tray.go b/internal/netprint/tray.go new file mode 100644 index 0000000..321e951 --- /dev/null +++ b/internal/netprint/tray.go @@ -0,0 +1,23 @@ +package netprint + +import "context" + +// RunTray blocks in the platform menu (Windows notification area, macOS status +// item, or a freedesktop StatusNotifierItem). It talks to the print service +// over the loopback control API. +func RunTray(ctx context.Context) error { + healReceiptsShare() + return runTray(ctx) +} + +// trayLoopExit describes why the Windows message loop returned. +func trayLoopExit(code int32) string { + switch { + case code == 0: + return "WM_QUIT" + case code < 0: + return "GetMessage error" + default: + return "stopped" + } +} diff --git a/internal/netprint/tray_darwin.go b/internal/netprint/tray_darwin.go new file mode 100644 index 0000000..5aeb36d --- /dev/null +++ b/internal/netprint/tray_darwin.go @@ -0,0 +1,361 @@ +//go:build darwin + +package netprint + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/ebitengine/purego" + "github.com/ebitengine/purego/objc" +) + +// trayLogRel is the menu-bar log under the user's home directory. +const trayLogRel = "Library/Logs/Verae/netprint-tray.log" + +var ( + trayOnce sync.Once + trayTarget objc.ID + trayMenu objc.ID + trayMu sync.Mutex + trayItems []MenuEntry + trayUserQuit bool + trayLogFile *os.File + selTray = objc.RegisterName("trayAction:") + selOpen = objc.RegisterName("menuWillOpen:") +) + +func runTray(ctx context.Context) error { + setupTrayLog() + log.Printf("netprint tray: start pid=%d args=%q", os.Getpid(), os.Args) + if os.Getenv("VERAE_TRAY_SMOKE") == "1" { + err := runTraySmoke() + syncTrayLog() + return err + } + if err := startDarwinTray(ctx); err != nil { + return failTray(err) + } + syncTrayLog() + return nil +} + +func startDarwinTray(ctx context.Context) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("tray panic: %v", r) + } + }() + if err = loadAppKit(); err != nil { + return err + } + if !mainThread() { + return fmt.Errorf("NSApplication must run on the main thread") + } + runtime.LockOSThread() + app, err := buildStatusItem() + if err != nil { + return err + } + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + startAirDropWatch(watchCtx) + log.Printf("netprint tray: entering NSApplication run") + syncTrayLog() + app.Send(objc.RegisterName("run")) + if trayUserQuit { + log.Printf("netprint tray: quit") + return nil + } + return fmt.Errorf("NSApplication run returned without quit") +} + +func runTraySmoke() error { + log.Printf("netprint tray: smoke") + if err := loadAppKit(); err != nil { + return err + } + if !mainThread() { + return fmt.Errorf("NSApplication must run on the main thread") + } + _, err := buildStatusItem() + if err != nil { + if !aquaSession() { + fmt.Println("no window server") + log.Printf("netprint tray: no window server") + return nil + } + return err + } + removeStatusItem() + fmt.Println("smoke ok") + log.Printf("netprint tray: smoke ok") + return nil +} + +func failTray(reason error) error { + if reason == nil { + reason = fmt.Errorf("tray failed") + } + log.Printf("netprint tray: %v", reason) + syncTrayLog() + if os.Getenv("VERAE_TRAY_SMOKE") == "1" { + return reason + } + helper, err := menuBarHelper() + if err != nil { + return fmt.Errorf("%w; menubar fallback: %v", reason, err) + } + log.Printf("netprint tray: exec %s", helper) + syncTrayLog() + if err := syscall.Exec(helper, []string{helper}, os.Environ()); err != nil { + return fmt.Errorf("%w; menubar fallback: %v", reason, err) + } + return nil +} + +func menuBarHelper() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + p := filepath.Join(filepath.Dir(exe), "verae-menubar") + st, err := os.Stat(p) + if err != nil || st.IsDir() || st.Mode()&0o111 == 0 { + return "", fmt.Errorf("verae-menubar missing next to %s", exe) + } + return p, nil +} + +func setupTrayLog() { + home, err := os.UserHomeDir() + if err != nil || home == "" { + home = "/tmp" + } + path := filepath.Join(home, trayLogRel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + log.SetOutput(os.Stderr) + log.Printf("netprint tray: cannot create %s: %v", filepath.Dir(path), err) + return + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.SetOutput(os.Stderr) + log.Printf("netprint tray: cannot open %s: %v", path, err) + return + } + trayLogFile = f + log.SetOutput(io.MultiWriter(os.Stderr, f)) + log.Printf("netprint tray: log %s", path) +} + +func syncTrayLog() { + if trayLogFile != nil { + _ = trayLogFile.Sync() + } +} + +func loadAppKit() error { + const ( + rtldLazy = 0x1 + rtldGlobal = 0x8 + ) + flags := rtldLazy | rtldGlobal + if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", flags); err != nil { + return fmt.Errorf("Foundation: %w", err) + } + if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", flags); err != nil { + return fmt.Errorf("AppKit: %w", err) + } + if objc.GetClass("NSApplication") == 0 || objc.GetClass("NSStatusBar") == 0 { + return fmt.Errorf("NSApplication missing after AppKit load") + } + log.Printf("netprint tray: AppKit loaded, mainThread=%v", mainThread()) + return nil +} + +func mainThread() bool { + return objc.Send[byte](cls("NSThread"), objc.RegisterName("isMainThread")) != 0 +} + +func aquaSession() bool { + if os.Getenv("SECURITYSESSIONID") != "" { + return true + } + out, err := exec.Command("/bin/launchctl", "managername").Output() + return err == nil && strings.TrimSpace(string(out)) == "Aqua" +} + +func buildStatusItem() (objc.ID, error) { + if err := ensureTrayClass(); err != nil { + return 0, err + } + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + if app == 0 { + return 0, fmt.Errorf("NSApplication sharedApplication is nil") + } + app.Send(objc.RegisterName("setActivationPolicy:"), int64(1)) // accessory, no Dock icon + bar := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + if bar == 0 { + return 0, fmt.Errorf("NSStatusBar is nil") + } + item := bar.Send(objc.RegisterName("statusItemWithLength:"), float64(-1)) + if item == 0 { + return 0, fmt.Errorf("status item is nil") + } + btn := item.Send(objc.RegisterName("button")) + if btn == 0 { + return 0, fmt.Errorf("status item button is nil") + } + if img := cls("NSImage").Send( + objc.RegisterName("imageWithSystemSymbolName:accessibilityDescription:"), + nsString("printer"), nsString("Verae"), + ); img != 0 { + img.Send(objc.RegisterName("setTemplate:"), byte(1)) + btn.Send(objc.RegisterName("setImage:"), img) + } + btn.Send(objc.RegisterName("setTitle:"), nsString("Verae")) + menu := cls("NSMenu").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + if menu == 0 { + return 0, fmt.Errorf("NSMenu is nil") + } + trayMenu = menu + trayTarget = cls("VeraeNetPrintTray").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + if trayTarget == 0 { + return 0, fmt.Errorf("tray target is nil") + } + menu.Send(objc.RegisterName("setDelegate:"), trayTarget) + item.Send(objc.RegisterName("setMenu:"), menu) + rebuildDarwinMenu(menu) + log.Printf("netprint tray: status item ready") + return app, nil +} + +func removeStatusItem() { + bar := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + item := cls("NSStatusBar").Send(objc.RegisterName("systemStatusBar")) + _ = bar + _ = item + // statusItemWithLength returns the item we would need to remove. Smoke + // exits immediately; the item disappears with the process. +} + +func ensureTrayClass() error { + var regErr error + trayOnce.Do(func() { + regErr = registerTrayClass() + }) + if cls("VeraeNetPrintTray") == 0 { + if regErr == nil { + regErr = fmt.Errorf("VeraeNetPrintTray was not registered") + } + return regErr + } + return nil +} + +func registerTrayClass() error { + methods := []objc.MethodDef{ + {Cmd: selOpen, Fn: menuWillOpen}, + {Cmd: selTray, Fn: trayAction}, + } + methods = append(methods, airDropMethods()...) + if _, err := objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods); err != nil { + log.Printf("netprint tray: register with notifications: %v", err) + methods = []objc.MethodDef{ + {Cmd: selOpen, Fn: menuWillOpen}, + {Cmd: selTray, Fn: trayAction}, + } + if _, err2 := objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods); err2 != nil { + return err2 + } + } + return nil +} + +func menuWillOpen(self objc.ID, _cmd objc.SEL, menu objc.ID) { + defer func() { _ = recover() }() + rebuildDarwinMenu(menu) +} + +func trayAction(self objc.ID, _cmd objc.SEL, sender objc.ID) { + defer func() { + if r := recover(); r != nil { + log.Printf("netprint tray: menu action panic: %v", r) + } + }() + tag := int(objc.Send[int64](sender, objc.RegisterName("tag"))) + trayMu.Lock() + items := append([]MenuEntry(nil), trayItems...) + trayMu.Unlock() + st := Probe() + for _, e := range items { + if e.ID != tag { + continue + } + err := Dispatch(st, e) + if IsQuit(err) { + trayUserQuit = true + log.Printf("netprint tray: quit selected") + syncTrayLog() + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + app.Send(objc.RegisterName("terminate:"), objc.ID(0)) + app.Send(objc.RegisterName("stop:"), objc.ID(0)) + return + } + if err != nil { + log.Printf("netprint tray: %s: %v", e.Kind, err) + } + return + } +} + +func rebuildDarwinMenu(menu objc.ID) { + st := Probe() + items := withAirDropAgain(BuildMenu(st)) + trayMu.Lock() + trayItems = items + trayMu.Unlock() + menu.Send(objc.RegisterName("removeAllItems")) + for _, e := range items { + if e.Separator { + sep := cls("NSMenuItem").Send(objc.RegisterName("separatorItem")) + menu.Send(objc.RegisterName("addItem:"), sep) + continue + } + item := cls("NSMenuItem").Send(objc.RegisterName("alloc")).Send( + objc.RegisterName("initWithTitle:action:keyEquivalent:"), + nsString(e.Label), selTray, nsString(""), + ) + item.Send(objc.RegisterName("setTag:"), int64(e.ID)) + item.Send(objc.RegisterName("setTarget:"), trayTarget) + en := byte(0) + if e.Enabled { + en = 1 + } + item.Send(objc.RegisterName("setEnabled:"), en) + if e.Checked { + item.Send(objc.RegisterName("setState:"), int64(1)) + } + menu.Send(objc.RegisterName("addItem:"), item) + } +} + +func cls(name string) objc.ID { return objc.ID(objc.GetClass(name)) } + +func nsString(s string) objc.ID { + b := append([]byte(s), 0) + id := cls("NSString").Send(objc.RegisterName("stringWithUTF8String:"), unsafe.Pointer(&b[0])) + runtime.KeepAlive(b) + return id +} diff --git a/internal/netprint/tray_launch.go b/internal/netprint/tray_launch.go new file mode 100644 index 0000000..2a97c7b --- /dev/null +++ b/internal/netprint/tray_launch.go @@ -0,0 +1,20 @@ +package netprint + +import "strings" + +// trayLaunch is the program to start. args is empty for verae-netprint-tray.exe, +// whose main is RunTray and which never listens. The verae-print.exe fallback +// must pass "tray" so it does not start the IPP server. +type trayLaunch struct { + exe string + args []string +} + +func (t trayLaunch) taskCommand() string { + if len(t.args) == 0 { + // explorer.exe exits after asking the running shell to start the + // tray, so Task Scheduler does not own the tray process. + return `explorer.exe "` + t.exe + `"` + } + return `"` + t.exe + `" ` + strings.Join(t.args, " ") +} diff --git a/internal/netprint/tray_linux.go b/internal/netprint/tray_linux.go new file mode 100644 index 0000000..3ce66e8 --- /dev/null +++ b/internal/netprint/tray_linux.go @@ -0,0 +1,182 @@ +//go:build linux + +package netprint + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/prop" +) + +type dbusItem struct { + ID int32 + Props map[string]dbus.Variant + Children []dbus.Variant +} + +type trayMenu struct { + mu sync.Mutex + rev uint32 + conn *dbus.Conn + entries []MenuEntry + status Status +} + +func (m *trayMenu) refresh() { + st := Probe() + entries := BuildMenu(st) + m.mu.Lock() + m.status = st + m.entries = entries + m.rev++ + rev := m.rev + conn := m.conn + m.mu.Unlock() + if conn != nil { + _ = conn.Emit(dbus.ObjectPath("/Menu"), "com.canonical.dbusmenu.LayoutUpdated", rev, int32(0)) + } +} + +func (m *trayMenu) layout() dbusItem { + root := dbusItem{ID: 0, Props: map[string]dbus.Variant{"children-display": dbus.MakeVariant("submenu")}, Children: []dbus.Variant{}} + for _, e := range m.entries { + if e.Separator { + root.Children = append(root.Children, dbus.MakeVariant(dbusItem{ + ID: 0, Props: map[string]dbus.Variant{"type": dbus.MakeVariant("separator")}, Children: []dbus.Variant{}, + })) + continue + } + props := map[string]dbus.Variant{ + "label": dbus.MakeVariant(e.Label), + "enabled": dbus.MakeVariant(e.Enabled), + } + if e.Checked { + props["toggle-type"] = dbus.MakeVariant("checkmark") + props["toggle-state"] = dbus.MakeVariant(int32(1)) + } + root.Children = append(root.Children, dbus.MakeVariant(dbusItem{ + ID: int32(e.ID), Props: props, Children: []dbus.Variant{}, + })) + } + return root +} + +func (m *trayMenu) GetLayout(parentID int32, recursionDepth int32, propertyNames []string) (uint32, dbusItem, *dbus.Error) { + m.mu.Lock() + defer m.mu.Unlock() + if parentID != 0 { + return m.rev, dbusItem{ID: parentID, Props: map[string]dbus.Variant{}, Children: []dbus.Variant{}}, nil + } + return m.rev, m.layout(), nil +} + +func (m *trayMenu) Event(id int32, eventID string, data dbus.Variant, timestamp uint32) *dbus.Error { + if eventID != "clicked" { + return nil + } + m.mu.Lock() + st := m.status + var entry MenuEntry + for _, e := range m.entries { + if e.ID == int(id) { + entry = e + break + } + } + m.mu.Unlock() + if entry.Kind == "" { + return nil + } + go func() { + if err := Dispatch(st, entry); err != nil && IsQuit(err) { + os.Exit(0) + } + }() + return nil +} + +func (m *trayMenu) AboutToShow(id int32) (bool, *dbus.Error) { + m.refresh() + return true, nil +} + +func (m *trayMenu) GetGroupProperties(ids []int32, props []string) ([]struct { + ID int32 + Props map[string]dbus.Variant +}, *dbus.Error) { + return nil, nil +} + +type sni struct{} + +func (sni) Activate(x, y int32) *dbus.Error { return nil } +func (sni) ContextMenu(x, y int32) *dbus.Error { return nil } +func (sni) SecondaryActivate(x, y int32) *dbus.Error { return nil } +func (sni) Scroll(delta int32, orientation string) *dbus.Error { + return nil +} + +func runTray(ctx context.Context) error { + conn, err := dbus.ConnectSessionBus() + if err != nil { + log.Printf("verae-netprint tray: no session bus (%v)", err) + return nil + } + defer conn.Close() + menu := &trayMenu{conn: conn} + menu.refresh() + if err := conn.Export(menu, "/Menu", "com.canonical.dbusmenu"); err != nil { + return err + } + if _, err := prop.Export(conn, "/Menu", prop.Map{ + "com.canonical.dbusmenu": { + "Version": {Value: int32(3), Emit: prop.EmitConst}, + "TextDirection": {Value: "ltr", Emit: prop.EmitConst}, + "Status": {Value: "normal", Emit: prop.EmitTrue}, + "IconThemePath": {Value: []string{}, Emit: prop.EmitConst}, + }, + }); err != nil { + return err + } + if err := conn.Export(sni{}, "/StatusNotifierItem", "org.kde.StatusNotifierItem"); err != nil { + return err + } + if _, err := prop.Export(conn, "/StatusNotifierItem", prop.Map{ + "org.kde.StatusNotifierItem": { + "Category": {Value: "ApplicationStatus", Emit: prop.EmitConst}, + "Id": {Value: "verae-netprint", Emit: prop.EmitConst}, + "Title": {Value: "Verae Timestamp", Emit: prop.EmitTrue}, + "Status": {Value: "Active", Emit: prop.EmitTrue}, + "IconName": {Value: "printer", Emit: prop.EmitConst}, + "ItemIsMenu": {Value: true, Emit: prop.EmitConst}, + "Menu": {Value: dbus.ObjectPath("/Menu"), Emit: prop.EmitConst}, + }, + }); err != nil { + return err + } + busName := fmt.Sprintf("org.kde.StatusNotifierItem-%d-1", os.Getpid()) + if _, err := conn.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil { + return err + } + obj := conn.Object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher") + if err := obj.Call("org.kde.StatusNotifierWatcher.RegisterStatusNotifierItem", 0, busName).Err; err != nil { + if err2 := obj.Call("org.kde.StatusNotifierWatcher.RegisterStatusNotifierItem", 0, "/StatusNotifierItem").Err; err2 != nil { + log.Printf("verae-netprint tray: status notifier watcher: %v", err) + return nil + } + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + select { + case <-ctx.Done(): + case <-sig: + } + return nil +} diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go new file mode 100644 index 0000000..3f3b7f3 --- /dev/null +++ b/internal/netprint/tray_menu.go @@ -0,0 +1,264 @@ +package netprint + +import ( + "fmt" + "strings" +) + +const ( + idStatus = 1 + idStart = 3 + idStop = 4 + idRestart = 5 + idLaunch = 7 + idTray = 8 + idPublic = 9 + idReceipts = 10 + idSettings = 11 + idEmail = 12 + idAirDrop = 13 + idAdvertise = 14 + idAirDropAgain = 15 + idShare = 16 + idQuit = 100 + idRecent = 20 +) + +// MenuEntry is one row in the tray or menu bar. The UI layers only display it. +type MenuEntry struct { + ID int + Label string + Enabled bool + Checked bool + Separator bool + URL string + Kind string +} + +// Probe reads status from the loopback control API. A failure means the service is stopped. +func Probe() Status { + st, err := NewClient(8630).Status() + if err != nil { + return StoppedStatus() + } + return st +} + +// BuildMenu is the control surface: status, service, login items, receipts, and settings. +func BuildMenu(st Status) []MenuEntry { + state := "Stopped" + if st.Running { + state = "Running" + } + where := st.Name + if where == "" { + where = "Verae Timestamp" + } + if len(st.Addresses) > 0 && st.Port > 0 { + where = fmt.Sprintf("%s · %s:%d", where, st.Addresses[0], st.Port) + } + var out []MenuEntry + out = append(out, + MenuEntry{ID: idStatus, Label: fmt.Sprintf("%s — %s · %d jobs today", state, where, st.JobsToday), Kind: "status"}, + MenuEntry{Label: discoveryLine(st), Kind: "status"}, + ) + if st.NetworkPublic && !st.AllowPublic { + out = append(out, MenuEntry{Label: "This network is Public. Other devices cannot reach the printer until you allow Public networks.", Kind: "status"}) + } + out = append(out, + MenuEntry{Separator: true}, + MenuEntry{ID: idStart, Label: "Start", Enabled: !st.Running, Kind: "start"}, + MenuEntry{ID: idStop, Label: "Stop", Enabled: st.Running, Kind: "stop"}, + MenuEntry{ID: idRestart, Label: "Restart", Enabled: st.Running, Kind: "restart"}, + MenuEntry{Separator: true}, + MenuEntry{ID: idLaunch, Label: "Launch printer at login", Enabled: st.Running, Checked: st.LaunchAtLogin, Kind: "launch"}, + MenuEntry{ID: idTray, Label: "Launch this menu at login", Enabled: st.Running, Checked: st.TrayAtLogin, Kind: "tray"}, + ) + if st.SupportsPublic { + out = append(out, MenuEntry{ID: idPublic, Label: "Allow Public networks", Enabled: st.Running, Checked: st.AllowPublic, Kind: "public"}) + } + if st.SupportsAirDrop { + out = append(out, MenuEntry{ID: idAdvertise, Label: "Advertise on the network", Enabled: st.Running, Checked: st.Advertise, Kind: "advertise"}) + } + out = append(out, MenuEntry{Separator: true}, + MenuEntry{ID: idEmail, Label: "Email stamped PDFs", Enabled: st.Running, Checked: st.EmailEnabled, Kind: "email"}, + ) + if st.SupportsAirDrop { + out = append(out, MenuEntry{ID: idAirDrop, Label: "AirDrop stamped PDF after each job", Enabled: st.Running, Checked: st.AirDropEnabled, Kind: "airdrop"}) + } + if st.SupportsSMBShare { + out = append(out, MenuEntry{ID: idShare, Label: "Share receipts folder over SMB", Enabled: st.Running, Checked: st.ShareReceiptsSMB, Kind: "share"}) + } + if st.SMBError != "" { + out = append(out, MenuEntry{Label: "SMB — " + clipMenu(st.SMBError, 90), Kind: "status"}) + } else if st.SMBURL != "" { + out = append(out, MenuEntry{Label: "Files — " + st.SMBURL, Kind: "status"}) + } + if st.EmailError != "" { + out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) + } else if st.EmailEnabled { + to := st.EmailTo + if to == "" { + to = DefaultEmailTo + } + out = append(out, MenuEntry{Label: "Email — on, to " + to, Kind: "status"}) + } + receipts := liveReceipts(st) + if receipts != "" { + out = append(out, MenuEntry{Label: "Receipts " + receipts, Kind: "status"}) + } + out = append(out, + MenuEntry{ID: idReceipts, Label: "Open Receipts…", Enabled: receipts != "", URL: receipts, Kind: "open"}, + MenuEntry{Label: iphonePrintHint(st), Kind: "status"}, + ) + for i, rec := range st.Recent { + if i >= 6 { + break + } + label := firstNonEmpty(rec.Title, fmt.Sprintf("Job %d", rec.ID)) + if rec.ReceiptID == "" { + continue + } + out = append(out, MenuEntry{ + ID: idRecent + i, Label: "Receipt · " + label, Enabled: true, + URL: receiptURL(st, rec), Kind: "open", + }) + } + settings := strings.TrimRight(st.ControlURL, "/") + "/settings" + if st.ControlURL == "" { + settings = "http://127.0.0.1:8630/settings" + } + out = append(out, + MenuEntry{ID: idSettings, Label: "Open Settings…", Enabled: st.Running || settings != "", URL: settings, Kind: "open"}, + MenuEntry{Separator: true}, + MenuEntry{ID: idQuit, Label: "Quit menu", Enabled: true, Kind: "quit"}, + ) + return out +} + +func discoveryLine(st Status) string { + if !st.Running { + return "Discovery — service stopped" + } + switch st.MDNSMode { + case "dns-sd": + if st.MDNSAdvertising { + if strings.Contains(st.MDNSDetail, "_ipp._tcp,_universal") { + return "Discovery — advertised (Bonjour _universal and _print)" + } + return "Discovery — advertised (Windows DNS-SD)" + } + case "udp": + if st.MDNSAdvertising { + return "Discovery — advertised (UDP 5353)" + } + case "off": + return "Discovery — off" + case "starting": + return "Discovery — starting" + case "error": + return "Discovery — not advertising: " + clipMenu(st.MDNSError, 80) + } + if st.MDNSError != "" { + return "Discovery — " + clipMenu(st.MDNSError, 80) + } + if st.MDNSDetail != "" { + return "Discovery — " + clipMenu(st.MDNSDetail, 80) + } + return "Discovery — waiting" +} + +func clipMenu(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + +// liveReceipts is the address advertised for other devices. The menu opens +// that URL. The loopback control page is only a fallback when status has +// no receipts address yet. +func liveReceipts(st Status) string { + if u := strings.TrimSpace(st.ReceiptsURL); u != "" { + return u + } + if u := strings.TrimSpace(st.AdminURL); u != "" { + return u + } + if u := strings.TrimRight(st.ControlURL, "/"); u != "" { + return u + "/receipts" + } + if st.Running { + return "http://127.0.0.1:8630/receipts" + } + return "" +} + +func iphonePrintHint(st Status) string { + name := strings.TrimSpace(st.Name) + if name == "" { + name = "Verae Timestamp" + } + return clipMenu("iPhone: Share → Print → Select Printer → "+name, 140) +} + +func receiptURL(st Status, rec Record) string { + base := strings.TrimRight(st.ControlURL, "/") + if base == "" { + base = "http://127.0.0.1:8630" + } + return fmt.Sprintf("%s/jobs/%d/receipt.pdf", base, rec.ID) +} + +// Dispatch performs one menu action. Settings and receipts open in the browser. +// Start uses the OS service manager when the control API is down. Other changes +// go through that API so this process never edits config files. +func Dispatch(st Status, e MenuEntry) error { + c := NewClient(8630) + if st.ControlURL != "" { + c.BaseURL = strings.TrimRight(st.ControlURL, "/") + } + switch e.Kind { + case "start": + if st.Running { + return c.Service("start") + } + return NewServiceControl().Start() + case "stop": + return c.Service("stop") + case "restart": + return c.Service("restart") + case "launch": + return c.SetLaunchAtLogin(!st.LaunchAtLogin) + case "tray": + return c.SetTrayAtLogin(!st.TrayAtLogin) + case "public": + return c.SetAllowPublic(!st.AllowPublic) + case "email": + return c.SetEmailEnabled(!st.EmailEnabled) + case "advertise": + return c.SetAdvertise(!st.Advertise) + case "airdrop": + return c.SetAirDropEnabled(!st.AirDropEnabled) + case "share": + return c.SetShareReceiptsSMB(!st.ShareReceiptsSMB) + case "airdrop-again": + return airDropAgain() + case "open": + return OpenURL(e.URL) + case "quit": + return errQuit + default: + return nil + } +} + +type quitError struct{} + +func (quitError) Error() string { return "quit" } + +var errQuit quitError + +func IsQuit(err error) bool { + return err == errQuit +} diff --git a/internal/netprint/tray_windows.go b/internal/netprint/tray_windows.go new file mode 100644 index 0000000..723a1e4 --- /dev/null +++ b/internal/netprint/tray_windows.go @@ -0,0 +1,229 @@ +//go:build windows + +package netprint + +import ( + "context" + "fmt" + "log" + "syscall" + "unsafe" +) + +const ( + wmUser = 0x0400 + wmCommand = 0x0111 + wmDestroy = 0x0002 + wmRButtonUp = 0x0205 + wmLButtonUp = 0x0202 + nimAdd = 0 + nimDelete = 2 + nifMessage = 1 + nifIcon = 2 + nifTip = 4 + mfString = 0 + mfSeparator = 0x800 + mfGrayed = 1 + mfChecked = 8 + idiApp = 32512 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + shell32 = syscall.NewLazyDLL("shell32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + pRegisterClassExW = user32.NewProc("RegisterClassExW") + pCreateWindowExW = user32.NewProc("CreateWindowExW") + pDefWindowProcW = user32.NewProc("DefWindowProcW") + pGetMessageW = user32.NewProc("GetMessageW") + pTranslateMessage = user32.NewProc("TranslateMessage") + pDispatchMessageW = user32.NewProc("DispatchMessageW") + pPostQuitMessage = user32.NewProc("PostQuitMessage") + pLoadIconW = user32.NewProc("LoadIconW") + pCreatePopupMenu = user32.NewProc("CreatePopupMenu") + pAppendMenuW = user32.NewProc("AppendMenuW") + pTrackPopupMenu = user32.NewProc("TrackPopupMenu") + pDestroyMenu = user32.NewProc("DestroyMenu") + pSetForegroundWindow = user32.NewProc("SetForegroundWindow") + pGetCursorPos = user32.NewProc("GetCursorPos") + pShellNotify = shell32.NewProc("Shell_NotifyIconW") + pGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") + pCreateMutexW = kernel32.NewProc("CreateMutexW") + wndProcCB = syscall.NewCallback(trayWndProc) + trayHWND uintptr + trayIconData notifyIcon + trayShown []MenuEntry +) + +type wndClassEx struct { + size uint32 + style uint32 + wndProc uintptr + clsExtra int32 + wndExtra int32 + instance uintptr + icon uintptr + cursor uintptr + background uintptr + menuName *uint16 + className *uint16 + iconSm uintptr +} + +type point struct{ x, y int32 } + +type winMsg struct { + hwnd uintptr + message uint32 + wParam uintptr + lParam uintptr + time uint32 + pt point +} + +type notifyIcon struct { + cbSize uint32 + hWnd uintptr + uID uint32 + uFlags uint32 + uCallbackMessage uint32 + hIcon uintptr + szTip [128]uint16 +} + +func runTray(ctx context.Context) error { + setupServiceLog() + release, ok, err := acquireTrayMutex() + if err != nil { + log.Printf("netprint: tray mutex: %v", err) + return err + } + if !ok { + log.Printf("netprint: tray already running; this copy is exiting") + return nil + } + defer release() + log.Printf("netprint: tray starting") + inst, _, _ := pGetModuleHandleW.Call(0) + class, _ := syscall.UTF16PtrFromString("VeraeNetPrintTray") + wc := wndClassEx{wndProc: wndProcCB, instance: inst, className: class} + wc.size = uint32(unsafe.Sizeof(wc)) + if r, _, err := pRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))); r == 0 { + log.Printf("netprint: tray window class: %v", err) + return fmt.Errorf("tray window class: %w", err) + } + title, _ := syscall.UTF16PtrFromString("Verae Network Printer") + hwnd, _, err := pCreateWindowExW.Call(0, uintptr(unsafe.Pointer(class)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, inst, 0) + if hwnd == 0 { + log.Printf("netprint: tray window: %v", err) + return fmt.Errorf("tray window: %w", err) + } + trayHWND = hwnd + icon, _, _ := pLoadIconW.Call(0, idiApp) + trayIconData = notifyIcon{ + hWnd: hwnd, uID: 1, + uFlags: nifMessage | nifIcon | nifTip, uCallbackMessage: wmUser + 1, hIcon: icon, + } + trayIconData.cbSize = uint32(unsafe.Sizeof(trayIconData)) + tip, _ := syscall.UTF16FromString("Verae Timestamp") + copy(trayIconData.szTip[:], tip) + if r, _, err := pShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&trayIconData))); r == 0 { + log.Printf("netprint: tray icon: %v", err) + } + defer pShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&trayIconData))) + log.Printf("netprint: tray icon added") + var m winMsg + var code int32 + for { + r, _, err := pGetMessageW.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + code = int32(r) + if code <= 0 { + if code < 0 { + log.Printf("netprint: tray GetMessage: %v", err) + } + break + } + pTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) + pDispatchMessageW.Call(uintptr(unsafe.Pointer(&m))) + } + log.Printf("netprint: tray message loop ended (%s)", trayLoopExit(code)) + return nil +} + +func acquireTrayMutex() (func(), bool, error) { + name, err := syscall.UTF16PtrFromString(`Local\VeraeNetPrintTray`) + if err != nil { + return nil, false, err + } + h, _, callErr := pCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(name))) + if h == 0 { + return nil, false, callErr + } + if errno, ok := callErr.(syscall.Errno); ok && errno == 183 { // ERROR_ALREADY_EXISTS + syscall.CloseHandle(syscall.Handle(h)) + return nil, false, nil + } + return func() { syscall.CloseHandle(syscall.Handle(h)) }, true, nil +} + +func trayWndProc(hwnd, msg, wParam, lParam uintptr) uintptr { + switch msg { + case wmUser + 1: + if lParam == wmRButtonUp || lParam == wmLButtonUp { + showTrayMenu(hwnd) + return 0 + } + case wmCommand: + id := int(wParam & 0xffff) + go func() { + st := Probe() + for _, e := range trayShown { + if e.ID == id { + if err := Dispatch(st, e); IsQuit(err) { + pPostQuitMessage.Call(0) + } + return + } + } + }() + return 0 + case 0x0010: // WM_CLOSE + log.Printf("netprint: tray received WM_CLOSE") + case wmDestroy: + log.Printf("netprint: tray window destroyed") + pPostQuitMessage.Call(0) + return 0 + } + r, _, _ := pDefWindowProcW.Call(hwnd, msg, wParam, lParam) + return r +} + +func showTrayMenu(hwnd uintptr) { + st := Probe() + items := BuildMenu(st) + trayShown = items + menu, _, _ := pCreatePopupMenu.Call() + if menu == 0 { + return + } + defer pDestroyMenu.Call(menu) + for _, e := range items { + if e.Separator { + pAppendMenuW.Call(menu, mfSeparator, 0, 0) + continue + } + flags := uintptr(mfString) + if !e.Enabled { + flags |= mfGrayed + } + if e.Checked { + flags |= mfChecked + } + label, _ := syscall.UTF16PtrFromString(e.Label) + pAppendMenuW.Call(menu, flags, uintptr(e.ID), uintptr(unsafe.Pointer(label))) + } + var pt point + pGetCursorPos.Call(uintptr(unsafe.Pointer(&pt))) + pSetForegroundWindow.Call(hwnd) + pTrackPopupMenu.Call(menu, 0x0000, uintptr(pt.x), uintptr(pt.y), 0, hwnd, 0) +} diff --git a/internal/netprint/version.go b/internal/netprint/version.go new file mode 100644 index 0000000..0a2e1b1 --- /dev/null +++ b/internal/netprint/version.go @@ -0,0 +1,161 @@ +package netprint + +import ( + "os/exec" + "strconv" + "strings" +) + +// compareVersions compares dotted numeric versions. +// It returns -1 when a is older than b, 0 when they are equal, and 1 when a is newer. +func compareVersions(a, b string) int { + ap := splitVer(a) + bp := splitVer(b) + n := len(ap) + if len(bp) > n { + n = len(bp) + } + for i := 0; i < n; i++ { + av, bv := 0, 0 + if i < len(ap) { + av = ap[i] + } + if i < len(bp) { + bv = bp[i] + } + if av < bv { + return -1 + } + if av > bv { + return 1 + } + } + return 0 +} + +func splitVer(s string) []int { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if i := strings.IndexAny(s, " \t\r\n"); i >= 0 { + s = s[:i] + } + if s == "" { + return nil + } + parts := strings.Split(s, ".") + out := make([]int, 0, len(parts)) + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + digits := strings.TrimLeftFunc(p, func(r rune) bool { return r < '0' || r > '9' }) + end := 0 + for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' { + end++ + } + if end == 0 { + break + } + n, _ = strconv.Atoi(digits[:end]) + } + out = append(out, n) + } + return out +} + +// versionOf runs `exe version` and returns the first line. +func versionOf(exe string) string { + exe = strings.TrimSpace(exe) + if exe == "" { + return "" + } + out, err := exec.Command(exe, "version").Output() + if err != nil { + return "" + } + line := strings.TrimSpace(string(out)) + if i := strings.IndexAny(line, "\r\n"); i >= 0 { + line = strings.TrimSpace(line[:i]) + } + return line +} + +// selectServiceBinary chooses which installed binary the shared service should run. +// ours is used when current is missing or ours is the same version or newer. +// A newer current binary is kept so an older installer cannot downgrade the service +// or replace the other product's files. +func selectServiceBinary(ours, current string) string { + return selectServiceBinaryVersions(ours, current, versionOf(ours), versionOf(current)) +} + +func selectServiceBinaryVersions(ours, current, oursVer, curVer string) string { + ours = strings.TrimSpace(ours) + current = strings.TrimSpace(current) + if current == "" { + return ours + } + if ours == "" { + return current + } + if compareVersions(oursVer, curVer) < 0 { + return current + } + return ours +} + +func parseWindowsBinPath(qc string) string { + for _, line := range strings.Split(qc, "\n") { + if !strings.Contains(strings.ToUpper(line), "BINARY_PATH_NAME") { + continue + } + i := strings.Index(line, ":") + if i < 0 { + continue + } + return firstExecutable(strings.TrimSpace(line[i+1:])) + } + return "" +} + +func parseExecStart(unit string) string { + for _, line := range strings.Split(unit, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "ExecStart=") { + continue + } + return firstExecutable(strings.TrimSpace(strings.TrimPrefix(line, "ExecStart="))) + } + return "" +} + +func parseDarwinProgram(plist string) string { + i := strings.Index(plist, "ProgramArguments") + if i < 0 { + return "" + } + rest := plist[i:] + s := strings.Index(rest, "") + e := strings.Index(rest, "") + if s < 0 || e < 0 || e <= s+len("") { + return "" + } + return strings.TrimSpace(rest[s+len("") : e]) +} + +func firstExecutable(rest string) string { + rest = strings.TrimSpace(rest) + if rest == "" { + return "" + } + if strings.HasPrefix(rest, `"`) { + rest = rest[1:] + if j := strings.Index(rest, `"`); j >= 0 { + return rest[:j] + } + return rest + } + fields := strings.Fields(rest) + if len(fields) == 0 { + return "" + } + return fields[0] +} diff --git a/internal/netprint/version_test.go b/internal/netprint/version_test.go new file mode 100644 index 0000000..ecf497f --- /dev/null +++ b/internal/netprint/version_test.go @@ -0,0 +1,66 @@ +package netprint + +import ( + "strings" + "testing" +) + +func TestFallbackPortAvoidsDriverHelper(t *testing.T) { + d := Defaults() + if d.FallbackPort != DefaultFallbackPort || d.FallbackPort == DriverHelperPort { + t.Fatalf("fallback port %d, want %d and not the driver helper %d", d.FallbackPort, DefaultFallbackPort, DriverHelperPort) + } + if d.Port == DriverHelperPort { + t.Fatal("primary port collides with the driver helper") + } + sample := string(ExampleJSON(d.DataDir)) + if !strings.Contains(sample, "8632") || strings.Contains(sample, "8631") { + t.Fatalf("example config should advertise 8632 and not 8631:\n%s", sample) + } + ports := ippListenPorts(Config{Port: 631, FallbackPort: DriverHelperPort}) + for _, p := range ports { + if p == DriverHelperPort { + t.Fatalf("listen ports include driver helper: %v", ports) + } + } + if len(ports) != 2 || ports[0] != 631 || ports[1] != DefaultFallbackPort { + t.Fatalf("631 with a saved 8631 fallback should try 631 then %d, got %v", DefaultFallbackPort, ports) + } + only := ippListenPorts(Config{Port: DriverHelperPort, FallbackPort: DriverHelperPort}) + if len(only) != 1 || only[0] != DefaultFallbackPort { + t.Fatalf("both ports set to the helper should bind %d, got %v", DefaultFallbackPort, only) + } +} + +func TestSelectServiceBinary(t *testing.T) { + if compareVersions("0.3.4", "0.3.3") <= 0 || compareVersions("0.3.4", "0.3.4") != 0 || compareVersions("0.3.4", "0.4") >= 0 { + t.Fatal("compareVersions") + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, `/old/verae-print.exe`, "0.3.4", "0.3.3"); got != `/new/verae-print.exe` { + t.Fatal(got) + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, `/old/verae-print.exe`, "0.3.3", "0.3.4"); got != `/old/verae-print.exe` { + t.Fatal(got) + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, `/old/verae-print.exe`, "0.3.4", "0.3.4"); got != `/new/verae-print.exe` { + t.Fatal(got) + } + if got := selectServiceBinaryVersions(`/new/verae-print.exe`, "", "0.3.4", ""); got != `/new/verae-print.exe` { + t.Fatal(got) + } +} + +func TestParseServiceExecutable(t *testing.T) { + qc := "BINARY_PATH_NAME : \"C:\\Program Files\\Verae\\NetPrint\\bin\\verae-print.exe\" netprint\r\n" + if got := parseWindowsBinPath(qc); got != `C:\Program Files\Verae\NetPrint\bin\verae-print.exe` { + t.Fatal(got) + } + unit := "ExecStart=/usr/lib/verae-netprint/verae-print netprint --config /etc/verae/netprint.json\n" + if got := parseExecStart(unit); got != "/usr/lib/verae-netprint/verae-print" { + t.Fatal(got) + } + plist := "ProgramArguments/Library/Application Support/Verae/NetPrint/bin/verae-printnetprint" + if got := parseDarwinProgram(plist); got != "/Library/Application Support/Verae/NetPrint/bin/verae-print" { + t.Fatal(got) + } +} diff --git a/platform/linux/cups/backend-verae.sh b/platform/linux/cups/backend-verae.sh index 9fa9dcd..6e3d315 100755 --- a/platform/linux/cups/backend-verae.sh +++ b/platform/linux/cups/backend-verae.sh @@ -2,13 +2,27 @@ # CUPS backend: verae — virtual printer "Verae Timestamping" (Linux) # Discovery (no args): advertise device URI verae:/ # Job: spool PDF and run verae-print as the job user (digests only). -set -euo pipefail +# +# MUST be installed mode 0700 root:root. CUPS runs 0755 backends as user "lp", +# which can't write into /home/ or switch to the job user, so every job +# fails with status 1 and sits in the queue being retried. +# +# Debug: touch /etc/verae-print/debug → full bash trace in the backend log. +# Log: /var/log/cups/verae-backend.log (plus ERROR:/INFO: lines in CUPS error_log +# and the job's status in `lpstat -l -o`). +set -uo pipefail -LOG_ROOT="${CUPS_SERVERROOT:-/var/log/cups}" -BACKEND_LOG="${LOG_ROOT}/verae-backend.log" +BACKEND_LOG="/var/log/cups/verae-backend.log" log() { - echo "$(date -u +%Y-%m-%dT%H:%MZ) $*" >>"$BACKEND_LOG" 2>/dev/null || true + echo "$(date '+%Y-%m-%dT%H:%M:%S%z') [job ${JOB_ID:-?}] $*" >>"$BACKEND_LOG" 2>/dev/null || true +} +# Messages prefixed ERROR:/INFO: on stderr show up in CUPS error_log and job status. +fail() { + local code="$1"; shift + log "FAIL: $*" + echo "ERROR: Verae: $*" >&2 + exit "$code" } if [[ $# -eq 0 ]]; then @@ -21,56 +35,131 @@ JOB_USER="${2:-}" JOB_TITLE="${3:-document}" JOB_FILE="${6:-}" -VERAE_PRINT="${VERAE_PRINT_BIN:-/usr/local/bin/verae-print}" +if [[ -f /etc/verae-print/debug ]]; then + exec 2> >(tee -a "$BACKEND_LOG" >&2) + set -x +fi + +log "start uid=$(id -u) user=$JOB_USER title=$JOB_TITLE file=${JOB_FILE:-stdin} argv=$*" + +if [[ "$(id -u)" != "0" ]]; then + fail 4 "backend is running as $(id -un), not root. Fix: sudo chown root:root $0 && sudo chmod 700 $0 && sudo systemctl restart cups" +fi + +# Find the CLI: env override, then the .deb path, then the tarball path. +VERAE_PRINT="${VERAE_PRINT_BIN:-}" +if [[ -z "$VERAE_PRINT" ]]; then + for c in /usr/bin/verae-print /usr/local/bin/verae-print; do + [[ -x "$c" ]] && { VERAE_PRINT="$c"; break; } + done +fi +[[ -x "${VERAE_PRINT:-}" ]] || fail 4 "verae-print CLI not found (looked in /usr/bin and /usr/local/bin)" CHANNEL="${VERAE_PRINT_CHANNEL:-zapier}" -if [[ -n "$JOB_USER" && -d "/home/$JOB_USER" ]]; then - SPOOL_BASE="/home/$JOB_USER/.local/share/verae/print-spool" - OUT_DIR="/home/$JOB_USER/Documents/Verae Timestamps" +USER_HOME="" +if [[ -n "$JOB_USER" && "$JOB_USER" != "root" ]] && id "$JOB_USER" >/dev/null 2>&1; then + USER_HOME="$(getent passwd "$JOB_USER" | cut -d: -f6)" +fi +if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then + SPOOL_BASE="$USER_HOME/.local/share/verae/print-spool" + OUT_DIR="$USER_HOME/Documents/Verae Timestamps" + USER_GROUP="$(id -gn "$JOB_USER")" else SPOOL_BASE="/var/spool/verae/${JOB_USER:-unknown}" OUT_DIR="/tmp/VeraeTimestamps" + USER_GROUP="" fi -mkdir -p "$SPOOL_BASE" -chmod 700 "$SPOOL_BASE" 2>/dev/null || true +# Pop the "get a signing key" window in the job user's desktop session. +# Detached (setsid) so the backend returns at once; throttled to one launch per +# 10 minutes per user so CUPS retries don't keep reopening it after "Skip". +launch_key_dialog() { + local reason="$1" + [[ -n "$USER_HOME" ]] || return 0 + local uid; uid="$(id -u "$JOB_USER")" + local stamp="$USER_HOME/.local/share/verae/.key-dialog-launched" + if [[ -f "$stamp" ]] && (( $(date +%s) - $(stat -c %Y "$stamp") < 600 )); then + log "key dialog launched recently; not reopening" + return 0 + fi + # Borrow DISPLAY/XAUTHORITY/DBUS from one of the user's graphical processes. + local pid envf disp="" xauth="" wl="" dbus="" + for pid in $(pgrep -u "$uid" 2>/dev/null); do + envf="/proc/$pid/environ" + [[ -r "$envf" ]] || continue + disp="$(tr '\0' '\n' <"$envf" | sed -n 's/^DISPLAY=//p' | head -n1)" + wl="$(tr '\0' '\n' <"$envf" | sed -n 's/^WAYLAND_DISPLAY=//p' | head -n1)" + [[ -n "$disp" || -n "$wl" ]] || continue + xauth="$(tr '\0' '\n' <"$envf" | sed -n 's/^XAUTHORITY=//p' | head -n1)" + dbus="$(tr '\0' '\n' <"$envf" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p' | head -n1)" + break + done + if [[ -z "$disp" && -z "$wl" ]]; then + log "no desktop session found for $JOB_USER; key dialog not shown" + return 0 + fi + [[ -n "$xauth" ]] || xauth="$USER_HOME/.Xauthority" + [[ -n "$dbus" ]] || dbus="unix:path=/run/user/$uid/bus" + mkdir -p "$USER_HOME/.local/share/verae" && touch "$stamp" + chown -R "$JOB_USER:$(id -gn "$JOB_USER")" "$USER_HOME/.local/share/verae" 2>/dev/null || true + log "launching key dialog for $JOB_USER on DISPLAY=$disp WAYLAND_DISPLAY=$wl" + runuser -u "$JOB_USER" -- env -i HOME="$USER_HOME" USER="$JOB_USER" LOGNAME="$JOB_USER" \ + PATH=/usr/local/bin:/usr/bin:/bin DISPLAY="$disp" WAYLAND_DISPLAY="$wl" XAUTHORITY="$xauth" \ + DBUS_SESSION_BUS_ADDRESS="$dbus" XDG_RUNTIME_DIR="/run/user/$uid" \ + VERAE_KEY_REASON="$reason" \ + setsid "$VERAE_PRINT" key-dialog /dev/null 2>&1 & + disown || true +} + +# Signing key must exist for the job user before we bother spooling. +if [[ -n "$USER_HOME" && -z "${XVERAE_TOKEN:-}" && ! -s "$USER_HOME/.verae/xverae-token" ]]; then + # Exit 1 = CUPS retries the job later (ErrorPolicy retry-job), so it prints + # by itself once the user runs setup. + launch_key_dialog "\"$JOB_TITLE\" is waiting to be timestamped. It will print by itself as soon as a Verae signing key is saved." + fail 1 "no signing key for $JOB_USER — finish the Verae sign-in window (or run 'verae-print key-dialog'); the job retries automatically" +fi SAFE_TITLE=$(echo "$JOB_TITLE" | tr -cd 'A-Za-z0-9._ -' | head -c 80) SAFE_TITLE=${SAFE_TITLE:-document} WORK="$SPOOL_BASE/job-${JOB_ID}-$$" -mkdir -p "$WORK" +mkdir -p "$WORK" "$OUT_DIR" || fail 1 "cannot create $WORK or $OUT_DIR" INPUT="$WORK/input.dat" if [[ -n "$JOB_FILE" && -f "$JOB_FILE" ]]; then - cp "$JOB_FILE" "$INPUT" + cp "$JOB_FILE" "$INPUT" || fail 1 "cannot copy $JOB_FILE" else - cat >"$INPUT" + cat >"$INPUT" || fail 1 "cannot read job from stdin" fi - PDF="$WORK/${SAFE_TITLE}.pdf" -if [[ "$(head -c 4 "$INPUT")" == "%PDF" ]]; then - cp "$INPUT" "$PDF" -else - # Best-effort: keep bytes; stamp accepts PDF/HTML - cp "$INPUT" "$PDF" -fi +mv "$INPUT" "$PDF" +log "spooled $(stat -c %s "$PDF") bytes, magic=$(head -c 4 "$PDF" | tr -cd '[:print:]') → $PDF" -log "job=$JOB_ID user=$JOB_USER title=$JOB_TITLE pdf=$PDF channel=$CHANNEL" +# Files were created by root inside the user's home: hand them to the user, +# otherwise verae-print (run as the user) can't read the PDF or write outputs. +if [[ -n "$USER_GROUP" ]]; then + chown -R "$JOB_USER:$USER_GROUP" "$USER_HOME/.local/share/verae" "$OUT_DIR" + chmod 700 "$SPOOL_BASE" +fi run_as_user() { - if [[ -n "$JOB_USER" && "$JOB_USER" != "root" ]] && command -v runuser >/dev/null 2>&1; then - runuser -u "$JOB_USER" -- "$@" - elif [[ -n "$JOB_USER" && "$JOB_USER" != "root" ]] && command -v su >/dev/null 2>&1; then - su - "$JOB_USER" -c "$(printf '%q ' "$@")" + if [[ -n "$USER_GROUP" ]]; then + runuser -u "$JOB_USER" -- env HOME="$USER_HOME" "$@" else "$@" fi } -mkdir -p "$OUT_DIR" 2>/dev/null || true -if run_as_user "$VERAE_PRINT" stamp -channel "$CHANNEL" -out-dir "$OUT_DIR" -skip-setup "$PDF"; then - log "job $JOB_ID ok → $OUT_DIR" +echo "INFO: Verae: registering digest" >&2 +OUT=$(run_as_user "$VERAE_PRINT" stamp -channel "$CHANNEL" -out-dir "$OUT_DIR" -skip-setup "$PDF" 2>&1) +RC=$? +log "verae-print rc=$RC output: $OUT" +if [[ $RC -eq 0 ]]; then + rm -rf "$WORK" + log "ok → $OUT_DIR" + echo "INFO: Verae: timestamped → $OUT_DIR" >&2 exit 0 fi -log "job $JOB_ID stamp failed" -exit 1 +if echo "$OUT" | grep -qiE '\b(401|403)\b|unauthori|rejected|invalid key'; then + launch_key_dialog "Verae did not accept the saved signing key. \"$JOB_TITLE\" will print by itself once a working key is saved." +fi +fail 1 "verae-print stamp failed (rc=$RC): $(echo "$OUT" | tail -n 2 | tr '\n' ' ')" diff --git a/platform/linux/deb/build-deb.sh b/platform/linux/deb/build-deb.sh index 38ce40d..2f488cc 100755 --- a/platform/linux/deb/build-deb.sh +++ b/platform/linux/deb/build-deb.sh @@ -11,7 +11,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" -VERSION="${VERSION:-0.1.2}" +VERSION="${VERSION:-0.3.4}" OUT="${OUT:-$ROOT/build/deb}" ARCHS="${1:-all}" PKG_NAME="verae-print" @@ -34,14 +34,17 @@ build_arch() { local stage="$OUT/stage-${arch}" rm -rf "$stage" mkdir -p "$stage/DEBIAN" \ - "$stage/usr/bin" \ + "$stage/usr/lib/verae-print" \ "$stage/usr/lib/cups/backend" \ "$stage/usr/share/doc/$PKG_NAME" \ - "$stage/usr/share/man/man1" + "$stage/usr/share/man/man1" \ + "$stage/usr/share/applications" - install -m 755 "$bin" "$stage/usr/bin/verae-print" + # The command is /usr/bin/verae-print, a symlink created in postinst. + # The standalone package uses /usr/lib/verae-netprint and does not replace this file. + install -m 755 "$bin" "$stage/usr/lib/verae-print/verae-print" if [[ -f "$ROOT/platform/linux/cups/backend-verae.sh" ]]; then - install -m 755 "$ROOT/platform/linux/cups/backend-verae.sh" "$stage/usr/lib/cups/backend/verae" + install -m 700 "$ROOT/platform/linux/cups/backend-verae.sh" "$stage/usr/lib/cups/backend/verae" fi cat >"$stage/usr/share/doc/$PKG_NAME/copyright" <"$stage/usr/share/doc/$PKG_NAME/README.Debian.gz" 2>/dev/null || true # Simple man page + cat >"$stage/usr/share/applications/verae-print-key.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=Verae Signing Key +Comment=Sign in to Verae or add the key used by the Verae Timestamping printer +Exec=verae-print key-dialog +Icon=printer +Terminal=false +Categories=Utility;Settings; +DESKTOP + chmod 0644 "$stage/usr/share/applications/verae-print-key.desktop" + cat >"$stage/usr/share/man/man1/verae-print.1" < -Depends: libc6 -Recommends: cups-client +Depends: libc6, debconf +Recommends: cups-client, zenity, libnotify-bin, xdg-utils +Suggests: verae-netprint Installed-Size: $size Homepage: https://printit.verae.ai/ Description: Digests-only Verae timestamping from Print / CLI Verae Print Stamp hashes a document locally and registers only the SHA-256 digest with Verae. Includes an optional CUPS backend for a "Verae Timestamping" print destination. + . + The same package can enable the Verae network timestamp printer + (IPP Everywhere on the LAN). That choice is off unless you accept + the debconf prompt, or you install the verae-netprint package. + Both use /etc/verae/netprint.json and systemd unit verae-netprint.service. + The driver binary stays in /usr/lib/verae-print. The standalone package + uses /usr/lib/verae-netprint and does not replace this binary. + /usr/bin/verae-print points at the driver while this package is installed. EOF + cat >"$stage/DEBIAN/templates" <<'EOF' +Template: verae-print/enable-netprint +Type: boolean +Default: false +Description: Enable the Verae network timestamp printer? + The network printer appears on your LAN so phones and other computers can + print to it. It stays off unless you choose this. You can also install the + separate verae-netprint package later. Either way there is one service + (verae-netprint) and one menu-bar / tray control. +EOF + cat >"$stage/DEBIAN/config" <<'EOF' +#!/bin/sh +set -e +. /usr/share/debconf/confmodule +db_input medium verae-print/enable-netprint || true +db_go || true +EOF + chmod 755 "$stage/DEBIAN/config" + + cat >"$stage/DEBIAN/preinst" <<'EOF' +#!/bin/sh +set -e +# Stop the network printer before unpack when it is running this package's +# binary. A service pointed at /usr/lib/verae-netprint is left alone. +if [ "$1" = "upgrade" ] || [ "$1" = "install" ]; then + unit=/usr/lib/systemd/system/verae-netprint.service + if [ -f "$unit" ] && grep -q '/usr/lib/verae-print/verae-print' "$unit"; then + if command -v systemctl >/dev/null 2>&1; then + systemctl stop verae-netprint.service >/dev/null 2>&1 || true + fi + fi + if [ -x /usr/lib/verae-print/verae-print ]; then + pkill -f '/usr/lib/verae-print/verae-print tray' >/dev/null 2>&1 || true + fi +fi +exit 0 +EOF + chmod 755 "$stage/DEBIAN/preinst" + cat >"$stage/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e +ln -sfn /usr/lib/verae-print/verae-print /usr/bin/verae-print if command -v lpadmin >/dev/null 2>&1 && [ -x /usr/lib/cups/backend/verae ]; then - lpadmin -x Verae_Timestamping 2>/dev/null || true - lpadmin -p Verae_Timestamping -E -v verae:/ -m raw \ - -D "Verae Timestamping" -L "Digests-only Verae stamp" 2>/dev/null || \ - lpadmin -p Verae_Timestamping -E -v verae:/ -m everywhere \ - -D "Verae Timestamping" 2>/dev/null || true + # Create the queue only if it doesn't exist: deleting it on upgrade purges + # any jobs waiting in it. + if ! lpstat -v Verae_Timestamping >/dev/null 2>&1; then + lpadmin -p Verae_Timestamping -E -v verae:/ -m raw \ + -D "Verae Timestamping" -L "Digests-only Verae stamp" 2>/dev/null || \ + lpadmin -p Verae_Timestamping -E -v verae:/ -m everywhere \ + -D "Verae Timestamping" 2>/dev/null || true + fi cupsenable Verae_Timestamping 2>/dev/null || true cupsaccept Verae_Timestamping 2>/dev/null || true fi +enable=false +if [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_get verae-print/enable-netprint || true + enable=${RET:-false} +fi +if [ "$enable" = "true" ]; then + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw status | grep -q "631/tcp" || ufw allow 631/tcp comment "Verae NetPrint" || true + ufw status | grep -q "8632/tcp" || ufw allow 8632/tcp comment "Verae NetPrint" || true + ufw status | grep -q "5353/udp" || ufw allow 5353/udp comment "Verae NetPrint" || true + fi + /usr/lib/verae-print/verae-print netprint install --owner driver || true +fi echo "verae-print installed. Next: verae-print setup" exit 0 EOF @@ -129,13 +210,40 @@ EOF cat >"$stage/DEBIAN/prerm" <<'EOF' #!/bin/sh set -e -if command -v lpadmin >/dev/null 2>&1; then - lpadmin -x Verae_Timestamping 2>/dev/null || true +# Only drop the queue on a real removal, not on upgrade. +if [ "$1" = "remove" ]; then + if [ -x /usr/lib/verae-print/verae-print ]; then + /usr/lib/verae-print/verae-print netprint uninstall --owner driver || true + fi + if command -v lpadmin >/dev/null 2>&1; then + lpadmin -x Verae_Timestamping 2>/dev/null || true + fi fi exit 0 EOF chmod 755 "$stage/DEBIAN/prerm" + cat >"$stage/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then + if [ -x /usr/lib/verae-netprint/verae-print ]; then + if [ -L /usr/bin/verae-print ] || [ ! -e /usr/bin/verae-print ]; then + ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + fi + elif [ -L /usr/bin/verae-print ]; then + rm -f /usr/bin/verae-print + fi + if [ ! -x /usr/lib/verae-netprint/verae-print ] && command -v ufw >/dev/null 2>&1; then + ufw --force delete allow 631/tcp >/dev/null 2>&1 || true + ufw --force delete allow 8632/tcp >/dev/null 2>&1 || true + ufw --force delete allow 5353/udp >/dev/null 2>&1 || true + fi +fi +exit 0 +EOF + chmod 755 "$stage/DEBIAN/postrm" + local deb="$OUT/${PKG_NAME}_${VERSION}_${arch}.deb" if command -v fakeroot >/dev/null 2>&1; then fakeroot dpkg-deb --build "$stage" "$deb" @@ -145,7 +253,7 @@ EOF # Stable name cp -f "$deb" "$OUT/${PKG_NAME}_${arch}.deb" echo "built $deb" - dpkg-deb -I "$deb" | head -20 + dpkg-deb -I "$deb" | head -20 || true } resolve_bin() { diff --git a/platform/linux/deb/build-netprint-deb.sh b/platform/linux/deb/build-netprint-deb.sh new file mode 100755 index 0000000..223e043 --- /dev/null +++ b/platform/linux/deb/build-netprint-deb.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Build the standalone verae-netprint Debian package. +# The binary lives in /usr/lib/verae-netprint and does not replace +# /usr/lib/verae-print. /usr/bin/verae-print stays on the driver when that +# package is installed. Both share /etc/verae/netprint.json and +# systemd unit verae-netprint.service. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +VERSION="${VERSION:-0.3.4}" +OUT="${OUT:-$ROOT/build/netprint}" +ARCH="${1:-amd64}" +BIN="${BIN:-}" + +if [[ -z "$BIN" ]]; then + case "$ARCH" in + amd64) BIN="${BIN_AMD64:-$ROOT/build/netprint/linux-amd64/verae-print}" ;; + arm64) BIN="${BIN_ARM64:-$ROOT/build/netprint/linux-arm64/verae-print}" ;; + esac +fi +if [[ ! -f "$BIN" ]]; then + echo "missing binary: $BIN" >&2 + exit 1 +fi +if ! command -v dpkg-deb >/dev/null 2>&1; then + echo "dpkg-deb required" >&2 + exit 1 +fi + +stage="$OUT/stage-netprint-${ARCH}" +rm -rf "$stage" +mkdir -p "$stage/DEBIAN" \ + "$stage/usr/lib/verae-netprint" \ + "$stage/usr/share/doc/verae-netprint" + +install -m 755 "$BIN" "$stage/usr/lib/verae-netprint/verae-print" +cat >"$stage/usr/share/doc/verae-netprint/copyright" <"$stage/DEBIAN/control" < +Depends: libc6, adduser +Recommends: cups-client +Installed-Size: $size +Homepage: https://printit.verae.ai/ +Description: Verae network timestamp printer (IPP Everywhere) + A LAN printer that timestamps each job with Verae and keeps the receipt + on this computer. Installs systemd unit verae-netprint, a tray icon at + login, and the loopback control API on 127.0.0.1:8630. + . + Uses the same service name as the optional component of the verae-print + driver package, so installing both keeps one server. The driver keeps + /usr/bin/verae-print when it is installed. This package does not replace + the driver binary. +EOF + +cat >"$stage/DEBIAN/preinst" <<'EOF' +#!/bin/sh +set -e +# dpkg unpacks the new binary after preinst. Stop the service first when it +# is running this package's binary, or the upgrade hits "Text file busy". +if [ "$1" = "upgrade" ] || [ "$1" = "install" ]; then + unit=/usr/lib/systemd/system/verae-netprint.service + if [ -f "$unit" ] && grep -q '/usr/lib/verae-netprint/verae-print' "$unit"; then + if command -v systemctl >/dev/null 2>&1; then + systemctl stop verae-netprint.service >/dev/null 2>&1 || true + fi + fi + if [ -x /usr/lib/verae-netprint/verae-print ]; then + # The tray is the same binary, started from the autostart desktop file. + pkill -f '/usr/lib/verae-netprint/verae-print tray' >/dev/null 2>&1 || true + fi +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/preinst" + +cat >"$stage/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "configure" ]; then + # Leave the CLI on the driver when that package is installed. Do not + # replace a real (non-symlink) /usr/bin/verae-print. + if [ ! -x /usr/lib/verae-print/verae-print ]; then + if [ -L /usr/bin/verae-print ] || [ ! -e /usr/bin/verae-print ]; then + ln -sfn /usr/lib/verae-netprint/verae-print /usr/bin/verae-print + fi + fi + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw status | grep -q "631/tcp" || ufw allow 631/tcp comment "Verae NetPrint" || true + ufw status | grep -q "8632/tcp" || ufw allow 8632/tcp comment "Verae NetPrint" || true + ufw status | grep -q "5353/udp" || ufw allow 5353/udp comment "Verae NetPrint" || true + fi + /usr/lib/verae-netprint/verae-print netprint install --owner standalone +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/postinst" + +cat >"$stage/DEBIAN/prerm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "remove" ] && [ -x /usr/lib/verae-netprint/verae-print ]; then + /usr/lib/verae-netprint/verae-print netprint uninstall --owner standalone || true +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/prerm" + +cat >"$stage/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then + if [ -x /usr/lib/verae-print/verae-print ]; then + ln -sfn /usr/lib/verae-print/verae-print /usr/bin/verae-print + elif [ -L /usr/bin/verae-print ]; then + rm -f /usr/bin/verae-print + fi + if [ ! -x /usr/lib/verae-print/verae-print ] && command -v ufw >/dev/null 2>&1; then + ufw --force delete allow 631/tcp >/dev/null 2>&1 || true + ufw --force delete allow 8632/tcp >/dev/null 2>&1 || true + ufw --force delete allow 5353/udp >/dev/null 2>&1 || true + fi +fi +exit 0 +EOF +chmod 755 "$stage/DEBIAN/postrm" + +mkdir -p "$OUT" +deb="$OUT/verae-netprint_${VERSION}_${ARCH}.deb" +if command -v fakeroot >/dev/null 2>&1; then + fakeroot dpkg-deb --build "$stage" "$deb" +else + dpkg-deb --build "$stage" "$deb" +fi +cp -f "$deb" "$OUT/verae-netprint_${ARCH}.deb" +echo "built $deb" +dpkg-deb -I "$deb" | head -20 || true diff --git a/platform/linux/install.sh b/platform/linux/install.sh index 8b7b02e..65804b2 100755 --- a/platform/linux/install.sh +++ b/platform/linux/install.sh @@ -24,7 +24,7 @@ if [[ "$WITH_CUPS" == "1" ]] && command -v lpadmin >/dev/null 2>&1; then fi if [[ -f "$BACKEND_SRC" ]]; then echo "→ CUPS backend $BACKEND_DST" - install -m 755 "$BACKEND_SRC" "$BACKEND_DST" + install -o root -g root -m 700 "$BACKEND_SRC" "$BACKEND_DST" # Point backend at installed binary if grep -q 'VERAE_PRINT_BIN:-/usr/local/bin/verae-print' "$BACKEND_DST" 2>/dev/null; then : diff --git a/platform/macos/airdrop/main.swift b/platform/macos/airdrop/main.swift new file mode 100644 index 0000000..b44ab38 --- /dev/null +++ b/platform/macos/airdrop/main.swift @@ -0,0 +1,39 @@ +import AppKit + +// verae-airdrop opens the AirDrop share sheet for the files it is given. +// The menu-bar app launches this helper when it is installed next to +// verae-print. The same NSSharingService call lives in the menu-bar app +// for builds that do not include this helper. +// +// Usage: verae-airdrop certificate.pdf receipt.json + +final class AirDropDelegate: NSObject, NSSharingServiceDelegate { + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + exit(0) + } + + func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) { + fputs("AirDrop failed: \(error.localizedDescription)\n", stderr) + exit(1) + } +} + +let paths = Array(CommandLine.arguments.dropFirst()).filter { !$0.isEmpty } +if paths.isEmpty { + fputs("usage: verae-airdrop certificate.pdf [receipt.json]\n", stderr) + exit(2) +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = AirDropDelegate() +guard let service = NSSharingService(named: .sendViaAirDrop) else { + fputs("AirDrop sharing service is unavailable\n", stderr) + exit(1) +} +service.delegate = delegate +let urls = paths.map { URL(fileURLWithPath: $0) } +app.activate(ignoringOtherApps: true) +service.perform(withItems: urls) +RunLoop.main.run(until: Date().addingTimeInterval(600)) +exit(0) diff --git a/platform/macos/menubar/main.swift b/platform/macos/menubar/main.swift new file mode 100644 index 0000000..52ab0d9 --- /dev/null +++ b/platform/macos/menubar/main.swift @@ -0,0 +1,430 @@ +import AppKit +import Foundation + +// verae-menubar is the AppKit status item used when the Go menu bar cannot +// stay running. It lives in Verae NetPrint.app next to verae-print. +// Usage: verae-menubar [--smoke] + +private let controlDefault = "http://127.0.0.1:8630" +private let logName = "Library/Logs/Verae/netprint-tray.log" + +private let session: URLSession = { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 3 + cfg.timeoutIntervalForResource = 8 + cfg.waitsForConnectivity = false + return URLSession(configuration: cfg) +}() + +private func logPath() -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return (home as NSString).appendingPathComponent(logName) +} + +private func trayLog(_ msg: String) { + let line = ISO8601DateFormatter().string(from: Date()) + " verae-menubar " + msg + "\n" + _ = line.withCString { fputs($0, stderr) } + let path = logPath() + let dir = (path as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: path), + let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile() + try? handle.write(contentsOf: data) + try? handle.close() + } else { + FileManager.default.createFile(atPath: path, contents: data) + } + } +} + +private func sibling(_ name: String) -> String { + var dir = (CommandLine.arguments[0] as NSString).deletingLastPathComponent + if dir.isEmpty || dir == "." { + dir = "/Applications/Verae NetPrint.app/Contents/MacOS" + } + return (dir as NSString).appendingPathComponent(name) +} + +private func aquaSession() -> Bool { + if ProcessInfo.processInfo.environment["SECURITYSESSIONID"] != nil { + return true + } + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/launchctl") + p.arguments = ["managername"] + let pipe = Pipe() + p.standardOutput = pipe + try? p.run() + p.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let name = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + return name == "Aqua" +} + +struct PrinterStatus { + var running = false + var name = "Verae Timestamp" + var jobsToday = 0 + var receipts = "" + var control = controlDefault + var address = "" + var port = 0 + var supportsShare = false + var shareOn = false + var smbURL = "" +} + +private func fetchStatus() -> PrinterStatus { + var st = PrinterStatus() + guard let url = URL(string: controlDefault + "/api/v1/status") else { return st } + let sem = DispatchSemaphore(value: 0) + var body: Data? + session.dataTask(with: url) { data, response, _ in + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + body = data + } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 3) + guard let body, let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { + return st + } + st.running = obj["running"] as? Bool ?? false + if let name = obj["name"] as? String, !name.isEmpty { st.name = name } + st.jobsToday = obj["jobsToday"] as? Int ?? 0 + st.receipts = obj["receiptsUrl"] as? String ?? "" + if st.receipts.isEmpty { st.receipts = obj["adminUrl"] as? String ?? "" } + if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c } + st.port = obj["port"] as? Int ?? 0 + if let addrs = obj["addresses"] as? [String], let first = addrs.first { st.address = first } + st.supportsShare = obj["supportsSMBShare"] as? Bool ?? false + st.shareOn = obj["shareReceiptsSMB"] as? Bool ?? false + st.smbURL = obj["smbUrl"] as? String ?? "" + return st +} + +private func postJSON(_ path: String, _ body: [String: Any]) -> Bool { + guard let url = URL(string: controlDefault + path), + let data = try? JSONSerialization.data(withJSONObject: body) else { return false } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = data + let sem = DispatchSemaphore(value: 0) + var ok = false + session.dataTask(with: req) { _, response, _ in + if let http = response as? HTTPURLResponse, http.statusCode < 300 { ok = true } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 4) + return ok +} + +final class EventWatch: NSObject, URLSessionDataDelegate { + var buffer = "" + var after: Int64 = 0 + var seen = Set() + let queue = OperationQueue() + var sse: URLSession? + var task: URLSessionDataTask? + weak var tray: MenuTray? + + func start() { + queue.maxConcurrentOperationCount = 1 + sse = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: queue) + connect() + } + + func connect() { + guard let sse, let url = URL(string: "\(controlDefault)/api/v1/events?after=\(after)") else { return } + task = sse.dataTask(with: url) + task?.resume() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + buffer += String(data: data, encoding: .utf8) ?? "" + while let range = buffer.range(of: "\n\n") { + let block = String(buffer[.. after { after = seq } + } + + func present(id: Int, receipt: String, pdfURL: String, jsonURL: String) -> Bool { + let root = (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path ?? "/tmp") + let dir = ((root as NSString).appendingPathComponent("Verae/AirDrop/\(id)")) + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let pdf = (dir as NSString).appendingPathComponent("certificate.pdf") + let js = (dir as NSString).appendingPathComponent("receipt.json") + guard download(pdfURL, to: pdf) else { return false } + var jsonPath = "" + if !jsonURL.isEmpty { + guard download(jsonURL, to: js) else { return false } + jsonPath = js + } + DispatchQueue.main.async { [weak self] in + self?.tray?.remember(pdf: pdf, json: jsonPath, receipt: receipt) + } + guard launchAirDrop(pdf: pdf, json: jsonPath) else { return false } + if !ack(id) { trayLog("airdrop ack job \(id) failed") } + return true + } + + func download(_ urlString: String, to dest: String) -> Bool { + guard let url = URL(string: urlString) else { return false } + let sem = DispatchSemaphore(value: 0) + var data: Data? + session.dataTask(with: url) { body, response, _ in + if let http = response as? HTTPURLResponse, http.statusCode == 200 { data = body } + sem.signal() + }.resume() + _ = sem.wait(timeout: .now() + 30) + guard let data else { return false } + return FileManager.default.createFile(atPath: dest, contents: data) + } + + func launchAirDrop(pdf: String, json: String) -> Bool { + let helper = sibling("verae-airdrop") + guard FileManager.default.isExecutableFile(atPath: helper) else { + trayLog("verae-airdrop missing") + return false + } + let p = Process() + p.executableURL = URL(fileURLWithPath: helper) + p.arguments = json.isEmpty ? [pdf] : [pdf, json] + do { + try p.run() + return true + } catch { + trayLog("verae-airdrop: \(error.localizedDescription)") + return false + } + } + + func ack(_ id: Int) -> Bool { + postJSON("/api/v1/airdrop/ack", ["id": id]) + } +} + +final class MenuTray: NSObject, NSMenuDelegate { + let item: NSStatusItem + let menu = NSMenu() + var status = PrinterStatus() + var lastPDF = "" + var lastJSON = "" + var lastReceipt = "" + let events = EventWatch() + + init(item: NSStatusItem) { + self.item = item + super.init() + menu.delegate = self + item.menu = menu + events.tray = self + events.start() + _ = postJSON("/api/v1/receipts-share/ensure", [String: Any]()) + rebuild() + } + + func remember(pdf: String, json: String, receipt: String) { + lastPDF = pdf + lastJSON = json + lastReceipt = receipt + rebuild() + } + + func menuWillOpen(_ menu: NSMenu) { + status = fetchStatus() + rebuild() + } + + func statusLine() -> String { + let state = status.running ? "Running" : "Stopped" + var where_ = status.name + if !status.address.isEmpty && status.port > 0 { + where_ = "\(status.name) · \(status.address):\(status.port)" + } + return "\(state) — \(where_) · \(status.jobsToday) jobs today" + } + + func receiptsURL() -> String { + if !status.receipts.isEmpty { return status.receipts } + return controlDefault + "/receipts" + } + + func settingsURL() -> String { + var base = status.control + while base.hasSuffix("/") { base.removeLast() } + if base.isEmpty { base = controlDefault } + return base + "/settings" + } + + func rebuild() { + menu.removeAllItems() + add(statusLine(), action: nil, enabled: false) + add("iPhone: Share → Print → Select Printer → \(status.name)", action: nil, enabled: false) + menu.addItem(.separator()) + add("Start", action: #selector(startService), enabled: !status.running) + add("Stop", action: #selector(stopService), enabled: status.running) + menu.addItem(.separator()) + add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty) + add("Open Settings…", action: #selector(openSettings), enabled: true) + if status.supportsShare { + let title = status.shareOn ? "Share receipts folder over SMB ✓" : "Share receipts folder over SMB" + add(title, action: #selector(toggleShare), enabled: status.running) + } + if !status.smbURL.isEmpty { + add("Files — \(status.smbURL)", action: nil, enabled: false) + } + if !lastPDF.isEmpty { + let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)" + add(label, action: #selector(airDropAgain), enabled: true) + } + menu.addItem(.separator()) + add("Quit menu", action: #selector(quit), enabled: true) + } + + func add(_ title: String, action: Selector?, enabled: Bool) { + let row = NSMenuItem(title: title, action: action, keyEquivalent: "") + row.target = self + row.isEnabled = enabled + menu.addItem(row) + } + + @objc func startService() { + if !postJSON("/api/v1/service", ["action": "start"]) { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/launchctl") + p.arguments = ["bootstrap", "system", "/Library/LaunchDaemons/com.verae.netprint.plist"] + try? p.run() + } + status = fetchStatus() + rebuild() + } + + @objc func stopService() { + _ = postJSON("/api/v1/service", ["action": "stop"]) + status = fetchStatus() + rebuild() + } + + @objc func openReceipts() { open(receiptsURL()) } + @objc func openSettings() { open(settingsURL()) } + + @objc func toggleShare() { + _ = postJSON("/api/v1/settings", ["shareReceiptsSMB": !status.shareOn]) + status = fetchStatus() + rebuild() + } + + @objc func airDropAgain() { + guard !lastPDF.isEmpty else { + trayLog("no stamped PDF to AirDrop") + return + } + if !events.launchAirDrop(pdf: lastPDF, json: lastJSON) { + trayLog("AirDrop again failed") + } + } + + func open(_ s: String) { + guard let u = URL(string: s) else { return } + NSWorkspace.shared.open(u) + } + + @objc func quit() { + trayLog("quit") + NSApp.terminate(nil) + } +} + +private func smoke() -> Never { + trayLog("smoke") + let app = NSApplication.shared + app.setActivationPolicy(.accessory) + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if item.button == nil { + if !aquaSession() { + trayLog("no window server") + fputs("no window server\n", stdout) + exit(0) + } + trayLog("status item button is nil") + fputs("status item button is nil\n", stderr) + exit(1) + } + item.button?.title = "Verae" + NSStatusBar.system.removeStatusItem(item) + trayLog("smoke ok") + fputs("smoke ok\n", stdout) + exit(0) +} + +if !Thread.isMainThread { + trayLog("not on the main thread") + fputs("verae-menubar: not on the main thread\n", stderr) + exit(1) +} + +if CommandLine.arguments.contains("--smoke") { + smoke() +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) +guard let button = item.button else { + trayLog("status item button is nil") + fputs("status item button is nil\n", stderr) + exit(1) +} +button.title = "Verae" +if let img = NSImage(systemSymbolName: "printer", accessibilityDescription: "Verae") { + img.isTemplate = true + button.image = img +} +let tray = MenuTray(item: item) +_ = tray +trayLog("status item ready") +app.run() +trayLog("NSApplication.run returned without quit") +fputs("NSApplication.run returned without quit\n", stderr) +exit(1) diff --git a/platform/macos/netprint/bootout.sh b/platform/macos/netprint/bootout.sh new file mode 100644 index 0000000..b3de0ce --- /dev/null +++ b/platform/macos/netprint/bootout.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Unload the network printer and its menu-bar agent so an upgrade can +# replace the binary. launchd KeepAlive would otherwise hold the file open. +bootout_netprint() { + launchctl bootout system/com.verae.netprint 2>/dev/null || true + local user uid + user=$(stat -f%Su /dev/console 2>/dev/null || true) + if [[ -n "$user" && "$user" != "root" && "$user" != "loginwindow" ]]; then + uid=$(id -u "$user" 2>/dev/null || true) + if [[ -n "$uid" ]]; then + launchctl bootout "gui/${uid}/com.verae.netprint.tray" 2>/dev/null || true + fi + fi + sleep 1 +} diff --git a/platform/macos/netprint/com.verae.netprint.plist b/platform/macos/netprint/com.verae.netprint.plist new file mode 100644 index 0000000..89def63 --- /dev/null +++ b/platform/macos/netprint/com.verae.netprint.plist @@ -0,0 +1,14 @@ + + + + Labelcom.verae.netprint + ProgramArguments + + /Library/Application Support/Verae/NetPrint/bin/verae-print + netprint + --config + /Library/Application Support/Verae/PrintStamp/netprint.json + + RunAtLoad + KeepAlive + diff --git a/platform/macos/netprint/com.verae.netprint.tray.plist b/platform/macos/netprint/com.verae.netprint.tray.plist new file mode 100644 index 0000000..50aa3c7 --- /dev/null +++ b/platform/macos/netprint/com.verae.netprint.tray.plist @@ -0,0 +1,12 @@ + + + + Labelcom.verae.netprint.tray + ProgramArguments + + /Applications/Verae NetPrint.app/Contents/MacOS/verae-print + tray + + RunAtLoad + LimitLoadToSessionTypeAqua + diff --git a/platform/macos/netprint/scripts/postinstall b/platform/macos/netprint/scripts/postinstall new file mode 100755 index 0000000..3d54813 --- /dev/null +++ b/platform/macos/netprint/scripts/postinstall @@ -0,0 +1,11 @@ +#!/bin/bash +# Enable the shared Verae network printer. Safe to run from the driver package's +# optional component (owner driver) or the standalone package (owner standalone). +set -euo pipefail +OWNER="${VERAE_NETPRINT_OWNER:-standalone}" +BIN="/Library/Application Support/Verae/PrintStamp/bin/verae-print" +if [[ ! -x "$BIN" ]]; then + echo "verae-print is not installed at $BIN" >&2 + exit 1 +fi +exec "$BIN" netprint install --owner "$OWNER" diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh new file mode 100755 index 0000000..1052a8e --- /dev/null +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Standalone Verae Network Printer .pkg. +# Built with pkgbuild and productbuild on macOS (the GitHub macos-14 runner). +# The package is unsigned until a Developer ID Installer certificate is present. +# Linux does not write a .pkg: a xar built there fails pkgutil with +# "Checksums do not match". +# CI package name: VeraeNetPrint-macos-arm64-0.3.4.pkg +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +VERSION="${VERSION:-0.3.4}" +OUT="${OUT:-$ROOT/build/netprint/macos}" +BIN="${BIN:-$ROOT/build/netprint/macos/verae-print}" +mkdir -p "$OUT" + +if [[ ! -x "$BIN" && ! -f "$BIN" ]]; then + echo "missing $BIN" >&2 + exit 1 +fi + +ARCH="${PKG_ARCH:-arm64}" +STAGE="$OUT/payload" +SCRIPTS="$OUT/scripts" +rm -rf "$STAGE" "$SCRIPTS" +APP="$STAGE/Applications/Verae NetPrint.app" +mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" \ + "$STAGE/Library/LaunchDaemons" \ + "$STAGE/Library/LaunchAgents" \ + "$APP/Contents/MacOS" \ + "$SCRIPTS" +cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" +chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" +# The menu-bar app and the AirDrop helper live in one bundle. LSUIElement=1 +# keeps it out of the Dock. The LaunchAgent runs the executable inside it. +cp -f "$BIN" "$APP/Contents/MacOS/verae-print" +chmod 755 "$APP/Contents/MacOS/verae-print" +cat >"$APP/Contents/Info.plist" < + + + CFBundleExecutableverae-print + CFBundleIdentifierai.verae.netprint + CFBundleNameVerae NetPrint + CFBundleDisplayNameVerae NetPrint + CFBundlePackageTypeAPPL + CFBundleShortVersionString${VERSION} + CFBundleVersion${VERSION} + LSMinimumSystemVersion14.0 + LSUIElement + NSHighResolutionCapable + NSLocalNetworkUsageDescriptionVerae NetPrint looks for the timestamp printer on the local network and opens AirDrop to send the stamped PDF. + +EOF +printf 'APPL????' >"$APP/Contents/PkgInfo" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" +# The package itself installs the root daemon and the Aqua LaunchAgent. +# postinstall bootstraps both; these copies are what launchd reads. +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" +chmod 644 "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" +# swiftc on Linux cannot import AppKit. The macOS runner always builds the helper. +if [[ "$(uname -s)" == "Darwin" ]]; then + if ! command -v swiftc >/dev/null 2>&1; then + echo "swiftc is required to build verae-airdrop" >&2 + exit 1 + fi + swiftc -O -o "$APP/Contents/MacOS/verae-airdrop" \ + "$ROOT/platform/macos/airdrop/main.swift" + chmod 755 "$APP/Contents/MacOS/verae-airdrop" + swiftc -O -o "$APP/Contents/MacOS/verae-menubar" \ + "$ROOT/platform/macos/menubar/main.swift" + chmod 755 "$APP/Contents/MacOS/verae-menubar" +fi +# preinstall runs before the payload replaces verae-print. postinstall +# bootstraps the service again. +cp -f "$ROOT/platform/macos/netprint/bootout.sh" "$SCRIPTS/bootout.sh" +cat >"$SCRIPTS/preinstall" <<'EOF' +#!/bin/bash +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=/dev/null +source "$HERE/bootout.sh" +bootout_netprint +exit 0 +EOF +cat >"$SCRIPTS/postinstall" <<'EOF' +#!/bin/bash +set -euo pipefail +APP="/Applications/Verae NetPrint.app/Contents/MacOS" +missing=0 +for b in verae-print verae-airdrop verae-menubar; do + if [[ ! -x "$APP/$b" ]]; then + echo "missing $APP/$b" >&2 + missing=1 + fi +done +if [[ "$missing" -ne 0 ]]; then + exit 1 +fi +echo "menu-bar bundle ok" +export VERAE_NETPRINT_OWNER=standalone +exec "/Library/Application Support/Verae/NetPrint/bin/verae-print" netprint install --owner standalone +EOF +chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" + +PKG_NAME="VeraeNetPrint-macos-${ARCH}-${VERSION}.pkg" +if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null 2>&1; then + echo "pkgbuild/productbuild not available — payload only; the package is built on macos-14" + tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts + exit 0 +fi +if [[ ! -x "$APP/Contents/MacOS/verae-airdrop" ]]; then + echo "verae-airdrop missing from Verae NetPrint.app" >&2 + exit 1 +fi +if [[ ! -x "$APP/Contents/MacOS/verae-menubar" ]]; then + echo "verae-menubar missing from Verae NetPrint.app" >&2 + exit 1 +fi + +COMPONENT="$OUT/netprint-component.pkg" +rm -f "$COMPONENT" "$OUT/$PKG_NAME" +pkgbuild \ + --root "$STAGE" \ + --scripts "$SCRIPTS" \ + --identifier "ai.verae.netprint.standalone" \ + --version "$VERSION" \ + --install-location "/" \ + "$COMPONENT" + +SMB_SCRIPTS="$OUT/smb-scripts" +SMB_COMPONENT="$OUT/smb-component.pkg" +rm -rf "$SMB_SCRIPTS" +mkdir -p "$SMB_SCRIPTS" +cp -f "$ROOT/platform/macos/pkg/smb-share.sh" "$SMB_SCRIPTS/postinstall" +chmod 755 "$SMB_SCRIPTS/postinstall" +rm -f "$SMB_COMPONENT" +pkgbuild \ + --nopayload \ + --scripts "$SMB_SCRIPTS" \ + --identifier "ai.verae.netprint.smb" \ + --version "$VERSION" \ + --install-location "/" \ + "$SMB_COMPONENT" + +DIST="$OUT/distribution-standalone.xml" +cat >"$DIST" < + + Verae Network Printer + + + + + + + + + + + + + netprint-component.pkg + smb-component.pkg + +EOF + +SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}" +if [[ -f "$SIGNING_ENV" ]]; then + # shellcheck source=/dev/null + source "$SIGNING_ENV" + ID_PKG="${APPLE_INSTALLER_SIGN_ID:-Developer ID Installer: FlightLookup Inc. (G8CKY7VMZC)}" + productbuild --distribution "$DIST" --package-path "$OUT" --sign "$ID_PKG" --timestamp "$OUT/$PKG_NAME" +else + productbuild --distribution "$DIST" --package-path "$OUT" "$OUT/$PKG_NAME" +fi +rm -f "$COMPONENT" "$SMB_COMPONENT" +echo "built $OUT/$PKG_NAME" diff --git a/platform/macos/pkg/build-pkg.sh b/platform/macos/pkg/build-pkg.sh index 9736648..587b3f9 100755 --- a/platform/macos/pkg/build-pkg.sh +++ b/platform/macos/pkg/build-pkg.sh @@ -9,7 +9,7 @@ SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}" # shellcheck source=/dev/null source "$SIGNING_ENV" -VERSION="${VERSION:-0.3.2}" +VERSION="${VERSION:-0.3.4}" BUILD_ROOT="$ROOT/build/macos-pkg" STAGE="$BUILD_ROOT/stage" SCRIPTS="$BUILD_ROOT/scripts" @@ -124,9 +124,36 @@ codesign --force --deep --sign "$ID_APP" --options runtime --timestamp \ echo "→ Preparing pkg scripts" cp -f "$PKG_DIR/scripts/postinstall" "$SCRIPTS/postinstall" -chmod 755 "$SCRIPTS/postinstall" +cp -f "$ROOT/platform/macos/netprint/bootout.sh" "$SCRIPTS/bootout.sh" +cat >"$SCRIPTS/preinstall" <<'EOF' +#!/bin/bash +# Only unload the printer when it is running the binary this package is +# about to replace. A standalone install under NetPrint is left running. +plist="/Library/LaunchDaemons/com.verae.netprint.plist" +if [[ -f "$plist" ]] && grep -q "PrintStamp/bin/verae-print" "$plist"; then + HERE="$(cd "$(dirname "$0")" && pwd)" + # shellcheck source=/dev/null + source "$HERE/bootout.sh" + bootout_netprint + touch /var/tmp/verae-netprint-stopped-for-upgrade +fi +exit 0 +EOF +chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" + +# Optional network-printer component. Off unless the user selects it in +# Customize. It calls the same install command as the standalone package. +NET_SCRIPTS="$BUILD_ROOT/netprint-scripts" +mkdir -p "$NET_SCRIPTS" +cat >"$NET_SCRIPTS/postinstall" <<'EOF' +#!/bin/bash +export VERAE_NETPRINT_OWNER=driver +exec "/Library/Application Support/Verae/PrintStamp/bin/verae-print" netprint install --owner driver +EOF +chmod 755 "$NET_SCRIPTS/postinstall" COMPONENT_PKG="$DIST/VeraePrintStamp-component.pkg" +NET_PKG="$DIST/VeraeNetPrint-component.pkg" PRODUCT_PKG="$DIST/VeraePrintStamp-${VERSION}.pkg" echo "→ pkgbuild component" @@ -138,9 +165,22 @@ pkgbuild \ --install-location "/" \ "$COMPONENT_PKG" +echo "→ pkgbuild optional network printer" +pkgbuild \ + --nopayload \ + --scripts "$NET_SCRIPTS" \ + --identifier "ai.verae.netprint.pkg" \ + --version "$VERSION" \ + --install-location "/" \ + "$NET_PKG" + +cp -f "$PKG_DIR/distribution.xml" "$DIST/distribution.xml" +# distribution.xml carries version 0; productbuild reads the component pkgs. + echo "→ productbuild + Developer ID Installer sign" productbuild \ - --package "$COMPONENT_PKG" \ + --distribution "$DIST/distribution.xml" \ + --package-path "$DIST" \ --sign "$ID_PKG" \ --timestamp \ "$PRODUCT_PKG" diff --git a/platform/macos/pkg/build-unsigned-pkg.sh b/platform/macos/pkg/build-unsigned-pkg.sh new file mode 100755 index 0000000..2a01fe3 --- /dev/null +++ b/platform/macos/pkg/build-unsigned-pkg.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Build an unsigned flat package on Linux. pkgbuild is not here. +# The result installs with: sudo installer -pkg VeraeNetPrint-macos-arm64-*.pkg -target / +# Finder: right-click the pkg and choose Open. It is not signed or notarized. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +STAGE="${1:?payload root}" +SCRIPTS="${2:?scripts dir}" +DEST="${3:?output pkg}" +VERSION="${4:?version}" + +if ! command -v cpio >/dev/null 2>&1; then + echo "cpio is required to build the macOS package" >&2 + exit 1 +fi + +MKBOM="${MKBOM:-}" +if [[ -z "$MKBOM" ]]; then + if command -v mkbom >/dev/null 2>&1 && mkbom -h >/dev/null 2>&1; then + MKBOM="$(command -v mkbom)" + fi +fi +if [[ -z "$MKBOM" || ! -x "$MKBOM" ]]; then + TOOL="$ROOT/build/tool" + mkdir -p "$TOOL" + if [[ ! -x "$TOOL/bomutils/build/bin/mkbom" ]]; then + rm -rf "$TOOL/bomutils" + git clone --depth 1 https://github.com/hogliux/bomutils.git "$TOOL/bomutils" + make -C "$TOOL/bomutils" all + fi + MKBOM="$TOOL/bomutils/build/bin/mkbom" +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +COMP="$WORK/netprint.pkg" +mkdir -p "$COMP" + +# odc cpio, root:admin (0:80), matching the Bom. GNU cpio accepts --owner. +( cd "$STAGE" && find . | sort | cpio -o --format odc --owner 0:80 | gzip -c ) > "$COMP/Payload" +( cd "$SCRIPTS" && find . | sort | cpio -o --format odc --owner 0:80 | gzip -c ) > "$COMP/Scripts" +"$MKBOM" -u 0 -g 80 "$STAGE" "$COMP/Bom" + +FILES="$(find "$STAGE" | wc -l | tr -d ' ')" +KB="$(du -sk "$STAGE" | awk '{print $1}')" +cat >"$COMP/PackageInfo" < + + + + + + + +EOF + +cat >"$WORK/Distribution" < + + Verae Network Printer + + + + + + + + + netprint.pkg + +EOF + +( + cd "$ROOT" + go run ./platform/macos/pkg/cmd/mkxar -o "$DEST" "$WORK" +) +echo "unsigned package $DEST" diff --git a/platform/macos/pkg/check-pkg.sh b/platform/macos/pkg/check-pkg.sh new file mode 100644 index 0000000..8f8ebbb --- /dev/null +++ b/platform/macos/pkg/check-pkg.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Confirm a pkgbuild/productbuild package is an Apple package. +# pkgutil --expand fails with "Checksums do not match" on the Linux xar. +set -euo pipefail +PKG="${1:?package path}" +if [[ ! -f "$PKG" ]]; then + echo "missing $PKG" >&2 + exit 1 +fi +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +pkgutil --expand "$PKG" "$WORKDIR/expanded" +pkgutil --expand-full "$PKG" "$WORKDIR/full" + +set +e +sig="$(pkgutil --check-signature "$PKG" 2>&1)" +sigrc=$? +set -e +printf '%s\n' "$sig" +if [[ $sigrc -ne 0 ]] && ! grep -qi 'no signature' <<<"$sig"; then + echo "pkgutil --check-signature failed" >&2 + exit 1 +fi + +info="$(installer -pkginfo -pkg "$PKG" 2>&1)" +printf '%s\n' "$info" +if [[ -z "$info" ]]; then + echo "installer -pkginfo returned nothing" >&2 + exit 1 +fi + +bom="$(find "$WORKDIR/full" -name Bom -print -quit)" +if [[ -z "$bom" ]]; then + echo "expanded package has no Bom" >&2 + exit 1 +fi +: >"$WORKDIR/bom.txt" +found=0 +while IFS= read -r bom; do + lsbom "$bom" >>"$WORKDIR/bom.txt" + found=1 +done < <(find "$WORKDIR/full" -name Bom -print) +if [[ "$found" -eq 0 ]]; then + echo "expanded package has no Bom" >&2 + exit 1 +fi +grep -q 'verae-airdrop' "$WORKDIR/bom.txt" +grep -q 'verae-menubar' "$WORKDIR/bom.txt" +grep -q 'LaunchAgents' "$WORKDIR/bom.txt" +grep -q 'LaunchDaemons' "$WORKDIR/bom.txt" +grep -q 'Verae NetPrint.app' "$WORKDIR/bom.txt" +plist="$(find "$WORKDIR/full" -path '*Verae NetPrint.app/Contents/Info.plist' -print -quit)" +if [[ -z "$plist" ]]; then + echo "Verae NetPrint.app has no Info.plist" >&2 + exit 1 +fi +grep -q 'LSUIElement' "$plist" +grep -q 'NSLocalNetworkUsageDescription' "$plist" +grep -q 'ai.verae.netprint' "$plist" +dist="$WORKDIR/expanded/Distribution" +if [[ ! -f "$dist" ]]; then + echo "expanded package has no Distribution" >&2 + exit 1 +fi +grep -q 'Share receipts folder over the network (SMB)' "$dist" +echo "package ok: $PKG" diff --git a/platform/macos/pkg/cmd/mkxar/main.go b/platform/macos/pkg/cmd/mkxar/main.go new file mode 100644 index 0000000..bd36f2c --- /dev/null +++ b/platform/macos/pkg/cmd/mkxar/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/platform/macos/pkg/mkxar" +) + +func main() { + out := flag.String("o", "", "output xar path") + flag.Parse() + if *out == "" || flag.NArg() != 1 { + fmt.Fprintln(os.Stderr, "usage: mkxar -o out.pkg directory") + os.Exit(2) + } + if err := mkxar.WriteDir(*out, flag.Arg(0)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/platform/macos/pkg/distribution.xml b/platform/macos/pkg/distribution.xml new file mode 100644 index 0000000..2f450a1 --- /dev/null +++ b/platform/macos/pkg/distribution.xml @@ -0,0 +1,18 @@ + + + Verae Print Stamp + + + + + + + + + + + + + VeraePrintStamp-component.pkg + VeraeNetPrint-component.pkg + diff --git a/platform/macos/pkg/mkxar/xar.go b/platform/macos/pkg/mkxar/xar.go new file mode 100644 index 0000000..7ca972f --- /dev/null +++ b/platform/macos/pkg/mkxar/xar.go @@ -0,0 +1,208 @@ +// Package mkxar writes an uncompressed xar archive. macOS flat packages are +// xar archives whose members are stored, not gzip-compressed, because the +// Payload inside is already gzip. +package mkxar + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +type fileRec struct { + id int + name string + isDir bool + mode os.FileMode + data []byte + kids []*fileRec + offset int +} + +// WriteDir writes root's children as the top level of a xar archive. +func WriteDir(dst string, root string) error { + entries, err := readTree(root) + if err != nil { + return err + } + var files []*fileRec + id := 1 + var walk func(list []*fileRec) + walk = func(list []*fileRec) { + for _, e := range list { + e.id = id + id++ + files = append(files, e) + walk(e.kids) + } + } + walk(entries) + + heap := []byte{} + // 20-byte SHA-1 of the TOC is filled in after the TOC exists. + heap = append(heap, make([]byte, 20)...) + for _, e := range files { + if e.isDir { + continue + } + e.offset = len(heap) + heap = append(heap, e.data...) + } + + toc := buildTOC(entries) + sum := sha1.Sum([]byte(toc)) + copy(heap[:20], sum[:]) + + var zbuf bytes.Buffer + zw, err := zlib.NewWriterLevel(&zbuf, zlib.BestSpeed) + if err != nil { + return err + } + if _, err := zw.Write([]byte(toc)); err != nil { + return err + } + if err := zw.Close(); err != nil { + return err + } + compressed := zbuf.Bytes() + + var hdr [28]byte + binary.BigEndian.PutUint32(hdr[0:4], 0x78617221) + binary.BigEndian.PutUint16(hdr[4:6], 28) + binary.BigEndian.PutUint16(hdr[6:8], 1) + binary.BigEndian.PutUint64(hdr[8:16], uint64(len(compressed))) + binary.BigEndian.PutUint64(hdr[16:24], uint64(len(toc))) + binary.BigEndian.PutUint32(hdr[24:28], 1) // sha1 + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := out.Write(hdr[:]); err != nil { + return err + } + if _, err := out.Write(compressed); err != nil { + return err + } + _, err = out.Write(heap) + return err +} + +func readTree(dir string) ([]*fileRec, error) { + ents, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() }) + var out []*fileRec + for _, ent := range ents { + info, err := ent.Info() + if err != nil { + return nil, err + } + rec := &fileRec{name: ent.Name(), mode: info.Mode()} + path := filepath.Join(dir, ent.Name()) + if ent.IsDir() { + rec.isDir = true + rec.kids, err = readTree(path) + if err != nil { + return nil, err + } + } else { + rec.data, err = os.ReadFile(path) + if err != nil { + return nil, err + } + } + out = append(out, rec) + } + return out, nil +} + +func buildTOC(entries []*fileRec) string { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString("\n \n") + b.WriteString(" \n 0\n 20\n \n") + for _, e := range entries { + writeFile(&b, e, 1) + } + b.WriteString(" \n\n") + return b.String() +} + +func writeFile(b *strings.Builder, e *fileRec, depth int) { + pad := strings.Repeat(" ", depth) + fmt.Fprintf(b, "%s\n", pad, e.id) + fmt.Fprintf(b, "%s %s\n", pad, xmlEscape(e.name)) + if e.isDir { + fmt.Fprintf(b, "%s directory\n", pad) + } else { + fmt.Fprintf(b, "%s file\n", pad) + } + fmt.Fprintf(b, "%s %04o\n", pad, e.mode.Perm()) + fmt.Fprintf(b, "%s 0\n%s 0\n", pad, pad) + if !e.isDir { + sum := sha1.Sum(e.data) + hexSum := hex.EncodeToString(sum[:]) + fmt.Fprintf(b, "%s \n", pad) + fmt.Fprintf(b, "%s %d\n", pad, e.offset) + fmt.Fprintf(b, "%s %d\n", pad, len(e.data)) + fmt.Fprintf(b, "%s %d\n", pad, len(e.data)) + fmt.Fprintf(b, "%s \n", pad) + fmt.Fprintf(b, "%s %s\n", pad, hexSum) + fmt.Fprintf(b, "%s %s\n", pad, hexSum) + fmt.Fprintf(b, "%s \n", pad) + } + for _, k := range e.kids { + writeFile(b, k, depth+1) + } + fmt.Fprintf(b, "%s\n", pad) +} + +func xmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">") + return r.Replace(s) +} + +// TOC extracts the uncompressed table of contents. Tests use it. +func TOC(r io.ReaderAt, size int64) (string, error) { + if size < 28 { + return "", fmt.Errorf("short xar") + } + var hdr [28]byte + if _, err := r.ReadAt(hdr[:], 0); err != nil { + return "", err + } + if binary.BigEndian.Uint32(hdr[0:4]) != 0x78617221 { + return "", fmt.Errorf("not a xar") + } + comp := binary.BigEndian.Uint64(hdr[8:16]) + rawLen := binary.BigEndian.Uint64(hdr[16:24]) + buf := make([]byte, comp) + if _, err := r.ReadAt(buf, 28); err != nil { + return "", err + } + zr, err := zlib.NewReader(bytes.NewReader(buf)) + if err != nil { + return "", err + } + defer zr.Close() + out, err := io.ReadAll(zr) + if err != nil { + return "", err + } + if uint64(len(out)) != rawLen { + return "", fmt.Errorf("toc length %d want %d", len(out), rawLen) + } + return string(out), nil +} diff --git a/platform/macos/pkg/mkxar/xar_test.go b/platform/macos/pkg/mkxar/xar_test.go new file mode 100644 index 0000000..194ae93 --- /dev/null +++ b/platform/macos/pkg/mkxar/xar_test.go @@ -0,0 +1,52 @@ +package mkxar + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteDirRoundTrip(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "netprint.pkg"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Distribution"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + payload := bytesRepeat(1000) + if err := os.WriteFile(filepath.Join(root, "netprint.pkg", "Payload"), payload, 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(t.TempDir(), "out.pkg") + if err := WriteDir(dst, root); err != nil { + t.Fatal(err) + } + f, err := os.Open(dst) + if err != nil { + t.Fatal(err) + } + defer f.Close() + st, err := f.Stat() + if err != nil { + t.Fatal(err) + } + toc, err := TOC(f, st.Size()) + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"Distribution", "netprint.pkg", "Payload", "application/octet-stream"} { + if !strings.Contains(toc, needle) { + t.Fatalf("toc missing %s\n%s", needle, toc) + } + } +} + +func bytesRepeat(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i) + } + return b +} diff --git a/platform/macos/pkg/scripts/postinstall b/platform/macos/pkg/scripts/postinstall index e1a1f7a..33caa45 100755 --- a/platform/macos/pkg/scripts/postinstall +++ b/platform/macos/pkg/scripts/postinstall @@ -78,5 +78,16 @@ if [[ -n "${CONSOLE_USER:-}" && "$CONSOLE_USER" != "root" && -n "${HOME_DIR:-}" fi fi +# The preinstall script unloaded the network printer so this package could +# replace its binary. Bring that job back. The optional NetPrint component +# bootstraps again when it is selected. +if [[ -f /var/tmp/verae-netprint-stopped-for-upgrade ]]; then + rm -f /var/tmp/verae-netprint-stopped-for-upgrade + if [[ -f /Library/LaunchDaemons/com.verae.netprint.plist ]]; then + launchctl bootstrap system /Library/LaunchDaemons/com.verae.netprint.plist \ + || echo "netprint bootstrap exited $? (component install may load it)" + fi +fi + echo "$(date -u +%Y-%m-%dT%H:%MZ) postinstall done" exit 0 diff --git a/platform/macos/pkg/smb-share.sh b/platform/macos/pkg/smb-share.sh new file mode 100755 index 0000000..0fa7a5c --- /dev/null +++ b/platform/macos/pkg/smb-share.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Optional SMB share of the macOS receipts folder. +# The installer runs this only when that choice is checked. +# The program stays in Application Support and is not in this folder. +# The first connection from iPhone Files needs a username and password +# from this Mac. Guest access stays off. +set -euo pipefail + +DIR="/Users/Shared/Verae Receipts" +NAME="Verae Receipts" +STAMP="/Library/Application Support/Verae/PrintStamp/share-receipts-smb.on" +BIN="/Library/Application Support/Verae/NetPrint/bin/verae-print" + +mkdir -p "$DIR" +# Root-owned, console-user-readable, not world-writable. No binaries live here. +chown root:wheel "$DIR" 2>/dev/null || chown root:admin "$DIR" 2>/dev/null || true +chmod 755 "$DIR" + +if ! command -v sharing >/dev/null 2>&1; then + echo "sharing(8) is not available; the SMB share was not created" >&2 + exit 1 +fi + +# Replace a previous point so a rerun stays SMB-only, guest-off, and read-only. +if sharing -l 2>/dev/null | grep -F "$NAME" >/dev/null 2>&1; then + sharing -r "$NAME" || true +fi + +# -s 001 is SMB only. -g 000 keeps guest access off. +sharing -a "$DIR" -n "$NAME" -S "$NAME" -s 001 -g 000 + +if dscl . -read "/SharePoints/${NAME}" >/dev/null 2>&1; then + dscl . -create "/SharePoints/${NAME}" smb_shared 1 + dscl . -create "/SharePoints/${NAME}" smb_guestaccess 0 + dscl . -create "/SharePoints/${NAME}" smb_read_only 1 + dscl . -create "/SharePoints/${NAME}" directory_path "$DIR" +fi + +# File Sharing stays enabled across reboots. Do not bounce smbd if it is already up. +launchctl enable system/com.apple.smbd >/dev/null 2>&1 || true +if ! launchctl print system/com.apple.smbd 2>/dev/null | grep -q "state = running"; then + launchctl bootstrap system /System/Library/LaunchDaemons/com.apple.smbd.plist >/dev/null 2>&1 || \ + launchctl kickstart system/com.apple.smbd >/dev/null 2>&1 || true +fi + +mkdir -p "$(dirname "$STAMP")" +echo enabled >"$STAMP" + +# Persist shareReceiptsSMB in netprint.json and reload the daemon when it is already installed. +# If this package runs before the main package, the stamp above is enough. +if [[ -x "$BIN" ]]; then + "$BIN" netprint enable-receipts-share +fi + +echo "SMB share \"${NAME}\" -> ${DIR} (SMB only, guest off, read-only)" diff --git a/platform/windows/installer-netprint.nsi b/platform/windows/installer-netprint.nsi new file mode 100644 index 0000000..49e047f --- /dev/null +++ b/platform/windows/installer-netprint.nsi @@ -0,0 +1,291 @@ +; Verae Network Printer — standalone Windows installer (NSIS). +; Installs into its own folder (C:\Program Files\Verae\NetPrint) so it does not +; overwrite the driver's C:\Program Files\Verae\PrintStamp\bin\verae-print.exe. +; The service name (VeraeNetPrint) and %ProgramData% config are shared. Installing +; one product when the other is present points the service at the same or newer +; binary and leaves the other product's files in place. +!ifndef VERSION + !define VERSION "0.3.4" +!endif +!ifndef ARCH + !define ARCH "amd64" +!endif +!ifndef SRCDIR + !define SRCDIR "." +!endif +!ifndef OUTDIR + !define OUTDIR "." +!endif + +!define PRODUCT_NAME "Verae Network Printer" +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\VeraeNetPrint" + +Name "${PRODUCT_NAME} ${VERSION}" +OutFile "${OUTDIR}\VeraeNetPrint-windows-${ARCH}-${VERSION}-setup.exe" +InstallDir "$PROGRAMFILES64\Verae\NetPrint" +RequestExecutionLevel admin +Unicode true +SetCompressor /SOLID lzma +ShowInstDetails show + +!include "MUI2.nsh" +!include "nsDialogs.nsh" +!include "x64.nsh" +!include "LogicLib.nsh" +!include "FileFunc.nsh" +!include "WinMessages.nsh" +!include "netprint-upgrade.nsh" +!insertmacro GetParameters +!insertmacro GetOptions + +!define MUI_ABORTWARNING +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_LICENSE "${SRCDIR}\LICENSE.txt" +!insertmacro MUI_PAGE_DIRECTORY +Page custom NetOptionsCreate NetOptionsLeave +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Var LogFile +Var LogDir +Var AllowPublic +Var LocalQueue +Var ShareSMB +Var hPublic +Var hQueue +Var hShare +Var FwProfile + +Function .onInit + ${IfNot} ${RunningX64} + MessageBox MB_ICONSTOP "This build targets 64-bit Windows." /SD IDOK + Abort + ${EndIf} + SetRegView 64 + SetShellVarContext all + AllowSkipFiles off + Call SetupLogPath + StrCpy $AllowPublic "0" + StrCpy $LocalQueue "0" + StrCpy $ShareSMB "0" + ${GetParameters} $R0 + ClearErrors + ${GetOptions} $R0 "/PUBLIC=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $AllowPublic "1" + ${EndIf} + ${EndIf} + ClearErrors + ${GetOptions} $R0 "/QUEUE=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $LocalQueue "1" + ${EndIf} + ${EndIf} + ClearErrors + ${GetOptions} $R0 "/SMB=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $ShareSMB "1" + ${EndIf} + ${EndIf} +FunctionEnd + +Function un.onInit + SetRegView 64 + SetShellVarContext all + AllowSkipFiles off + Call un.SetupLogPath +FunctionEnd + +Function SetupLogPath + ReadEnvStr $LogDir "ProgramData" + ${If} $LogDir == "" + StrCpy $LogDir "C:\ProgramData" + ${EndIf} + StrCpy $LogDir "$LogDir\Verae\logs" + StrCpy $LogFile "$LogDir\netprint-installer.log" +FunctionEnd + +Function un.SetupLogPath + ReadEnvStr $LogDir "ProgramData" + ${If} $LogDir == "" + StrCpy $LogDir "C:\ProgramData" + ${EndIf} + StrCpy $LogDir "$LogDir\Verae\logs" + StrCpy $LogFile "$LogDir\netprint-installer.log" +FunctionEnd + +; Inbound TCP 631 (IPP), TCP 8632 (fallback; 8631 is the driver helper), UDP 5353 (mDNS). +; $FwProfile is private,domain unless the installer opted into Public. +; remoteip=any includes IPv4, global IPv6, and link-local fe80::/10. +; A macro so the uninstaller can run the same commands. +!macro NETPRINT_FIREWALL_ADD + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 remoteip=any profile=$FwProfile' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 remoteip=any profile=$FwProfile' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 remoteip=any profile=$FwProfile' + Pop $0 +!macroend + +Function NetOptionsCreate + !insertmacro MUI_HEADER_TEXT "Network options" "These stay off unless you turn them on." + nsDialogs::Create 1018 + Pop $0 + ${NSD_CreateCheckbox} 0 0 100% 24u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." + Pop $hPublic + ${NSD_CreateCheckbox} 0 32u 100% 24u "Add a local Windows printer named &Verae Timestamp (off by default).$\r$\nPrints to this computer through http://127.0.0.1 on the port the service binds." + Pop $hQueue + ${NSD_CreateCheckbox} 0 64u 100% 28u "Share the receipts folder over SMB, read-only (off by default).$\r$\nOther computers can open stamped files in Verae Receipts. The program is not in that folder." + Pop $hShare + ${If} $AllowPublic == "1" + ${NSD_Check} $hPublic + ${EndIf} + ${If} $LocalQueue == "1" + ${NSD_Check} $hQueue + ${EndIf} + ${If} $ShareSMB == "1" + ${NSD_Check} $hShare + ${EndIf} + nsDialogs::Show +FunctionEnd + +Function NetOptionsLeave + ${NSD_GetState} $hPublic $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $AllowPublic "1" + ${Else} + StrCpy $AllowPublic "0" + ${EndIf} + ${NSD_GetState} $hQueue $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $LocalQueue "1" + ${Else} + StrCpy $LocalQueue "0" + ${EndIf} + ${NSD_GetState} $hShare $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $ShareSMB "1" + ${Else} + StrCpy $ShareSMB "0" + ${EndIf} +FunctionEnd + +Function ShareReceiptsSMB + ReadEnvStr $R7 "ProgramData" + ${If} $R7 == "" + StrCpy $R7 "C:\ProgramData" + ${EndIf} + StrCpy $R7 "$R7\Verae\NetPrint" + CreateDirectory "$R7" + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 + nsExec::Exec 'net share "Verae Receipts"="$R7" /GRANT:Everyone,READ /REMARK:"Verae timestamp receipts (read-only)"' + Pop $0 +FunctionEnd + +!macro NETPRINT_FIREWALL_REMOVE + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 +!macroend + +Section "Verae Network Printer" SecMain + SectionIn RO + ; The service and tray keep the installed exes locked. Copying first + ; skips those files under /S and leaves the old binary in place. + !insertmacro NETPRINT_STOP_SERVICE + !insertmacro NETPRINT_KILL_TRAY + SetOverwrite on + AllowSkipFiles off + SetOutPath "$INSTDIR\bin" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-print.exe" "verae-print.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-netprint-tray.exe" "verae-netprint-tray.exe" + SetOutPath "$INSTDIR" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\LICENSE.txt" "LICENSE.txt" + WriteUninstaller "$INSTDIR\Uninstall-NetPrint.exe" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall-NetPrint.exe"' + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "Verae" + CreateDirectory "$SMPROGRAMS\Verae" + CreateShortCut "$SMPROGRAMS\Verae\Verae Network Printer.lnk" "$INSTDIR\bin\verae-netprint-tray.exe" + StrCpy $FwProfile "private,domain" + StrCpy $R8 "false" + ${If} $AllowPublic == "1" + StrCpy $FwProfile "private,domain,public" + StrCpy $R8 "true" + ${EndIf} + !insertmacro NETPRINT_FIREWALL_ADD + CreateDirectory "$LogDir" + StrCpy $R9 '"$INSTDIR\bin\verae-print.exe" netprint install --owner standalone --allow-public=$R8' + ${If} $LocalQueue == "1" + StrCpy $R9 '$R9 --local-queue' + ${EndIf} + ${If} $ShareSMB == "1" + Call ShareReceiptsSMB + ${EndIf} + nsExec::ExecToStack $R9 + Pop $0 + Pop $1 + ClearErrors + FileOpen $2 "$LogFile" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "netprint install allow-public=$R8 local-queue=$LocalQueue smb=$ShareSMB exit $0$\r$\n$1$\r$\n" + FileClose $2 + ${EndIf} + ${If} $0 != 0 + MessageBox MB_ICONEXCLAMATION "The service did not start (exit $0).$\r$\nDetails were written to $LogFile$\r$\nYou can start it from the tray icon after signing in." /SD IDOK + ${EndIf} +SectionEnd + +Section "Uninstall" + !insertmacro NETPRINT_STOP_SERVICE + !insertmacro NETPRINT_KILL_TRAY + nsExec::ExecToStack '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner standalone' + Pop $0 + Pop $1 + CreateDirectory "$LogDir" + ClearErrors + FileOpen $2 "$LogFile" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "netprint uninstall exit $0$\r$\n$1$\r$\n" + FileClose $2 + ${EndIf} + ClearErrors + ReadRegDWORD $R1 HKLM "Software\Verae\NetPrint" "Driver" + ${If} ${Errors} + StrCpy $R1 0 + ${EndIf} + ClearErrors + ${If} $R1 != 1 + !insertmacro NETPRINT_FIREWALL_REMOVE + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 + ${EndIf} + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-print.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-netprint-tray.exe" + RMDir "$INSTDIR\bin" + Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\Uninstall-NetPrint.exe" + RMDir "$INSTDIR" + Delete "$SMPROGRAMS\Verae\Verae Network Printer.lnk" + RMDir "$SMPROGRAMS\Verae" + DeleteRegKey HKLM "${UNINST_KEY}" +SectionEnd diff --git a/platform/windows/installer-windows.nsi b/platform/windows/installer-windows.nsi index d4196b9..e620df5 100644 --- a/platform/windows/installer-windows.nsi +++ b/platform/windows/installer-windows.nsi @@ -40,6 +40,11 @@ BrandingText "Verae TimeStamp PDF ${VERSION}" !include "LogicLib.nsh" !include "FileFunc.nsh" !include "WinMessages.nsh" +!include "netprint-upgrade.nsh" +!include "StrFunc.nsh" +${Using:StrFunc} StrStr +!insertmacro GetParameters +!insertmacro GetOptions Var LogDir Var LogFile @@ -77,6 +82,16 @@ Var hOpenPdf Var OpenMode ; folder|pdf|none Var hPromptChk Var PromptMode ; true|false (promptForFilename) +Var hNetChk +Var NetPrint ; 1 when the optional LAN printer is selected +Var hNetPublic +Var hNetQueue +Var hNetSMB +Var NetPublic ; 1 to also allow Public networks (off by default) +Var NetQueue ; 1 to add the local "Verae Timestamp" queue (off by default) +Var NetSMB ; 1 to share the receipts folder read-only over SMB (off by default) +Var NetStopped ; 1 when this upgrade stopped VeraeNetPrint to unlock verae-print.exe +Var FwProfile Var TargetProfile ; results Var FinishTitle @@ -98,6 +113,7 @@ Page custom CreatePageCreate CreatePageLeave Page custom VerifyPageCreate VerifyPageLeave Page custom PastePageCreate PastePageLeave Page custom OptionsPageCreate OptionsPageLeave +Page custom NetExtraCreate NetExtraLeave !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH !insertmacro MUI_UNPAGE_CONFIRM @@ -141,11 +157,12 @@ FunctionEnd Function .onInit ${IfNot} ${RunningX64} - MessageBox MB_ICONSTOP "This build targets 64-bit Windows." + MessageBox MB_ICONSTOP "This build targets 64-bit Windows." /SD IDOK Abort ${EndIf} SetRegView 64 SetShellVarContext all + AllowSkipFiles off ReadEnvStr $LogDir "ProgramData" StrCpy $LogDir "$LogDir\Verae\logs" CreateDirectory $LogDir @@ -158,6 +175,33 @@ Function .onInit File "${SRCDIR}\scripts\common.ps1" File "${SRCDIR}\scripts\user-token.ps1" StrCpy $Exe "$PLUGINSDIR\verae-print.exe" + StrCpy $NetPrint "0" + StrCpy $NetPublic "0" + StrCpy $NetQueue "0" + StrCpy $NetSMB "0" + StrCpy $NetStopped "0" + ${GetParameters} $R0 + ClearErrors + ${GetOptions} $R0 "/PUBLIC=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $NetPublic "1" + ${EndIf} + ${EndIf} + ClearErrors + ${GetOptions} $R0 "/QUEUE=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $NetQueue "1" + ${EndIf} + ${EndIf} + ClearErrors + ${GetOptions} $R0 "/SMB=" $R1 + ${IfNot} ${Errors} + ${If} $R1 == "1" + StrCpy $NetSMB "1" + ${EndIf} + ${EndIf} StrCpy $Checked 0 StrCpy $TokenReady 0 StrCpy $KeyChoice "skip" @@ -168,6 +212,13 @@ FunctionEnd Function un.onInit SetRegView 64 SetShellVarContext all + AllowSkipFiles off + ReadEnvStr $LogDir "ProgramData" + ${If} $LogDir == "" + StrCpy $LogDir "C:\ProgramData" + ${EndIf} + StrCpy $LogDir "$LogDir\Verae\logs" + StrCpy $LogFile "$LogDir\netprint-installer.log" FunctionEnd ; ======================= Page: Verae key status / choice ======================= @@ -249,7 +300,7 @@ Function KeyPageLeave StrCpy $KeyChoice "paste" ${Else} StrCpy $KeyChoice "skip" - MessageBox MB_YESNO|MB_ICONEXCLAMATION "Continue without a Verae key?$\r$\n$\r$\nThe printer will be installed, but documents cannot be timestamped until you add a key. On your first print you will be asked for one (or use Start > Verae > Setup signing key)." IDYES +2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION "Continue without a Verae key?$\r$\n$\r$\nThe printer will be installed, but documents cannot be timestamped until you add a key. On your first print you will be asked for one (or use Start > Verae > Setup signing key)." /SD IDYES IDYES +2 Abort ${EndIf} StrCpy $TokenReady 0 @@ -298,7 +349,7 @@ Function LoginPageLeave ${NSD_GetText} $hTotp $3 ${If} $1 == "" ${OrIf} $2 == "" - MessageBox MB_ICONEXCLAMATION "Please enter your email and password." + MessageBox MB_ICONEXCLAMATION "Please enter your email and password." /SD IDOK Abort ${EndIf} !insertmacro REQ_OPEN "$PLUGINSDIR\req.txt" @@ -328,18 +379,18 @@ Function HandleAccountResult ${EndIf} ${If} $4 == "ok" StrCpy $TokenReady 1 - MessageBox MB_ICONINFORMATION "A new Verae key was created for this PC.$\r$\nIt will be saved for $TargetUser." + MessageBox MB_ICONINFORMATION "A new Verae key was created for this PC.$\r$\nIt will be saved for $TargetUser." /SD IDOK ${ElseIf} $4 == "code_sent" ; handled by caller ${ElseIf} $4 == "totp_required" - MessageBox MB_ICONINFORMATION "$5" + MessageBox MB_ICONINFORMATION "$5" /SD IDOK ${NSD_SetFocus} $hTotp Abort ${Else} ${If} $4 == "" StrCpy $5 "No response from the helper (exit $0)." ${EndIf} - MessageBox MB_ICONEXCLAMATION "Verae said: $5" + MessageBox MB_ICONEXCLAMATION "Verae said: $5" /SD IDOK Abort ${EndIf} FunctionEnd @@ -390,7 +441,7 @@ Function CreatePageLeave ${NSD_GetText} $hPass $3 ${NSD_GetText} $hConfirm $6 ${If} $3 != $6 - MessageBox MB_ICONEXCLAMATION "The passwords do not match." + MessageBox MB_ICONEXCLAMATION "The passwords do not match." /SD IDOK Abort ${EndIf} !insertmacro REQ_OPEN "$PLUGINSDIR\req.txt" @@ -446,7 +497,7 @@ Function ResendCode Pop $0 ReadINIStr $5 "$PLUGINSDIR\acct.ini" account detail Delete "$PLUGINSDIR\acct.ini" - MessageBox MB_ICONINFORMATION "$5" + MessageBox MB_ICONINFORMATION "$5" /SD IDOK FunctionEnd Function VerifyPageLeave @@ -487,7 +538,7 @@ FunctionEnd Function PastePageLeave ${NSD_GetText} $hKey $1 ${If} $1 == "" - MessageBox MB_ICONEXCLAMATION "Please paste your key (or go Back and choose Skip)." + MessageBox MB_ICONEXCLAMATION "Please paste your key (or go Back and choose Skip)." /SD IDOK Abort ${EndIf} FileOpen $9 "$PLUGINSDIR\newtoken.txt" w @@ -510,14 +561,14 @@ Function PastePageLeave StrCpy $TokenReady 1 ${ElseIf} $4 == "invalid" Delete "$PLUGINSDIR\newtoken.txt" - MessageBox MB_ICONEXCLAMATION "Verae rejected this key ($5). Please check it and paste again." + MessageBox MB_ICONEXCLAMATION "Verae rejected this key ($5). Please check it and paste again." /SD IDOK Abort ${ElseIf} $4 == "badformat" Delete "$PLUGINSDIR\newtoken.txt" - MessageBox MB_ICONEXCLAMATION "That does not look like a Verae key: $5" + MessageBox MB_ICONEXCLAMATION "That does not look like a Verae key: $5" /SD IDOK Abort ${Else} - MessageBox MB_YESNO|MB_ICONQUESTION "Could not reach Verae to check the key ($5).$\r$\nSave it anyway?" IDYES +3 + MessageBox MB_YESNO|MB_ICONQUESTION "Could not reach Verae to check the key ($5).$\r$\nSave it anyway?" /SD IDYES IDYES +3 Delete "$PLUGINSDIR\newtoken.txt" Abort StrCpy $TokenReady 1 @@ -540,8 +591,10 @@ Function OptionsPageCreate Pop $hOpenFolder ${NSD_CreateRadioButton} 14u 66u 90% 12u "Open the certificate PDF in my PDF viewer" Pop $hOpenPdf - ${NSD_CreateLabel} 0 88u 100% 30u "Without the Save As dialog, files are saved in Documents\Verae Timestamps. You can change these later in %USERPROFILE%\.verae\config.json (promptForFilename: true/false; openOnComplete: folder, pdf or none)." + ${NSD_CreateLabel} 0 88u 100% 24u "Without the Save As dialog, files are saved in Documents\Verae Timestamps. You can change these later in %USERPROFILE%\.verae\config.json (promptForFilename: true/false; openOnComplete: folder, pdf or none)." Pop $0 + ${NSD_CreateCheckbox} 0 116u 100% 28u "Also install the &network timestamp printer (off unless checked)$\r$\nSame service and tray icon as the standalone Verae Network Printer setup. Other computers on your LAN can print to it." + Pop $hNetChk ${If} $PromptMode != "false" ${NSD_Check} $hPromptChk ${EndIf} @@ -588,6 +641,59 @@ Function OptionsPageLeave ${Else} StrCpy $OpenMode "folder" ${EndIf} + ${NSD_GetState} $hNetChk $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetPrint "1" + ${Else} + StrCpy $NetPrint "0" + ${EndIf} +FunctionEnd + +; Shown only when the network printer option is checked. Both boxes stay off by default. +Function NetExtraCreate + ${If} $NetPrint != "1" + Abort + ${EndIf} + !insertmacro MUI_HEADER_TEXT "Network printer" "Optional. These stay off unless you turn them on." + nsDialogs::Create 1018 + Pop $Dlg + ${NSD_CreateCheckbox} 0 0 100% 24u "Allow connections from &Public networks (off by default).$\r$\nPrivate and Domain networks are always allowed." + Pop $hNetPublic + ${NSD_CreateCheckbox} 0 32u 100% 20u "Add a local Windows printer named &Verae Timestamp (off by default)." + Pop $hNetQueue + ${NSD_CreateCheckbox} 0 58u 100% 28u "Share the receipts folder over SMB, read-only (off by default).$\r$\nOther computers can open stamped files in Verae Receipts. The program is not in that folder." + Pop $hNetSMB + ${If} $NetPublic == "1" + ${NSD_Check} $hNetPublic + ${EndIf} + ${If} $NetQueue == "1" + ${NSD_Check} $hNetQueue + ${EndIf} + ${If} $NetSMB == "1" + ${NSD_Check} $hNetSMB + ${EndIf} + nsDialogs::Show +FunctionEnd + +Function NetExtraLeave + ${NSD_GetState} $hNetPublic $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetPublic "1" + ${Else} + StrCpy $NetPublic "0" + ${EndIf} + ${NSD_GetState} $hNetQueue $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetQueue "1" + ${Else} + StrCpy $NetQueue "0" + ${EndIf} + ${NSD_GetState} $hNetSMB $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $NetSMB "1" + ${Else} + StrCpy $NetSMB "0" + ${EndIf} FunctionEnd ; ======================= Install ======================= @@ -603,6 +709,9 @@ Section "Verae Print Stamp + printer" SecMain Pop $0 nsExec::Exec 'taskkill /F /IM verae-printhelper.exe' Pop $0 + ; VeraeNetPrint may be running this folder's verae-print.exe. Stop it + ; before the File copy, or a locked exe is skipped and the old binary stays. + !insertmacro NETPRINT_STOP_IF_OURS ; replace a previous DEBUG test build (0.3.0-debug) cleanly DeleteRegKey HKLM "${OLD_DEBUG_KEY}" Delete "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" @@ -610,9 +719,11 @@ Section "Verae Print Stamp + printer" SecMain StrCpy $R9 "OK" ClearErrors + SetOverwrite on + AllowSkipFiles off SetOutPath "$INSTDIR\bin" - File "${SRCDIR}\verae-print.exe" - File "${SRCDIR}\verae-printhelper.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-print.exe" "verae-print.exe" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-printhelper.exe" "verae-printhelper.exe" SetOutPath "$INSTDIR\scripts" File "${SRCDIR}\scripts\common.ps1" File "${SRCDIR}\scripts\user-token.ps1" @@ -628,6 +739,16 @@ Section "Verae Print Stamp + printer" SecMain ${EndIf} ${If} $R9 != "OK" ${LOG} "ERROR could not write all program files to $INSTDIR" + nsExec::Exec 'sc start VeraeNetPrint' + Pop $0 + MessageBox MB_ICONSTOP "Could not write all program files to $INSTDIR.$\r$\nDetails: $LogFile" /SD IDOK + Abort + ${EndIf} + ${If} $NetStopped == "1" + ${AndIf} $NetPrint != "1" + nsExec::Exec 'sc start VeraeNetPrint' + Pop $0 + !insertmacro NETPRINT_LOG "restarted VeraeNetPrint after replacing verae-print.exe (exit $0)" ${EndIf} WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME} ${VERSION}" WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\Uninstall.exe"' @@ -701,6 +822,74 @@ Section "Verae Print Stamp + printer" SecMain ${LOG} "ERROR printer setup step(s) failed - details in $8" ${EndIf} + ${If} $NetPrint == "1" + !insertmacro NETPRINT_KILL_TRAY + ${If} $NetStopped != "1" + !insertmacro NETPRINT_STOP_IF_OURS + ${EndIf} + SetOverwrite on + AllowSkipFiles off + SetOutPath "$INSTDIR\bin" + !insertmacro NETPRINT_COPY_CHECKED "${SRCDIR}\verae-netprint-tray.exe" "verae-netprint-tray.exe" + DetailPrint "Installing the Verae network printer (service + tray)" + ; TCP 631, TCP 8632 (not the helper's 8631), UDP 5353. + ; Private and domain unless the Public checkbox was turned on. + ; remoteip=any includes IPv4, global IPv6, and link-local fe80::/10. + StrCpy $FwProfile "private,domain" + StrCpy $R8 "false" + ${If} $NetPublic == "1" + StrCpy $FwProfile "private,domain,public" + StrCpy $R8 "true" + ${EndIf} + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP" dir=in action=allow protocol=TCP localport=631 remoteip=any profile=$FwProfile' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint IPP Fallback" dir=in action=allow protocol=TCP localport=8632 remoteip=any profile=$FwProfile' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall add rule name="Verae NetPrint mDNS" dir=in action=allow protocol=UDP localport=5353 remoteip=any profile=$FwProfile' + Pop $0 + CreateDirectory "$LogDir" + StrCpy $R9 '"$INSTDIR\bin\verae-print.exe" netprint install --owner driver --allow-public=$R8' + ${If} $NetQueue == "1" + StrCpy $R9 '$R9 --local-queue' + ${EndIf} + ${If} $NetSMB == "1" + ReadEnvStr $R7 "ProgramData" + ${If} $R7 == "" + StrCpy $R7 "C:\ProgramData" + ${EndIf} + StrCpy $R7 "$R7\Verae\NetPrint" + CreateDirectory "$R7" + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 + nsExec::Exec 'net share "Verae Receipts"="$R7" /GRANT:Everyone,READ /REMARK:"Verae timestamp receipts (read-only)"' + Pop $0 + ${EndIf} + nsExec::ExecToStack $R9 + Pop $0 + Pop $1 + ClearErrors + FileOpen $2 "$LogDir\netprint-installer.log" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "driver netprint install allow-public=$R8 local-queue=$NetQueue smb=$NetSMB exit $0$\r$\n$1$\r$\n" + FileClose $2 + ${EndIf} + ${If} $0 == 0 + !insertmacro STEP_RESULT "Network printer" "OK - Verae Timestamp on the LAN, tray at login" + ${Else} + ${LOG} "ERROR network printer install exit $0. See $LogDir\netprint-installer.log" + !insertmacro STEP_RESULT "Network printer" "FAIL (exit $0) - see netprint-installer.log" + ${EndIf} + ${Else} + !insertmacro STEP_RESULT "Network printer" "SKIPPED" + ${EndIf} + ${If} $5 == "OK" StrCpy $FinishTitle "Verae TimeStamp PDF is installed" StrCpy $FinishText "$FinishText$\r$\nPrint from any app and choose 'Verae TimeStamp PDF'. Results go to Documents\Verae Timestamps." @@ -713,12 +902,35 @@ Section "Verae Print Stamp + printer" SecMain SectionEnd Section "Uninstall" + !insertmacro NETPRINT_STOP_SERVICE + !insertmacro NETPRINT_KILL_TRAY nsExec::Exec '"$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "$INSTDIR\scripts\uninstall-printer.ps1"' Pop $0 + nsExec::Exec '"$INSTDIR\bin\verae-print.exe" netprint uninstall --owner driver' + Pop $0 nsExec::Exec 'taskkill /F /IM verae-printhelper.exe' Pop $0 - Delete "$INSTDIR\bin\verae-print.exe" - Delete "$INSTDIR\bin\verae-printhelper.exe" + ; Standalone NetPrint lives in its own folder. Always remove this driver's files. + ; Drop the shared firewall rules only when the standalone product is gone. + ClearErrors + ReadRegDWORD $R1 HKLM "Software\Verae\NetPrint" "Standalone" + ${If} ${Errors} + StrCpy $R1 0 + ${EndIf} + ClearErrors + ${If} $R1 != 1 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint IPP Fallback"' + Pop $0 + nsExec::Exec 'netsh advfirewall firewall delete rule name="Verae NetPrint mDNS"' + Pop $0 + nsExec::Exec 'net share "Verae Receipts" /DELETE /Y' + Pop $0 + ${EndIf} + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-print.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-printhelper.exe" + !insertmacro NETPRINT_DELETE_CHECKED "$INSTDIR\bin\verae-netprint-tray.exe" RMDir "$INSTDIR\bin" Delete "$INSTDIR\scripts\*.ps1" RMDir "$INSTDIR\scripts" @@ -726,9 +938,14 @@ Section "Uninstall" Delete "$INSTDIR\LICENSE.txt" Delete "$INSTDIR\Uninstall.exe" RMDir "$INSTDIR" - Delete "$SMPROGRAMS\Verae\*.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Setup signing key.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Start print helper.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Logs folder.lnk" + Delete "$SMPROGRAMS\Verae\Verae - Collect diagnostics.lnk" + Delete "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp.lnk" + Delete "$SMPROGRAMS\Verae\Uninstall Verae Print Stamp (debug).lnk" RMDir "$SMPROGRAMS\Verae" - DeleteRegKey HKLM "${UNINST_KEY}" DeleteRegKey HKLM "Software\Verae\PrintStamp" + DeleteRegKey HKLM "${UNINST_KEY}" ; logs and the user's .verae\xverae-token are kept on purpose SectionEnd diff --git a/platform/windows/netprint-upgrade.nsh b/platform/windows/netprint-upgrade.nsh new file mode 100644 index 0000000..8deec27 --- /dev/null +++ b/platform/windows/netprint-upgrade.nsh @@ -0,0 +1,91 @@ +; Stop VeraeNetPrint and the tray before replacing their binaries, and refuse +; to finish an install that left the old exe in place. A silent /S install +; otherwise skips a locked file and exits 0. + +!macro NETPRINT_LOG line + CreateDirectory "$LogDir" + ClearErrors + FileOpen $2 "$LogFile" a + ${IfNot} ${Errors} + FileSeek $2 0 END + FileWrite $2 "${line}$\r$\n" + FileClose $2 + ${EndIf} +!macroend + +; Poll until sc reports STOPPED, or the service does not exist (1060). +; Clobbers $R6 and $0. +!macro NETPRINT_STOP_SERVICE + DetailPrint "Stopping VeraeNetPrint" + nsExec::Exec 'sc stop VeraeNetPrint' + Pop $0 + StrCpy $R6 0 + ${Do} + IntOp $R6 $R6 + 1 + nsExec::Exec 'cmd /c "sc query VeraeNetPrint > %TEMP%\verae-netprint-sc.txt 2>&1 & findstr /C:STOPPED /C:1060 %TEMP%\verae-netprint-sc.txt >NUL"' + Pop $0 + ${If} $0 == 0 + ${Break} + ${EndIf} + ${If} $R6 >= 40 + !insertmacro NETPRINT_LOG "WARN VeraeNetPrint was still running 20s after sc stop" + ${Break} + ${EndIf} + Sleep 500 + ${Loop} + nsExec::Exec 'taskkill /F /FI "SERVICES eq VeraeNetPrint"' + Pop $0 + Sleep 400 + !insertmacro NETPRINT_LOG "stopped VeraeNetPrint before replacing files" +!macroend + +!macro NETPRINT_KILL_TRAY + DetailPrint "Closing the Verae network printer tray" + nsExec::Exec 'taskkill /F /IM verae-netprint-tray.exe' + Pop $0 + Sleep 400 + !insertmacro NETPRINT_LOG "closed verae-netprint-tray.exe before replacing files" +!macroend + +; Stop only when this install folder is the service binary, so a driver +; upgrade does not take down a standalone service in the other folder. +; Clobbers $R5, $0, and $1. Sets $NetStopped to 1 when it stops the service. +!macro NETPRINT_STOP_IF_OURS + nsExec::ExecToStack 'sc qc VeraeNetPrint' + Pop $0 + Pop $1 + ${StrStr} $R5 $1 $INSTDIR + ${If} $R5 != "" + StrCpy $NetStopped "1" + !insertmacro NETPRINT_STOP_SERVICE + ${EndIf} +!macroend + +; SetOverwrite on and AllowSkipFiles off must already be set. A failed +; extract used to be skipped; abort instead, and start the service again +; so a failed upgrade does not leave the printer stopped. +!macro NETPRINT_COPY_CHECKED src label + ClearErrors + File "${src}" + ${If} ${Errors} + !insertmacro NETPRINT_LOG "ERROR could not replace ${label} in $INSTDIR. The file is still in use, so setup did not upgrade." + nsExec::Exec 'sc start VeraeNetPrint' + Pop $0 + MessageBox MB_ICONSTOP "Could not replace ${label}.$\r$\nThe service or tray still has it open, so nothing was upgraded.$\r$\nDetails: $LogFile" /SD IDOK + Abort + ${EndIf} +!macroend + +!macro NETPRINT_DELETE_CHECKED path + !define NETPRINT_DEL_ID ${__COUNTER__} + IfFileExists "${path}" 0 netprint_del_skip_${NETPRINT_DEL_ID} + ClearErrors + Delete "${path}" + ${If} ${Errors} + !insertmacro NETPRINT_LOG "ERROR could not delete ${path}. It is still in use." + MessageBox MB_ICONSTOP "Could not delete ${path}.$\r$\nClose the Verae network printer and run uninstall again.$\r$\nDetails: $LogFile" /SD IDOK + Abort + ${EndIf} + netprint_del_skip_${NETPRINT_DEL_ID}: + !undef NETPRINT_DEL_ID +!macroend diff --git a/scripts/build-netprint-installers.sh b/scripts/build-netprint-installers.sh new file mode 100755 index 0000000..4edb25f --- /dev/null +++ b/scripts/build-netprint-installers.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Build the network-printer installers that this environment can produce: +# Debian packages (driver + standalone), Windows NSIS setups, and macOS +# binaries plus a .pkg when pkgbuild exists. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +VERSION="${VERSION:-0.3.4}" +OUT="${OUT:-$ROOT/build/netprint}" +mkdir -p "$OUT" + +echo "→ test" +go test ./internal/netprint/ ./internal/auth/ ./cmd/verae-print/ + +build_unix() { + local goos="$1" goarch="$2" name="$3" + local dir="$OUT/$name" + mkdir -p "$dir" + echo "→ $goos/$goarch verae-print" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "-s -w" -o "$dir/verae-print" ./cmd/verae-print +} + +build_unix linux amd64 linux-amd64 +build_unix linux arm64 linux-arm64 +build_unix darwin arm64 darwin-arm64 +build_unix darwin amd64 darwin-amd64 + +echo "→ windows binaries" +mkdir -p "$OUT/windows-amd64" "$OUT/windows-arm64" +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o "$OUT/windows-amd64/verae-print.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-amd64/verae-printhelper.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-amd64/verae-netprint-tray.exe" ./cmd/verae-netprint-tray +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-s -w" -o "$OUT/windows-arm64/verae-print.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-arm64/verae-printhelper.exe" ./cmd/verae-print +CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "-s -w -H windowsgui" -o "$OUT/windows-arm64/verae-netprint-tray.exe" ./cmd/verae-netprint-tray + +echo "→ debian packages" +chmod +x platform/linux/deb/build-deb.sh platform/linux/deb/build-netprint-deb.sh +NET="$OUT" +VERSION="$VERSION" OUT="$ROOT/build/deb" BIN_AMD64="$NET/linux-amd64/verae-print" BIN_ARM64="$NET/linux-arm64/verae-print" \ + ./platform/linux/deb/build-deb.sh all +cp -f "$ROOT/build/deb"/verae-print_*"$VERSION"*.deb "$NET"/ +cp -f "$ROOT/build/deb"/verae-print_amd64.deb "$ROOT/build/deb"/verae-print_arm64.deb "$NET"/ +VERSION="$VERSION" OUT="$NET" BIN="$NET/linux-amd64/verae-print" ./platform/linux/deb/build-netprint-deb.sh amd64 +VERSION="$VERSION" OUT="$NET" BIN="$NET/linux-arm64/verae-print" ./platform/linux/deb/build-netprint-deb.sh arm64 + +echo "→ windows installers" +if ! command -v makensis >/dev/null 2>&1; then + echo "makensis missing" >&2 + exit 1 +fi +build_win() { + local arch="$1" + local stage="$OUT/win-stage-$arch" + rm -rf "$stage" + mkdir -p "$stage/scripts" + cp "$OUT/windows-$arch/verae-print.exe" "$OUT/windows-$arch/verae-printhelper.exe" "$OUT/windows-$arch/verae-netprint-tray.exe" "$stage/" + cp platform/windows/scripts/*.ps1 "$stage/scripts/" + cp platform/windows/LICENSE.txt platform/windows/README-WINDOWS.txt "$stage/" + ( + cd "$OUT" + makensis -V2 -DVERSION="$VERSION" -DARCH="$arch" -DSRCDIR="$stage" -DOUTDIR="$OUT" \ + "$ROOT/platform/windows/installer-windows.nsi" + makensis -V2 -DVERSION="$VERSION" -DARCH="$arch" -DSRCDIR="$stage" -DOUTDIR="$OUT" \ + "$ROOT/platform/windows/installer-netprint.nsi" + ) +} +build_win amd64 +build_win arm64 + +echo "→ macos payload" +mkdir -p "$OUT/macos" +# Universal binary when lipo exists; otherwise ship both slices. +if command -v lipo >/dev/null 2>&1; then + lipo -create -output "$OUT/macos/verae-print" "$OUT/darwin-arm64/verae-print" "$OUT/darwin-amd64/verae-print" +else + cp -f "$OUT/darwin-arm64/verae-print" "$OUT/macos/verae-print-arm64" + cp -f "$OUT/darwin-amd64/verae-print" "$OUT/macos/verae-print-amd64" + cp -f "$OUT/darwin-arm64/verae-print" "$OUT/macos/verae-print" +fi +chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/build-unsigned-pkg.sh +# Apple Silicon package. The arm64 slice is the binary George runs. +BIN="$OUT/darwin-arm64/verae-print" PKG_ARCH=arm64 OUT="$OUT/macos" VERSION="$VERSION" ./platform/macos/pkg/build-netprint-pkg.sh +cp -f platform/macos/pkg/distribution.xml "$OUT/macos/distribution.xml" +cp -f platform/macos/netprint/scripts/postinstall "$OUT/macos/postinstall-netprint.sh" + +( + cd "$OUT" + shopt -s nullglob + files=( *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh ) + if [[ ${#files[@]} -gt 0 ]]; then + sha256sum "${files[@]}" | tee SHA256SUMS + fi +) +echo +echo "Artifacts in $OUT" +find "$OUT" -maxdepth 2 -type f \( -name '*.deb' -o -name '*.exe' -o -name '*.pkg' -o -name '*.tar.gz' -o -name 'verae-print*' \) -printf '%s %p\n' | sort diff --git a/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt b/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt new file mode 100644 index 0000000..59e237c --- /dev/null +++ b/site/printit.verae.ai/DOWNLOADS-MANIFEST.txt @@ -0,0 +1,61 @@ +total 164968 +drwxr-xr-x 2 marchon marchon 4096 Oct 3 15:36 . +drwxr-xr-x 5 marchon marchon 4096 Oct 4 08:19 .. +-rw-r--r-- 1 marchon marchon 2981 Oct 3 15:36 SHA256SUMS +-rw-r--r-- 1 marchon marchon 2483600 Oct 3 00:08 verae-print_0.1.2_amd64.deb +-rw-r--r-- 1 marchon marchon 2114536 Oct 3 00:08 verae-print_0.1.2_arm64.deb +-rw-r--r-- 1 marchon marchon 2483600 Oct 3 00:08 verae-print_amd64.deb +-rw-r--r-- 1 marchon marchon 2114536 Oct 3 00:08 verae-print_arm64.deb +-rw-r--r-- 1 marchon marchon 18606858 Oct 3 00:00 VeraePrintStamp-0.1.2.pkg +-rw-r--r-- 1 marchon marchon 19787377 Oct 3 14:45 VeraePrintStamp-0.3.1.pkg +-rw-r--r-- 1 marchon marchon 19816111 Oct 3 15:36 VeraePrintStamp-0.3.2.pkg +-rw-r--r-- 1 marchon marchon 3149530 Oct 3 00:04 VeraePrintStamp-linux-amd64-0.1.2.tar.gz +-rw-r--r-- 1 marchon marchon 3456802 Oct 3 14:45 VeraePrintStamp-linux-amd64-0.3.1.tar.gz +-rw-r--r-- 1 marchon marchon 3462076 Oct 3 15:36 VeraePrintStamp-linux-amd64-0.3.2.tar.gz +-rw-r--r-- 1 marchon marchon 3462076 Oct 3 15:36 VeraePrintStamp-linux-amd64.tar.gz +-rw-r--r-- 1 marchon marchon 2832626 Oct 3 00:04 VeraePrintStamp-linux-arm64-0.1.2.tar.gz +-rw-r--r-- 1 marchon marchon 3106858 Oct 3 14:45 VeraePrintStamp-linux-arm64-0.3.1.tar.gz +-rw-r--r-- 1 marchon marchon 3113159 Oct 3 15:36 VeraePrintStamp-linux-arm64-0.3.2.tar.gz +-rw-r--r-- 1 marchon marchon 3113159 Oct 3 15:36 VeraePrintStamp-linux-arm64.tar.gz +-rw-r--r-- 1 marchon marchon 19816111 Oct 3 15:36 VeraePrintStamp-macos.pkg +-rw-r--r-- 1 marchon marchon 2612860 Oct 3 00:08 VeraePrintStamp-windows-amd64-0.1.2-setup.exe +-rw-r--r-- 1 marchon marchon 3219570 Oct 3 00:04 VeraePrintStamp-windows-amd64-0.1.2.zip +-rw-r--r-- 1 marchon marchon 6092955 Oct 3 14:45 VeraePrintStamp-windows-amd64-0.3.1-setup.exe +-rw-r--r-- 1 marchon marchon 6105599 Oct 3 15:36 VeraePrintStamp-windows-amd64-0.3.2-setup.exe +-rw-r--r-- 1 marchon marchon 6105599 Oct 3 15:36 VeraePrintStamp-windows-amd64-setup.exe +-rw-r--r-- 1 marchon marchon 3219570 Oct 3 00:04 VeraePrintStamp-windows-amd64.zip +-rw-r--r-- 1 marchon marchon 2217567 Oct 3 00:08 VeraePrintStamp-windows-arm64-0.1.2-setup.exe +-rw-r--r-- 1 marchon marchon 2865633 Oct 3 00:04 VeraePrintStamp-windows-arm64-0.1.2.zip +-rw-r--r-- 1 marchon marchon 2217567 Oct 3 00:08 VeraePrintStamp-windows-arm64-setup.exe +-rw-r--r-- 1 marchon marchon 2865633 Oct 3 00:04 VeraePrintStamp-windows-arm64.zip +-rw-r--r-- 1 marchon marchon 9198592 Oct 3 14:45 verae-print-windows-amd64-0.3.1.exe +-rw-r--r-- 1 marchon marchon 9216512 Oct 3 15:36 verae-print-windows-amd64-0.3.2.exe +# Verae Print Stamp — printit.verae.ai (latest: 0.3.2 for Windows amd64, macOS, Linux; Windows arm64, .deb and .zip are 0.1.2) +56cb72d8d65c5d2e1292b0a05f235ded0a267e66530229a53980887fc5de1b72 verae-print-windows-amd64-0.3.1.exe +aa2e7f1843ccbd4d466c66e92a5460f0e3d499e6af0dcba2f93cbd49bc2012e5 verae-print-windows-amd64-0.3.2.exe +079482b38ae3b49b268863209e309bb35072a3ec136f076f0ec5062d67a7c26e verae-print_0.1.2_amd64.deb +f4e77bb3a300563882a100ab667e6e6fdff4a82a4254c04cb4c658f677fe2ca5 verae-print_0.1.2_arm64.deb +079482b38ae3b49b268863209e309bb35072a3ec136f076f0ec5062d67a7c26e verae-print_amd64.deb +f4e77bb3a300563882a100ab667e6e6fdff4a82a4254c04cb4c658f677fe2ca5 verae-print_arm64.deb +403642bb889d6057b60ef70e1d5be13cbf01ec77792fac8d4bf4268e0823b8d9 VeraePrintStamp-0.1.2.pkg +34e158f39bce31a43c50801bd9e18bb66a81c54e2e3d0db60847fdc299de3ed8 VeraePrintStamp-0.3.1.pkg +47ef01906ed6408b2a29592a909548ace9eb0d20aa7ce7fd3f92a55ab55d8500 VeraePrintStamp-0.3.2.pkg +525e5f2dcd5b5ed20004eb899c5bf855e946586ab193877947e8e12f1902386b VeraePrintStamp-linux-amd64-0.1.2.tar.gz +20bcf8627586e823f44ef75fd7280a8a6c8dbc693dc441b52558ac8cbf067499 VeraePrintStamp-linux-amd64-0.3.1.tar.gz +5ec34c27c46c08463a09fc127e9c53814158037d345f18f5a4abe8eb5312727e VeraePrintStamp-linux-amd64-0.3.2.tar.gz +5ec34c27c46c08463a09fc127e9c53814158037d345f18f5a4abe8eb5312727e VeraePrintStamp-linux-amd64.tar.gz +90074fb0cc7dbb573deae8f33076806b05bffcea7cdcf69e7d79007ec3e2fd92 VeraePrintStamp-linux-arm64-0.1.2.tar.gz +61686df3c891015fc6d1e2659262005cd60370d3f92f4972bcc0b08c0c7d50bc VeraePrintStamp-linux-arm64-0.3.1.tar.gz +29ee11b01024048ce6ebeca9d5da10c8e0d9c606c8fa465bdb8cceb1dabae6d9 VeraePrintStamp-linux-arm64-0.3.2.tar.gz +29ee11b01024048ce6ebeca9d5da10c8e0d9c606c8fa465bdb8cceb1dabae6d9 VeraePrintStamp-linux-arm64.tar.gz +47ef01906ed6408b2a29592a909548ace9eb0d20aa7ce7fd3f92a55ab55d8500 VeraePrintStamp-macos.pkg +930280c07cb505332be994ff1b5e58da8105c40643ec6d310543ea9179794e66 VeraePrintStamp-windows-amd64-0.1.2-setup.exe +fe81e79b03402d41072050d863fa1da6a41337dd449a1bd23ebfc623edbf60e2 VeraePrintStamp-windows-amd64-0.1.2.zip +0fc5e3c052267f9295393fdf9e68a4a101bf03fc647322a99d2daef45fc30887 VeraePrintStamp-windows-amd64-0.3.1-setup.exe +e561af8f8c614f43237c5b97db5f5be2834009fb669c764955a06ce69b8ac987 VeraePrintStamp-windows-amd64-0.3.2-setup.exe +e561af8f8c614f43237c5b97db5f5be2834009fb669c764955a06ce69b8ac987 VeraePrintStamp-windows-amd64-setup.exe +fe81e79b03402d41072050d863fa1da6a41337dd449a1bd23ebfc623edbf60e2 VeraePrintStamp-windows-amd64.zip +764ba39f7285375db5771f45452216c424d2d2a6713e48bf16fdbaa18286a9d7 VeraePrintStamp-windows-arm64-0.1.2-setup.exe +3d4309f01f11396b0686ec1b55af7dbf8223ad6534da89864e013a024651d386 VeraePrintStamp-windows-arm64-0.1.2.zip +764ba39f7285375db5771f45452216c424d2d2a6713e48bf16fdbaa18286a9d7 VeraePrintStamp-windows-arm64-setup.exe +3d4309f01f11396b0686ec1b55af7dbf8223ad6534da89864e013a024651d386 VeraePrintStamp-windows-arm64.zip diff --git a/site/printit.verae.ai/README.md b/site/printit.verae.ai/README.md new file mode 100644 index 0000000..d2d63d5 --- /dev/null +++ b/site/printit.verae.ai/README.md @@ -0,0 +1,9 @@ +# printit.verae.ai + +Static site served by Caddy on 70.88.205.138 from `/var/www/printit.verae.ai`. + +- `index.html` — download page, "How it works" welcome dialog, "Learn more" FAQ. +- `img/`, `samples/` — screenshots and the sample receipt PDF used by the page. +- `downloads/` is not in git (162 MB of release builds); see `DOWNLOADS-MANIFEST.txt` for the files and checksums that were live. + +Deploy: `rsync -a --exclude downloads site/printit.verae.ai/ marchon@70.88.205.138:/var/www/printit.verae.ai/` (back up `index.html` first). diff --git a/site/printit.verae.ai/img/how-it-works-thumb.jpg b/site/printit.verae.ai/img/how-it-works-thumb.jpg new file mode 100644 index 0000000..d78f3fa Binary files /dev/null and b/site/printit.verae.ai/img/how-it-works-thumb.jpg differ diff --git a/site/printit.verae.ai/img/print-dialog.jpg b/site/printit.verae.ai/img/print-dialog.jpg new file mode 100644 index 0000000..d8e042d Binary files /dev/null and b/site/printit.verae.ai/img/print-dialog.jpg differ diff --git a/site/printit.verae.ai/img/receipt-thumb.jpg b/site/printit.verae.ai/img/receipt-thumb.jpg new file mode 100644 index 0000000..a37bb3e Binary files /dev/null and b/site/printit.verae.ai/img/receipt-thumb.jpg differ diff --git a/site/printit.verae.ai/img/receipt.jpg b/site/printit.verae.ai/img/receipt.jpg new file mode 100644 index 0000000..383f3c2 Binary files /dev/null and b/site/printit.verae.ai/img/receipt.jpg differ diff --git a/site/printit.verae.ai/img/source-document.jpg b/site/printit.verae.ai/img/source-document.jpg new file mode 100644 index 0000000..279a2d7 Binary files /dev/null and b/site/printit.verae.ai/img/source-document.jpg differ diff --git a/site/printit.verae.ai/img/validation-thumb.jpg b/site/printit.verae.ai/img/validation-thumb.jpg new file mode 100644 index 0000000..a5c2236 Binary files /dev/null and b/site/printit.verae.ai/img/validation-thumb.jpg differ diff --git a/site/printit.verae.ai/img/validation.jpg b/site/printit.verae.ai/img/validation.jpg new file mode 100644 index 0000000..c1b1474 Binary files /dev/null and b/site/printit.verae.ai/img/validation.jpg differ diff --git a/site/printit.verae.ai/index.html b/site/printit.verae.ai/index.html new file mode 100644 index 0000000..ab5c60d --- /dev/null +++ b/site/printit.verae.ai/index.html @@ -0,0 +1,303 @@ + + + + + + Print it — Verae Timestamping + + + + + +
+

Verae · Print it

+

Print → Verae Timestamping

+

+ Digests-only timestamping. Create a free account at + customer.verae.ai, + mint an xVerae- signing key, then stamp from Print or the CLI. +

+
+ + + Sample Verae Timestamp Registration receipt + Verae explorer showing the receipt as VALID +
+ +
+

Downloads

+

Version 0.3.2

+ +
+
+
+

macOS

+

Apple silicon & Intel · Developer ID signed & notarized · Print Destination

+
+ +
+ +
+
+

Windows

+

GUI installer (NSIS) · Start Menu + PATH · finish page runs setup

+
+ +
+ +
+
+

Debian / Ubuntu

+

.deb package 0.3.4 · verae-print + “Verae Timestamping” printer. Your first print opens a sign-in window to get your key.

+
+ +
+ +
+
+

Network printer

+

Optional LAN queue “Verae Timestamp” (IPP Everywhere) plus a tray or menu-bar control. Standalone installers, or the off-by-default option inside the driver installers. One service either way. On Windows, discovery goes through DNS-SD so it still works when the DNS Client owns UDP 5353, and the setup starts the tray for the signed-in user. Public networks and a local Windows queue stay off unless you opt in. The Windows standalone setup uses C:\Program Files\Verae\NetPrint and does not replace the driver. If port 631 is taken, the printer uses 8632 (the driver helper keeps 8631). Version 0.3.4.

+
+ +
+ +
+
+

Linux (tarball)

+

Portable CLI + install.sh for any distro

+
+ +
+
+ +

+ Checksums: SHA256SUMS +

+
+ +
+

Quick start

+
    +
  1. macOS: open the .pkg → first launch saves your signing key → Print → Destination → Verae Timestamping.
  2. +
  3. Windows: run the setup .exe → Finish with “Run setup” → paste your xVerae- key → verae-print stamp file.pdf.
  4. +
  5. Debian/Ubuntu: sudo apt install ./verae-print_amd64.deb, then print to “Verae Timestamping” and sign in when the window appears (or open “Verae Signing Key” from your apps menu). Leave the network-printer question at “no” unless you want the LAN queue.
  6. +
  7. Network printer: install verae-netprint, or accept that prompt / the Windows checkbox / the macOS Customize choice. Other devices print to “Verae Timestamp”. Receipts are on this computer; the menu-bar or tray icon opens them.
  8. +
  9. Outputs: Documents/Verae Timestamps/.
  10. +
+
+ +
+

Learn more

+ +
+ What is a global timestamp? +

A global timestamp is public, permanent proof that a specific document existed in a specific form at a specific moment. When you print to Verae Timestamping, your computer calculates the document’s SHA-256 fingerprint, a 64-character code that changes completely if even one character of the file changes. Only that fingerprint is registered on the Verae Timestamping blockchain; the document itself never leaves your computer.

+

Once it’s recorded in a block, the entry can’t be edited or backdated. Anyone, anywhere, can later check that your file matches the fingerprint and see when it was registered. You don’t have to trust us, your lawyer, or anyone else’s word for it.

+
+ +
+ Who is Verae Inc.? +

Verae builds cryptographic timestamping that gives businesses independently verifiable proof of their records. The company was co-founded by Stuart Haber, who co-invented cryptographic timestamping in 1991. That work is the foundation under every blockchain since.

+

Verae’s patent-pending technology seals records the moment they’re created without ever seeing their contents, which stay in your own storage. Verae is based at 575 Lexington Ave, 14th Floor, New York, NY 10022. Learn more at verae.com or write to info@verae.com.

+
+ +
+ How does online verification work? +

Every receipt includes a lookup link and QR code. Opening it takes you to the public VeraeTime explorer, which checks the document’s fingerprint against the blockchain record:

+
    +
  1. The link carries the document’s SHA-256 fingerprint and the receipt ID.
  2. +
  3. The explorer finds the matching entry on the Verae Timestamping blockchain and shows the block it’s in and when it was registered.
  4. +
  5. If the fingerprint and receipt match the chain, it shows VALID. To prove a particular file is the one that was stamped, compute its SHA-256 (for example shasum -a 256 file.pdf) and confirm it matches.
  6. +
+

Try it with the sample receipt shown in the screenshots above (block 113224):

+

sha256 001ba8b3e94aaa3af8730343a821727b354b8545fb19b908bb2b6f1e4350678f

+ Open the sample verification + View the sample receipt +
+
+ + +
+ + + + + diff --git a/site/printit.verae.ai/robots.txt b/site/printit.verae.ai/robots.txt new file mode 100644 index 0000000..5dc2896 --- /dev/null +++ b/site/printit.verae.ai/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://printit.verae.ai/sitemap.xml diff --git a/site/printit.verae.ai/samples/verae-receipt-sample.pdf b/site/printit.verae.ai/samples/verae-receipt-sample.pdf new file mode 100644 index 0000000..fc7d73c Binary files /dev/null and b/site/printit.verae.ai/samples/verae-receipt-sample.pdf differ diff --git a/site/printit.verae.ai/sitemap.xml b/site/printit.verae.ai/sitemap.xml new file mode 100644 index 0000000..4715f40 --- /dev/null +++ b/site/printit.verae.ai/sitemap.xml @@ -0,0 +1,4 @@ + + + https://printit.verae.ai/ +