From 4a892ee29d0c375b64237db09adb8d8c243160f5 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:06:24 +0200 Subject: [PATCH 01/14] Move the scan module to brother, ADR 0467 clause 1: the code unchanged in what it does (issue 1453) --- modules/{scan => brother}/README.md | 6 +++--- .../cmd/scan => brother/cmd/brother}/crop.go | 0 .../cmd/scan => brother/cmd/brother}/crop_test.go | 0 .../cmd/scan => brother/cmd/brother}/escl.go | 0 .../cmd/scan => brother/cmd/brother}/escl_test.go | 0 .../cmd/scan => brother/cmd/brother}/file.go | 0 .../cmd/scan => brother/cmd/brother}/file_test.go | 0 .../scan => brother/cmd/brother}/helpers_test.go | 0 .../cmd/scan => brother/cmd/brother}/main.go | 2 +- .../scan => brother/cmd/brother}/manifest_test.go | 4 ++-- .../{scan/cmd/scan => brother/cmd/brother}/pdf.go | 0 .../cmd/scan => brother/cmd/brother}/scan.go | 4 ++-- .../scan => brother/cmd/brother}/service_test.go | 0 .../cmd/scan => brother/cmd/brother}/settings.go | 8 ++++---- .../cmd/brother}/testdata/escl/capabilities.xml | 0 .../cmd/brother}/testdata/escl/doc-headers.txt | 0 .../cmd/brother}/testdata/escl/post-headers.txt | 0 .../cmd/brother}/testdata/escl/scan.jpg | Bin .../cmd/brother}/testdata/escl/settings.xml | 0 .../cmd/brother}/testdata/escl/status-after.xml | 0 .../cmd/brother}/testdata/escl/status-idle.xml | 0 .../testdata/ipp/get-printer-attributes.answer | Bin 0 -> 339 bytes .../testdata/ipp/get-printer-attributes.request | Bin 0 -> 261 bytes modules/{scan => brother}/files/settings.json | 0 modules/{scan => brother}/go.mod | 2 +- modules/{scan => brother}/go.sum | 0 modules/{scan => brother}/module.json | 10 +++++----- 27 files changed, 18 insertions(+), 18 deletions(-) rename modules/{scan => brother}/README.md (94%) rename modules/{scan/cmd/scan => brother/cmd/brother}/crop.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/crop_test.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/escl.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/escl_test.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/file.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/file_test.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/helpers_test.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/main.go (96%) rename modules/{scan/cmd/scan => brother/cmd/brother}/manifest_test.go (97%) rename modules/{scan/cmd/scan => brother/cmd/brother}/pdf.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/scan.go (95%) rename modules/{scan/cmd/scan => brother/cmd/brother}/service_test.go (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/settings.go (84%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/capabilities.xml (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/doc-headers.txt (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/post-headers.txt (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/scan.jpg (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/settings.xml (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/status-after.xml (100%) rename modules/{scan/cmd/scan => brother/cmd/brother}/testdata/escl/status-idle.xml (100%) create mode 100644 modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.answer create mode 100644 modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request rename modules/{scan => brother}/files/settings.json (100%) rename modules/{scan => brother}/go.mod (85%) rename modules/{scan => brother}/go.sum (100%) rename modules/{scan => brother}/module.json (93%) diff --git a/modules/scan/README.md b/modules/brother/README.md similarity index 94% rename from modules/scan/README.md rename to modules/brother/README.md index 0b339f15..44733dba 100644 --- a/modules/scan/README.md +++ b/modules/brother/README.md @@ -1,4 +1,4 @@ -# scan — scan a document, crop it, file it, say so +# brother — scan a document, crop it, file it, say so Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF @@ -47,7 +47,7 @@ makes it (mode 0755) when it files the first scan. ## The event -`scan.added`, on the wire `mesh.mod.scan.event.scan.added`; a consumer declares `scan.scan.added`. Its body: +`scan.added`, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body: `path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it), `color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC). @@ -62,5 +62,5 @@ Each directory or archive this module declares says how it treats a file it did ## Tests -The unit tests use the captured answers of a real scanner (`cmd/scan/testdata/escl`, captured 2026-10-20) served +The unit tests use the captured answers of a real scanner (`cmd/brother/testdata/escl`, captured 2026-10-20) served by a test HTTP server; no test reaches a scanner. diff --git a/modules/scan/cmd/scan/crop.go b/modules/brother/cmd/brother/crop.go similarity index 100% rename from modules/scan/cmd/scan/crop.go rename to modules/brother/cmd/brother/crop.go diff --git a/modules/scan/cmd/scan/crop_test.go b/modules/brother/cmd/brother/crop_test.go similarity index 100% rename from modules/scan/cmd/scan/crop_test.go rename to modules/brother/cmd/brother/crop_test.go diff --git a/modules/scan/cmd/scan/escl.go b/modules/brother/cmd/brother/escl.go similarity index 100% rename from modules/scan/cmd/scan/escl.go rename to modules/brother/cmd/brother/escl.go diff --git a/modules/scan/cmd/scan/escl_test.go b/modules/brother/cmd/brother/escl_test.go similarity index 100% rename from modules/scan/cmd/scan/escl_test.go rename to modules/brother/cmd/brother/escl_test.go diff --git a/modules/scan/cmd/scan/file.go b/modules/brother/cmd/brother/file.go similarity index 100% rename from modules/scan/cmd/scan/file.go rename to modules/brother/cmd/brother/file.go diff --git a/modules/scan/cmd/scan/file_test.go b/modules/brother/cmd/brother/file_test.go similarity index 100% rename from modules/scan/cmd/scan/file_test.go rename to modules/brother/cmd/brother/file_test.go diff --git a/modules/scan/cmd/scan/helpers_test.go b/modules/brother/cmd/brother/helpers_test.go similarity index 100% rename from modules/scan/cmd/scan/helpers_test.go rename to modules/brother/cmd/brother/helpers_test.go diff --git a/modules/scan/cmd/scan/main.go b/modules/brother/cmd/brother/main.go similarity index 96% rename from modules/scan/cmd/scan/main.go rename to modules/brother/cmd/brother/main.go index de13cfa4..9d699f48 100644 --- a/modules/scan/cmd/scan/main.go +++ b/modules/brother/cmd/brother/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - if err := stdio.Serve("", tools(os.Getenv("SCAN_SETTINGS"), emit, time.Now)); err != nil { + if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), emit, time.Now)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/modules/scan/cmd/scan/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go similarity index 97% rename from modules/scan/cmd/scan/manifest_test.go rename to modules/brother/cmd/brother/manifest_test.go index c250e66d..86c60a74 100644 --- a/modules/scan/cmd/scan/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -86,11 +86,11 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { env := "" for _, a := range m.Build.Artifacts { if a["kind"] == "bundle" { - env, _ = a["env"].(map[string]any)["SCAN_SETTINGS"].(string) + env, _ = a["env"].(map[string]any)["BROTHER_SETTINGS"].(string) } } if env != "${dir:state}/settings.json" { - t.Errorf("SCAN_SETTINGS = %q", env) + t.Errorf("BROTHER_SETTINGS = %q", env) } raw, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json")) if err != nil { diff --git a/modules/scan/cmd/scan/pdf.go b/modules/brother/cmd/brother/pdf.go similarity index 100% rename from modules/scan/cmd/scan/pdf.go rename to modules/brother/cmd/brother/pdf.go diff --git a/modules/scan/cmd/scan/scan.go b/modules/brother/cmd/brother/scan.go similarity index 95% rename from modules/scan/cmd/scan/scan.go rename to modules/brother/cmd/brother/scan.go index 87c9002a..d625fa03 100644 --- a/modules/scan/cmd/scan/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -9,8 +9,8 @@ import ( "time" ) -// EventAdded is the module's event: a scan was filed (ADR 0464). Consumers declare scan.scan.added; it -// travels as mesh.mod.scan.event.scan.added. It names the file and never carries the image. +// EventAdded is the module's event: a scan was filed (ADR 0464). Consumers declare brother.scan.added; it +// travels as mesh.mod.brother.event.scan.added. It names the file and never carries the image. const EventAdded = "scan.added" // Emitter says an event on the bus under a message id, so a repeated publish is kept to one (ADR 0436). diff --git a/modules/scan/cmd/scan/service_test.go b/modules/brother/cmd/brother/service_test.go similarity index 100% rename from modules/scan/cmd/scan/service_test.go rename to modules/brother/cmd/brother/service_test.go diff --git a/modules/scan/cmd/scan/settings.go b/modules/brother/cmd/brother/settings.go similarity index 84% rename from modules/scan/cmd/scan/settings.go rename to modules/brother/cmd/brother/settings.go index 3f6ddb8d..b15175e3 100644 --- a/modules/scan/cmd/scan/settings.go +++ b/modules/brother/cmd/brother/settings.go @@ -1,4 +1,4 @@ -// Package main is the scan module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a +// Package main is the brother module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a // network scanner over eSCL, crops the document, files it as a one-page PDF in a folder and says so on the bus. // A scan happens when a tool is called; the module runs no process of its own. package main @@ -25,12 +25,12 @@ type Settings struct { Crop bool } -// LoadSettings reads settings.json (the file named by SCAN_SETTINGS) and lays the defaults under it. A missing +// LoadSettings reads settings.json (the file named by BROTHER_SETTINGS) and lays the defaults under it. A missing // or unreadable file is an error that names it; an unset scanner or folder is Missing's to say. func LoadSettings(path string) (Settings, error) { s := Settings{Resolution: 300, ColorMode: "color", Crop: true} if path == "" { - return s, fmt.Errorf("SCAN_SETTINGS names no settings file: the module's config does not reach the code") + return s, fmt.Errorf("BROTHER_SETTINGS names no settings file: the module's config does not reach the code") } raw, err := os.ReadFile(path) if err != nil { @@ -71,6 +71,6 @@ func (s Settings) Missing() error { if len(unset) == 0 { return nil } - return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the scan module on this node", + return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", strings.Join(unset, " and ")) } diff --git a/modules/scan/cmd/scan/testdata/escl/capabilities.xml b/modules/brother/cmd/brother/testdata/escl/capabilities.xml similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/capabilities.xml rename to modules/brother/cmd/brother/testdata/escl/capabilities.xml diff --git a/modules/scan/cmd/scan/testdata/escl/doc-headers.txt b/modules/brother/cmd/brother/testdata/escl/doc-headers.txt similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/doc-headers.txt rename to modules/brother/cmd/brother/testdata/escl/doc-headers.txt diff --git a/modules/scan/cmd/scan/testdata/escl/post-headers.txt b/modules/brother/cmd/brother/testdata/escl/post-headers.txt similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/post-headers.txt rename to modules/brother/cmd/brother/testdata/escl/post-headers.txt diff --git a/modules/scan/cmd/scan/testdata/escl/scan.jpg b/modules/brother/cmd/brother/testdata/escl/scan.jpg similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/scan.jpg rename to modules/brother/cmd/brother/testdata/escl/scan.jpg diff --git a/modules/scan/cmd/scan/testdata/escl/settings.xml b/modules/brother/cmd/brother/testdata/escl/settings.xml similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/settings.xml rename to modules/brother/cmd/brother/testdata/escl/settings.xml diff --git a/modules/scan/cmd/scan/testdata/escl/status-after.xml b/modules/brother/cmd/brother/testdata/escl/status-after.xml similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/status-after.xml rename to modules/brother/cmd/brother/testdata/escl/status-after.xml diff --git a/modules/scan/cmd/scan/testdata/escl/status-idle.xml b/modules/brother/cmd/brother/testdata/escl/status-idle.xml similarity index 100% rename from modules/scan/cmd/scan/testdata/escl/status-idle.xml rename to modules/brother/cmd/brother/testdata/escl/status-idle.xml diff --git a/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.answer b/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.answer new file mode 100644 index 0000000000000000000000000000000000000000..2ec08548133cd8a5b048aa19fe1ff06d50af263a GIT binary patch literal 339 zcmY+8F;Bxl427Mv2no>vBv`t%>eA(;N-SmMQUxUFj2PXnE80dGn|pqcaYD-C<3TlOHc&^1+Q)Vk3%5 zFpjyS4cz>j8YYzB1+FysXm#OaPRPFTwS~(T`51>DTN23avyBm1k?JeCHOpZRu zNxHI}M_+&LVgMMRvcC)a-~tPrFIGu?Ue9IJHoA%26Q2bn*) VIODjN#V6NbO$u$H8ds1RJ^=}2Qds~1 literal 0 HcmV?d00001 diff --git a/modules/scan/files/settings.json b/modules/brother/files/settings.json similarity index 100% rename from modules/scan/files/settings.json rename to modules/brother/files/settings.json diff --git a/modules/scan/go.mod b/modules/brother/go.mod similarity index 85% rename from modules/scan/go.mod rename to modules/brother/go.mod index 8affa504..18d9b8c4 100644 --- a/modules/scan/go.mod +++ b/modules/brother/go.mod @@ -1,4 +1,4 @@ -module scan +module brother go 1.26.0 diff --git a/modules/scan/go.sum b/modules/brother/go.sum similarity index 100% rename from modules/scan/go.sum rename to modules/brother/go.sum diff --git a/modules/scan/module.json b/modules/brother/module.json similarity index 93% rename from modules/scan/module.json rename to modules/brother/module.json index e5f3bba5..ce4c1a00 100644 --- a/modules/scan/module.json +++ b/modules/brother/module.json @@ -1,5 +1,5 @@ { - "module": "scan", + "module": "brother", "version": "1", "emits": [ "scan.added" @@ -83,13 +83,13 @@ "kind": "bundle", "language": "go", "system": "arch", - "from": "cmd/scan", - "binary": "scan", + "from": "cmd/brother", + "binary": "brother", "loads": [ - "scan" + "brother" ], "env": { - "SCAN_SETTINGS": "${dir:state}/settings.json" + "BROTHER_SETTINGS": "${dir:state}/settings.json" } }, { From 78f9731d4af6e26dd11312a5848da1b71720a6fa Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:07:00 +0200 Subject: [PATCH 02/14] brother: the scanner seat declared and claimed, its verbs scan, status and list; preferences untrusted (issue 1453, 1446) --- modules/brother/README.md | 8 ++-- modules/brother/cmd/brother/main.go | 8 ++-- modules/brother/cmd/brother/manifest_test.go | 46 ++++++++++++++++++++ modules/brother/cmd/brother/service_test.go | 10 ++--- modules/brother/module.json | 31 +++++++++++-- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/modules/brother/README.md b/modules/brother/README.md index 44733dba..b4a3902a 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -41,9 +41,9 @@ makes it (mode 0755) when it files the first scan. | tool | does | |---|---| -| `scan_now` | scans, crops, files and emits; answers the event's body | -| `scan_status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | -| `scan_list` | the newest filed scans, newest first (`limit`, default 20). Read-only | +| `scanner.scan` | scans, crops, files and emits; answers the event's body | +| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | +| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only | ## The event @@ -58,7 +58,7 @@ Each directory or archive this module declares says how it treats a file it did - `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). - The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there, - and `scan_list` only reads. + and `scanner.list` only reads. ## Tests diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 9d699f48..11866ab7 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -21,7 +21,7 @@ func emit(body map[string]any, id string) error { return stdio.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id}) } -// tools are the three tools. The settings are read at each call, so a changed config value is the next +// tools are the scanner seat's three verbs, served as . (ADR 0467 clause 2). The settings are read at each call, so a changed config value is the next // call's. func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool { service := func() (*Service, error) { @@ -33,7 +33,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool } return []stdio.Tool{ { - Name: "scan_now", + Name: "scanner.scan", Description: "Scan the glass of the configured network scanner, cut the document out of the platen's white, " + "file it as a one-page PDF in the scan folder and say scan.added on the bus. Answers the event's body: " + "the file's path, its size and sha256, its real size in millimetres, the resolution the scanner " + @@ -47,7 +47,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool }, }, { - Name: "scan_status", + Name: "scanner.status", Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " + "and its document feeder's state. A scanner that does not answer is said as answers false with why. Read-only.", Run: func(map[string]any) (any, error) { @@ -61,7 +61,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool }, }, { - Name: "scan_list", + Name: "scanner.list", Description: "The newest scans filed in the scan folder, newest first, with their size and time. Read-only.", Input: map[string]any{ "type": "object", diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 86c60a74..ea3daea0 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -11,6 +11,8 @@ import ( type manifest struct { Emits []string `json:"emits"` + Seats []seat `json:"seats"` + Claims []seat `json:"claims"` Tools []string `json:"tools"` Config map[string]map[string]any `json:"config"` Resources []map[string]any `json:"resources"` @@ -19,6 +21,12 @@ type manifest struct { } `json:"build"` } +type seat struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` +} + func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -116,3 +124,41 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { } } } + +// 1453/AC6: the manifest declares the scanner seat, held once for the mesh, and claims it, with the verbs +// ADR 0467 clause 2 names; the module serves each verb as the tool .. +func TestTheManifestDeclaresAndClaimsTheScannerSeat(t *testing.T) { + m := readManifest(t) + want := seat{Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}} + for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} { + found := false + for _, s := range got { + if s.Name == "scanner" { + found = true + if !reflect.DeepEqual(s, want) { + t.Errorf("%s: scanner = %+v, want %+v", what, s, want) + } + } + } + if !found { + t.Errorf("%s does not hold the scanner seat", what) + } + } +} + +// 1453/AC6 (issue 1446): the three preferences are not trusted, so a person's own setting is not refused. +func TestThePreferencesAreNotTrusted(t *testing.T) { + m := readManifest(t) + for _, k := range []string{"resolution", "color-mode", "crop"} { + if v, ok := m.Config[k]["trusted"]; !ok || v != false { + t.Errorf("%s: trusted = %v, want false", k, v) + } + } +} + +// 1453/AC6: the scan module is gone from the catalogue. +func TestTheScanModuleIsGone(t *testing.T) { + if _, err := os.Stat(filepath.Join("..", "..", "..", "scan")); !os.IsNotExist(err) { + t.Errorf("modules/scan still exists (err %v)", err) + } +} diff --git a/modules/brother/cmd/brother/service_test.go b/modules/brother/cmd/brother/service_test.go index 845b892a..94b7f43c 100644 --- a/modules/brother/cmd/brother/service_test.go +++ b/modules/brother/cmd/brother/service_test.go @@ -209,7 +209,7 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) { js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder) var out []said tl := tools(settingsFile(t, js), func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now) - for _, name := range []string{"scan_now", "scan_status", "scan_list"} { + for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} { _, err := tool(t, tl, name).Run(map[string]any{}) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s with %s: error = %v, want it to name %q", name, c.json, err, c.want) @@ -227,7 +227,7 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) { // 1440/AC7: the settings file is not there at all. func TestAMissingSettingsFileIsSaid(t *testing.T) { tl := tools(filepath.Join(t.TempDir(), "none.json"), nil, time.Now) - if _, err := tool(t, tl, "scan_status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") { + if _, err := tool(t, tl, "scanner.status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") { t.Fatalf("error = %v", err) } } @@ -260,7 +260,7 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) { os.Chtimes(p, base.Add(time.Duration(i)*time.Hour), base.Add(time.Duration(i)*time.Hour)) } tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), nil, time.Now) - got, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(5)}) + got, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(5)}) if err != nil { t.Fatal(err) } @@ -268,7 +268,7 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) { if len(scans) != 2 || filepath.Base(scans[0].Path) != "scan-2.pdf" { t.Fatalf("scans = %v", scans) } - if _, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(0)}); err == nil { + if _, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(0)}); err == nil { t.Error("limit 0 accepted") } } @@ -280,7 +280,7 @@ func TestScanNowToolScansWithTheFileSettings(t *testing.T) { var out []said tl := tools(settingsFile(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`), func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now) - got, err := tool(t, tl, "scan_now").Run(map[string]any{}) + got, err := tool(t, tl, "scanner.scan").Run(map[string]any{}) if err != nil { t.Fatal(err) } diff --git a/modules/brother/module.json b/modules/brother/module.json index ce4c1a00..98add799 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -28,6 +28,7 @@ 300, 600 ], + "trusted": false, "default": 300 }, "color-mode": { @@ -37,18 +38,42 @@ "color", "gray" ], + "trusted": false, "default": "color" }, "crop": { "type": "boolean", "description": "Whether the document is cut out of the platen's white lid; false files the whole platen.", + "trusted": false, "default": true } }, + "seats": [ + { + "name": "scanner", + "scope": "mesh", + "serves": [ + "scan", + "status", + "list" + ] + } + ], + "claims": [ + { + "name": "scanner", + "scope": "mesh", + "serves": [ + "scan", + "status", + "list" + ] + } + ], "tools": [ - "scan_now", - "scan_status", - "scan_list" + "scanner.scan", + "scanner.status", + "scanner.list" ], "resources": [ { From 2b62a1819e5c39daa8723273cd70bf644adc8972 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:10:14 +0200 Subject: [PATCH 03/14] brother: the printer config and admin-password secret, HTTPS pinned to one certificate, and the login by the form's own field (issue 1453) --- modules/brother/cmd/brother/admin.go | 184 +++++++++++ modules/brother/cmd/brother/admin_test.go | 230 +++++++++++++ modules/brother/cmd/brother/html.go | 309 ++++++++++++++++++ modules/brother/cmd/brother/manifest_test.go | 50 ++- modules/brother/cmd/brother/pin.go | 47 +++ modules/brother/cmd/brother/settings.go | 49 ++- .../testdata/admin/login-page-headers.txt | 11 + .../brother/testdata/admin/login-page.html | 3 + .../testdata/admin/synthetic-settings.html | 1 + .../testdata/admin/synthetic-status.html | 1 + modules/brother/files/settings.json | 2 + modules/brother/module.json | 25 +- 12 files changed, 901 insertions(+), 11 deletions(-) create mode 100644 modules/brother/cmd/brother/admin.go create mode 100644 modules/brother/cmd/brother/admin_test.go create mode 100644 modules/brother/cmd/brother/html.go create mode 100644 modules/brother/cmd/brother/pin.go create mode 100644 modules/brother/cmd/brother/testdata/admin/login-page-headers.txt create mode 100644 modules/brother/cmd/brother/testdata/admin/login-page.html create mode 100644 modules/brother/cmd/brother/testdata/admin/synthetic-settings.html create mode 100644 modules/brother/cmd/brother/testdata/admin/synthetic-status.html diff --git a/modules/brother/cmd/brother/admin.go b/modules/brother/cmd/brother/admin.go new file mode 100644 index 00000000..ce7aed23 --- /dev/null +++ b/modules/brother/cmd/brother/admin.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" +) + +// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467 +// clauses 3 and 4). The device names the password field itself, so every login reads the form first. +type Admin struct { + Host string + Password string + HTTP *http.Client +} + +// loginPath is a page that needs a login: asking for it is answered by the login page. +const loginPath = "/general/status.html" + +// maxPage bounds a page of the admin site. +const maxPage = 4 << 20 + +// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a +// cookie jar for the session. +func NewAdmin(host, fingerprint, password string) (*Admin, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + return &Admin{ + Host: host, + Password: password, + HTTP: &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + Transport: &http.Transport{ + TLSClientConfig: pinnedTLS(fingerprint), + DisableKeepAlives: true, + }, + }, + }, nil +} + +func (a *Admin) url(path string) string { return "https://" + a.Host + path } + +// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects. +func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) { + var req *http.Request + var err error + if form != nil { + req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode())) + if err == nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + } else { + req, err = http.NewRequestWithContext(ctx, method, rawURL, nil) + } + if err != nil { + return nil, nil, err + } + resp, err := a.HTTP.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1)) + if err != nil { + return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err) + } + if len(buf) > maxPage { + return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage) + } + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path) + } + return buf, resp.Request.URL, nil +} + +// passwordForm is the form of a page that holds a password field, or nil. +func passwordForm(p Page) (*Form, *Field) { + for i := range p.Forms { + for j := range p.Forms[i].Fields { + if p.Forms[i].Fields[j].Type == "password" { + return &p.Forms[i], &p.Forms[i].Fields[j] + } + } + } + return nil, nil +} + +// Login asks for a page that needs one, reads the login form it is answered with, and posts the form's own +// fields with the password in the field the form names. A login is failed when the answer is a login page +// again. +func (a *Admin) Login(ctx context.Context) error { + body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginPath), nil) + if err != nil { + return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err) + } + form, pw := passwordForm(ParsePage(body)) + if form == nil { + return fmt.Errorf("the printer's answer to %s holds no login form with a password field", loginPath) + } + target, err := at.Parse(form.Action) + if err != nil { + return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err) + } + if target.Host != a.Host { + return fmt.Errorf("the login form posts to %s, not to the printer %s: the password is not sent", target.Host, a.Host) + } + vals := url.Values{} + for _, f := range form.Fields { + if f.Type == "hidden" { + vals.Set(f.Name, f.Value) + } + } + vals.Set(pw.Name, a.Password) + answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals) + if err != nil { + return fmt.Errorf("the login was not answered: %w", err) + } + if again, _ := passwordForm(ParsePage(answer)); again != nil { + return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password") + } + return nil +} + +// Get is a page of the admin site, after a login. +func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) { + return a.fetch(ctx, http.MethodGet, a.url(path), nil) +} + +// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with +// none leaves the session to expire, and the answer says so. +func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string { + p := ParsePage(page) + isLogout := func(s string) bool { + s = strings.ToLower(strings.ReplaceAll(s, " ", "")) + return strings.Contains(s, "logout") || strings.Contains(s, "logoff") + } + for _, f := range p.Forms { + if !isLogout(f.Action) { + continue + } + target, err := at.Parse(f.Action) + if err != nil || target.Host != a.Host { + continue + } + vals := url.Values{} + for _, fl := range f.Fields { + if fl.Type == "hidden" { + vals.Set(fl.Name, fl.Value) + } + } + method := http.MethodPost + var send url.Values = vals + if f.Method == "get" { + method, send = http.MethodGet, nil + target.RawQuery = vals.Encode() + } + if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil { + return "logout failed: " + err.Error() + } + return "logged out" + } + for _, l := range p.Links { + if !isLogout(l.Href) && !isLogout(l.Text) { + continue + } + target, err := at.Parse(l.Href) + if err != nil || target.Host != a.Host { + continue + } + if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil { + return "logout failed: " + err.Error() + } + return "logged out" + } + return "no logout link or form on the page: the session is left to expire on the printer" +} diff --git a/modules/brother/cmd/brother/admin_test.go b/modules/brother/cmd/brother/admin_test.go new file mode 100644 index 00000000..f346d8cd --- /dev/null +++ b/modules/brother/cmd/brother/admin_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// fakePrinter is a TLS server standing in for the printer's admin page. Its login page is the captured one +// (testdata/admin/login-page.html, 2026-10-20) with the password field's name changed to the one the test +// chooses, as the real device generates it. The pages behind the login are SYNTHETIC (testdata/admin/ +// synthetic-*.html): the real ones arrive in round 2 of issue 1453. +type fakePrinter struct { + *httptest.Server + field string // the password field's name the login form gives + password string + mu sync.Mutex + requests []string // method and path of every request + posted []url.Values // every form body posted + session string // the cookie value of the one logged-in session + loggedIn map[string]bool // cookie values that are logged in + pages map[string]string // path -> body behind the login +} + +func adminFixture(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "admin", name)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func newFakePrinter(t *testing.T, field, password string) *fakePrinter { + t.Helper() + f := &fakePrinter{field: field, password: password, loggedIn: map[string]bool{}, pages: map[string]string{}} + login := strings.ReplaceAll(adminFixture(t, "login-page.html"), "B1891", field) + synthetic := adminFixture(t, "synthetic-status.html") + f.pages["/general/status.html"] = synthetic + f.pages["/general/other.html"] = adminFixture(t, "synthetic-settings.html") + authed := func(r *http.Request) bool { + c, err := r.Cookie("session") + return err == nil && f.loggedIn[c.Value] + } + mux := http.NewServeMux() + mux.HandleFunc("/etc/loginerror.html", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(login)) + }) + mux.HandleFunc("/home/status.html", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Method != http.MethodPost { + if authed(r) { + w.Write([]byte(synthetic)) + return + } + w.Write([]byte(login)) + return + } + r.ParseForm() + f.posted = append(f.posted, r.PostForm) + if r.PostForm.Get(field) != password || r.PostForm.Get("loginurl") == "" { + w.Write([]byte(login)) + return + } + f.session = "s" + field + f.loggedIn[f.session] = true + http.SetCookie(w, &http.Cookie{Name: "session", Value: f.session, Path: "/"}) + w.Write([]byte(synthetic)) + }) + mux.HandleFunc("/home/logout.html", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if c, err := r.Cookie("session"); err == nil { + delete(f.loggedIn, c.Value) + } + w.Write([]byte(login)) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + body, ok := f.pages[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + if !authed(r) { + http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound) + return + } + w.Write([]byte(body)) + }) + f.Server = httptest.NewTLSServer(mux) + t.Cleanup(f.Close) + return f +} + +func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https://") } + +// print is the fingerprint of the certificate the server serves. +func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) } + +func (f *fakePrinter) loggedInNow() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.loggedIn) +} + +// 1453/AC2: a certificate whose fingerprint is not the pinned one is refused, the error names both +// fingerprints, and the server saw no request at all, so no password was sent. +func TestACertificateOtherThanThePinnedOneIsRefusedBeforeAnythingIsSent(t *testing.T) { + f := newFakePrinter(t, "B1891", "hunter2") + other := strings.Repeat("AA:", 31) + "AA" + a, err := NewAdmin(f.host(), other, "hunter2") + if err != nil { + t.Fatal(err) + } + err = a.Login(context.Background()) + if err == nil { + t.Fatal("a certificate that is not the pinned one was accepted") + } + for _, want := range []string{f.print(), other} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name the fingerprint %s", err, want) + } + } + if len(f.requests) != 0 || len(f.posted) != 0 { + t.Errorf("the server was sent %v and the forms %v: the password may have gone", f.requests, f.posted) + } +} + +// 1453/AC2: the pinned certificate itself is accepted, in either case of hex digits. +func TestThePinnedCertificateIsAccepted(t *testing.T) { + f := newFakePrinter(t, "B1891", "hunter2") + a, _ := NewAdmin(f.host(), strings.ToLower(f.print()), "hunter2") + if err := a.Login(context.Background()); err != nil { + t.Fatal(err) + } +} + +// 1453/AC3: the login posts the password in the field the form names, whatever the name is (the test changes +// it), along with the form's hidden fields, and the session cookie it gets is the one later pages are asked with. +func TestLoginUsesThePasswordFieldNameTheFormGives(t *testing.T) { + for _, name := range []string{"B1891", "Zq77x"} { + f := newFakePrinter(t, name, "s3cret pass") + a, _ := NewAdmin(f.host(), f.print(), "s3cret pass") + ctx := context.Background() + if err := a.Login(ctx); err != nil { + t.Fatalf("field %s: %v", name, err) + } + if len(f.posted) != 1 { + t.Fatalf("field %s: %d forms posted", name, len(f.posted)) + } + if got := f.posted[0].Get(name); got != "s3cret pass" { + t.Errorf("field %s: the password field holds %q", name, got) + } + if f.posted[0].Get("loginurl") != "/general/status.html" { + t.Errorf("field %s: the form's hidden loginurl was %q", name, f.posted[0].Get("loginurl")) + } + if len(f.posted[0]) != 2 { + t.Errorf("field %s: posted %v, want the password and loginurl only", name, f.posted[0]) + } + body, _, err := a.Get(ctx, "/general/other.html") + if err != nil || !strings.Contains(string(body), "Synthetic settings") { + t.Errorf("field %s: a page behind the login was not given with the session cookie: %v %.60s", name, err, body) + } + } +} + +// 1453/AC4: a wrong password is a failed login, said so: the login page comes back. +func TestAWrongPasswordIsAFailedLogin(t *testing.T) { + f := newFakePrinter(t, "B1891", "right") + a, _ := NewAdmin(f.host(), f.print(), "wrong") + err := a.Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "login failed") { + t.Fatalf("error = %v, want a failed login", err) + } + if f.loggedInNow() != 0 { + t.Errorf("a session was opened") + } +} + +// 1453: the logout link on a page ends the session; a page with none says so. +func TestLogoutEndsTheSessionAndAPageWithoutOneSaysSo(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + a, _ := NewAdmin(f.host(), f.print(), "pw") + ctx := context.Background() + if err := a.Login(ctx); err != nil { + t.Fatal(err) + } + body, at, err := a.Get(ctx, "/general/status.html") + if err != nil { + t.Fatal(err) + } + if got := a.Logout(ctx, body, at); got != "logged out" { + t.Errorf("logout = %q", got) + } + if f.loggedInNow() != 0 { + t.Errorf("the session is still open") + } + if got := a.Logout(ctx, []byte("nothing"), at); !strings.Contains(got, "no logout") { + t.Errorf("logout without a link = %q", got) + } +} + +// 1453: the module never sends the password to a host other than the printer, even if the form says so. +func TestThePasswordIsNotPostedToAnotherHost(t *testing.T) { + // A login form whose action names another host. + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`
`)) + })) + srv.TLS = &tls.Config{} + srv.StartTLS() + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "https://") + a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw") + err := a.Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "not sent") { + t.Fatalf("error = %v", err) + } +} diff --git a/modules/brother/cmd/brother/html.go b/modules/brother/cmd/brother/html.go new file mode 100644 index 00000000..a3f5e136 --- /dev/null +++ b/modules/brother/cmd/brother/html.go @@ -0,0 +1,309 @@ +package main + +import ( + "html" + "regexp" + "strings" + "unicode/utf8" +) + +// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module +// reads is taken out of them: the title, the links, and the forms with their fields. + +// Link is an anchor of a page. +type Link struct { + Href string `json:"href"` + Text string `json:"text"` +} + +// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4). +type Field struct { + Name string `json:"name"` + Type string `json:"type"` + Value string `json:"value"` + Label string `json:"label"` + Options []string `json:"options,omitempty"` + id string +} + +// Form is a form of a page with its named fields. +type Form struct { + Action string `json:"action"` + Method string `json:"method"` + Fields []Field `json:"fields"` +} + +// Page is what a page of the admin site holds. +type Page struct { + Title string `json:"title"` + Links []Link `json:"links"` + Forms []Form `json:"forms"` +} + +var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`) + +// attrs reads a tag's attributes, names in lower case and values unescaped. +func attrs(s string) map[string]string { + out := map[string]string{} + for _, m := range attrRE.FindAllStringSubmatch(s, -1) { + v := m[2] + m[3] + m[4] + out[strings.ToLower(m[1])] = html.UnescapeString(v) + } + return out +} + +func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") } + +// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number: +// the printer's pages say iso-8859-1. +func latin1(b []byte) string { + if utf8.Valid(b) { + return string(b) + } + r := make([]rune, len(b)) + for i, c := range b { + r[i] = rune(c) + } + return string(r) +} + +// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1. +func tagEnd(s string) int { + var q byte + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case q != 0: + if c == q { + q = 0 + } + case c == '"' || c == '\'': + q = c + case c == '>': + return i + } + } + return -1 +} + +// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end. +func ParsePage(body []byte) Page { + s := latin1(body) + var p Page + var ( + inTitle bool + title strings.Builder + link *Link + formOpen bool + form Form + labelFor string + inLabel bool + label strings.Builder + labelIDs = map[string]string{} + wrapped []int // fields inside a label that names none, by index in form.Fields + selIdx = -1 + inOption bool + optValue string + optHasV bool + optSel bool + optText strings.Builder + inTextA bool + textA strings.Builder + textAIdx = -1 + ) + finishOption := func() { + if !inOption || selIdx < 0 { + inOption = false + return + } + v := optValue + if !optHasV { + v = squash(optText.String()) + } + f := &form.Fields[selIdx] + f.Options = append(f.Options, v) + if optSel { + f.Value = v + } + inOption = false + } + closeForm := func() { + finishOption() + selIdx = -1 + if formOpen { + p.Forms = append(p.Forms, form) + } + formOpen = false + form = Form{} + } + addField := func(f Field) { + form.Fields = append(form.Fields, f) + if inLabel && labelFor == "" { + wrapped = append(wrapped, len(form.Fields)-1) + } + } + text := func(t string) { + switch { + case inTitle: + title.WriteString(t) + case inOption: + optText.WriteString(t) + case inTextA: + textA.WriteString(t) + } + if link != nil { + link.Text += t + } + if inLabel { + label.WriteString(t) + } + } + i := 0 + for i < len(s) { + lt := strings.IndexByte(s[i:], '<') + if lt < 0 { + text(s[i:]) + break + } + text(s[i : i+lt]) + i += lt + switch { + case strings.HasPrefix(s[i:], "") + if end < 0 { + i = len(s) + } else { + i += end + 3 + } + continue + case strings.HasPrefix(s[i:], "') + if end < 0 { + i = len(s) + } else { + i += end + 1 + } + continue + } + end := tagEnd(s[i:]) + if end < 0 { + break + } + raw := s[i+1 : i+end] + i += end + 1 + closing := strings.HasPrefix(raw, "/") + raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/") + name := raw + rest := "" + if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 { + name, rest = raw[:sp], raw[sp:] + } + name = strings.ToLower(name) + if !closing && (name == "script" || name == "style") { + if e := strings.Index(strings.ToLower(s[i:]), "= 0 { + i += e + } else { + i = len(s) + } + continue + } + a := map[string]string{} + if !closing { + a = attrs(rest) + } + switch name { + case "title": + inTitle = !closing + case "a": + if closing { + if link != nil { + link.Text = squash(link.Text) + p.Links = append(p.Links, *link) + } + link = nil + } else if href, ok := a["href"]; ok { + link = &Link{Href: href} + } + case "form": + closeForm() + if !closing { + formOpen = true + form = Form{Action: a["action"], Method: strings.ToLower(a["method"])} + if form.Method == "" { + form.Method = "get" + } + } + case "label": + if closing { + t := squash(label.String()) + if labelFor != "" { + labelIDs[labelFor] = t + } + for _, k := range wrapped { + form.Fields[k].Label = t + } + inLabel, labelFor, wrapped = false, "", nil + label.Reset() + } else { + inLabel, labelFor = true, a["for"] + label.Reset() + } + case "input": + if closing || a["name"] == "" { + break + } + typ := strings.ToLower(a["type"]) + if typ == "" { + typ = "text" + } + if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" { + break + } + f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]} + if typ == "password" { + f.Value = "" + } + addField(f) + case "select": + finishOption() + selIdx = -1 + if !closing && a["name"] != "" { + addField(Field{Name: a["name"], Type: "select", id: a["id"]}) + selIdx = len(form.Fields) - 1 + } + case "option": + finishOption() + if !closing && selIdx >= 0 { + inOption = true + optValue, optHasV = a["value"], false + if _, ok := a["value"]; ok { + optHasV = true + } + _, optSel = a["selected"] + optText.Reset() + } + case "textarea": + if closing { + if inTextA && textAIdx >= 0 { + form.Fields[textAIdx].Value = html.UnescapeString(textA.String()) + } + inTextA = false + } else if a["name"] != "" { + addField(Field{Name: a["name"], Type: "textarea", id: a["id"]}) + inTextA, textAIdx = true, len(form.Fields)-1 + textA.Reset() + } + } + } + closeForm() + p.Title = squash(title.String()) + for fi := range p.Forms { + for k := range p.Forms[fi].Fields { + f := &p.Forms[fi].Fields[k] + if f.Label == "" && f.id != "" { + f.Label = labelIDs[f.id] + } + } + } + return p +} diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index ea3daea0..557ee790 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -5,18 +5,20 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strings" "testing" ) type manifest struct { - Emits []string `json:"emits"` - Seats []seat `json:"seats"` - Claims []seat `json:"claims"` - Tools []string `json:"tools"` - Config map[string]map[string]any `json:"config"` - Resources []map[string]any `json:"resources"` - Build struct { + Emits []string `json:"emits"` + OwnSecrets map[string]map[string]any `json:"own-secrets"` + Seats []seat `json:"seats"` + Claims []seat `json:"claims"` + Tools []string `json:"tools"` + Config map[string]map[string]any `json:"config"` + Resources []map[string]any `json:"resources"` + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -73,7 +75,7 @@ func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) { // code reads is declared, taken by the file and in its template, with the default the code lays under it. func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { m := readManifest(t) - keys := []string{"scanner", "scan-folder", "resolution", "color-mode", "crop"} + keys := []string{"scanner", "scan-folder", "printer", "printer-certificate", "resolution", "color-mode", "crop"} for _, k := range keys { if _, ok := m.Config[k]; !ok { t.Errorf("config does not declare %s", k) @@ -82,6 +84,13 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { if len(m.Config) != len(keys) { t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys)) } + // The printer's pins and addresses are written into the file as they are given, and match what the + // code's own validators accept (ADR 0467 clause 3). + fp := regexp.MustCompile(m.Config["printer-certificate"]["pattern"].(string)) + host := regexp.MustCompile(m.Config["printer"]["pattern"].(string)) + if !fp.MatchString(strings.Repeat("0A:", 31)+"0A") || fp.MatchString("0a:0a") || !host.MatchString("192.0.2.10") || host.MatchString("a b/c") { + t.Errorf("the printer patterns do not say a host and a SHA-256 fingerprint") + } var taken []any for _, r := range m.Resources { if r["id"] == "settings" { @@ -118,7 +127,7 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want) } } - for _, k := range []string{"scanner", "scan-folder"} { + for _, k := range []string{"scanner", "scan-folder", "printer", "printer-certificate"} { if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true { t.Errorf("%s is the operator's: required and trusted", k) } @@ -162,3 +171,26 @@ func TestTheScanModuleIsGone(t *testing.T) { t.Errorf("modules/scan still exists (err %v)", err) } } + +// 1453/AC4 (ADR 0467 clause 4): the admin password is the module's own secret, issued by the operator (never +// generated), and reaches the code as the file BROTHER_ADMIN_PASSWORD names. +func TestTheAdminPasswordIsTheOperatorsOwnSecret(t *testing.T) { + m := readManifest(t) + s, ok := m.OwnSecrets["admin-password"] + if !ok { + t.Fatal("own-secrets does not declare admin-password") + } + if s["issued-by"] != "outside" { + t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"]) + } + path, _ := s["path"].(string) + var env string + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + env, _ = a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"].(string) + } + } + if path == "" || env != path { + t.Errorf("the secret's path %q and BROTHER_ADMIN_PASSWORD %q differ", path, env) + } +} diff --git a/modules/brother/cmd/brother/pin.go b/modules/brother/cmd/brother/pin.go new file mode 100644 index 00000000..08702820 --- /dev/null +++ b/modules/brother/cmd/brother/pin.go @@ -0,0 +1,47 @@ +package main + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "fmt" + "strings" +) + +// Fingerprint is the SHA-256 of a certificate's DER bytes, as upper-case hex pairs joined by colons: the +// form the printer's own certificate page and the config value printer-certificate give (ADR 0467 clause 3). +func Fingerprint(der []byte) string { + sum := sha256.Sum256(der) + parts := make([]string, len(sum)) + for i, b := range sum { + parts[i] = fmt.Sprintf("%02X", b) + } + return strings.Join(parts, ":") +} + +// pinned is the check on the certificate a printer serves: the leaf's fingerprint must be the one pinned, and +// any other certificate is refused, with both fingerprints in the message. It runs in the handshake, so a +// refused certificate is refused before one byte of a request (a password) is sent. +func pinned(want string) func([][]byte, [][]*x509.Certificate) error { + want = strings.ToUpper(strings.TrimSpace(want)) + return func(raw [][]byte, _ [][]*x509.Certificate) error { + if len(raw) == 0 { + return fmt.Errorf("the printer served no certificate; the pinned printer-certificate is %s", want) + } + if got := Fingerprint(raw[0]); got != want { + return fmt.Errorf("the printer's certificate is %s, but the pinned printer-certificate is %s: refused", got, want) + } + return nil + } +} + +// pinnedTLS is the TLS configuration for the printer's admin page: the printer's certificate is self-signed, +// so no chain is verified; the pinned fingerprint is the whole of the trust (ADR 0467 clause 3). +func pinnedTLS(fingerprint string) *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + // The chain is not verified because the pinned fingerprint is checked in its place, below. + InsecureSkipVerify: true, + VerifyPeerCertificate: pinned(fingerprint), + } +} diff --git a/modules/brother/cmd/brother/settings.go b/modules/brother/cmd/brother/settings.go index b15175e3..a643b500 100644 --- a/modules/brother/cmd/brother/settings.go +++ b/modules/brother/cmd/brother/settings.go @@ -10,7 +10,7 @@ import ( "strings" ) -// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348). +// Settings are the module's seven config values, as the mesh writes them into settings.json (ADR 0348). type Settings struct { // Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL. Scanner string @@ -21,6 +21,11 @@ type Settings struct { Resolution int // ColorMode is "color" or "gray". ColorMode string + // Printer is the device's host (with a port if it is not 443), such as 192.0.2.10: the host of its admin + // page and its IPP service (ADR 0467 clause 3). + Printer string + // PrinterCertificate is the SHA-256 fingerprint of the one certificate the device's admin page may serve. + PrinterCertificate string // Crop says whether the document is cut out of the platen's white. Crop bool } @@ -52,6 +57,12 @@ func LoadSettings(path string) (Settings, error) { if v, ok := in["color-mode"].(string); ok && v != "" { s.ColorMode = v } + if v, ok := in["printer"].(string); ok { + s.Printer = strings.TrimSpace(v) + } + if v, ok := in["printer-certificate"].(string); ok { + s.PrinterCertificate = strings.TrimSpace(v) + } if v, ok := in["crop"].(bool); ok { s.Crop = v } @@ -74,3 +85,39 @@ func (s Settings) Missing() error { return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", strings.Join(unset, " and ")) } + +// MissingPrinter is nil when the two values the printer's verbs need are set, and otherwise says which is not: +// nothing is contacted before that (1453/AC4). +func (s Settings) MissingPrinter() error { + var unset []string + if s.Printer == "" { + unset = append(unset, "printer") + } + if s.PrinterCertificate == "" { + unset = append(unset, "printer-certificate") + } + if len(unset) == 0 { + return nil + } + return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", + strings.Join(unset, " and ")) +} + +// AdminSecret is the module's own secret admin-password: the device's admin password, given by the operator +// at the desk and never generated or rotated by the mesh (ADR 0467 clause 4). path is the file the mesh +// writes it to. A secret not yet given is said by name, and the device is not contacted. +func AdminSecret(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code") + } + raw, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("the secret admin-password cannot be read: %w", err) + } + pw := strings.TrimRight(string(raw), "\r\n") + if pw == "" { + return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " + + "who gives it at their desk (ADR 0283)") + } + return pw, nil +} diff --git a/modules/brother/cmd/brother/testdata/admin/login-page-headers.txt b/modules/brother/cmd/brother/testdata/admin/login-page-headers.txt new file mode 100644 index 00000000..d68f9a23 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/admin/login-page-headers.txt @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +Cache-Control: no-store +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Content-Length: 2974 +Content-Type: text/html +Content-Language: en-gb +Connection: close +X-XSS-Protection: 1; mode=block +Pragma: no-cache + diff --git a/modules/brother/cmd/brother/testdata/admin/login-page.html b/modules/brother/cmd/brother/testdata/admin/login-page.html new file mode 100644 index 00000000..9ac63bd5 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/admin/login-page.html @@ -0,0 +1,3 @@ + + +Brother MFC-L8390CDW series
MFC-L8390CDW series

Please Login

To Access this page you are required to Login, please Login.

Copyright(C) 2000-2023 Brother Industries, Ltd. All Rights Reserved.
\ No newline at end of file diff --git a/modules/brother/cmd/brother/testdata/admin/synthetic-settings.html b/modules/brother/cmd/brother/testdata/admin/synthetic-settings.html new file mode 100644 index 00000000..eb93c6b5 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/admin/synthetic-settings.html @@ -0,0 +1 @@ +Synthetic settingsLogoutStatus
diff --git a/modules/brother/cmd/brother/testdata/admin/synthetic-status.html b/modules/brother/cmd/brother/testdata/admin/synthetic-status.html new file mode 100644 index 00000000..f37e8655 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/admin/synthetic-status.html @@ -0,0 +1 @@ +Synthetic status

Status

diff --git a/modules/brother/files/settings.json b/modules/brother/files/settings.json index 5bf936f4..80aed322 100644 --- a/modules/brother/files/settings.json +++ b/modules/brother/files/settings.json @@ -1,6 +1,8 @@ { "scanner": "", "scan-folder": "", + "printer": "", + "printer-certificate": "", "resolution": 300, "color-mode": "color", "crop": true diff --git a/modules/brother/module.json b/modules/brother/module.json index 98add799..0f8e3a5d 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -19,6 +19,20 @@ "trusted": true, "required": true }, + "printer": { + "type": "string", + "description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.", + "pattern": "^[A-Za-z0-9.:-]+$", + "trusted": true, + "required": true + }, + "printer-certificate": { + "type": "string", + "description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons. The module trusts that certificate alone and refuses any other; the operator's, with no default.", + "pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}$", + "trusted": true, + "required": true + }, "resolution": { "type": "number", "description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.", @@ -48,6 +62,12 @@ "default": true } }, + "own-secrets": { + "admin-password": { + "path": "${dir:state}/admin-password.secret", + "issued-by": "outside" + } + }, "seats": [ { "name": "scanner", @@ -93,6 +113,8 @@ "takes-config": [ "scanner", "scan-folder", + "printer", + "printer-certificate", "resolution", "color-mode", "crop" @@ -114,7 +136,8 @@ "brother" ], "env": { - "BROTHER_SETTINGS": "${dir:state}/settings.json" + "BROTHER_SETTINGS": "${dir:state}/settings.json", + "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret" } }, { From 34260d8c2980439c504c4c4ddcd4a622cfdcba44 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:11:15 +0200 Subject: [PATCH 04/14] brother: the printer seat's settings verb, read-only through the login, with the page's links and form fields (issue 1453) --- modules/brother/cmd/brother/admin_test.go | 12 +- modules/brother/cmd/brother/main.go | 34 +++- modules/brother/cmd/brother/manifest_test.go | 29 ++-- modules/brother/cmd/brother/printer.go | 82 ++++++++++ modules/brother/cmd/brother/printer_test.go | 160 +++++++++++++++++++ modules/brother/cmd/brother/scan.go | 2 + modules/brother/module.json | 15 ++ 7 files changed, 320 insertions(+), 14 deletions(-) create mode 100644 modules/brother/cmd/brother/printer.go create mode 100644 modules/brother/cmd/brother/printer_test.go diff --git a/modules/brother/cmd/brother/admin_test.go b/modules/brother/cmd/brother/admin_test.go index f346d8cd..c78a61e1 100644 --- a/modules/brother/cmd/brother/admin_test.go +++ b/modules/brother/cmd/brother/admin_test.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/tls" + "net" "net/http" "net/http/httptest" "net/url" @@ -22,6 +23,7 @@ type fakePrinter struct { field string // the password field's name the login form gives password string mu sync.Mutex + conns int // connections opened to it requests []string // method and path of every request posted []url.Values // every form body posted session string // the cookie value of the one logged-in session @@ -99,7 +101,15 @@ func newFakePrinter(t *testing.T, field, password string) *fakePrinter { } w.Write([]byte(body)) }) - f.Server = httptest.NewTLSServer(mux) + f.Server = httptest.NewUnstartedServer(mux) + f.Config.ConnState = func(_ net.Conn, st http.ConnState) { + if st == http.StateNew { + f.mu.Lock() + f.conns++ + f.mu.Unlock() + } + } + f.StartTLS() t.Cleanup(f.Close) return f } diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 11866ab7..2f9ca11d 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -29,9 +29,41 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool if err != nil { return nil, err } - return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say}, nil + return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say, + PasswordFile: os.Getenv("BROTHER_ADMIN_PASSWORD")}, nil } return []stdio.Tool{ + { + Name: "printer.settings", + Description: "Read one page of the printer's admin site, read-only: log in with the secret admin-password (the " + + "operator's; the module trusts the printer's pinned certificate alone), get the page, log out. Answers the " + + "page's title, its links (href and text) and its forms with their fields (name, type, value, label); a " + + "password field's value is never given. It sends nothing but the login and the logout, and writes no " + + "setting of the printer.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "page": map[string]any{"type": "string", "description": "a page of the admin site: a path starting with / and ending in .html (default /general/status.html)"}, + }, + }, + Run: func(args map[string]any) (any, error) { + page := "" + if v, ok := args["page"]; ok { + str, isStr := v.(string) + if !isStr { + return nil, fmt.Errorf("page is a string") + } + page = str + } + s, err := service() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + return s.PrinterSettings(ctx, page) + }, + }, { Name: "scanner.scan", Description: "Scan the glass of the configured network scanner, cut the document out of the platen's white, " + diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 557ee790..06d3f6c4 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -136,21 +136,26 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { // 1453/AC6: the manifest declares the scanner seat, held once for the mesh, and claims it, with the verbs // ADR 0467 clause 2 names; the module serves each verb as the tool .. -func TestTheManifestDeclaresAndClaimsTheScannerSeat(t *testing.T) { +func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) { m := readManifest(t) - want := seat{Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}} - for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} { - found := false - for _, s := range got { - if s.Name == "scanner" { - found = true - if !reflect.DeepEqual(s, want) { - t.Errorf("%s: scanner = %+v, want %+v", what, s, want) + for _, want := range []seat{ + {Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}}, + // restart is round 2 of issue 1453, and is not declared yet. + {Name: "printer", Scope: "mesh", Serves: []string{"settings"}}, + } { + for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} { + found := false + for _, s := range got { + if s.Name == want.Name { + found = true + if !reflect.DeepEqual(s, want) { + t.Errorf("%s: %s = %+v, want %+v", what, want.Name, s, want) + } } } - } - if !found { - t.Errorf("%s does not hold the scanner seat", what) + if !found { + t.Errorf("%s does not hold the %s seat", what, want.Name) + } } } } diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go new file mode 100644 index 00000000..b3330e58 --- /dev/null +++ b/modules/brother/cmd/brother/printer.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" +) + +// pagePattern is a page of the admin site a caller may ask for: an absolute path under the site ending in +// .html, with no query, no dot-dot and no second slash at its start (which would name another host). +var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`) + +// DefaultPage is the page printer.settings reads when none is asked for. +const DefaultPage = "/general/status.html" + +// admin is a session on the printer's admin page, once the settings and the secret say it may be opened. The +// printer is contacted by nothing before this has answered without an error (1453/AC4). +func (s *Service) admin() (*Admin, error) { + if err := s.Settings.MissingPrinter(); err != nil { + return nil, err + } + pw, err := AdminSecret(s.PasswordFile) + if err != nil { + return nil, err + } + return NewAdmin(s.Settings.Printer, s.Settings.PrinterCertificate, pw) +} + +// PrinterSettings logs in, reads one page of the admin site and logs out. It is read-only: the login (and the +// logout) are the only things it sends, and a password field's value is never in the answer (ADR 0467 clause 5). +func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) { + if page == "" { + page = DefaultPage + } + if !pagePattern.MatchString(page) || strings.Contains(page, "..") || strings.Contains(page, "//") { + return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page) + } + a, err := s.admin() + if err != nil { + return nil, err + } + if err := a.Login(ctx); err != nil { + return nil, err + } + body, at, err := a.Get(ctx, page) + if err != nil { + // Log out with a context of its own: the caller's may be what ended this. + lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if home, hat, herr := a.Get(lctx, DefaultPage); herr == nil { + a.Logout(lctx, home, hat) + } + return nil, fmt.Errorf("page %s cannot be read: %w", page, err) + } + lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + logout := a.Logout(lctx, body, at) + p := ParsePage(body) + return map[string]any{ + "page": page, + "title": p.Title, + "links": nonNil(p.Links), + "forms": nonNilForms(p.Forms), + "logout": logout, + }, nil +} + +func nonNil(l []Link) []Link { + if l == nil { + return []Link{} + } + return l +} + +func nonNilForms(f []Form) []Form { + if f == nil { + return []Form{} + } + return f +} diff --git a/modules/brother/cmd/brother/printer_test.go b/modules/brother/cmd/brother/printer_test.go new file mode 100644 index 00000000..35e3ff9c --- /dev/null +++ b/modules/brother/cmd/brother/printer_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// printerTools is the tool set over a settings file for the fake printer, with the secret in a file the way the +// mesh writes it (a trailing newline included). +func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) { + t.Helper() + secret := filepath.Join(t.TempDir(), "admin-password.secret") + if password != "" { + if err := os.WriteFile(secret, []byte(password+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("BROTHER_ADMIN_PASSWORD", secret) + js := `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}` + tl := tools(settingsFile(t, js), nil, time.Now) + return js, tool(t, tl, "printer.settings").Run +} + +// 1453/AC3: printer.settings logs in by the field name the form gives, sends the password from the secret +// file, follows the session cookie, and returns the page's title, links and form fields; a password field's +// value is never returned, and the session is logged out. The page is SYNTHETIC (testdata/admin/ +// synthetic-settings.html): the real pages arrive in round 2. +func TestPrinterSettingsReadsAPageBehindTheLogin(t *testing.T) { + f := newFakePrinter(t, "Qw9z3", "p4ss word") + _, run := printerTools(t, f, "p4ss word") + got, err := run(map[string]any{"page": "/general/other.html"}) + if err != nil { + t.Fatal(err) + } + if len(f.posted) != 1 || f.posted[0].Get("Qw9z3") != "p4ss word" { + t.Errorf("the login posted %v", f.posted) + } + raw, _ := json.Marshal(got) + out := string(raw) + var a struct { + Title string + Links []Link + Forms []Form + Logout string + } + json.Unmarshal(raw, &a) + if a.Title != "Synthetic settings" { + t.Errorf("title = %q", a.Title) + } + if len(a.Links) != 2 || a.Links[1] != (Link{Href: "/general/status.html", Text: "Status"}) { + t.Errorf("links = %v", a.Links) + } + if len(a.Forms) != 1 || a.Forms[0].Action != "/general/other.html" || a.Forms[0].Method != "post" { + t.Fatalf("forms = %+v", a.Forms) + } + want := map[string]Field{ + "lock": {Name: "lock", Type: "checkbox", Value: "on", Label: "Scan lock"}, + "node": {Name: "node", Type: "text", Value: "BRN-SYNTH", Label: "Name"}, + "pw": {Name: "pw", Type: "password", Value: "", Label: "Secret"}, + "note": {Name: "note", Type: "textarea", Value: "hello & bye"}, + "token": {Name: "token", Type: "hidden", Value: "t1"}, + } + for _, fl := range a.Forms[0].Fields { + if fl.Name == "mode" { + if fl.Type != "select" || fl.Value != "m" || fl.Label != "Mode" || strings.Join(fl.Options, ",") != "a,m" { + t.Errorf("mode = %+v", fl) + } + continue + } + w, ok := want[fl.Name] + if !ok || fl.Name != w.Name || fl.Type != w.Type || fl.Value != w.Value || fl.Label != w.Label { + t.Errorf("field %+v, want %+v", fl, w) + } + delete(want, fl.Name) + } + if len(want) != 0 { + t.Errorf("fields missing: %v", want) + } + if strings.Contains(out, "shouldnotshow") { + t.Errorf("a password field's value was returned: %s", out) + } + if a.Logout != "logged out" || f.loggedInNow() != 0 { + t.Errorf("logout = %q, sessions open %d", a.Logout, f.loggedInNow()) + } + // Read-only: the only form posted is the login. + if len(f.posted) != 1 { + t.Errorf("forms posted: %v", f.posted) + } +} + +// 1453/AC3: with no page asked for, the status page is read. +func TestPrinterSettingsDefaultsToTheStatusPage(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + _, run := printerTools(t, f, "pw") + got, err := run(nil) + if err != nil { + t.Fatal(err) + } + if got.(map[string]any)["title"] != "Synthetic status" { + t.Errorf("answer = %v", got) + } +} + +// 1453/AC4: a wrong password is said as a failed login. +func TestPrinterSettingsSaysAFailedLogin(t *testing.T) { + f := newFakePrinter(t, "B1891", "right") + _, run := printerTools(t, f, "wrong") + if _, err := run(nil); err == nil || !strings.Contains(err.Error(), "login failed") { + t.Fatalf("error = %v", err) + } +} + +// 1453/AC4: a value or the secret not set is said by name, and the printer is not contacted at all. +func TestPrinterSettingsSaysWhatIsUnsetAndContactsNothing(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + secretOK := filepath.Join(t.TempDir(), "s") + os.WriteFile(secretOK, []byte("pw\n"), 0o600) + for _, c := range []struct{ name, settings, secret, want string }{ + {"no printer", `{"printer-certificate":"` + f.print() + `"}`, secretOK, "printer is not set"}, + {"no certificate", `{"printer":"` + f.host() + `"}`, secretOK, "printer-certificate is not set"}, + {"neither", `{}`, secretOK, "printer and printer-certificate"}, + {"no secret file", `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`, filepath.Join(t.TempDir(), "none"), "admin-password is not given"}, + {"empty secret", `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`, func() string { + p := filepath.Join(t.TempDir(), "e") + os.WriteFile(p, []byte("\n"), 0o600) + return p + }(), "admin-password is not given"}, + } { + t.Setenv("BROTHER_ADMIN_PASSWORD", c.secret) + tl := tools(settingsFile(t, c.settings), nil, time.Now) + _, err := tool(t, tl, "printer.settings").Run(nil) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: error = %v, want it to say %q", c.name, err, c.want) + } + } + if f.conns != 0 || len(f.requests) != 0 { + t.Errorf("the printer was contacted: %d connections, %v", f.conns, f.requests) + } +} + +// 1453: a page that is not a page of the admin site is refused before anything is sent. +func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + _, run := printerTools(t, f, "pw") + for _, p := range []string{"general/status.html", "/general/status", "//evil.example/x.html", "/a/../b.html", "/a.html?x=1", "/a b.html"} { + if _, err := run(map[string]any{"page": p}); err == nil || !strings.Contains(err.Error(), "not a page") { + t.Errorf("page %q: error = %v", p, err) + } + } + if _, err := run(map[string]any{"page": 5}); err == nil { + t.Errorf("a page that is no string was accepted") + } + if f.conns != 0 { + t.Errorf("the printer was contacted %d times", f.conns) + } +} diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index d625fa03..f29d26ed 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -23,6 +23,8 @@ type Service struct { Node string Now func() time.Time Emit Emitter + // PasswordFile is the file the module's own secret admin-password is written to. + PasswordFile string } func mm(pixels, dpi int) float64 { diff --git a/modules/brother/module.json b/modules/brother/module.json index 0f8e3a5d..91f4eb00 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -77,6 +77,13 @@ "status", "list" ] + }, + { + "name": "printer", + "scope": "mesh", + "serves": [ + "settings" + ] } ], "claims": [ @@ -88,9 +95,17 @@ "status", "list" ] + }, + { + "name": "printer", + "scope": "mesh", + "serves": [ + "settings" + ] } ], "tools": [ + "printer.settings", "scanner.scan", "scanner.status", "scanner.list" From 6782816707b56d88f673eae83b6b0117bc38745f Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:12:45 +0200 Subject: [PATCH 05/14] brother: the printer seat's status verb, IPP Get-Printer-Attributes by hand and the eSCL state, replayed from captures (issue 1453) --- modules/brother/cmd/brother/ipp.go | 236 ++++++++++++++++++ modules/brother/cmd/brother/ipp_test.go | 143 +++++++++++ modules/brother/cmd/brother/main.go | 16 ++ modules/brother/cmd/brother/manifest_test.go | 2 +- modules/brother/cmd/brother/printer.go | 35 +++ .../ipp/get-printer-attributes.request | Bin 261 -> 258 bytes modules/brother/module.json | 3 + 7 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 modules/brother/cmd/brother/ipp.go create mode 100644 modules/brother/cmd/brother/ipp_test.go diff --git a/modules/brother/cmd/brother/ipp.go b/modules/brother/cmd/brother/ipp.go new file mode 100644 index 00000000..306f6ad1 --- /dev/null +++ b/modules/brother/cmd/brother/ipp.go @@ -0,0 +1,236 @@ +package main + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// IPP (RFC 8010, RFC 8011) by hand: one operation, Get-Printer-Attributes, which needs no login. + +const ( + ippGetPrinterAttributes = 0x000B + ippOperation = 0x01 + ippEnd = 0x03 + ippInteger = 0x21 + ippEnum = 0x23 + ippKeyword = 0x44 + ippURItag = 0x45 + ippCharset = 0x47 + ippLanguage = 0x48 +) + +// statusAttributes are the attributes printer.status asks for. +var statusAttributes = []string{ + "printer-state", "printer-state-reasons", "marker-names", "marker-levels", "marker-colors", "printer-make-and-model", +} + +// ippURL is where a printer's IPP service answers: the port IPP is registered on, and the path the printer +// serves (CUPS shows ipp:///ipp/port1). A variable so a test can put a server of its own in its place. +var ippURL = func(host string) string { + h := host + if hh, _, err := net.SplitHostPort(host); err == nil { + h = hh + } + if strings.Contains(h, ":") { + h = "[" + h + "]" + } + return "http://" + h + ":631/ipp/port1" +} + +// ippPrinterURI is the printer-uri operation attribute for a host. +func ippPrinterURI(host string) string { + h := host + if hh, _, err := net.SplitHostPort(host); err == nil { + h = hh + } + return "ipp://" + h + "/ipp/port1" +} + +func appendAttr(b []byte, tag byte, name, value string) []byte { + b = append(b, tag) + b = binary.BigEndian.AppendUint16(b, uint16(len(name))) + b = append(b, name...) + b = binary.BigEndian.AppendUint16(b, uint16(len(value))) + return append(b, value...) +} + +// EncodeGetPrinterAttributes is the request: IPP 2.0, operation 0x000B, request id 1, the operation attributes +// RFC 8011 requires first (charset, natural language, printer-uri), and the attributes asked for. +func EncodeGetPrinterAttributes(printerURI string, requested []string) []byte { + b := []byte{0x02, 0x00, 0x00, byte(ippGetPrinterAttributes), 0, 0, 0, 1, ippOperation} + b = appendAttr(b, ippCharset, "attributes-charset", "utf-8") + b = appendAttr(b, ippLanguage, "attributes-natural-language", "en") + b = appendAttr(b, ippURItag, "printer-uri", printerURI) + for i, r := range requested { + name := "" + if i == 0 { + name = "requested-attributes" + } + b = appendAttr(b, ippKeyword, name, r) + } + return append(b, ippEnd) +} + +// IPPValue is one value of an attribute: an integer or enum, or text. +type IPPValue struct { + Tag byte + Integer int + Text string +} + +// IPPAnswer is a decoded response: its status code and its attributes by name, each with its values in order. +type IPPAnswer struct { + Status int + Attributes map[string][]IPPValue +} + +// DecodeIPP reads a response. It reads only what Get-Printer-Attributes answers hold: groups of attributes of +// integers, enums and strings, whose additional values have an empty name. +func DecodeIPP(b []byte) (IPPAnswer, error) { + a := IPPAnswer{Attributes: map[string][]IPPValue{}} + if len(b) < 9 { + return a, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b)) + } + a.Status = int(binary.BigEndian.Uint16(b[2:4])) + i := 8 + last := "" + for i < len(b) { + tag := b[i] + i++ + if tag == ippEnd { + return a, nil + } + if tag <= 0x05 { // a group's start + last = "" + continue + } + if i+2 > len(b) { + return a, fmt.Errorf("the IPP answer ends inside an attribute") + } + nl := int(binary.BigEndian.Uint16(b[i:])) + i += 2 + if i+nl+2 > len(b) { + return a, fmt.Errorf("the IPP answer ends inside an attribute name") + } + name := string(b[i : i+nl]) + i += nl + vl := int(binary.BigEndian.Uint16(b[i:])) + i += 2 + if i+vl > len(b) { + return a, fmt.Errorf("the IPP answer ends inside the value of %q", name) + } + raw := b[i : i+vl] + i += vl + if name == "" { + name = last + } + last = name + v := IPPValue{Tag: tag} + if (tag == ippInteger || tag == ippEnum) && vl == 4 { + v.Integer = int(int32(binary.BigEndian.Uint32(raw))) + } else { + v.Text = string(raw) + } + a.Attributes[name] = append(a.Attributes[name], v) + } + return a, fmt.Errorf("the IPP answer has no end-of-attributes tag") +} + +func (a IPPAnswer) texts(name string) []string { + var out []string + for _, v := range a.Attributes[name] { + out = append(out, v.Text) + } + return out +} + +// printerStates are the values RFC 8011 gives printer-state. +var printerStates = map[int]string{3: "idle", 4: "processing", 5: "stopped"} + +// Marker is one consumable of the printer: a toner, a drum, a belt. A level below 0 is the standard's way +// to say it is unknown (-1) or not reported (-2). +type Marker struct { + Name string `json:"name"` + Level int `json:"level"` + Color string `json:"color,omitempty"` +} + +// PrinterState is what printer.status reads over IPP. +type PrinterState struct { + State string `json:"state"` + StateCode int `json:"state-code"` + Reasons []string `json:"state-reasons"` + MakeAndModel string `json:"make-and-model"` + Markers []Marker `json:"markers"` +} + +// State reads the answer into the printer's state; an answer whose status is not a success is an error. +func (a IPPAnswer) State() (PrinterState, error) { + if a.Status >= 0x0100 { + return PrinterState{}, fmt.Errorf("the printer refused the IPP request with status 0x%04x", a.Status) + } + st := a.Attributes["printer-state"] + if len(st) == 0 { + return PrinterState{}, fmt.Errorf("the IPP answer holds no printer-state") + } + code := st[0].Integer + name, ok := printerStates[code] + if !ok { + name = fmt.Sprintf("unknown (%d)", code) + } + s := PrinterState{State: name, StateCode: code, Reasons: a.texts("printer-state-reasons"), Markers: []Marker{}} + if s.Reasons == nil { + s.Reasons = []string{} + } + if m := a.texts("printer-make-and-model"); len(m) > 0 { + s.MakeAndModel = m[0] + } + names, levels, colors := a.texts("marker-names"), a.Attributes["marker-levels"], a.texts("marker-colors") + for i, n := range names { + m := Marker{Name: n, Level: -1} + if i < len(levels) { + m.Level = levels[i].Integer + } + if i < len(colors) { + m.Color = colors[i] + } + s.Markers = append(s.Markers, m) + } + return s, nil +} + +// PrinterStateOf asks the printer for its state over IPP. The printer is asked on its IPP port, not through the +// pinned HTTPS client, and nothing secret is sent. +func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host), + bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), statusAttributes))) + if err != nil { + return PrinterState{}, err + } + req.Header.Set("Content-Type", "application/ipp") + c := &http.Client{Timeout: 15 * time.Second} + resp, err := c.Do(req) + if err != nil { + return PrinterState{}, fmt.Errorf("the printer's IPP service does not answer: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return PrinterState{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return PrinterState{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status) + } + ans, err := DecodeIPP(body) + if err != nil { + return PrinterState{}, err + } + return ans.State() +} diff --git a/modules/brother/cmd/brother/ipp_test.go b/modules/brother/cmd/brother/ipp_test.go new file mode 100644 index 00000000..0a25934f --- /dev/null +++ b/modules/brother/cmd/brother/ipp_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func ippFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "ipp", name)) + if err != nil { + t.Fatal(err) + } + return b +} + +// fakeIPP replays the captured Get-Printer-Attributes answer (testdata/ipp, 2026-10-20) and records what it +// was sent. It stands in for the printer's port 631: no test reaches a real printer. +func fakeIPP(t *testing.T, answer []byte) (url string, got *[]byte) { + t.Helper() + var sent []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sent, _ = io.ReadAll(r.Body) + if r.Header.Get("Content-Type") != "application/ipp" { + http.Error(w, "not ipp", http.StatusUnsupportedMediaType) + return + } + w.Header().Set("Content-Type", "application/ipp") + w.Write(answer) + })) + t.Cleanup(srv.Close) + return srv.URL, &sent +} + +// 1453/AC5: the request the module encodes is byte for byte the captured one the printer answered. +func TestTheIPPRequestIsTheCapturedOne(t *testing.T) { + got := EncodeGetPrinterAttributes(ippPrinterURI("192.0.2.10"), statusAttributes) + if want := ippFixture(t, "get-printer-attributes.request"); !bytes.Equal(got, want) { + t.Errorf("request =\n%x\nwant\n%x", got, want) + } +} + +// 1453/AC5: the captured IPP answer decodes to the printer's state, its reasons and its marker levels. +func TestTheCapturedIPPAnswerDecodes(t *testing.T) { + ans, err := DecodeIPP(ippFixture(t, "get-printer-attributes.answer")) + if err != nil { + t.Fatal(err) + } + st, err := ans.State() + if err != nil { + t.Fatal(err) + } + want := PrinterState{State: "idle", StateCode: 3, Reasons: []string{"toner-low-warning"}, + MakeAndModel: "Brother MFC-L8390CDW series", + Markers: []Marker{{"BK", 40, "#000000"}, {"C", 10, "#00FFFF"}, {"M", 0, "#FF00FF"}, {"Y", 100, "#FFFF00"}}} + if !reflect.DeepEqual(st, want) { + t.Errorf("state = %+v\nwant %+v", st, want) + } +} + +// 1453/AC5: an answer cut short, or one with a failing status, is an error and not a state. +func TestABrokenIPPAnswerIsAnError(t *testing.T) { + good := ippFixture(t, "get-printer-attributes.answer") + if _, err := DecodeIPP(good[:len(good)-20]); err == nil { + t.Errorf("a truncated answer decoded") + } + refused := append([]byte{}, good...) + refused[2], refused[3] = 0x04, 0x00 + ans, err := DecodeIPP(refused) + if err != nil { + t.Fatal(err) + } + if _, err := ans.State(); err == nil || !strings.Contains(err.Error(), "0x0400") { + t.Errorf("error = %v", err) + } +} + +// 1453/AC5: printer.status gives the IPP state and the eSCL state, replayed from the captures, and sends the +// captured request. +func TestPrinterStatusToolGivesTheIPPAndESCLState(t *testing.T) { + url, sent := fakeIPP(t, ippFixture(t, "get-printer-attributes.answer")) + old := ippURL + ippURL = func(string) string { return url } + t.Cleanup(func() { ippURL = old }) + f := newFakeScanner(t, nil) + tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), nil, time.Now) + got, err := tool(t, tl, "printer.status").Run(nil) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["answers"] != true || m["state"] != "idle" || m["make-and-model"] != "Brother MFC-L8390CDW series" { + t.Errorf("answer = %v", m) + } + if r := m["state-reasons"].([]string); !reflect.DeepEqual(r, []string{"toner-low-warning"}) { + t.Errorf("reasons = %v", r) + } + if mk := m["markers"].([]Marker); len(mk) != 4 || mk[0] != (Marker{"BK", 40, "#000000"}) { + t.Errorf("markers = %v", mk) + } + if sc := m["scanner"].(map[string]any); sc["answers"] != true || sc["state"] != "Idle" || sc["adf-state"] != "ScannerAdfEmpty" { + t.Errorf("scanner = %v", sc) + } + if !bytes.Equal(*sent, ippFixture(t, "get-printer-attributes.request")) { + t.Errorf("the printer was sent %x", *sent) + } +} + +// 1453/AC4 and AC5: a part that does not answer is answers false with why, the other part is still given; an +// unset printer or scanner is said by name and nothing is contacted. +func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) { + old := ippURL + ippURL = func(string) string { return "http://127.0.0.1:1/ipp/port1" } + t.Cleanup(func() { ippURL = old }) + f := newFakeScanner(t, nil) + tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), nil, time.Now) + got, err := tool(t, tl, "printer.status").Run(nil) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["answers"] != false || m["why"] == nil || m["scanner"].(map[string]any)["answers"] != true { + t.Errorf("answer = %v", m) + } + for _, c := range []struct{ js, want string }{ + {`{"scanner":"` + f.base() + `"}`, "printer is not set"}, + {`{"printer":"192.0.2.10"}`, "scanner is not set"}, + {`{}`, "printer and scanner"}, + } { + tl := tools(settingsFile(t, c.js), nil, time.Now) + if _, err := tool(t, tl, "printer.status").Run(nil); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: error = %v, want %q", c.js, err, c.want) + } + } +} diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 2f9ca11d..22084ddd 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -33,6 +33,22 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool PasswordFile: os.Getenv("BROTHER_ADMIN_PASSWORD")}, nil } return []stdio.Tool{ + { + Name: "printer.status", + Description: "The printer's state without a login: over IPP its state (idle, processing or stopped), its state " + + "reasons (such as toner-low-warning), its toner and other markers with their levels in percent (below 0 " + + "is unknown), and over eSCL the scanner's state and its document feeder's. A part that does not answer " + + "is said as answers false with why. Read-only.", + Run: func(map[string]any) (any, error) { + s, err := service() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return s.PrinterStatus(ctx) + }, + }, { Name: "printer.settings", Description: "Read one page of the printer's admin site, read-only: log in with the secret admin-password (the " + diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 06d3f6c4..9e22a14c 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -141,7 +141,7 @@ func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) { for _, want := range []seat{ {Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}}, // restart is round 2 of issue 1453, and is not declared yet. - {Name: "printer", Scope: "mesh", Serves: []string{"settings"}}, + {Name: "printer", Scope: "mesh", Serves: []string{"status", "settings"}}, } { for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} { found := false diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index b3330e58..89cc13ff 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -80,3 +80,38 @@ func nonNilForms(f []Form) []Form { } return f } + +// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login +// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and +// scanner is not set is said by name, and nothing is contacted. +func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) { + var unset []string + if s.Settings.Printer == "" { + unset = append(unset, "printer") + } + if s.Settings.Scanner == "" { + unset = append(unset, "scanner") + } + if len(unset) > 0 { + return nil, fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", + strings.Join(unset, " and ")) + } + out := map[string]any{} + if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil { + out["answers"] = false + out["why"] = err.Error() + } else { + out["answers"] = true + out["state"] = st.State + out["state-code"] = st.StateCode + out["state-reasons"] = st.Reasons + out["make-and-model"] = st.MakeAndModel + out["markers"] = st.Markers + } + if es, err := NewClient(s.Settings.Scanner).Status(ctx); err != nil { + out["scanner"] = map[string]any{"answers": false, "why": err.Error()} + } else { + out["scanner"] = map[string]any{"answers": true, "state": es.State, "adf-state": es.AdfState} + } + return out, nil +} diff --git a/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request b/modules/brother/cmd/brother/testdata/ipp/get-printer-attributes.request index f33e31f97bc90e1edb4299c3196ffe0eb4ce238f..58ed36b647747c0b44fb6cda4c4b7a9e314a7e72 100644 GIT binary patch delta 27 icmZo=YGRrYCMcCzP++C6Z)jF8iwAuG delta 30 lcmZo-YGs-bCL)_zP++C6Z)j Date: Tue, 20 Oct 2026 21:12:56 +0200 Subject: [PATCH 06/14] brother: README for the two seats, the pinned certificate, the admin password and the login (issue 1453) --- modules/brother/README.md | 48 ++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/modules/brother/README.md b/modules/brother/README.md index b4a3902a..2b2e661c 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -1,4 +1,11 @@ -# brother — scan a document, crop it, file it, say so +# brother — the Brother multifunction device: scan, printer state, admin pages + +One module for one device (novox/hq ADR 0467, issue 1453): the scanner and the printer are one machine on the +network, so one module serves both. It claims two seats, each held once for the mesh: `scanner` (verbs `scan`, +`status`, `list`) and `printer` (verbs `status`, `settings`; `restart` follows in a later round). It replaces the +`scan` module of issue 1440 (ADR 0464), which ran on no node and is gone from the catalogue. + +## The scanner seat Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF @@ -26,28 +33,54 @@ A scanner that refuses or gives no page files nothing and says nothing on the bu |---|---|---| | `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none | | `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none | +| `printer` | the printer's host, such as `192.0.2.10`, for its admin page and IPP (required, trusted) | none | +| `printer-certificate` | the SHA-256 fingerprint of the one certificate the admin page may serve, `AA:BB:...` (required, trusted) | none | | `resolution` | dpi asked: 100, 200, 300 or 600 | 300 | | `color-mode` | `color` or `gray` | `color` | | `crop` | cut the document out of the lid's white | true | The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes -from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every tool says which and -writes and asks nothing. +from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every scanner verb says which and +writes and asks nothing; `printer` and `printer-certificate` are the printer verbs' to say. The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code makes it (mode 0755) when it files the first scan. ## Tools -| tool | does | +| verb (tool) | does | |---|---| | `scanner.scan` | scans, crops, files and emits; answers the event's body | | `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | | `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only | +## The printer seat + +| verb (tool) | does | +|---|---| +| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` | +| `printer.settings` | logs in, reads one admin page, logs out. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else | + +### Trust and the password + +The admin page is HTTPS with a self-signed certificate. The module verifies no chain: it accepts the one certificate +whose SHA-256 fingerprint is `printer-certificate` and refuses any other, in the handshake, so no password is sent +to it; the error names both fingerprints. Get the fingerprint from the device's certificate page, and give it as 32 +upper-case hex pairs joined by colons. + +The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it +at their desk, the mesh never generates or rotates it, and it reaches the code as the file `BROTHER_ADMIN_PASSWORD` +names (`${dir:state}/admin-password.secret`). While it is not given, `printer.settings` says that the secret is not +given and contacts nothing. + +The login reads the form each time: the device generates the password field's name, so the code takes the name from +the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is +the login page again. The logout follows a logout link or form on the page; a page with none is said in the answer +(`logout`), and the session is left to expire on the device. + ## The event -`scan.added`, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body: +`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body: `path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it), `color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC). @@ -63,4 +96,7 @@ Each directory or archive this module declares says how it treats a file it did ## Tests The unit tests use the captured answers of a real scanner (`cmd/brother/testdata/escl`, captured 2026-10-20) served -by a test HTTP server; no test reaches a scanner. +by a test HTTP server, the captured IPP exchange (`testdata/ipp`) replayed, and the captured login page +(`testdata/admin/login-page.html`) served by a test TLS server with its own certificate. The pages behind the login +are synthetic (`testdata/admin/synthetic-*.html`) until round 2 captures the real ones. No test reaches the real +device. From 29107b0d934578bfd5c34fb97115a179092dda03 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:20:16 +0200 Subject: [PATCH 07/14] brother: the admin process alone reads the admin password and logs in; printer.settings queues an ask and reads its answer (issue 1453) --- .../cmd/brother/admin_boundary_test.go | 68 +++++ modules/brother/cmd/brother/adminproc.go | 195 +++++++++++++++ .../cmd/brother/internal/secret/secret.go | 30 +++ .../brother/internal/secret/secret_test.go | 27 ++ modules/brother/cmd/brother/main.go | 12 +- modules/brother/cmd/brother/printer.go | 155 ++++++++---- modules/brother/cmd/brother/printer_test.go | 234 +++++++++++++++--- modules/brother/cmd/brother/scan.go | 6 +- modules/brother/cmd/brother/settings.go | 19 -- modules/brother/go.mod | 9 + modules/brother/go.sum | 24 ++ 11 files changed, 668 insertions(+), 111 deletions(-) create mode 100644 modules/brother/cmd/brother/admin_boundary_test.go create mode 100644 modules/brother/cmd/brother/adminproc.go create mode 100644 modules/brother/cmd/brother/internal/secret/secret.go create mode 100644 modules/brother/cmd/brother/internal/secret/secret_test.go diff --git a/modules/brother/cmd/brother/admin_boundary_test.go b/modules/brother/cmd/brother/admin_boundary_test.go new file mode 100644 index 00000000..004f7afb --- /dev/null +++ b/modules/brother/cmd/brother/admin_boundary_test.go @@ -0,0 +1,68 @@ +package main + +// The tools never open the admin password (novox/hq ADR 0436 §12, ADR 0467 clause 4): only the admin process +// reads it, as its own account, brother-admin. The build seat runs every test under one account, so no +// permission test can show the split; this is the file-level proxy mesh-mail's secret_boundary_test.go uses +// instead: an AST check that the tools' files import no password-reading package and name no password file. + +import ( + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +const secretPackage = "brother/cmd/brother/internal/secret" + +// toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings' +// ask), as opposed to the admin process's: adminproc.go, admin.go, pin.go and html.go. +var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go"} + +// 1453: none of the tools' files imports the password-reading package or names the password's file. +func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) { + for _, file := range toolFiles { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("%s: %v", file, err) + } + for _, imp := range f.Imports { + if strings.Trim(imp.Path.Value, `"`) == secretPackage { + t.Errorf("%s imports %s, which only the admin process may (ADR 0436 §12)", file, secretPackage) + } + } + src, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(src), `Getenv("BROTHER_ADMIN_PASSWORD")`) { + t.Errorf("%s reads BROTHER_ADMIN_PASSWORD, the admin process's alone", file) + } + } +} + +// 1453: the admin process does read it through the package, and the package is the one place a password file is +// opened. +func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "adminproc.go", nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + found := false + for _, imp := range f.Imports { + if strings.Trim(imp.Path.Value, `"`) == secretPackage { + found = true + } + } + if !found { + t.Errorf("adminproc.go does not import %s", secretPackage) + } + for _, file := range append(append([]string{}, toolFiles...), "admin.go", "adminproc.go", "pin.go", "html.go") { + src, _ := os.ReadFile(file) + if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" { + t.Errorf("%s reads a file: only settings.json (settings.go) and the scan folder's PDFs are read outside the secret package", file) + } + } +} diff --git a/modules/brother/cmd/brother/adminproc.go b/modules/brother/cmd/brother/adminproc.go new file mode 100644 index 00000000..7e0d6178 --- /dev/null +++ b/modules/brother/cmd/brother/adminproc.go @@ -0,0 +1,195 @@ +package main + +// mesh brother admin (novox/hq ADR 0467 clause 4, in the shape of ADR 0436 §7): the admin process, a process +// with a bus account of its own (brother-admin), never a seat. It alone of the module's parts reads the admin +// password and logs in to the printer's admin page; the tools queue an ask on the work-queue stream printer-asks +// and read its answer in the state printer-answers. The admin password is never in an answer, an event or a log. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/process" + + "brother/cmd/brother/internal/secret" +) + +// Answers is the state the worker writes an answer in, once. +type Answers interface { + Create(key string, value any) (uint64, error) +} + +// action is what is done to the queued message. +type action int + +const ( + actionAck action = iota + actionRetry + actionTerm +) + +// adminWorker answers the asks. Settings and the password are read at each ask, so a changed config value or a +// password given later is the next ask's. +type adminWorker struct { + Settings func() (Settings, error) + Password func() (string, error) + Answers Answers + Now func() time.Time + Logf func(format string, a ...any) +} + +func (w *adminWorker) logf(format string, a ...any) { + if w.Logf != nil { + w.Logf(format, a...) + } +} + +// Handle answers one queued ask. An ask that cannot be read is terminated; an ask too old for anyone to wait +// for is acknowledged and the printer not contacted; every other outcome, a failure included, is written as the +// answer under the ask's id and the message acknowledged; only an answer that could not be written is +// redelivered (the bus's limit ends that). +func (w *adminWorker) Handle(body []byte) (action, time.Duration) { + var ask Ask + if err := json.Unmarshal(body, &ask); err != nil || ask.ID == "" { + w.logf("[brother] admin: a queued ask could not be read: %v", err) + return actionTerm, 0 + } + if w.Now().Sub(ask.At) > StaleAfter { + w.logf("[brother] admin: ask %s is %s old; nobody waits for it, the printer is not contacted", ask.ID, w.Now().Sub(ask.At).Round(time.Second)) + return actionAck, 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + var answer map[string]any + if page, err := w.read(ctx, ask.Page); err != nil { + answer = map[string]any{"error": err.Error()} + } else { + answer = page + } + if _, err := w.Answers.Create(ask.ID, answer); err != nil && !errors.Is(err, stdio.ErrChanged) { + w.logf("[brother] admin: the answer to %s could not be written: %v", ask.ID, err) + return actionRetry, 5 * time.Second + } + return actionAck, 0 +} + +// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are +// the only things it sends, and a password field's value is never in the answer. The printer is not contacted +// before the settings and the password are known to be given (1453/AC4). +func (w *adminWorker) read(ctx context.Context, page string) (map[string]any, error) { + if page == "" { + page = DefaultPage + } + if !validPage(page) { + return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page) + } + set, err := w.Settings() + if err != nil { + return nil, err + } + if err := set.MissingPrinter(); err != nil { + return nil, err + } + pw, err := w.Password() + if err != nil { + return nil, err + } + a, err := NewAdmin(set.Printer, set.PrinterCertificate, pw) + if err != nil { + return nil, err + } + if err := a.Login(ctx); err != nil { + return nil, err + } + // Log out with a context of its own: the ask's may be what ended this. + lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + body, at, err := a.Get(ctx, page) + if err != nil { + if home, hat, herr := a.Get(lctx, DefaultPage); herr == nil { + a.Logout(lctx, home, hat) + } + return nil, fmt.Errorf("page %s cannot be read: %w", page, err) + } + logout := a.Logout(lctx, body, at) + p := ParsePage(body) + return map[string]any{ + "page": page, + "title": p.Title, + "links": nonNil(p.Links), + "forms": nonNilForms(p.Forms), + "logout": logout, + }, nil +} + +func nonNil(l []Link) []Link { + if l == nil { + return []Link{} + } + return l +} + +func nonNilForms(f []Form) []Form { + if f == nil { + return []Form{} + } + return f +} + +// admin is `brother admin`: it reads its own bus account, connects, and consumes printer-asks until stopped. +// It refuses to start naming what is missing when the account cannot be read, and never logs a password. +func admin() int { + settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER") + if brokerFile == "" { + fmt.Fprintln(os.Stderr, "[brother] admin was started without BROTHER_BROKER, its bus account's file") + return 2 + } + account, err := process.ReadAccount(brokerFile) + if err != nil { + fmt.Fprintf(os.Stderr, "[brother] admin: %v\n", err) + return 1 + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + conn, err := process.Connect(account) + if err != nil { + fmt.Fprintf(os.Stderr, "[brother] admin: the bus cannot be reached: %v\n", err) + return 1 + } + defer conn.Close() + w := &adminWorker{ + Settings: func() (Settings, error) { return LoadSettings(settings) }, + Password: func() (string, error) { return secret.Read(passwordFile) }, + Answers: conn.State(AnswersState), + Now: time.Now, + Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, + } + fmt.Fprintf(os.Stderr, "[brother] admin is consuming %s as %s\n", AsksStream, account.User) + err = conn.Consume(ctx, AsksStream, func(msg *process.Message) { + act, delay := w.Handle(msg.Body) + var aerr error + switch act { + case actionAck: + aerr = msg.Ack() + case actionRetry: + aerr = msg.NakWithDelay(delay) + case actionTerm: + aerr = msg.Term() + } + if aerr != nil { + w.logf("[brother] admin: settling %s: %v", msg.Subject, aerr) + } + }) + if err != nil && ctx.Err() == nil { + fmt.Fprintf(os.Stderr, "[brother] admin stopped: %v\n", err) + return 1 + } + return 0 +} diff --git a/modules/brother/cmd/brother/internal/secret/secret.go b/modules/brother/cmd/brother/internal/secret/secret.go new file mode 100644 index 00000000..9e6d9e71 --- /dev/null +++ b/modules/brother/cmd/brother/internal/secret/secret.go @@ -0,0 +1,30 @@ +// Package secret reads the module's own secret admin-password (novox/hq ADR 0283, ADR 0467 clause 4). It is +// imported only by the admin process (adminproc.go), which runs as its own account, brother-admin, and alone +// can open the file: never by the tools, which the operator account's tool runner serves and which must never +// open a password file (novox/hq ADR 0436 §12). admin_boundary_test.go checks that. +package secret + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// Read is the password in the file at path. A password not given yet (no file, or an empty one) is an error +// that names the secret and says the operator gives it at the desk, and that nothing was contacted. +func Read(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code") + } + raw, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("the secret admin-password cannot be read: %w", err) + } + pw := strings.TrimRight(string(raw), "\r\n") + if pw == "" { + return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " + + "who gives it at their desk (ADR 0283)") + } + return pw, nil +} diff --git a/modules/brother/cmd/brother/internal/secret/secret_test.go b/modules/brother/cmd/brother/internal/secret/secret_test.go new file mode 100644 index 00000000..5ec7b2f6 --- /dev/null +++ b/modules/brother/cmd/brother/internal/secret/secret_test.go @@ -0,0 +1,27 @@ +package secret + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The password is the file's content without its trailing newline; no file or an empty one is "not given", +// said by the secret's name; the password itself is never in an error. +func TestReadGivesThePasswordOrSaysItIsNotGiven(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "admin-password.secret") + os.WriteFile(p, []byte("p4ss\n"), 0o600) + if pw, err := Read(p); err != nil || pw != "p4ss" { + t.Errorf("Read = %q, %v", pw, err) + } + for _, path := range []string{filepath.Join(dir, "none"), func() string { e := filepath.Join(dir, "e"); os.WriteFile(e, []byte("\r\n"), 0o600); return e }()} { + if _, err := Read(path); err == nil || !strings.Contains(err.Error(), "admin-password is not given") { + t.Errorf("Read(%s) error = %v", path, err) + } + } + if _, err := Read(""); err == nil || !strings.Contains(err.Error(), "BROTHER_ADMIN_PASSWORD") { + t.Errorf("Read(\"\") error = %v", err) + } +} diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 22084ddd..af076c4a 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -10,6 +10,9 @@ import ( ) func main() { + if len(os.Args) > 1 && os.Args[1] == "admin" { + os.Exit(admin()) + } if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), emit, time.Now)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -29,8 +32,8 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool if err != nil { return nil, err } - return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say, - PasswordFile: os.Getenv("BROTHER_ADMIN_PASSWORD")}, nil + return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say, Asks: askBus, + AnswerWait: answerWait, AnswerPoll: answerPoll}, nil } return []stdio.Tool{ { @@ -51,8 +54,9 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool }, { Name: "printer.settings", - Description: "Read one page of the printer's admin site, read-only: log in with the secret admin-password (the " + - "operator's; the module trusts the printer's pinned certificate alone), get the page, log out. Answers the " + + Description: "Read one page of the printer's admin site, read-only: the module's admin process logs in with the " + + "secret admin-password (the operator's; it trusts the printer's pinned certificate alone), gets the page and logs " + + "out; this tool asks it and waits up to a minute for its answer. Answers the " + "page's title, its links (href and text) and its forms with their fields (name, type, value, label); a " + "password field's value is never given. It sends nothing but the login and the logout, and writes no " + "setting of the printer.", diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index 89cc13ff..071ef18f 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -2,10 +2,15 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" + "encoding/json" "fmt" "regexp" "strings" "time" + + stdio "git.novox.be/novox/mesh-sdk/go" ) // pagePattern is a page of the admin site a caller may ask for: an absolute path under the site ending in @@ -15,70 +20,122 @@ var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`) // DefaultPage is the page printer.settings reads when none is asked for. const DefaultPage = "/general/status.html" -// admin is a session on the printer's admin page, once the settings and the secret say it may be opened. The -// printer is contacted by nothing before this has answered without an error (1453/AC4). -func (s *Service) admin() (*Admin, error) { - if err := s.Settings.MissingPrinter(); err != nil { - return nil, err - } - pw, err := AdminSecret(s.PasswordFile) - if err != nil { - return nil, err - } - return NewAdmin(s.Settings.Printer, s.Settings.PrinterCertificate, pw) +// The ask printer.settings puts to the admin process (ADR 0467 clause 4; the shape of ADR 0436's mail-sends): +// the tool never holds the admin password, so it queues the ask on the work-queue stream printer-asks and the +// admin process, an account of its own that alone reads the password, answers under the ask's id in the state +// printer-answers. +const ( + // EventAsked is the event on the work queue; the stream printer-asks carries it. + EventAsked = "printer.asked" + // AsksStream is the work-queue stream's name on the bus: the manifest's local name printer-asks, capitalised + // with its hyphen an underscore, as the controller composes a stream's own bus name. + AsksStream = "PRINTER_ASKS" + // AnswersState is the state the admin process writes its answers in. + AnswersState = "printer-answers" + // AdminProcessID is the admin process's id as module.json's resource "admin" names it: the controller + // delivers its bus account as the own secret broker-admin. + AdminProcessID = "admin" + // AnswerWait is how long printer.settings waits for the admin process. + AnswerWait = 60 * time.Second + // StaleAfter is how old an ask may be when the admin process takes it: older than twice the wait, nobody waits + // for it any more, so the printer is not contacted for it. + StaleAfter = 2 * AnswerWait +) + +// Ask is what the tool queues: which page, and when it was asked. +type Ask struct { + ID string `json:"id"` + Page string `json:"page"` + At time.Time `json:"asked-at"` } -// PrinterSettings logs in, reads one page of the admin site and logs out. It is read-only: the login (and the -// logout) are the only things it sends, and a password field's value is never in the answer (ADR 0467 clause 5). +// AskBus is how the tool reaches the admin process: it queues an ask and reads an answer back. +type AskBus interface { + Publish(id string, body any) error + Answer(id string) (json.RawMessage, error) +} + +type sdkAskBus struct{} + +func (sdkAskBus) Publish(id string, body any) error { + return stdio.Emit(EventAsked, body, stdio.EmitOptions{MessageID: id}) +} + +func (sdkAskBus) Answer(id string) (json.RawMessage, error) { + e, err := stdio.State(AnswersState).Get(id) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil +} + +// answerWait and answerPoll are what the tools wait with; a test shortens them. +var ( + answerWait = AnswerWait + answerPoll = 500 * time.Millisecond +) + +// askBus is the bus the tools use; a test puts a bus of its own in its place. +var askBus AskBus = sdkAskBus{} + +func newAskID() string { + b := make([]byte, 12) + rand.Read(b) + return hex.EncodeToString(b) +} + +// PrinterSettings asks the admin process to log in, read one page of the admin site and log out, and waits for +// its answer. It checks the page and that printer and printer-certificate are set, and reads, opens and sends +// nothing of the admin password (ADR 0467 clause 4; ADR 0436 §12): nothing is queued before those checks pass. func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) { if page == "" { page = DefaultPage } - if !pagePattern.MatchString(page) || strings.Contains(page, "..") || strings.Contains(page, "//") { + if !validPage(page) { return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page) } - a, err := s.admin() - if err != nil { + if err := s.Settings.MissingPrinter(); err != nil { return nil, err } - if err := a.Login(ctx); err != nil { - return nil, err + wait, poll := s.AnswerWait, s.AnswerPoll + if wait <= 0 { + wait = AnswerWait } - body, at, err := a.Get(ctx, page) - if err != nil { - // Log out with a context of its own: the caller's may be what ended this. - lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if home, hat, herr := a.Get(lctx, DefaultPage); herr == nil { - a.Logout(lctx, home, hat) - } - return nil, fmt.Errorf("page %s cannot be read: %w", page, err) + if poll <= 0 { + poll = 500 * time.Millisecond } - lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ask := Ask{ID: newAskID(), Page: page, At: s.Now().UTC()} + if err := s.Asks.Publish(ask.ID, ask); err != nil { + return nil, fmt.Errorf("the ask for the admin process could not be queued: %w", err) + } + ctx, cancel := context.WithTimeout(ctx, wait) defer cancel() - logout := a.Logout(lctx, body, at) - p := ParsePage(body) - return map[string]any{ - "page": page, - "title": p.Title, - "links": nonNil(p.Links), - "forms": nonNilForms(p.Forms), - "logout": logout, - }, nil + for { + raw, err := s.Asks.Answer(ask.ID) + if err != nil { + return nil, fmt.Errorf("the admin process's answers cannot be read: %w", err) + } + if raw != nil { + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("the admin process's answer is not JSON: %w", err) + } + if msg, bad := out["error"].(string); bad { + return nil, fmt.Errorf("%s", msg) + } + return out, nil + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("the admin process did not answer within %s: it may not be running, or the printer is slow (ask %s)", + wait, ask.ID) + case <-time.After(poll): + } + } } -func nonNil(l []Link) []Link { - if l == nil { - return []Link{} - } - return l -} - -func nonNilForms(f []Form) []Form { - if f == nil { - return []Form{} - } - return f +func validPage(page string) bool { + return pagePattern.MatchString(page) && !strings.Contains(page, "..") && !strings.Contains(page, "//") } // PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login diff --git a/modules/brother/cmd/brother/printer_test.go b/modules/brother/cmd/brother/printer_test.go index 35e3ff9c..cb1dabce 100644 --- a/modules/brother/cmd/brother/printer_test.go +++ b/modules/brother/cmd/brother/printer_test.go @@ -2,27 +2,121 @@ package main import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" + "sync" "testing" "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "brother/cmd/brother/internal/secret" ) -// printerTools is the tool set over a settings file for the fake printer, with the secret in a file the way the -// mesh writes it (a trailing newline included). -func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) { +// memAnswers is the state printer-answers, in memory. +type memAnswers struct { + mu sync.Mutex + m map[string]json.RawMessage + // fail, when set, is the error every Create answers. + fail error +} + +func (a *memAnswers) Create(key string, v any) (uint64, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.fail != nil { + return 0, a.fail + } + if _, ok := a.m[key]; ok { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + if a.m == nil { + a.m = map[string]json.RawMessage{} + } + raw, _ := json.Marshal(v) + a.m[key] = raw + return 1, nil +} + +func (a *memAnswers) get(key string) json.RawMessage { + a.mu.Lock() + defer a.mu.Unlock() + return a.m[key] +} + +// fakeAskBus is the bus between the tool and the admin process: an ask published on it is handed to the +// worker, as the work queue hands it to the admin process, and the worker's answers are what the tool reads. +type fakeAskBus struct { + worker *adminWorker // nil: nobody consumes the queue + answers *memAnswers + mu sync.Mutex + published []Ask +} + +func (b *fakeAskBus) Publish(id string, body any) error { + raw, _ := json.Marshal(body) + var ask Ask + json.Unmarshal(raw, &ask) + b.mu.Lock() + b.published = append(b.published, ask) + b.mu.Unlock() + if b.worker != nil { + go b.worker.Handle(raw) + } + return nil +} + +func (b *fakeAskBus) Answer(id string) (json.RawMessage, error) { return b.answers.get(id), nil } + +func (b *fakeAskBus) asks() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.published) +} + +// printerRig is the tool, the bus and the admin worker over a settings file for the fake printer, with the +// secret in a file the way the mesh writes it (a trailing newline included); an empty password writes none. +type printerRig struct { + run func(args map[string]any) (any, error) + bus *fakeAskBus + worker *adminWorker + answers *memAnswers +} + +func newRig(t *testing.T, settings, password string) *printerRig { t.Helper() - secret := filepath.Join(t.TempDir(), "admin-password.secret") + secretFile := filepath.Join(t.TempDir(), "admin-password.secret") if password != "" { - if err := os.WriteFile(secret, []byte(password+"\n"), 0o600); err != nil { + if err := os.WriteFile(secretFile, []byte(password+"\n"), 0o600); err != nil { t.Fatal(err) } } - t.Setenv("BROTHER_ADMIN_PASSWORD", secret) - js := `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}` - tl := tools(settingsFile(t, js), nil, time.Now) - return js, tool(t, tl, "printer.settings").Run + file := settingsFile(t, settings) + answers := &memAnswers{} + w := &adminWorker{ + Settings: func() (Settings, error) { return LoadSettings(file) }, + Password: func() (string, error) { return secret.Read(secretFile) }, + Answers: answers, + Now: time.Now, + } + bus := &fakeAskBus{worker: w, answers: answers} + old, oldWait, oldPoll := askBus, answerWait, answerPoll + askBus, answerWait, answerPoll = bus, 5*time.Second, 5*time.Millisecond + t.Cleanup(func() { askBus, answerWait, answerPoll = old, oldWait, oldPoll }) + tl := tools(file, nil, time.Now) + return &printerRig{run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers} +} + +func printerSettingsJSON(f *fakePrinter) string { + return `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}` +} + +func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) { + t.Helper() + js := printerSettingsJSON(f) + return js, newRig(t, js, password).run } // 1453/AC3: printer.settings logs in by the field name the form gives, sends the password from the secret @@ -114,47 +208,113 @@ func TestPrinterSettingsSaysAFailedLogin(t *testing.T) { } } -// 1453/AC4: a value or the secret not set is said by name, and the printer is not contacted at all. -func TestPrinterSettingsSaysWhatIsUnsetAndContactsNothing(t *testing.T) { +// 1453/AC4: printer or printer-certificate not set is said by name by the tool, nothing is queued and the +// printer is not contacted. +func TestPrinterSettingsSaysWhichConfigIsUnsetAndQueuesNothing(t *testing.T) { f := newFakePrinter(t, "B1891", "pw") - secretOK := filepath.Join(t.TempDir(), "s") - os.WriteFile(secretOK, []byte("pw\n"), 0o600) - for _, c := range []struct{ name, settings, secret, want string }{ - {"no printer", `{"printer-certificate":"` + f.print() + `"}`, secretOK, "printer is not set"}, - {"no certificate", `{"printer":"` + f.host() + `"}`, secretOK, "printer-certificate is not set"}, - {"neither", `{}`, secretOK, "printer and printer-certificate"}, - {"no secret file", `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`, filepath.Join(t.TempDir(), "none"), "admin-password is not given"}, - {"empty secret", `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}`, func() string { - p := filepath.Join(t.TempDir(), "e") - os.WriteFile(p, []byte("\n"), 0o600) - return p - }(), "admin-password is not given"}, + for _, c := range []struct{ name, settings, want string }{ + {"no printer", `{"printer-certificate":"` + f.print() + `"}`, "printer is not set"}, + {"no certificate", `{"printer":"` + f.host() + `"}`, "printer-certificate is not set"}, + {"neither", `{}`, "printer and printer-certificate"}, } { - t.Setenv("BROTHER_ADMIN_PASSWORD", c.secret) - tl := tools(settingsFile(t, c.settings), nil, time.Now) - _, err := tool(t, tl, "printer.settings").Run(nil) + r := newRig(t, c.settings, "pw") + _, err := r.run(nil) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: error = %v, want it to say %q", c.name, err, c.want) } + if r.bus.asks() != 0 { + t.Errorf("%s: an ask was queued", c.name) + } } if f.conns != 0 || len(f.requests) != 0 { t.Errorf("the printer was contacted: %d connections, %v", f.conns, f.requests) } } -// 1453: a page that is not a page of the admin site is refused before anything is sent. -func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) { +// 1453/AC4: the secret not given is said by name, by the admin process, and the printer is not contacted. +func TestPrinterSettingsSaysTheSecretIsNotGivenAndContactsNothing(t *testing.T) { f := newFakePrinter(t, "B1891", "pw") - _, run := printerTools(t, f, "pw") - for _, p := range []string{"general/status.html", "/general/status", "//evil.example/x.html", "/a/../b.html", "/a.html?x=1", "/a b.html"} { - if _, err := run(map[string]any{"page": p}); err == nil || !strings.Contains(err.Error(), "not a page") { - t.Errorf("page %q: error = %v", p, err) - } + _, run := printerTools(t, f, "") + if _, err := run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") { + t.Errorf("no secret file: error = %v", err) } - if _, err := run(map[string]any{"page": 5}); err == nil { - t.Errorf("a page that is no string was accepted") + // An empty file is not given either. + r := newRig(t, printerSettingsJSON(f), "") + r.worker.Password = func() (string, error) { + p := filepath.Join(t.TempDir(), "e") + os.WriteFile(p, []byte("\n"), 0o600) + return secret.Read(p) } - if f.conns != 0 { - t.Errorf("the printer was contacted %d times", f.conns) + if _, err := r.run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") { + t.Errorf("empty secret: error = %v", err) + } + if f.conns != 0 || len(f.requests) != 0 { + t.Errorf("the printer was contacted: %d connections, %v", f.conns, f.requests) + } +} + +// 1453: a page that is not a page of the admin site is refused before anything is queued, and by the admin +// process too if one reaches it. +func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + r := newRig(t, printerSettingsJSON(f), "pw") + for _, p := range []string{"general/status.html", "/general/status", "//evil.example/x.html", "/a/../b.html", "/a.html?x=1", "/a b.html"} { + if _, err := r.run(map[string]any{"page": p}); err == nil || !strings.Contains(err.Error(), "not a page") { + t.Errorf("page %q: error = %v", p, err) + } + if _, err := r.worker.read(t.Context(), p); err == nil || !strings.Contains(err.Error(), "not a page") { + t.Errorf("the admin process, page %q: error = %v", p, err) + } + } + if _, err := r.run(map[string]any{"page": 5}); err == nil { + t.Errorf("a page that is no string was accepted") + } + if r.bus.asks() != 0 || f.conns != 0 { + t.Errorf("%d asks queued, the printer contacted %d times", r.bus.asks(), f.conns) + } +} + +// 1453: nobody consuming the queue is said as the admin process not answering, naming the wait. +func TestPrinterSettingsSaysWhenTheAdminProcessDoesNotAnswer(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + r := newRig(t, printerSettingsJSON(f), "pw") + r.bus.worker = nil + answerWait = 100 * time.Millisecond + _, err := r.run(nil) + if err == nil || !strings.Contains(err.Error(), "admin process did not answer") { + t.Fatalf("error = %v", err) + } + if r.bus.asks() != 1 { + t.Errorf("%d asks queued", r.bus.asks()) + } +} + +// 1453: the admin process terminates an ask it cannot read, drops one nobody waits for any more without +// contacting the printer, acknowledges one already answered, and asks for redelivery when it cannot write its +// answer. +func TestTheAdminWorkerSettlesEachAsk(t *testing.T) { + f := newFakePrinter(t, "B1891", "pw") + r := newRig(t, printerSettingsJSON(f), "pw") + w := r.worker + if act, _ := w.Handle([]byte("not json")); act != actionTerm { + t.Errorf("an unreadable ask: action %v, want term", act) + } + old, _ := json.Marshal(Ask{ID: "old", Page: DefaultPage, At: time.Now().Add(-StaleAfter - time.Second)}) + if act, _ := w.Handle(old); act != actionAck || r.answers.get("old") != nil || f.conns != 0 { + t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.conns) + } + fresh, _ := json.Marshal(Ask{ID: "a1", Page: DefaultPage, At: time.Now()}) + if act, _ := w.Handle(fresh); act != actionAck || r.answers.get("a1") == nil { + t.Errorf("a fresh ask: action %v", act) + } + conns := f.conns + if act, _ := w.Handle(fresh); act != actionAck { + t.Errorf("an ask already answered: action %v, want ack", act) + } + _ = conns + r.answers.fail = fmt.Errorf("the bus is away") + again, _ := json.Marshal(Ask{ID: "a2", Page: DefaultPage, At: time.Now()}) + if act, delay := w.Handle(again); act != actionRetry || delay <= 0 { + t.Errorf("an answer that cannot be written: action %v delay %v, want retry", act, delay) } } diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index f29d26ed..8c1bbc6b 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -23,8 +23,10 @@ type Service struct { Node string Now func() time.Time Emit Emitter - // PasswordFile is the file the module's own secret admin-password is written to. - PasswordFile string + // Asks is how printer.settings reaches the admin process, which alone logs in to the printer. + Asks AskBus + // AnswerWait is how long printer.settings waits for the admin process's answer; AnswerPoll how often it looks. + AnswerWait, AnswerPoll time.Duration } func mm(pixels, dpi int) float64 { diff --git a/modules/brother/cmd/brother/settings.go b/modules/brother/cmd/brother/settings.go index a643b500..bbaad577 100644 --- a/modules/brother/cmd/brother/settings.go +++ b/modules/brother/cmd/brother/settings.go @@ -102,22 +102,3 @@ func (s Settings) MissingPrinter() error { return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", strings.Join(unset, " and ")) } - -// AdminSecret is the module's own secret admin-password: the device's admin password, given by the operator -// at the desk and never generated or rotated by the mesh (ADR 0467 clause 4). path is the file the mesh -// writes it to. A secret not yet given is said by name, and the device is not contacted. -func AdminSecret(path string) (string, error) { - if path == "" { - return "", fmt.Errorf("BROTHER_ADMIN_PASSWORD names no file: the module's secret admin-password does not reach the code") - } - raw, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return "", fmt.Errorf("the secret admin-password cannot be read: %w", err) - } - pw := strings.TrimRight(string(raw), "\r\n") - if pw == "" { - return "", fmt.Errorf("the secret admin-password is not given: the printer's admin password waits for the operator, " + - "who gives it at their desk (ADR 0283)") - } - return pw, nil -} diff --git a/modules/brother/go.mod b/modules/brother/go.mod index 18d9b8c4..34ac1eb4 100644 --- a/modules/brother/go.mod +++ b/modules/brother/go.mod @@ -3,3 +3,12 @@ module brother go 1.26.0 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef + +require ( + github.com/klauspost/compress v1.20.0 // indirect + github.com/nats-io/nats.go v1.54.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/modules/brother/go.sum b/modules/brother/go.sum index 8a563497..a53f2835 100644 --- a/modules/brother/go.sum +++ b/modules/brother/go.sum @@ -1,2 +1,26 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= From adfb61c053a1f55515bed018bc4e00e0ebabaaab Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:21:15 +0200 Subject: [PATCH 08/14] brother: the admin account, its owned secrets, the process and the printer-asks stream declared as mesh-mail's sender is; an integration test and the README (issue 1453) --- modules/brother/README.md | 21 ++- .../cmd/brother/admin_integration_test.go | 130 ++++++++++++++++++ modules/brother/cmd/brother/adminproc.go | 32 +++-- modules/brother/cmd/brother/manifest_test.go | 121 ++++++++++++++-- modules/brother/go.mod | 12 +- modules/brother/go.sum | 1 + modules/brother/module.json | 69 +++++++++- 7 files changed, 351 insertions(+), 35 deletions(-) create mode 100644 modules/brother/cmd/brother/admin_integration_test.go diff --git a/modules/brother/README.md b/modules/brother/README.md index 2b2e661c..379c893e 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -59,7 +59,7 @@ makes it (mode 0755) when it files the first scan. | verb (tool) | does | |---|---| | `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` | -| `printer.settings` | logs in, reads one admin page, logs out. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else | +| `printer.settings` | asks the admin process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else | ### Trust and the password @@ -69,9 +69,22 @@ to it; the error names both fingerprints. Get the fingerprint from the device's upper-case hex pairs joined by colons. The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it -at their desk, the mesh never generates or rotates it, and it reaches the code as the file `BROTHER_ADMIN_PASSWORD` -names (`${dir:state}/admin-password.secret`). While it is not given, `printer.settings` says that the secret is not -given and contacts nothing. +at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-admin`, which the +module makes, because the tools run as the operator account and must never read it (ADR 0436 §1, §12; the shape of +mesh-mail's sender). The file is `${dir:state}/admin-password.secret`, mode 0600, and `BROTHER_ADMIN_PASSWORD` names +it for the admin process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that +no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret +is not given, and the printer is not contacted. + +### The admin process + +`brother admin` runs as `brother-admin` with a bus account of its own (the own secret `broker-admin`), never a +seat. It consumes the work-queue stream `printer-asks` (event `printer.asked`, body `id`, `page`, `asked-at`) and +writes each answer, once, into the state `printer-answers` under the ask's id (ten minutes' life): the page's +title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the +page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three +deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can +reach its own files; both are the choices that let its account read what it needs. Its health is its unit. The login reads the form each time: the device generates the password field's name, so the code takes the name from the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is diff --git a/modules/brother/cmd/brother/admin_integration_test.go b/modules/brother/cmd/brother/admin_integration_test.go new file mode 100644 index 00000000..a9706a0a --- /dev/null +++ b/modules/brother/cmd/brother/admin_integration_test.go @@ -0,0 +1,130 @@ +//go:build integration + +package main + +// The admin process over a real bus (novox/hq ADR 0467 clause 4; the shape of mesh-mail's +// sender_integration_test.go, ADR 0436 §7): a JetStream server of the test's own, the work-queue stream +// printer-asks and its consumer, the state bucket printer-answers, and a fake printer. An ask published on the +// stream is answered in the state under its id and acknowledged. Compiled by the merge gate's +// `go vet -tags integration`; never run on this machine. + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/process" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "brother/cmd/brother/internal/secret" +) + +// printerAskedSubject is the wire subject the controller composes for the stream's one event: +// "mesh.stream." + module + ".event." + event (mesh-controller internal/broker/modstreams.go). +const printerAskedSubject = "mesh.stream.brother.event." + EventAsked + +func newTestBus(t *testing.T) string { + t.Helper() + opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), + NoLog: true, NoSigs: true} + s, err := server.NewServer(opts) + if err != nil { + t.Fatalf("a bus for this test could not be made: %v", err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + s.Shutdown() + t.Fatal("a bus for this test did not come up within 30s") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + return s.ClientURL() +} + +// 1453: an ask on printer-asks is answered under its id in printer-answers by the admin process, over the +// printer's pinned HTTPS and the login, and acknowledged; stopping it leaves nothing in hand. +func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) { + url := newTestBus(t) + ctx := context.Background() + nc, err := nats.Connect(url) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: AsksStream, Subjects: []string{printerAskedSubject}, + Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil { + t.Fatal(err) + } + account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + AdminProcessID, Password: "pw"} + if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(), + AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil { + t.Fatal(err) + } + kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: account.Module + "_" + AnswersState, TTL: 10 * time.Minute}) + if err != nil { + t.Fatal(err) + } + + f := newFakePrinter(t, "B1891", "pw") + dir := t.TempDir() + settings := filepath.Join(dir, "settings.json") + os.WriteFile(settings, []byte(`{"printer":"`+f.host()+`","printer-certificate":"`+f.print()+`"}`), 0o600) + pwFile := filepath.Join(dir, "admin-password.secret") + os.WriteFile(pwFile, []byte("pw\n"), 0o600) + w := &adminWorker{ + Settings: func() (Settings, error) { return LoadSettings(settings) }, + Password: func() (string, error) { return secret.Read(pwFile) }, + Now: time.Now, + } + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- runAdmin(runCtx, account, w) }() + + body, _ := json.Marshal(Ask{ID: "ask-1", Page: "/general/other.html", At: time.Now()}) + if _, err := js.Publish(ctx, printerAskedSubject, body, jetstream.WithMsgID("ask-1")); err != nil { + t.Fatal(err) + } + var answer string + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if e, err := kv.Get(ctx, "ask-1"); err == nil { + answer = string(e.Value()) + break + } + } + if !strings.Contains(answer, "Synthetic settings") { + t.Fatalf("the answer under ask-1 is %q", answer) + } + if strings.Contains(answer, "shouldnotshow") { + t.Errorf("a password field's value is in the answer") + } + time.Sleep(500 * time.Millisecond) + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("runAdmin returned nil, want ctx.Err() once its context was cancelled") + } + case <-time.After(10 * time.Second): + t.Fatal("runAdmin did not stop once its context was cancelled") + } + cons, err := js.Consumer(ctx, AsksStream, account.QueueConsumerName()) + if err != nil { + t.Fatal(err) + } + info, err := cons.Info(ctx) + if err != nil { + t.Fatal(err) + } + if info.NumPending != 0 || info.NumAckPending != 0 { + t.Errorf("the ask was not acknowledged: %d pending, %d awaiting an acknowledgement", info.NumPending, info.NumAckPending) + } +} diff --git a/modules/brother/cmd/brother/adminproc.go b/modules/brother/cmd/brother/adminproc.go index 7e0d6178..e758e9bc 100644 --- a/modules/brother/cmd/brother/adminproc.go +++ b/modules/brother/cmd/brother/adminproc.go @@ -158,21 +158,32 @@ func admin() int { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - conn, err := process.Connect(account) - if err != nil { - fmt.Fprintf(os.Stderr, "[brother] admin: the bus cannot be reached: %v\n", err) - return 1 - } - defer conn.Close() w := &adminWorker{ Settings: func() (Settings, error) { return LoadSettings(settings) }, Password: func() (string, error) { return secret.Read(passwordFile) }, - Answers: conn.State(AnswersState), Now: time.Now, Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, } fmt.Fprintf(os.Stderr, "[brother] admin is consuming %s as %s\n", AsksStream, account.User) - err = conn.Consume(ctx, AsksStream, func(msg *process.Message) { + err = runAdmin(ctx, account, w) + if err != nil && ctx.Err() == nil { + fmt.Fprintf(os.Stderr, "[brother] admin stopped: %v\n", err) + return 1 + } + return 0 +} + +// runAdmin connects as the admin process's own account and consumes printer-asks until ctx ends, answering each +// ask in the state printer-answers on that connection and then settling the message as Handle says. Apart from +// admin so a test can drive it over a bus of its own. +func runAdmin(ctx context.Context, account process.Account, w *adminWorker) error { + conn, err := process.Connect(account) + if err != nil { + return fmt.Errorf("the bus cannot be reached: %w", err) + } + defer conn.Close() + w.Answers = conn.State(AnswersState) + return conn.Consume(ctx, AsksStream, func(msg *process.Message) { act, delay := w.Handle(msg.Body) var aerr error switch act { @@ -187,9 +198,4 @@ func admin() int { w.logf("[brother] admin: settling %s: %v", msg.Subject, aerr) } }) - if err != nil && ctx.Err() == nil { - fmt.Fprintf(os.Stderr, "[brother] admin stopped: %v\n", err) - return 1 - } - return 0 } diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 9e22a14c..32f631e2 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -177,9 +177,23 @@ func TestTheScanModuleIsGone(t *testing.T) { } } -// 1453/AC4 (ADR 0467 clause 4): the admin password is the module's own secret, issued by the operator (never -// generated), and reaches the code as the file BROTHER_ADMIN_PASSWORD names. -func TestTheAdminPasswordIsTheOperatorsOwnSecret(t *testing.T) { +// resource is the manifest's resource of this id. +func resource(t *testing.T, m manifest, id string) map[string]any { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// 1453/AC4 (ADR 0467 clause 4, ADR 0436 §1): the admin password is the module's own secret, issued by the +// operator (never generated), and owned by an account the module makes, brother-admin: the tools' runner +// (the operator account) cannot read it, the admin process (that account) can. The process's bus account is +// the own secret broker-admin, owned by the same account. +func TestTheAdminPasswordIsTheOperatorsOwnSecretOwnedByTheAdminAccount(t *testing.T) { m := readManifest(t) s, ok := m.OwnSecrets["admin-password"] if !ok { @@ -188,14 +202,99 @@ func TestTheAdminPasswordIsTheOperatorsOwnSecret(t *testing.T) { if s["issued-by"] != "outside" { t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"]) } - path, _ := s["path"].(string) - var env string - for _, a := range m.Build.Artifacts { - if a["kind"] == "bundle" { - env, _ = a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"].(string) - } + if s["owner"] != "brother-admin" { + t.Errorf("owner = %v, want brother-admin (never root or ${machine:account}: ADR 0436 §1)", s["owner"]) } - if path == "" || env != path { - t.Errorf("the secret's path %q and BROTHER_ADMIN_PASSWORD %q differ", path, env) + if p, _ := s["prompt"].(string); p != "the admin password of the Brother printer" { + t.Errorf("prompt = %q", p) + } + if b := m.OwnSecrets["broker-admin"]; b["owner"] != "brother-admin" || b["path"] != "${dir:state}/broker-admin.secret" { + t.Errorf("broker-admin = %v", b) + } + acct := resource(t, m, "account") + if acct["type"] != "user" || acct["name"] != "brother-admin" || acct["shell"] != "/usr/bin/nologin" { + t.Errorf("account = %v", acct) + } +} + +// 1453 (ADR 0467 clause 4): the admin process runs as brother-admin with the bus access of ADR 0436 §7: it +// consumes the work queue printer-asks and writes the state printer-answers, and is judged by its unit. The +// tools' bundle is given no password file. +func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { + m := readManifest(t) + p := resource(t, m, "admin") + if p["type"] != "process" || p["name"] != "brother-admin" || p["user"] != "brother-admin" || p["artifact"] != "tools" { + t.Errorf("process = %v", p) + } + if !reflect.DeepEqual(p["run"], []any{"./brother", "admin"}) { + t.Errorf("run = %v", p["run"]) + } + bus, _ := p["bus"].(map[string]any) + if !reflect.DeepEqual(bus["queues"], []any{"printer-asks"}) || !reflect.DeepEqual(bus["state"], []any{"printer-answers"}) { + t.Errorf("bus = %v", bus) + } + if h, _ := p["health"].(map[string]any); h["kind"] != "unit" { + t.Errorf("health = %v", p["health"]) + } + env, _ := p["env"].(map[string]any) + want := map[string]any{ + "BROTHER_SETTINGS": "${dir:state}/settings.json", + "BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"], + "BROTHER_BROKER": m.OwnSecrets["broker-admin"]["path"], + } + if !reflect.DeepEqual(env, want) { + t.Errorf("env = %v, want %v", env, want) + } + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + if _, has := a["env"].(map[string]any)["BROTHER_ADMIN_PASSWORD"]; has { + t.Errorf("the tools' bundle is given the password file") + } + } + } + // The admin account reads the settings file and walks the state directory; neither holds a secret. + if r := resource(t, m, "settings"); r["mode"] != "0644" { + t.Errorf("settings mode = %v: the admin account must read it", r["mode"]) + } + if r := resource(t, m, "state"); r["mode"] != "0711" { + t.Errorf("state mode = %v: the admin account must reach its files in it", r["mode"]) + } + for _, r := range m.Resources { + if r["id"] != "admin" { + if r["takes-config"] != nil { + for _, k := range r["takes-config"].([]any) { + if k == "admin-password" { + t.Errorf("the password is a config value") + } + } + } + } + } +} + +// 1453 (ADR 0467 clause 4): the stream printer-asks is a work queue like mesh-mail's mail-sends, and the +// answers are a state that expires. +func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) { + raw, _ := os.ReadFile(filepath.Join("..", "..", "module.json")) + var m struct { + Streams []map[string]any `json:"streams"` + State []any `json:"state"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if len(m.Streams) != 1 { + t.Fatalf("streams = %v", m.Streams) + } + st := m.Streams[0] + if st["name"] != "printer-asks" || st["retention"] != "work-queue" || st["max-deliveries"] != float64(3) || + !reflect.DeepEqual(st["events"], []any{EventAsked}) { + t.Errorf("stream = %v", st) + } + if st["max-age-days"] == nil || st["max-mib"] == nil { + t.Errorf("the stream is bounded: %v", st) + } + if len(m.State) != 1 || m.State[0].(map[string]any)["name"] != AnswersState || m.State[0].(map[string]any)["ttl-seconds"] == nil { + t.Errorf("state = %v", m.State) } } diff --git a/modules/brother/go.mod b/modules/brother/go.mod index 34ac1eb4..4c4a0e28 100644 --- a/modules/brother/go.mod +++ b/modules/brother/go.mod @@ -2,13 +2,21 @@ module brother go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.54.0 +) require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.0 // indirect - github.com/nats-io/nats.go v1.54.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/modules/brother/go.sum b/modules/brother/go.sum index a53f2835..628b153b 100644 --- a/modules/brother/go.sum +++ b/modules/brother/go.sum @@ -20,6 +20,7 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= diff --git a/modules/brother/module.json b/modules/brother/module.json index cf5dfb2d..8b35310b 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -65,9 +65,33 @@ "own-secrets": { "admin-password": { "path": "${dir:state}/admin-password.secret", - "issued-by": "outside" + "issued-by": "outside", + "prompt": "the admin password of the Brother printer", + "owner": "brother-admin" + }, + "broker-admin": { + "path": "${dir:state}/broker-admin.secret", + "owner": "brother-admin" } }, + "streams": [ + { + "name": "printer-asks", + "events": [ + "printer.asked" + ], + "max-age-days": 1, + "max-mib": 8, + "retention": "work-queue", + "max-deliveries": 3 + } + ], + "state": [ + { + "name": "printer-answers", + "ttl-seconds": 600 + } + ], "seats": [ { "name": "scanner", @@ -114,10 +138,16 @@ "scanner.list" ], "resources": [ + { + "id": "account", + "type": "user", + "name": "brother-admin", + "shell": "/usr/bin/nologin" + }, { "id": "state", "type": "directory", - "mode": "0700", + "mode": "0711", "owner": "${machine:account}", "place": ".", "unknown": "program" @@ -127,7 +157,7 @@ "type": "file", "path": "${dir:state}/settings.json", "owner": "${machine:account}", - "mode": "0600", + "mode": "0644", "takes-config": [ "scanner", "scan-folder", @@ -139,6 +169,36 @@ ], "merge": "json", "template": "files/settings.json" + }, + { + "id": "admin", + "type": "process", + "name": "brother-admin", + "artifact": "tools", + "run": [ + "./brother", + "admin" + ], + "user": "brother-admin", + "env": { + "BROTHER_SETTINGS": "${dir:state}/settings.json", + "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret", + "BROTHER_BROKER": "${dir:state}/broker-admin.secret" + }, + "restart-on": [ + "settings" + ], + "bus": { + "queues": [ + "printer-asks" + ], + "state": [ + "printer-answers" + ] + }, + "health": { + "kind": "unit" + } } ], "build": { @@ -154,8 +214,7 @@ "brother" ], "env": { - "BROTHER_SETTINGS": "${dir:state}/settings.json", - "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret" + "BROTHER_SETTINGS": "${dir:state}/settings.json" } }, { From feada9fb149847e7ca1463dc7fd1141d0ce63542 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:23:28 +0200 Subject: [PATCH 09/14] brother: the admin process becomes the device process, brother-device, with device-asks and device-answers (issue 1453, ADR 0468 clause 1) --- modules/brother/README.md | 14 +++--- ...undary_test.go => device_boundary_test.go} | 18 +++---- ...ion_test.go => device_integration_test.go} | 16 +++---- .../brother/{adminproc.go => deviceproc.go} | 48 +++++++++---------- modules/brother/cmd/brother/main.go | 6 +-- modules/brother/cmd/brother/manifest_test.go | 36 +++++++------- modules/brother/cmd/brother/printer.go | 42 ++++++++-------- modules/brother/cmd/brother/printer_test.go | 22 ++++----- modules/brother/cmd/brother/scan.go | 4 +- modules/brother/module.json | 30 ++++++------ 10 files changed, 118 insertions(+), 118 deletions(-) rename modules/brother/cmd/brother/{admin_boundary_test.go => device_boundary_test.go} (72%) rename modules/brother/cmd/brother/{admin_integration_test.go => device_integration_test.go} (87%) rename modules/brother/cmd/brother/{adminproc.go => deviceproc.go} (70%) diff --git a/modules/brother/README.md b/modules/brother/README.md index 379c893e..c70de68f 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -59,7 +59,7 @@ makes it (mode 0755) when it files the first scan. | verb (tool) | does | |---|---| | `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` | -| `printer.settings` | asks the admin process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else | +| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else | ### Trust and the password @@ -69,18 +69,18 @@ to it; the error names both fingerprints. Get the fingerprint from the device's upper-case hex pairs joined by colons. The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it -at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-admin`, which the +at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-device`, which the module makes, because the tools run as the operator account and must never read it (ADR 0436 §1, §12; the shape of mesh-mail's sender). The file is `${dir:state}/admin-password.secret`, mode 0600, and `BROTHER_ADMIN_PASSWORD` names -it for the admin process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that +it for the device process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret is not given, and the printer is not contacted. -### The admin process +### The device process -`brother admin` runs as `brother-admin` with a bus account of its own (the own secret `broker-admin`), never a -seat. It consumes the work-queue stream `printer-asks` (event `printer.asked`, body `id`, `page`, `asked-at`) and -writes each answer, once, into the state `printer-answers` under the ask's id (ten minutes' life): the page's +`brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a +seat. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and +writes each answer, once, into the state `device-answers` under the ask's id (ten minutes' life): the page's title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can diff --git a/modules/brother/cmd/brother/admin_boundary_test.go b/modules/brother/cmd/brother/device_boundary_test.go similarity index 72% rename from modules/brother/cmd/brother/admin_boundary_test.go rename to modules/brother/cmd/brother/device_boundary_test.go index 004f7afb..ad06cc1a 100644 --- a/modules/brother/cmd/brother/admin_boundary_test.go +++ b/modules/brother/cmd/brother/device_boundary_test.go @@ -1,7 +1,7 @@ package main -// The tools never open the admin password (novox/hq ADR 0436 §12, ADR 0467 clause 4): only the admin process -// reads it, as its own account, brother-admin. The build seat runs every test under one account, so no +// The tools never open the admin password (novox/hq ADR 0436 §12, ADR 0467 clause 4): only the device process +// reads it, as its own account, brother-device. The build seat runs every test under one account, so no // permission test can show the split; this is the file-level proxy mesh-mail's secret_boundary_test.go uses // instead: an AST check that the tools' files import no password-reading package and name no password file. @@ -16,7 +16,7 @@ import ( const secretPackage = "brother/cmd/brother/internal/secret" // toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings' -// ask), as opposed to the admin process's: adminproc.go, admin.go, pin.go and html.go. +// ask), as opposed to the device process's: deviceproc.go, admin.go, pin.go and html.go. var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go"} // 1453: none of the tools' files imports the password-reading package or names the password's file. @@ -29,7 +29,7 @@ func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) { } for _, imp := range f.Imports { if strings.Trim(imp.Path.Value, `"`) == secretPackage { - t.Errorf("%s imports %s, which only the admin process may (ADR 0436 §12)", file, secretPackage) + t.Errorf("%s imports %s, which only the device process may (ADR 0436 §12)", file, secretPackage) } } src, err := os.ReadFile(file) @@ -37,16 +37,16 @@ func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) { t.Fatal(err) } if strings.Contains(string(src), `Getenv("BROTHER_ADMIN_PASSWORD")`) { - t.Errorf("%s reads BROTHER_ADMIN_PASSWORD, the admin process's alone", file) + t.Errorf("%s reads BROTHER_ADMIN_PASSWORD, the device process's alone", file) } } } -// 1453: the admin process does read it through the package, and the package is the one place a password file is +// 1453: the device process does read it through the package, and the package is the one place a password file is // opened. func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) { fset := token.NewFileSet() - f, err := parser.ParseFile(fset, "adminproc.go", nil, parser.ImportsOnly) + f, err := parser.ParseFile(fset, "deviceproc.go", nil, parser.ImportsOnly) if err != nil { t.Fatal(err) } @@ -57,9 +57,9 @@ func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) { } } if !found { - t.Errorf("adminproc.go does not import %s", secretPackage) + t.Errorf("deviceproc.go does not import %s", secretPackage) } - for _, file := range append(append([]string{}, toolFiles...), "admin.go", "adminproc.go", "pin.go", "html.go") { + for _, file := range append(append([]string{}, toolFiles...), "admin.go", "deviceproc.go", "pin.go", "html.go") { src, _ := os.ReadFile(file) if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" { t.Errorf("%s reads a file: only settings.json (settings.go) and the scan folder's PDFs are read outside the secret package", file) diff --git a/modules/brother/cmd/brother/admin_integration_test.go b/modules/brother/cmd/brother/device_integration_test.go similarity index 87% rename from modules/brother/cmd/brother/admin_integration_test.go rename to modules/brother/cmd/brother/device_integration_test.go index a9706a0a..0237a162 100644 --- a/modules/brother/cmd/brother/admin_integration_test.go +++ b/modules/brother/cmd/brother/device_integration_test.go @@ -2,9 +2,9 @@ package main -// The admin process over a real bus (novox/hq ADR 0467 clause 4; the shape of mesh-mail's +// The device process over a real bus (novox/hq ADR 0467 clause 4; the shape of mesh-mail's // sender_integration_test.go, ADR 0436 §7): a JetStream server of the test's own, the work-queue stream -// printer-asks and its consumer, the state bucket printer-answers, and a fake printer. An ask published on the +// device-asks and its consumer, the state bucket device-answers, and a fake printer. An ask published on the // stream is answered in the state under its id and acknowledged. Compiled by the merge gate's // `go vet -tags integration`; never run on this machine. @@ -46,7 +46,7 @@ func newTestBus(t *testing.T) string { return s.ClientURL() } -// 1453: an ask on printer-asks is answered under its id in printer-answers by the admin process, over the +// 1453: an ask on device-asks is answered under its id in device-answers by the device process, over the // printer's pinned HTTPS and the login, and acknowledged; stopping it leaves nothing in hand. func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) { url := newTestBus(t) @@ -64,7 +64,7 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) { Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil { t.Fatal(err) } - account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + AdminProcessID, Password: "pw"} + account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"} if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(), AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil { t.Fatal(err) @@ -80,14 +80,14 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) { os.WriteFile(settings, []byte(`{"printer":"`+f.host()+`","printer-certificate":"`+f.print()+`"}`), 0o600) pwFile := filepath.Join(dir, "admin-password.secret") os.WriteFile(pwFile, []byte("pw\n"), 0o600) - w := &adminWorker{ + w := &deviceWorker{ Settings: func() (Settings, error) { return LoadSettings(settings) }, Password: func() (string, error) { return secret.Read(pwFile) }, Now: time.Now, } runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) - go func() { done <- runAdmin(runCtx, account, w) }() + go func() { done <- runDevice(runCtx, account, w) }() body, _ := json.Marshal(Ask{ID: "ask-1", Page: "/general/other.html", At: time.Now()}) if _, err := js.Publish(ctx, printerAskedSubject, body, jetstream.WithMsgID("ask-1")); err != nil { @@ -111,10 +111,10 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) { select { case err := <-done: if err == nil { - t.Fatal("runAdmin returned nil, want ctx.Err() once its context was cancelled") + t.Fatal("runDevice returned nil, want ctx.Err() once its context was cancelled") } case <-time.After(10 * time.Second): - t.Fatal("runAdmin did not stop once its context was cancelled") + t.Fatal("runDevice did not stop once its context was cancelled") } cons, err := js.Consumer(ctx, AsksStream, account.QueueConsumerName()) if err != nil { diff --git a/modules/brother/cmd/brother/adminproc.go b/modules/brother/cmd/brother/deviceproc.go similarity index 70% rename from modules/brother/cmd/brother/adminproc.go rename to modules/brother/cmd/brother/deviceproc.go index e758e9bc..b96d0577 100644 --- a/modules/brother/cmd/brother/adminproc.go +++ b/modules/brother/cmd/brother/deviceproc.go @@ -1,9 +1,9 @@ package main -// mesh brother admin (novox/hq ADR 0467 clause 4, in the shape of ADR 0436 §7): the admin process, a process -// with a bus account of its own (brother-admin), never a seat. It alone of the module's parts reads the admin -// password and logs in to the printer's admin page; the tools queue an ask on the work-queue stream printer-asks -// and read its answer in the state printer-answers. The admin password is never in an answer, an event or a log. +// mesh brother device (novox/hq ADR 0467 clause 4, in the shape of ADR 0436 §7): the device process, a process +// with a bus account of its own (brother-device), never a seat. It alone of the module's parts reads the admin +// password and logs in to the printer's admin page; the tools queue an ask on the work-queue stream device-asks +// and read its answer in the state device-answers. The admin password is never in an answer, an event or a log. import ( "context" @@ -35,9 +35,9 @@ const ( actionTerm ) -// adminWorker answers the asks. Settings and the password are read at each ask, so a changed config value or a +// deviceWorker answers the asks. Settings and the password are read at each ask, so a changed config value or a // password given later is the next ask's. -type adminWorker struct { +type deviceWorker struct { Settings func() (Settings, error) Password func() (string, error) Answers Answers @@ -45,7 +45,7 @@ type adminWorker struct { Logf func(format string, a ...any) } -func (w *adminWorker) logf(format string, a ...any) { +func (w *deviceWorker) logf(format string, a ...any) { if w.Logf != nil { w.Logf(format, a...) } @@ -55,14 +55,14 @@ func (w *adminWorker) logf(format string, a ...any) { // for is acknowledged and the printer not contacted; every other outcome, a failure included, is written as the // answer under the ask's id and the message acknowledged; only an answer that could not be written is // redelivered (the bus's limit ends that). -func (w *adminWorker) Handle(body []byte) (action, time.Duration) { +func (w *deviceWorker) Handle(body []byte) (action, time.Duration) { var ask Ask if err := json.Unmarshal(body, &ask); err != nil || ask.ID == "" { - w.logf("[brother] admin: a queued ask could not be read: %v", err) + w.logf("[brother] device: a queued ask could not be read: %v", err) return actionTerm, 0 } if w.Now().Sub(ask.At) > StaleAfter { - w.logf("[brother] admin: ask %s is %s old; nobody waits for it, the printer is not contacted", ask.ID, w.Now().Sub(ask.At).Round(time.Second)) + w.logf("[brother] device: ask %s is %s old; nobody waits for it, the printer is not contacted", ask.ID, w.Now().Sub(ask.At).Round(time.Second)) return actionAck, 0 } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) @@ -74,7 +74,7 @@ func (w *adminWorker) Handle(body []byte) (action, time.Duration) { answer = page } if _, err := w.Answers.Create(ask.ID, answer); err != nil && !errors.Is(err, stdio.ErrChanged) { - w.logf("[brother] admin: the answer to %s could not be written: %v", ask.ID, err) + w.logf("[brother] device: the answer to %s could not be written: %v", ask.ID, err) return actionRetry, 5 * time.Second } return actionAck, 0 @@ -83,7 +83,7 @@ func (w *adminWorker) Handle(body []byte) (action, time.Duration) { // read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are // the only things it sends, and a password field's value is never in the answer. The printer is not contacted // before the settings and the password are known to be given (1453/AC4). -func (w *adminWorker) read(ctx context.Context, page string) (map[string]any, error) { +func (w *deviceWorker) read(ctx context.Context, page string) (map[string]any, error) { if page == "" { page = DefaultPage } @@ -143,40 +143,40 @@ func nonNilForms(f []Form) []Form { return f } -// admin is `brother admin`: it reads its own bus account, connects, and consumes printer-asks until stopped. +// admin is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped. // It refuses to start naming what is missing when the account cannot be read, and never logs a password. -func admin() int { +func device() int { settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER") if brokerFile == "" { - fmt.Fprintln(os.Stderr, "[brother] admin was started without BROTHER_BROKER, its bus account's file") + fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file") return 2 } account, err := process.ReadAccount(brokerFile) if err != nil { - fmt.Fprintf(os.Stderr, "[brother] admin: %v\n", err) + fmt.Fprintf(os.Stderr, "[brother] device: %v\n", err) return 1 } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - w := &adminWorker{ + w := &deviceWorker{ Settings: func() (Settings, error) { return LoadSettings(settings) }, Password: func() (string, error) { return secret.Read(passwordFile) }, Now: time.Now, Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, } - fmt.Fprintf(os.Stderr, "[brother] admin is consuming %s as %s\n", AsksStream, account.User) - err = runAdmin(ctx, account, w) + fmt.Fprintf(os.Stderr, "[brother] device is consuming %s as %s\n", AsksStream, account.User) + err = runDevice(ctx, account, w) if err != nil && ctx.Err() == nil { - fmt.Fprintf(os.Stderr, "[brother] admin stopped: %v\n", err) + fmt.Fprintf(os.Stderr, "[brother] device stopped: %v\n", err) return 1 } return 0 } -// runAdmin connects as the admin process's own account and consumes printer-asks until ctx ends, answering each -// ask in the state printer-answers on that connection and then settling the message as Handle says. Apart from +// runDevice connects as the device process's own account and consumes device-asks until ctx ends, answering each +// ask in the state device-answers on that connection and then settling the message as Handle says. Apart from // admin so a test can drive it over a bus of its own. -func runAdmin(ctx context.Context, account process.Account, w *adminWorker) error { +func runDevice(ctx context.Context, account process.Account, w *deviceWorker) error { conn, err := process.Connect(account) if err != nil { return fmt.Errorf("the bus cannot be reached: %w", err) @@ -195,7 +195,7 @@ func runAdmin(ctx context.Context, account process.Account, w *adminWorker) erro aerr = msg.Term() } if aerr != nil { - w.logf("[brother] admin: settling %s: %v", msg.Subject, aerr) + w.logf("[brother] device: settling %s: %v", msg.Subject, aerr) } }) } diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index af076c4a..22802571 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -10,8 +10,8 @@ import ( ) func main() { - if len(os.Args) > 1 && os.Args[1] == "admin" { - os.Exit(admin()) + if len(os.Args) > 1 && os.Args[1] == "device" { + os.Exit(device()) } if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), emit, time.Now)); err != nil { fmt.Fprintln(os.Stderr, err) @@ -54,7 +54,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool }, { Name: "printer.settings", - Description: "Read one page of the printer's admin site, read-only: the module's admin process logs in with the " + + Description: "Read one page of the printer's admin site, read-only: the module's device process logs in with the " + "secret admin-password (the operator's; it trusts the printer's pinned certificate alone), gets the page and logs " + "out; this tool asks it and waits up to a minute for its answer. Answers the " + "page's title, its links (href and text) and its forms with their fields (name, type, value, label); a " + diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 32f631e2..f98f8342 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -190,9 +190,9 @@ func resource(t *testing.T, m manifest, id string) map[string]any { } // 1453/AC4 (ADR 0467 clause 4, ADR 0436 §1): the admin password is the module's own secret, issued by the -// operator (never generated), and owned by an account the module makes, brother-admin: the tools' runner -// (the operator account) cannot read it, the admin process (that account) can. The process's bus account is -// the own secret broker-admin, owned by the same account. +// operator (never generated), and owned by an account the module makes, brother-device: the tools' runner +// (the operator account) cannot read it, the device process (that account) can. The process's bus account is +// the own secret broker-device, owned by the same account. func TestTheAdminPasswordIsTheOperatorsOwnSecretOwnedByTheAdminAccount(t *testing.T) { m := readManifest(t) s, ok := m.OwnSecrets["admin-password"] @@ -202,35 +202,35 @@ func TestTheAdminPasswordIsTheOperatorsOwnSecretOwnedByTheAdminAccount(t *testin if s["issued-by"] != "outside" { t.Errorf("issued-by = %v, want outside: the mesh never generates the device's password", s["issued-by"]) } - if s["owner"] != "brother-admin" { - t.Errorf("owner = %v, want brother-admin (never root or ${machine:account}: ADR 0436 §1)", s["owner"]) + if s["owner"] != "brother-device" { + t.Errorf("owner = %v, want brother-device (never root or ${machine:account}: ADR 0436 §1)", s["owner"]) } if p, _ := s["prompt"].(string); p != "the admin password of the Brother printer" { t.Errorf("prompt = %q", p) } - if b := m.OwnSecrets["broker-admin"]; b["owner"] != "brother-admin" || b["path"] != "${dir:state}/broker-admin.secret" { - t.Errorf("broker-admin = %v", b) + if b := m.OwnSecrets["broker-device"]; b["owner"] != "brother-device" || b["path"] != "${dir:state}/broker-device.secret" { + t.Errorf("broker-device = %v", b) } acct := resource(t, m, "account") - if acct["type"] != "user" || acct["name"] != "brother-admin" || acct["shell"] != "/usr/bin/nologin" { + if acct["type"] != "user" || acct["name"] != "brother-device" || acct["shell"] != "/usr/bin/nologin" { t.Errorf("account = %v", acct) } } -// 1453 (ADR 0467 clause 4): the admin process runs as brother-admin with the bus access of ADR 0436 §7: it -// consumes the work queue printer-asks and writes the state printer-answers, and is judged by its unit. The +// 1453 (ADR 0467 clause 4): the device process runs as brother-device with the bus access of ADR 0436 §7: it +// consumes the work queue device-asks and writes the state device-answers, and is judged by its unit. The // tools' bundle is given no password file. func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { m := readManifest(t) - p := resource(t, m, "admin") - if p["type"] != "process" || p["name"] != "brother-admin" || p["user"] != "brother-admin" || p["artifact"] != "tools" { + p := resource(t, m, "device") + if p["type"] != "process" || p["name"] != "brother-device" || p["user"] != "brother-device" || p["artifact"] != "tools" { t.Errorf("process = %v", p) } - if !reflect.DeepEqual(p["run"], []any{"./brother", "admin"}) { + if !reflect.DeepEqual(p["run"], []any{"./brother", "device"}) { t.Errorf("run = %v", p["run"]) } bus, _ := p["bus"].(map[string]any) - if !reflect.DeepEqual(bus["queues"], []any{"printer-asks"}) || !reflect.DeepEqual(bus["state"], []any{"printer-answers"}) { + if !reflect.DeepEqual(bus["queues"], []any{"device-asks"}) || !reflect.DeepEqual(bus["state"], []any{"device-answers"}) { t.Errorf("bus = %v", bus) } if h, _ := p["health"].(map[string]any); h["kind"] != "unit" { @@ -240,7 +240,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { want := map[string]any{ "BROTHER_SETTINGS": "${dir:state}/settings.json", "BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"], - "BROTHER_BROKER": m.OwnSecrets["broker-admin"]["path"], + "BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"], } if !reflect.DeepEqual(env, want) { t.Errorf("env = %v, want %v", env, want) @@ -260,7 +260,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { t.Errorf("state mode = %v: the admin account must reach its files in it", r["mode"]) } for _, r := range m.Resources { - if r["id"] != "admin" { + if r["id"] != "device" { if r["takes-config"] != nil { for _, k := range r["takes-config"].([]any) { if k == "admin-password" { @@ -272,7 +272,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { } } -// 1453 (ADR 0467 clause 4): the stream printer-asks is a work queue like mesh-mail's mail-sends, and the +// 1453 (ADR 0467 clause 4): the stream device-asks is a work queue like mesh-mail's mail-sends, and the // answers are a state that expires. func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) { raw, _ := os.ReadFile(filepath.Join("..", "..", "module.json")) @@ -287,7 +287,7 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) { t.Fatalf("streams = %v", m.Streams) } st := m.Streams[0] - if st["name"] != "printer-asks" || st["retention"] != "work-queue" || st["max-deliveries"] != float64(3) || + if st["name"] != "device-asks" || st["retention"] != "work-queue" || st["max-deliveries"] != float64(3) || !reflect.DeepEqual(st["events"], []any{EventAsked}) { t.Errorf("stream = %v", st) } diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index 071ef18f..29436fcb 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -20,24 +20,24 @@ var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`) // DefaultPage is the page printer.settings reads when none is asked for. const DefaultPage = "/general/status.html" -// The ask printer.settings puts to the admin process (ADR 0467 clause 4; the shape of ADR 0436's mail-sends): -// the tool never holds the admin password, so it queues the ask on the work-queue stream printer-asks and the -// admin process, an account of its own that alone reads the password, answers under the ask's id in the state -// printer-answers. +// The ask printer.settings puts to the device process (ADR 0467 clause 4; the shape of ADR 0436's mail-sends): +// the tool never holds the admin password, so it queues the ask on the work-queue stream device-asks and the +// device process, an account of its own that alone reads the password, answers under the ask's id in the state +// device-answers. const ( - // EventAsked is the event on the work queue; the stream printer-asks carries it. - EventAsked = "printer.asked" - // AsksStream is the work-queue stream's name on the bus: the manifest's local name printer-asks, capitalised + // EventAsked is the event on the work queue; the stream device-asks carries it. + EventAsked = "device.asked" + // AsksStream is the work-queue stream's name on the bus: the manifest's local name device-asks, capitalised // with its hyphen an underscore, as the controller composes a stream's own bus name. - AsksStream = "PRINTER_ASKS" - // AnswersState is the state the admin process writes its answers in. - AnswersState = "printer-answers" - // AdminProcessID is the admin process's id as module.json's resource "admin" names it: the controller - // delivers its bus account as the own secret broker-admin. - AdminProcessID = "admin" - // AnswerWait is how long printer.settings waits for the admin process. + AsksStream = "DEVICE_ASKS" + // AnswersState is the state the device process writes its answers in. + AnswersState = "device-answers" + // DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller + // delivers its bus account as the own secret broker-device. + DeviceProcessID = "device" + // AnswerWait is how long printer.settings waits for the device process. AnswerWait = 60 * time.Second - // StaleAfter is how old an ask may be when the admin process takes it: older than twice the wait, nobody waits + // StaleAfter is how old an ask may be when the device process takes it: older than twice the wait, nobody waits // for it any more, so the printer is not contacted for it. StaleAfter = 2 * AnswerWait ) @@ -49,7 +49,7 @@ type Ask struct { At time.Time `json:"asked-at"` } -// AskBus is how the tool reaches the admin process: it queues an ask and reads an answer back. +// AskBus is how the tool reaches the device process: it queues an ask and reads an answer back. type AskBus interface { Publish(id string, body any) error Answer(id string) (json.RawMessage, error) @@ -84,7 +84,7 @@ func newAskID() string { return hex.EncodeToString(b) } -// PrinterSettings asks the admin process to log in, read one page of the admin site and log out, and waits for +// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for // its answer. It checks the page and that printer and printer-certificate are set, and reads, opens and sends // nothing of the admin password (ADR 0467 clause 4; ADR 0436 §12): nothing is queued before those checks pass. func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) { @@ -106,19 +106,19 @@ func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string] } ask := Ask{ID: newAskID(), Page: page, At: s.Now().UTC()} if err := s.Asks.Publish(ask.ID, ask); err != nil { - return nil, fmt.Errorf("the ask for the admin process could not be queued: %w", err) + return nil, fmt.Errorf("the ask for the device process could not be queued: %w", err) } ctx, cancel := context.WithTimeout(ctx, wait) defer cancel() for { raw, err := s.Asks.Answer(ask.ID) if err != nil { - return nil, fmt.Errorf("the admin process's answers cannot be read: %w", err) + return nil, fmt.Errorf("the device process's answers cannot be read: %w", err) } if raw != nil { var out map[string]any if err := json.Unmarshal(raw, &out); err != nil { - return nil, fmt.Errorf("the admin process's answer is not JSON: %w", err) + return nil, fmt.Errorf("the device process's answer is not JSON: %w", err) } if msg, bad := out["error"].(string); bad { return nil, fmt.Errorf("%s", msg) @@ -127,7 +127,7 @@ func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string] } select { case <-ctx.Done(): - return nil, fmt.Errorf("the admin process did not answer within %s: it may not be running, or the printer is slow (ask %s)", + return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the printer is slow (ask %s)", wait, ask.ID) case <-time.After(poll): } diff --git a/modules/brother/cmd/brother/printer_test.go b/modules/brother/cmd/brother/printer_test.go index cb1dabce..679c6f2c 100644 --- a/modules/brother/cmd/brother/printer_test.go +++ b/modules/brother/cmd/brother/printer_test.go @@ -15,7 +15,7 @@ import ( "brother/cmd/brother/internal/secret" ) -// memAnswers is the state printer-answers, in memory. +// memAnswers is the state device-answers, in memory. type memAnswers struct { mu sync.Mutex m map[string]json.RawMessage @@ -46,10 +46,10 @@ func (a *memAnswers) get(key string) json.RawMessage { return a.m[key] } -// fakeAskBus is the bus between the tool and the admin process: an ask published on it is handed to the -// worker, as the work queue hands it to the admin process, and the worker's answers are what the tool reads. +// fakeAskBus is the bus between the tool and the device process: an ask published on it is handed to the +// worker, as the work queue hands it to the device process, and the worker's answers are what the tool reads. type fakeAskBus struct { - worker *adminWorker // nil: nobody consumes the queue + worker *deviceWorker // nil: nobody consumes the queue answers *memAnswers mu sync.Mutex published []Ask @@ -81,7 +81,7 @@ func (b *fakeAskBus) asks() int { type printerRig struct { run func(args map[string]any) (any, error) bus *fakeAskBus - worker *adminWorker + worker *deviceWorker answers *memAnswers } @@ -95,7 +95,7 @@ func newRig(t *testing.T, settings, password string) *printerRig { } file := settingsFile(t, settings) answers := &memAnswers{} - w := &adminWorker{ + w := &deviceWorker{ Settings: func() (Settings, error) { return LoadSettings(file) }, Password: func() (string, error) { return secret.Read(secretFile) }, Answers: answers, @@ -231,7 +231,7 @@ func TestPrinterSettingsSaysWhichConfigIsUnsetAndQueuesNothing(t *testing.T) { } } -// 1453/AC4: the secret not given is said by name, by the admin process, and the printer is not contacted. +// 1453/AC4: the secret not given is said by name, by the device process, and the printer is not contacted. func TestPrinterSettingsSaysTheSecretIsNotGivenAndContactsNothing(t *testing.T) { f := newFakePrinter(t, "B1891", "pw") _, run := printerTools(t, f, "") @@ -263,7 +263,7 @@ func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) { t.Errorf("page %q: error = %v", p, err) } if _, err := r.worker.read(t.Context(), p); err == nil || !strings.Contains(err.Error(), "not a page") { - t.Errorf("the admin process, page %q: error = %v", p, err) + t.Errorf("the device process, page %q: error = %v", p, err) } } if _, err := r.run(map[string]any{"page": 5}); err == nil { @@ -274,14 +274,14 @@ func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) { } } -// 1453: nobody consuming the queue is said as the admin process not answering, naming the wait. +// 1453: nobody consuming the queue is said as the device process not answering, naming the wait. func TestPrinterSettingsSaysWhenTheAdminProcessDoesNotAnswer(t *testing.T) { f := newFakePrinter(t, "B1891", "pw") r := newRig(t, printerSettingsJSON(f), "pw") r.bus.worker = nil answerWait = 100 * time.Millisecond _, err := r.run(nil) - if err == nil || !strings.Contains(err.Error(), "admin process did not answer") { + if err == nil || !strings.Contains(err.Error(), "device process did not answer") { t.Fatalf("error = %v", err) } if r.bus.asks() != 1 { @@ -289,7 +289,7 @@ func TestPrinterSettingsSaysWhenTheAdminProcessDoesNotAnswer(t *testing.T) { } } -// 1453: the admin process terminates an ask it cannot read, drops one nobody waits for any more without +// 1453: the device process terminates an ask it cannot read, drops one nobody waits for any more without // contacting the printer, acknowledges one already answered, and asks for redelivery when it cannot write its // answer. func TestTheAdminWorkerSettlesEachAsk(t *testing.T) { diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index 8c1bbc6b..b10279cf 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -23,9 +23,9 @@ type Service struct { Node string Now func() time.Time Emit Emitter - // Asks is how printer.settings reaches the admin process, which alone logs in to the printer. + // Asks is how printer.settings reaches the device process, which alone logs in to the printer. Asks AskBus - // AnswerWait is how long printer.settings waits for the admin process's answer; AnswerPoll how often it looks. + // AnswerWait is how long printer.settings waits for the device process's answer; AnswerPoll how often it looks. AnswerWait, AnswerPoll time.Duration } diff --git a/modules/brother/module.json b/modules/brother/module.json index 8b35310b..94457484 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -67,18 +67,18 @@ "path": "${dir:state}/admin-password.secret", "issued-by": "outside", "prompt": "the admin password of the Brother printer", - "owner": "brother-admin" + "owner": "brother-device" }, - "broker-admin": { - "path": "${dir:state}/broker-admin.secret", - "owner": "brother-admin" + "broker-device": { + "path": "${dir:state}/broker-device.secret", + "owner": "brother-device" } }, "streams": [ { - "name": "printer-asks", + "name": "device-asks", "events": [ - "printer.asked" + "device.asked" ], "max-age-days": 1, "max-mib": 8, @@ -88,7 +88,7 @@ ], "state": [ { - "name": "printer-answers", + "name": "device-answers", "ttl-seconds": 600 } ], @@ -141,7 +141,7 @@ { "id": "account", "type": "user", - "name": "brother-admin", + "name": "brother-device", "shell": "/usr/bin/nologin" }, { @@ -171,29 +171,29 @@ "template": "files/settings.json" }, { - "id": "admin", + "id": "device", "type": "process", - "name": "brother-admin", + "name": "brother-device", "artifact": "tools", "run": [ "./brother", - "admin" + "device" ], - "user": "brother-admin", + "user": "brother-device", "env": { "BROTHER_SETTINGS": "${dir:state}/settings.json", "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret", - "BROTHER_BROKER": "${dir:state}/broker-admin.secret" + "BROTHER_BROKER": "${dir:state}/broker-device.secret" }, "restart-on": [ "settings" ], "bus": { "queues": [ - "printer-asks" + "device-asks" ], "state": [ - "printer-answers" + "device-answers" ] }, "health": { From d6e88fac170d39ac6c2544e4ce7d72419d1072ef Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:24:19 +0200 Subject: [PATCH 10/14] brother: the module requires an s3-bucket, with mesh-mail's S3 client and store.json read by the device account alone (issue 1453, ADR 0468 clause 2) --- modules/brother/cmd/brother/manifest_test.go | 36 +++ modules/brother/cmd/brother/objects.go | 234 +++++++++++++++++++ modules/brother/cmd/brother/objects_test.go | 134 +++++++++++ modules/brother/files/store.json | 9 + modules/brother/module.json | 23 +- 5 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 modules/brother/cmd/brother/objects.go create mode 100644 modules/brother/cmd/brother/objects_test.go create mode 100644 modules/brother/files/store.json diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index f98f8342..270ead7e 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -11,6 +11,9 @@ import ( ) type manifest struct { + Requires []string `json:"requires"` + Binds map[string]string `json:"binds"` + Secrets map[string]string `json:"secrets"` Emits []string `json:"emits"` OwnSecrets map[string]map[string]any `json:"own-secrets"` Seats []seat `json:"seats"` @@ -241,6 +244,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { "BROTHER_SETTINGS": "${dir:state}/settings.json", "BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"], "BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"], + "BROTHER_STORE": "${dir:state}/store.json", } if !reflect.DeepEqual(env, want) { t.Errorf("env = %v, want %v", env, want) @@ -298,3 +302,35 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) { t.Errorf("state = %v", m.State) } } + +// 1453/AC8 (ADR 0468 clause 2): the module requires an s3-bucket as mesh-mail does, and its facts and key are +// written into store.json, a file only the device account can read; the tools' bundle is not told where it is. +func TestTheBucketIsRequiredAndItsKeyIsOnlyTheDeviceAccounts(t *testing.T) { + m := readManifest(t) + if !reflect.DeepEqual(m.Requires, []string{"s3-bucket"}) { + t.Errorf("requires = %v", m.Requires) + } + if m.Binds["s3-bucket"] != "${dir:state}/store-binding.json" || m.Secrets["s3-bucket"] != "${dir:state}/store.secret" { + t.Errorf("binds %v, secrets %v", m.Binds, m.Secrets) + } + r := resource(t, m, "store") + if r["type"] != "file" || r["path"] != "${dir:state}/store.json" || r["mode"] != "0600" || r["owner"] != "brother-device" || r["template"] != "files/store.json" { + t.Errorf("store = %v", r) + } + mine, _ := os.ReadFile(filepath.Join("..", "..", "files", "store.json")) + theirs, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-mail", "files", "store.json")) + if err != nil || string(mine) != string(theirs) { + t.Errorf("files/store.json is not mesh-mail's (%v)", err) + } + p := resource(t, m, "device") + if env, _ := p["env"].(map[string]any); env["BROTHER_STORE"] != "${dir:state}/store.json" { + t.Errorf("the device process's BROTHER_STORE = %v", env["BROTHER_STORE"]) + } + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + if _, has := a["env"].(map[string]any)["BROTHER_STORE"]; has { + t.Errorf("the tools' bundle is told where the bucket's key is") + } + } + } +} diff --git a/modules/brother/cmd/brother/objects.go b/modules/brother/cmd/brother/objects.go new file mode 100644 index 00000000..6370f055 --- /dev/null +++ b/modules/brother/cmd/brother/objects.go @@ -0,0 +1,234 @@ +package main + +// The module's bucket (novox/hq ADR 0468 clause 2): each scan's PDF kept once, by its SHA-256, and each upload +// under uploads/, in the bucket the `minio` module provides this module through the `s3-bucket` provision. The +// mesh writes the bucket's facts and its key into one file, store.json, which only the device process (account +// brother-device) can read (BROTHER_STORE); this speaks S3 to it with the standard library alone, as mesh-mail's +// store does (modules/mesh-mail/cmd/mesh-mail/objects.go): a HEAD, a PUT and a GET, each signed with AWS +// Signature Version 4, path-style. + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" +) + +// Objects is the module's bucket. +type Objects interface { + // Bucket is the bucket's name, as an event names it. + Bucket() string + // Has says whether the key is kept. + Has(ctx context.Context, key string) (bool, error) + // Put keeps the bytes under the key. + Put(ctx context.Context, key, mediaType string, data []byte) error + // Get is the bytes kept under the key. + Get(ctx context.Context, key string) ([]byte, error) +} + +// Store keeps the bytes under the key once: a key already kept is left as it is, since a key that names the +// content (the SHA-256 of a scan) names the same bytes. +func Store(ctx context.Context, o Objects, key, mediaType string, data []byte) error { + has, err := o.Has(ctx, key) + if err != nil { + return err + } + if has { + return nil + } + return o.Put(ctx, key, mediaType, data) +} + +// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret. +type StoreFacts struct { + Scheme string `json:"scheme"` + At string `json:"at"` + Port string `json:"port"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Key string `json:"key"` + Secret string `json:"secret"` +} + +// ReadStoreFacts reads the store's facts. +func ReadStoreFacts(path string) (StoreFacts, error) { + var f StoreFacts + if path == "" { + return f, errors.New("the module was started without its store's facts (BROTHER_STORE)") + } + raw, err := os.ReadFile(path) + if err != nil { + return f, fmt.Errorf("the store's facts cannot be read: %w", err) + } + if err := json.Unmarshal(raw, &f); err != nil { + return f, fmt.Errorf("the store's facts are not the module's: %v", err) + } + if f.At == "" || f.Bucket == "" || f.Key == "" || f.Secret == "" { + return f, errors.New("the store's facts name no address, bucket or key") + } + if f.Scheme == "" { + f.Scheme = "http" + } + if f.Region == "" { + f.Region = "us-east-1" + } + return f, nil +} + +// S3 is the module's bucket over S3. +type S3 struct { + Endpoint string // scheme://host[:port] + Name string + Region string + Access string + Secret string + Client *http.Client + Now func() time.Time +} + +// NewS3 is the store the facts name. +func NewS3(f StoreFacts) *S3 { + host := f.At + if f.Port != "" { + host += ":" + f.Port + } + return &S3{Endpoint: f.Scheme + "://" + host, Name: f.Bucket, Region: f.Region, Access: f.Key, Secret: f.Secret, + Client: &http.Client{Timeout: 2 * time.Minute}, Now: time.Now} +} + +func (s *S3) Bucket() string { return s.Name } + +func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) { + u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + if mediaType != "" { + req.Header.Set("Content-Type", mediaType) + } + req.ContentLength = int64(len(body)) + Sign(req, body, s.Access, s.Secret, s.Region, s.Now().UTC()) + return s.Client.Do(req) +} + +func (s *S3) Has(ctx context.Context, key string) (bool, error) { + resp, err := s.do(ctx, http.MethodHead, key, "", nil) + if err != nil { + return false, fmt.Errorf("the module's bucket cannot be reached: %w", err) + } + resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + } + return false, fmt.Errorf("the module's bucket answered %s to asking for %s", resp.Status, key) +} + +func (s *S3) Put(ctx context.Context, key, mediaType string, data []byte) error { + resp, err := s.do(ctx, http.MethodPut, key, mediaType, data) + if err != nil { + return fmt.Errorf("the module's bucket cannot be reached: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + said, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("the module's bucket refused %s: %s %s", key, resp.Status, strings.TrimSpace(string(said))) + } + return nil +} + +func (s *S3) Get(ctx context.Context, key string) ([]byte, error) { + resp, err := s.do(ctx, http.MethodGet, key, "", nil) + if err != nil { + return nil, fmt.Errorf("the module's bucket cannot be reached: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("the module's bucket answered %s for %s", resp.Status, key) + } + return io.ReadAll(resp.Body) +} + +// Sign signs a request with AWS Signature Version 4 for S3: every header it carries but the agent and the length is +// signed, with the payload's hash. +func Sign(req *http.Request, body []byte, access, secret, region string, now time.Time) { + payload := sha256.Sum256(body) + payloadHex := hex.EncodeToString(payload[:]) + amzDate := now.Format("20060102T150405Z") + day := now.Format("20060102") + req.Header.Set("x-amz-date", amzDate) + req.Header.Set("x-amz-content-sha256", payloadHex) + + headers := map[string]string{"host": req.URL.Host} + for k, v := range req.Header { + lk := strings.ToLower(k) + if lk == "user-agent" || lk == "content-length" || lk == "authorization" { + continue + } + headers[lk] = strings.TrimSpace(strings.Join(v, ",")) + } + names := make([]string, 0, len(headers)) + for k := range headers { + names = append(names, k) + } + sort.Strings(names) + var canonHeaders strings.Builder + for _, k := range names { + canonHeaders.WriteString(k + ":" + headers[k] + "\n") + } + signed := strings.Join(names, ";") + path := req.URL.EscapedPath() + if path == "" { + path = "/" + } + canonical := strings.Join([]string{req.Method, path, canonicalQuery(req.URL.Query()), canonHeaders.String(), signed, payloadHex}, "\n") + scope := day + "/" + region + "/s3/aws4_request" + sum := sha256.Sum256([]byte(canonical)) + toSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + hex.EncodeToString(sum[:]) + key := hmacOf([]byte("AWS4"+secret), day) + key = hmacOf(key, region) + key = hmacOf(key, "s3") + key = hmacOf(key, "aws4_request") + sig := hex.EncodeToString(hmacOf(key, toSign)) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+access+"/"+scope+", SignedHeaders="+signed+", Signature="+sig) +} + +func canonicalQuery(q url.Values) string { + keys := make([]string, 0, len(q)) + for k := range q { + keys = append(keys, k) + } + sort.Strings(keys) + var parts []string + for _, k := range keys { + vs := append([]string(nil), q[k]...) + sort.Strings(vs) + for _, v := range vs { + parts = append(parts, url.QueryEscape(k)+"="+strings.ReplaceAll(url.QueryEscape(v), "+", "%20")) + } + } + return strings.Join(parts, "&") +} + +func hmacOf(key []byte, data string) []byte { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return h.Sum(nil) +} diff --git a/modules/brother/cmd/brother/objects_test.go b/modules/brother/cmd/brother/objects_test.go new file mode 100644 index 00000000..2ba39fbc --- /dev/null +++ b/modules/brother/cmd/brother/objects_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// memObjects is the bucket in memory, for the tests of what the device process keeps. +type memObjects struct { + mu sync.Mutex + kept map[string][]byte + puts int +} + +func newMemObjects() *memObjects { return &memObjects{kept: map[string][]byte{}} } + +func (m *memObjects) Bucket() string { return "brother-test" } + +func (m *memObjects) Has(_ context.Context, key string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.kept[key] + return ok, nil +} + +func (m *memObjects) Put(_ context.Context, key, _ string, data []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + m.kept[key] = append([]byte(nil), data...) + m.puts++ + return nil +} + +func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + b, ok := m.kept[key] + if !ok { + return nil, fmt.Errorf("%s is not kept", key) + } + return b, nil +} + +func (m *memObjects) keys() []string { + m.mu.Lock() + defer m.mu.Unlock() + var k []string + for key := range m.kept { + k = append(k, key) + } + return k +} + +// 1453/AC8: the S3 client puts, heads and gets path-style under a Signature Version 4 header, and Store keeps +// a key once. +func TestTheBucketIsSpokenToPathStyleSignedAndKeptOnce(t *testing.T) { + var mu sync.Mutex + objects := map[string][]byte{} + var puts int + var auth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + auth = r.Header.Get("Authorization") + if !strings.HasPrefix(r.URL.Path, "/scans/") { + http.Error(w, "path-style: //", http.StatusBadRequest) + return + } + key := strings.TrimPrefix(r.URL.Path, "/scans/") + switch r.Method { + case http.MethodPut: + b, _ := io.ReadAll(r.Body) + objects[key] = b + puts++ + case http.MethodHead, http.MethodGet: + b, ok := objects[key] + if !ok { + http.NotFound(w, r) + return + } + if r.Method == http.MethodGet { + w.Write(b) + } + } + })) + defer srv.Close() + host := strings.TrimPrefix(srv.URL, "http://") + at, port, _ := strings.Cut(host, ":") + s3 := NewS3(StoreFacts{Scheme: "http", At: at, Port: port, Bucket: "scans", Region: "us-east-1", Key: "AKID", Secret: "sek"}) + ctx := context.Background() + for i := 0; i < 2; i++ { + if err := Store(ctx, s3, "abc.pdf", "application/pdf", []byte("%PDF-x")); err != nil { + t.Fatal(err) + } + } + if puts != 1 { + t.Errorf("%d puts, want the one: a key is kept once", puts) + } + got, err := s3.Get(ctx, "abc.pdf") + if err != nil || string(got) != "%PDF-x" { + t.Errorf("Get = %q, %v", got, err) + } + if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=AKID/") || !strings.Contains(auth, "SignedHeaders=") || strings.Contains(auth, "sek") { + t.Errorf("Authorization = %q", auth) + } + if _, err := s3.Get(ctx, "none.pdf"); err == nil { + t.Errorf("a key not kept was got") + } +} + +// 1453: the store's facts are read from store.json, and one that names no bucket or key is refused by name. +func TestTheStoresFactsAreRead(t *testing.T) { + p := filepath.Join(t.TempDir(), "store.json") + os.WriteFile(p, []byte(`{"at":"minio.example","port":"9000","bucket":"brother","key":"k","secret":"s"}`), 0o600) + f, err := ReadStoreFacts(p) + if err != nil || f.Scheme != "http" || f.Region != "us-east-1" || f.Bucket != "brother" { + t.Errorf("facts = %+v, %v", f, err) + } + os.WriteFile(p, []byte(`{"at":"x"}`), 0o600) + if _, err := ReadStoreFacts(p); err == nil || !strings.Contains(err.Error(), "bucket") { + t.Errorf("error = %v", err) + } + if _, err := ReadStoreFacts(""); err == nil || !strings.Contains(err.Error(), "BROTHER_STORE") { + t.Errorf("error = %v", err) + } +} diff --git a/modules/brother/files/store.json b/modules/brother/files/store.json new file mode 100644 index 00000000..8c2075da --- /dev/null +++ b/modules/brother/files/store.json @@ -0,0 +1,9 @@ +{ + "scheme": "${bound:s3-bucket:scheme}", + "at": "${bound:s3-bucket:at}", + "port": "${bound:s3-bucket:port}", + "bucket": "${bound:s3-bucket:bucket}", + "region": "${bound:s3-bucket:region}", + "key": "${bound:s3-bucket:as}", + "secret": "${secret:s3-bucket}" +} diff --git a/modules/brother/module.json b/modules/brother/module.json index 94457484..bdd76ba9 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -1,6 +1,15 @@ { "module": "brother", "version": "1", + "requires": [ + "s3-bucket" + ], + "binds": { + "s3-bucket": "${dir:state}/store-binding.json" + }, + "secrets": { + "s3-bucket": "${dir:state}/store.secret" + }, "emits": [ "scan.added" ], @@ -170,6 +179,14 @@ "merge": "json", "template": "files/settings.json" }, + { + "id": "store", + "type": "file", + "path": "${dir:state}/store.json", + "owner": "brother-device", + "mode": "0600", + "template": "files/store.json" + }, { "id": "device", "type": "process", @@ -183,10 +200,12 @@ "env": { "BROTHER_SETTINGS": "${dir:state}/settings.json", "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret", - "BROTHER_BROKER": "${dir:state}/broker-device.secret" + "BROTHER_BROKER": "${dir:state}/broker-device.secret", + "BROTHER_STORE": "${dir:state}/store.json" }, "restart-on": [ - "settings" + "settings", + "store" ], "bus": { "queues": [ From 1d6230500110e34285abd81689ecf2038bd182f1 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:30:51 +0200 Subject: [PATCH 11/14] brother: scanning moves into the device process and its scans are kept in the bucket; scanner.document, printer.upload and printer.print carry documents in parts and print over IPP (issue 1453, ADR 0468) --- modules/brother/cmd/brother/capture.go | 67 +++ .../cmd/brother/device_boundary_test.go | 12 +- .../cmd/brother/device_integration_test.go | 2 +- modules/brother/cmd/brother/deviceproc.go | 276 ++++++++++- modules/brother/cmd/brother/documents_test.go | 440 ++++++++++++++++++ modules/brother/cmd/brother/ipp.go | 123 ++++- modules/brother/cmd/brother/ipp_test.go | 6 +- modules/brother/cmd/brother/main.go | 133 +++++- modules/brother/cmd/brother/manifest_test.go | 31 +- modules/brother/cmd/brother/parts.go | 59 +++ modules/brother/cmd/brother/printer.go | 201 ++++++-- modules/brother/cmd/brother/printer_test.go | 123 +---- modules/brother/cmd/brother/rig_test.go | 159 +++++++ modules/brother/cmd/brother/scan.go | 102 ++-- modules/brother/cmd/brother/service_test.go | 175 ++++--- modules/brother/cmd/brother/settings.go | 6 + modules/brother/module.json | 29 +- 17 files changed, 1609 insertions(+), 335 deletions(-) create mode 100644 modules/brother/cmd/brother/capture.go create mode 100644 modules/brother/cmd/brother/documents_test.go create mode 100644 modules/brother/cmd/brother/parts.go create mode 100644 modules/brother/cmd/brother/rig_test.go diff --git a/modules/brother/cmd/brother/capture.go b/modules/brother/cmd/brother/capture.go new file mode 100644 index 00000000..fbf1bb30 --- /dev/null +++ b/modules/brother/cmd/brother/capture.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "math" + "time" +) + +// The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of +// the platen's white and built into a one-page PDF. Keeping it in the bucket and saying scan.added are the +// device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go). + +func mm(pixels, dpi int) float64 { + return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10 +} + +// Capture is a scanned, cropped page as a PDF, with what the event says of it. +type Capture struct { + PDF []byte + SHA256 string + WidthMM float64 + HeightMM float64 + Resolution int + Cropped bool + At time.Time +} + +// Capture scans the glass, crops and builds the PDF. Nothing is kept and nothing is said here; an error says why +// the scanner gave no page. The resolution is the image's own, never the one asked (1440/AC2). +func (s *Service) Capture(ctx context.Context) (Capture, error) { + at := s.Now() + scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode) + if err != nil { + return Capture{}, err + } + dpi, err := Density(scan) + if err != nil { + return Capture{}, err + } + cut, err := Crop(scan, s.Settings.Crop) + if err != nil { + return Capture{}, err + } + pdf, err := PDF(cut.JPEG, dpi) + if err != nil { + return Capture{}, err + } + return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(cut.Width, dpi), HeightMM: mm(cut.Height, dpi), + Resolution: dpi, Cropped: cut.Cropped, At: at}, nil +} + +// Body is the event scan.added's body (ADR 0464 §5 with the object key of ADR 0468 clause 2): never the image, +// and no path, since filing in a folder is the tool's, after the event. +func (c Capture) Body(node, colorMode string) map[string]any { + return map[string]any{ + "object": c.SHA256 + ".pdf", + "node": node, + "bytes": len(c.PDF), + "sha256": c.SHA256, + "width-mm": c.WidthMM, + "height-mm": c.HeightMM, + "resolution": c.Resolution, + "color-mode": colorMode, + "cropped": c.Cropped, + "scanned-at": c.At.UTC().Format(time.RFC3339), + } +} diff --git a/modules/brother/cmd/brother/device_boundary_test.go b/modules/brother/cmd/brother/device_boundary_test.go index ad06cc1a..5c355612 100644 --- a/modules/brother/cmd/brother/device_boundary_test.go +++ b/modules/brother/cmd/brother/device_boundary_test.go @@ -17,7 +17,7 @@ const secretPackage = "brother/cmd/brother/internal/secret" // toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings' // ask), as opposed to the device process's: deviceproc.go, admin.go, pin.go and html.go. -var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go"} +var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go", "parts.go"} // 1453: none of the tools' files imports the password-reading package or names the password's file. func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) { @@ -36,8 +36,10 @@ func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Contains(string(src), `Getenv("BROTHER_ADMIN_PASSWORD")`) { - t.Errorf("%s reads BROTHER_ADMIN_PASSWORD, the device process's alone", file) + for _, bad := range []string{`Getenv("BROTHER_ADMIN_PASSWORD")`, `Getenv("BROTHER_STORE")`, "ReadStoreFacts", "NewS3", "StoreFacts"} { + if strings.Contains(string(src), bad) { + t.Errorf("%s names %s: the password's and the bucket key's files are the device process's alone (ADR 0468 clause 1)", file, bad) + } } } } @@ -59,9 +61,9 @@ func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) { if !found { t.Errorf("deviceproc.go does not import %s", secretPackage) } - for _, file := range append(append([]string{}, toolFiles...), "admin.go", "deviceproc.go", "pin.go", "html.go") { + for _, file := range append(append([]string{}, toolFiles...), "admin.go", "deviceproc.go", "pin.go", "html.go", "objects.go", "capture.go") { src, _ := os.ReadFile(file) - if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" { + if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" && file != "objects.go" { t.Errorf("%s reads a file: only settings.json (settings.go) and the scan folder's PDFs are read outside the secret package", file) } } diff --git a/modules/brother/cmd/brother/device_integration_test.go b/modules/brother/cmd/brother/device_integration_test.go index 0237a162..e54cd5fa 100644 --- a/modules/brother/cmd/brother/device_integration_test.go +++ b/modules/brother/cmd/brother/device_integration_test.go @@ -89,7 +89,7 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) { done := make(chan error, 1) go func() { done <- runDevice(runCtx, account, w) }() - body, _ := json.Marshal(Ask{ID: "ask-1", Page: "/general/other.html", At: time.Now()}) + body, _ := json.Marshal(Ask{ID: "ask-1", Op: OpSettings, Page: "/general/other.html", At: time.Now()}) if _, err := js.Publish(ctx, printerAskedSubject, body, jetstream.WithMsgID("ask-1")); err != nil { t.Fatal(err) } diff --git a/modules/brother/cmd/brother/deviceproc.go b/modules/brother/cmd/brother/deviceproc.go index b96d0577..bbd1702d 100644 --- a/modules/brother/cmd/brother/deviceproc.go +++ b/modules/brother/cmd/brother/deviceproc.go @@ -7,11 +7,13 @@ package main import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" "os" "os/signal" + "sync" "syscall" "time" @@ -33,16 +35,38 @@ const ( actionAck action = iota actionRetry actionTerm + // actionSettled says the message was acknowledged already, before the work began: what is not safe to do twice + // (a scan, a print) is acknowledged first, so a slow one is not delivered again after the bus's wait. + actionSettled ) -// deviceWorker answers the asks. Settings and the password are read at each ask, so a changed config value or a -// password given later is the next ask's. +// pendingUpload is the parts of an upload that have come. +type pendingUpload struct { + parts map[int][]byte + n int + sha string + at time.Time +} + +// pendingFor is how long an incomplete upload's parts are kept. +const pendingFor = 15 * time.Minute + +// deviceWorker answers the asks: it alone talks to the device (the scanner, the printer's pages and IPP) and to +// the bucket. Settings and the password are read at each ask, so a changed config value or a password given later +// is the next ask's. type deviceWorker struct { Settings func() (Settings, error) Password func() (string, error) - Answers Answers - Now func() time.Time - Logf func(format string, a ...any) + Objects func() (Objects, error) + // Emit says scan.added on the bus under a message id. + Emit func(body map[string]any, id string) error + Answers Answers + Node string + Now func() time.Time + Logf func(format string, a ...any) + + mu sync.Mutex + pending map[string]*pendingUpload } func (w *deviceWorker) logf(format string, a ...any) { @@ -51,35 +75,229 @@ func (w *deviceWorker) logf(format string, a ...any) { } } -// Handle answers one queued ask. An ask that cannot be read is terminated; an ask too old for anyone to wait -// for is acknowledged and the printer not contacted; every other outcome, a failure included, is written as the -// answer under the ask's id and the message acknowledged; only an answer that could not be written is -// redelivered (the bus's limit ends that). -func (w *deviceWorker) Handle(body []byte) (action, time.Duration) { +// Handle answers one queued ask. An ask that cannot be read is terminated; an ask too old for anyone to wait for +// is acknowledged and the device not touched; every other outcome, a failure included, is written as the answer +// under the ask's id and the message acknowledged; only an answer that could not be written is redelivered (the +// bus's limit ends that). early acknowledges the message; it is called before a scan or a print, which are not +// done twice. +func (w *deviceWorker) Handle(body []byte, early func()) (action, time.Duration) { var ask Ask if err := json.Unmarshal(body, &ask); err != nil || ask.ID == "" { w.logf("[brother] device: a queued ask could not be read: %v", err) return actionTerm, 0 } - if w.Now().Sub(ask.At) > StaleAfter { - w.logf("[brother] device: ask %s is %s old; nobody waits for it, the printer is not contacted", ask.ID, w.Now().Sub(ask.At).Round(time.Second)) + if age := w.Now().Sub(ask.At); age > StaleAfter { + w.logf("[brother] device: ask %s is %s old; nobody waits for it, the device is not touched", ask.ID, age.Round(time.Second)) return actionAck, 0 } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + settled := false + if (ask.Op == OpScan || ask.Op == OpPrint) && early != nil { + early() + settled = true + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() var answer map[string]any - if page, err := w.read(ctx, ask.Page); err != nil { + if got, err := w.run(ctx, ask); err != nil { answer = map[string]any{"error": err.Error()} } else { - answer = page + answer = got } if _, err := w.Answers.Create(ask.ID, answer); err != nil && !errors.Is(err, stdio.ErrChanged) { w.logf("[brother] device: the answer to %s could not be written: %v", ask.ID, err) + if settled { + return actionSettled, 0 + } return actionRetry, 5 * time.Second } + if settled { + return actionSettled, 0 + } return actionAck, 0 } +// run is the operation an ask names. +func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) { + switch ask.Op { + case OpSettings: + return w.read(ctx, ask.Page) + case OpScan: + return w.scan(ctx) + case OpDocument: + return w.document(ctx, ask) + case OpUpload: + return w.upload(ctx, ask) + case OpPrint: + return w.print(ctx, ask) + } + return nil, fmt.Errorf("the ask %s names an operation, %q, the device process does not do", ask.ID, ask.Op) +} + +// scan scans the glass, crops, builds the PDF, keeps it in the bucket under its SHA-256 and says scan.added with +// the object's key (ADR 0468 clause 2), in that order: nothing is said of a scan that is not kept, and nothing is +// kept nor said when the scanner gives no page (1440/AC6). The answer is the event's body with key, size and the +// number of parts. +func (w *deviceWorker) scan(ctx context.Context) (map[string]any, error) { + set, err := w.Settings() + if err != nil { + return nil, err + } + if set.Scanner == "" { + return nil, unsetError("scanner") + } + objects, err := w.Objects() + if err != nil { + return nil, err + } + svc := &Service{Settings: set, Node: w.Node, Now: w.Now} + c, err := svc.Capture(ctx) + if err != nil { + return nil, err + } + body := c.Body(w.Node, set.ColorMode) + key := body["object"].(string) + if err := Store(ctx, objects, key, "application/pdf", c.PDF); err != nil { + return nil, err + } + if err := w.Emit(body, c.SHA256); err != nil { + return nil, fmt.Errorf("the scan is kept as %s, but %s could not be said on the bus: %w", key, EventAdded, err) + } + answer := map[string]any{"key": key, "size": len(c.PDF), "parts": partCount(len(c.PDF))} + for k, v := range body { + answer[k] = v + } + return answer, nil +} + +// document is one part of a scan kept in the bucket. +func (w *deviceWorker) document(ctx context.Context, ask Ask) (map[string]any, error) { + if err := checkDocument(ask); err != nil { + return nil, err + } + objects, err := w.Objects() + if err != nil { + return nil, err + } + data, err := objects.Get(ctx, ask.Object) + if err != nil { + return nil, err + } + part, n, err := cutPart(data, ask.Part) + if err != nil { + return nil, err + } + return map[string]any{"part": ask.Part, "parts": n, "size": len(data), "sha256": sha256Hex(data), + "data": base64.StdEncoding.EncodeToString(part)}, nil +} + +// upload keeps a part, and once all have come joins them, checks the SHA-256 and the format and stores the document +// as uploads/.. A wrong SHA-256 or a document that is neither PDF nor JPEG stores nothing, and the +// parts are dropped; a part out of range is refused. +func (w *deviceWorker) upload(ctx context.Context, ask Ask) (map[string]any, error) { + if err := checkUpload(ask); err != nil { + return nil, err + } + raw, _ := base64.StdEncoding.DecodeString(ask.Data) + w.mu.Lock() + if w.pending == nil { + w.pending = map[string]*pendingUpload{} + } + for id, p := range w.pending { + if w.Now().Sub(p.at) > pendingFor { + delete(w.pending, id) + } + } + p := w.pending[ask.Upload] + if p == nil { + p = &pendingUpload{parts: map[int][]byte{}, n: ask.Parts, sha: ask.SHA256} + w.pending[ask.Upload] = p + } + if p.n != ask.Parts || p.sha != ask.SHA256 { + w.mu.Unlock() + return nil, fmt.Errorf("upload %s was begun with %d parts and SHA-256 %s: its parts all say the same", ask.Upload, p.n, p.sha) + } + p.parts[ask.Part] = raw + p.at = w.Now() + if len(p.parts) < p.n { + got := len(p.parts) + w.mu.Unlock() + return map[string]any{"upload": ask.Upload, "complete": false, "received": got, "parts": ask.Parts}, nil + } + delete(w.pending, ask.Upload) + w.mu.Unlock() + + ordered := make([][]byte, p.n) + for i := range ordered { + ordered[i] = p.parts[i+1] + } + doc, err := joinParts(ordered, p.sha) + if err != nil { + return nil, fmt.Errorf("upload %s: %w", ask.Upload, err) + } + format, ext, err := SniffFormat(doc) + if err != nil { + return nil, fmt.Errorf("upload %s: %w", ask.Upload, err) + } + objects, err := w.Objects() + if err != nil { + return nil, err + } + key := "uploads/" + ask.Upload + "." + ext + if err := objects.Put(ctx, key, format, doc); err != nil { + return nil, err + } + return map[string]any{"upload": ask.Upload, "complete": true, "object": key, "size": len(doc), "sha256": p.sha}, nil +} + +// print fetches a scan or a completed upload from the bucket and sends it to the printer as an IPP Print-Job in +// its own format, and answers the job's id and state. The printer's host is its IPP address; nothing is fetched +// or sent before the ask is checked. +func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, error) { + if err := checkPrint(&ask); err != nil { + return nil, err + } + set, err := w.Settings() + if err != nil { + return nil, err + } + if set.Printer == "" { + return nil, unsetError("printer") + } + objects, err := w.Objects() + if err != nil { + return nil, err + } + key := ask.Object + if key == "" { + for _, ext := range []string{"pdf", "jpg"} { + k := "uploads/" + ask.Upload + "." + ext + if has, err := objects.Has(ctx, k); err != nil { + return nil, err + } else if has { + key = k + break + } + } + if key == "" { + return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload) + } + } + doc, err := objects.Get(ctx, key) + if err != nil { + return nil, err + } + format, _, err := SniffFormat(doc) + if err != nil { + return nil, err + } + res, err := PrintJob(ctx, set.Printer, PrintOptions{Format: format, Copies: ask.Copies, Sides: ask.Sides, Media: ask.Media, Name: "brother print"}, doc) + if err != nil { + return nil, err + } + return map[string]any{"job-id": res.JobID, "job-state": res.State, "job-state-code": res.StateCode, + "job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}, nil +} + // read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are // the only things it sends, and a password field's value is never in the answer. The printer is not contacted // before the settings and the password are known to be given (1453/AC4). @@ -143,10 +361,11 @@ func nonNilForms(f []Form) []Form { return f } -// admin is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped. +// device is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped. // It refuses to start naming what is missing when the account cannot be read, and never logs a password. func device() int { settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER") + store := os.Getenv("BROTHER_STORE") if brokerFile == "" { fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file") return 2 @@ -161,8 +380,15 @@ func device() int { w := &deviceWorker{ Settings: func() (Settings, error) { return LoadSettings(settings) }, Password: func() (string, error) { return secret.Read(passwordFile) }, - Now: time.Now, - Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, + Objects: func() (Objects, error) { + facts, err := ReadStoreFacts(store) + if err != nil { + return nil, err + } + return NewS3(facts), nil + }, + Now: time.Now, + Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, } fmt.Fprintf(os.Stderr, "[brother] device is consuming %s as %s\n", AsksStream, account.User) err = runDevice(ctx, account, w) @@ -175,7 +401,7 @@ func device() int { // runDevice connects as the device process's own account and consumes device-asks until ctx ends, answering each // ask in the state device-answers on that connection and then settling the message as Handle says. Apart from -// admin so a test can drive it over a bus of its own. +// device so a test can drive it over a bus of its own. func runDevice(ctx context.Context, account process.Account, w *deviceWorker) error { conn, err := process.Connect(account) if err != nil { @@ -183,8 +409,18 @@ func runDevice(ctx context.Context, account process.Account, w *deviceWorker) er } defer conn.Close() w.Answers = conn.State(AnswersState) + if w.Node == "" { + w.Node = account.Node + } + w.Emit = func(body map[string]any, id string) error { + return conn.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id}) + } return conn.Consume(ctx, AsksStream, func(msg *process.Message) { - act, delay := w.Handle(msg.Body) + var settleErr error + act, delay := w.Handle(msg.Body, func() { settleErr = msg.Ack() }) + if settleErr != nil { + w.logf("[brother] device: acknowledging %s early: %v", msg.Subject, settleErr) + } var aerr error switch act { case actionAck: diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go new file mode 100644 index 00000000..fae36de4 --- /dev/null +++ b/modules/brother/cmd/brother/documents_test.go @@ -0,0 +1,440 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// pdfOf is a document that is a PDF to the sniff and size bytes long, its bytes varying so that a part out of +// place changes the join. +func pdfOf(size int) []byte { + b := make([]byte, size) + copy(b, "%PDF-1.4\n") + for i := 9; i < size; i++ { + b[i] = byte(i*7 + i/251) + } + return b +} + +// 1453/AC10: a document is cut into parts of at most PartSize, under the 768 KiB limit, and the parts joined in +// order are the document, with the SHA-256 checked; a wrong SHA-256 or a part missing is refused; a part out of +// range is refused naming the range. +func TestADocumentIsCutIntoPartsAndJoinedWithItsSha256Checked(t *testing.T) { + doc := pdfOf(2*PartSize + 1234) + n := partCount(len(doc)) + if n != 3 { + t.Fatalf("%d parts, want 3", n) + } + var parts [][]byte + for i := 1; i <= n; i++ { + p, total, err := cutPart(doc, i) + if err != nil || total != n { + t.Fatalf("part %d: %v (of %d)", i, err, total) + } + if len(p) > MaxPart { + t.Errorf("part %d is %d bytes, over %d", i, len(p), MaxPart) + } + parts = append(parts, p) + } + got, err := joinParts(parts, sha256Hex(doc)) + if err != nil || !bytes.Equal(got, doc) { + t.Fatalf("the join is not the document: %v", err) + } + if _, err := joinParts(parts, sha256Hex([]byte("other"))); err == nil || !strings.Contains(err.Error(), sha256Hex(doc)) { + t.Errorf("a wrong SHA-256 was accepted or the error does not name the joined one: %v", err) + } + if _, err := joinParts(parts[:2], sha256Hex(doc)); err == nil { + t.Errorf("a join with a part missing passed its SHA-256") + } + for _, bad := range []int{0, -1, n + 1} { + if _, _, err := cutPart(doc, bad); err == nil || !strings.Contains(err.Error(), "out of range") { + t.Errorf("part %d: error = %v", bad, err) + } + } + if partCount(0) != 1 { + t.Errorf("an empty document has one part") + } +} + +// 1453/AC10: scanner.document gives each part of a kept scan with the part count, the size and the SHA-256, each +// answer within the bus's 1 MiB; the parts joined are the scan. A part out of range, or a key that is no scan's, is +// refused, the first before anything is queued. +func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) { + r := newRig(t, `{}`, "pw") + doc := pdfOf(PartSize + 5000) + key := sha256Hex(doc) + ".pdf" + r.objects.Put(t.Context(), key, "application/pdf", doc) + var joined []byte + for i := 1; i <= 2; i++ { + got, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(i)}) + if err != nil { + t.Fatalf("part %d: %v", i, err) + } + m := got.(map[string]any) + if m["parts"] != float64(2) || m["size"] != float64(len(doc)) || m["sha256"] != sha256Hex(doc) || m["part"] != float64(i) { + t.Errorf("part %d answer = %v", i, m) + } + raw, err := base64.StdEncoding.DecodeString(m["data"].(string)) + if err != nil { + t.Fatal(err) + } + joined = append(joined, raw...) + } + if !bytes.Equal(joined, doc) { + t.Errorf("the parts fetched are not the scan") + } + before := r.bus.asks() + if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(0)}); err == nil { + t.Errorf("part 0 was asked for") + } + if r.bus.asks() != before { + t.Errorf("an ask was queued for a part the tool could refuse") + } + if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(3)}); err == nil || !strings.Contains(err.Error(), "out of range") { + t.Errorf("part 3: error = %v", err) + } + for _, k := range []string{"../etc/passwd", "uploads/x.pdf", strings.ToUpper(key)} { + if _, err := r.call(t, "scanner.document", map[string]any{"object": k, "part": float64(1)}); err == nil || !strings.Contains(err.Error(), "not a scan's key") { + t.Errorf("object %q: error = %v", k, err) + } + } + if _, err := r.call(t, "scanner.document", map[string]any{"object": sha256Hex([]byte("none")) + ".pdf", "part": float64(1)}); err == nil { + t.Errorf("a scan that is not kept was given") + } +} + +// 1453/AC10: a scan of more than one part is fetched whole by scanner.scan, joined, checked and filed. +func TestScanNowFetchesAScanOfSeveralParts(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + r, _ := scanRig(t, f, Settings{Crop: false}) + // Everything a scan is made of but its size is the real one; the stored object is what the parts carry, so a + // bigger object stands for a bigger scan: replace what the device keeps with a three-part document. + big := pdfOf(2*PartSize + 99) + bigKey := sha256Hex(big) + ".pdf" + r.objects.Put(t.Context(), bigKey, "application/pdf", big) + got := make([][]byte, 0, 3) + for i := 1; i <= 3; i++ { + p, err := r.worker.document(t.Context(), Ask{Op: OpDocument, Object: bigKey, Part: i}) + if err != nil { + t.Fatal(err) + } + raw, _ := base64.StdEncoding.DecodeString(p["data"].(string)) + got = append(got, raw) + } + if joined, err := joinParts(got, sha256Hex(big)); err != nil || !bytes.Equal(joined, big) { + t.Fatalf("three parts do not join: %v", err) + } + body, err := r.scan(t) + if err != nil { + t.Fatal(err) + } + if body["path"] == nil { + t.Errorf("no path: %v", body) + } +} + +func upload(t *testing.T, r *printerRig, id string, doc []byte, sha string, part, parts int, piece []byte) (any, error) { + t.Helper() + return r.call(t, "printer.upload", map[string]any{"upload": id, "part": float64(part), "parts": float64(parts), "sha256": sha, + "data": base64.StdEncoding.EncodeToString(piece)}) +} + +// 1453/AC11: an upload in parts, in any order, is stored as uploads/.pdf once all have come and the joined +// SHA-256 matches; until then it says how many came and stores nothing. +func TestAnUploadIsStoredOnceAllItsPartsCameAndTheSha256Matches(t *testing.T) { + r := newRig(t, `{}`, "pw") + doc := pdfOf(PartSize + 700) + sum := sha256Hex(doc) + p1, _, _ := cutPart(doc, 1) + p2, _, _ := cutPart(doc, 2) + got, err := upload(t, r, "up-1", doc, sum, 2, 2, p2) + if err != nil { + t.Fatal(err) + } + if m := got.(map[string]any); m["complete"] != false || m["received"] != float64(1) { + t.Errorf("after one part: %v", m) + } + if len(r.objects.keys()) != 0 { + t.Errorf("a document was stored before its parts all came: %v", r.objects.keys()) + } + got, err = upload(t, r, "up-1", doc, sum, 1, 2, p1) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["complete"] != true || m["object"] != "uploads/up-1.pdf" || m["sha256"] != sum || m["size"] != float64(len(doc)) { + t.Errorf("after the last part: %v", m) + } + if kept, err := r.objects.Get(t.Context(), "uploads/up-1.pdf"); err != nil || !bytes.Equal(kept, doc) { + t.Errorf("the stored document is not the upload (%v)", err) + } + // A JPEG is stored with its own extension. + jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{7}, 100)...) + if _, err := upload(t, r, "pic", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil { + t.Fatal(err) + } + if _, err := r.objects.Get(t.Context(), "uploads/pic.jpg"); err != nil { + t.Errorf("the JPEG is not stored as uploads/pic.jpg: %v", err) + } +} + +// 1453/AC11: a wrong SHA-256 on the joined document stores nothing and is said; a document that is neither PDF nor +// JPEG stores nothing; a part out of range, a part larger than the limit, a part that is not base64 and parts that +// disagree are refused; the upload can then be sent again whole. +func TestAnUploadWithAWrongSha256OrAnOutOfRangePartStoresNothing(t *testing.T) { + r := newRig(t, `{}`, "pw") + doc := pdfOf(300) + wrong := sha256Hex([]byte("not it")) + if _, err := upload(t, r, "bad", doc, wrong, 1, 1, doc); err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), sha256Hex(doc)) { + t.Errorf("a wrong SHA-256: error = %v", err) + } + if len(r.objects.keys()) != 0 || r.objects.puts != 0 { + t.Errorf("a document with a wrong SHA-256 was stored: %v", r.objects.keys()) + } + text := []byte("plain text, no PDF") + if _, err := upload(t, r, "txt", text, sha256Hex(text), 1, 1, text); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") { + t.Errorf("a text upload: error = %v", err) + } + for _, c := range []struct{ part, parts int }{{0, 1}, {2, 1}, {1, 0}, {1, MostParts + 1}} { + if _, err := upload(t, r, "range", doc, sha256Hex(doc), c.part, c.parts, doc); err == nil { + t.Errorf("part %d of %d was taken", c.part, c.parts) + } + } + if _, err := upload(t, r, "range", doc, sha256Hex(doc), 2, 1, doc); err == nil || !strings.Contains(err.Error(), "out of range") { + t.Errorf("part 2 of 1: error = %v", err) + } + if _, err := r.call(t, "printer.upload", map[string]any{"upload": "x", "part": float64(1), "parts": float64(1), "sha256": sha256Hex(doc), "data": "!!"}); err == nil { + t.Errorf("a part that is not base64 was taken") + } + if _, err := upload(t, r, "big", doc, sha256Hex(doc), 1, 1, make([]byte, MaxPart+1)); err == nil || !strings.Contains(err.Error(), "at most") { + t.Errorf("a part over the limit: error = %v", err) + } + if r.bus.asks() != 2 { + // Only the two uploads that were complete in form reached the device process; every refusal above them + // was the tool's. + t.Errorf("%d asks queued, want the 2 whose form was right", r.bus.asks()) + } + // Parts that disagree about the whole. + p := pdfOf(PartSize + 10) + p1, _, _ := cutPart(p, 1) + if _, err := upload(t, r, "mix", p, sha256Hex(p), 1, 2, p1); err != nil { + t.Fatal(err) + } + if _, err := upload(t, r, "mix", p, sha256Hex(p), 2, 3, p1); err == nil || !strings.Contains(err.Error(), "all say the same") { + t.Errorf("parts that disagree: error = %v", err) + } + if len(r.objects.keys()) != 0 { + t.Errorf("objects stored: %v", r.objects.keys()) + } +} + +// 1453/AC11: an incomplete upload's parts are dropped after fifteen minutes. +func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) { + r := newRig(t, `{}`, "pw") + doc := pdfOf(PartSize + 10) + p1, _, _ := cutPart(doc, 1) + p2, _, _ := cutPart(doc, 2) + now := fixedNow + r.worker.Now = func() time.Time { return now } + if _, err := upload(t, r, "late", doc, sha256Hex(doc), 1, 2, p1); err != nil { + t.Fatal(err) + } + now = now.Add(pendingFor + time.Minute) + // Asked of the worker directly: the tool's own clock is the fixed one, and an ask this old would be stale. + got, err := r.worker.upload(t.Context(), Ask{Op: OpUpload, Upload: "late", Part: 2, Parts: 2, SHA256: sha256Hex(doc), + Data: base64.StdEncoding.EncodeToString(p2)}) + if err != nil { + t.Fatal(err) + } + if got["complete"] != false { + t.Errorf("the dropped part was still there: %v", got) + } +} + +// ippFake is a printer's IPP port: it records the request and answers a Print-Job answer with a job id and state. +type ippFake struct { + url string + body []byte + status int +} + +func newIPPFake(t *testing.T, jobID, jobState int) *ippFake { + t.Helper() + f := &ippFake{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.body, _ = io.ReadAll(r.Body) + ans := []byte{0x02, 0x00, byte(f.status >> 8), byte(f.status), 0, 0, 0, 1, ippOperation} + ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8") + ans = append(ans, ippJob) + ans = appendAttr(ans, ippInteger, "job-id", string(binary.BigEndian.AppendUint32(nil, uint32(jobID)))) + ans = appendAttr(ans, ippEnum, "job-state", string(binary.BigEndian.AppendUint32(nil, uint32(jobState)))) + ans = appendAttr(ans, ippKeyword, "job-state-reasons", "none") + ans = append(ans, ippEnd) + w.Header().Set("Content-Type", "application/ipp") + w.Write(ans) + })) + t.Cleanup(srv.Close) + f.url = srv.URL + old := ippURL + ippURL = func(string) string { return srv.URL } + t.Cleanup(func() { ippURL = old }) + return f +} + +// request splits what the fake was sent into its attributes and its document. +func (f *ippFake) request(t *testing.T) (IPPAnswer, []byte) { + t.Helper() + a, off, err := decodeIPP(f.body) + if err != nil { + t.Fatal(err) + } + return a, f.body[off:] +} + +// 1453/AC12: printer.print sends a kept scan to the printer as an IPP Print-Job (operation 0x0002): the document +// bytes after the attributes, document-format by sniffing, copies, sides and media as asked, to the printer's own +// IPP address; the answer is the job's id and state. +func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) { + fake := newIPPFake(t, 77, 5) + r := newRig(t, `{"printer":"192.0.2.10"}`, "pw") + doc := pdfOf(5000) + key := sha256Hex(doc) + ".pdf" + r.objects.Put(t.Context(), key, "application/pdf", doc) + got, err := r.call(t, "printer.print", map[string]any{"object": key, "copies": float64(2), "sides": "two-sided-long-edge", "media": "na_letter_8.5x11in"}) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + if m["job-id"] != float64(77) || m["job-state"] != "processing" || m["format"] != "application/pdf" { + t.Errorf("answer = %v", m) + } + if binary.BigEndian.Uint16(fake.body[2:4]) != 0x0002 { + t.Errorf("operation = 0x%04x, want Print-Job 0x0002", binary.BigEndian.Uint16(fake.body[2:4])) + } + attrs, sent := fake.request(t) + if !bytes.Equal(sent, doc) { + t.Errorf("the document sent is not the kept one (%d bytes, want %d)", len(sent), len(doc)) + } + text := func(n string) string { return strings.Join(attrs.texts(n), ",") } + if text("document-format") != "application/pdf" || text("sides") != "two-sided-long-edge" || text("media") != "na_letter_8.5x11in" || + text("printer-uri") != "ipp://192.0.2.10/ipp/port1" { + t.Errorf("attributes = %v", attrs.Attributes) + } + if c := attrs.Attributes["copies"]; len(c) != 1 || c[0].Integer != 2 { + t.Errorf("copies = %v", c) + } +} + +// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; an upload is printed by its id. +func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) { + fake := newIPPFake(t, 8, 3) + r := newRig(t, `{"printer":"192.0.2.10"}`, "pw") + jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{9}, 200)...) + if _, err := upload(t, r, "photo", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil { + t.Fatal(err) + } + got, err := r.call(t, "printer.print", map[string]any{"upload": "photo"}) + if err != nil { + t.Fatal(err) + } + if got.(map[string]any)["job-state"] != "pending" { + t.Errorf("answer = %v", got) + } + attrs, sent := fake.request(t) + if !bytes.Equal(sent, jpg) || strings.Join(attrs.texts("document-format"), "") != "image/jpeg" || + strings.Join(attrs.texts("sides"), "") != "one-sided" || strings.Join(attrs.texts("media"), "") != DefaultMedia || attrs.Attributes["copies"][0].Integer != 1 { + t.Errorf("attributes = %v, %d bytes sent", attrs.Attributes, len(sent)) + } + if _, err := r.call(t, "printer.print", map[string]any{"upload": "never-uploaded"}); err == nil || !strings.Contains(err.Error(), "not complete") { + t.Errorf("an upload never made: error = %v", err) + } +} + +// 1453/AC12: a print that is not well formed is refused before anything is queued; the printer's refusal is an +// error naming its IPP status; a printer not set is said by name. +func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) { + fake := newIPPFake(t, 1, 3) + r := newRig(t, `{"printer":"192.0.2.10"}`, "pw") + key := sha256Hex([]byte("x")) + ".pdf" + for name, args := range map[string]map[string]any{ + "neither": {}, + "both": {"object": key, "upload": "u"}, + "copies 0": {"object": key, "copies": float64(-1)}, + "copies 100": {"object": key, "copies": float64(100)}, + "sides": {"object": key, "sides": "sideways"}, + "media": {"object": key, "media": "A4 paper"}, + "not a key": {"object": "../x.pdf"}, + "copies float": {"object": key, "copies": 1.5}, + } { + if _, err := r.call(t, "printer.print", args); err == nil { + t.Errorf("%s: accepted", name) + } + } + if r.bus.asks() != 0 || fake.body != nil { + t.Errorf("%d asks queued, the printer was sent %d bytes", r.bus.asks(), len(fake.body)) + } + doc := pdfOf(100) + dk := sha256Hex(doc) + ".pdf" + r.objects.Put(t.Context(), dk, "application/pdf", doc) + fake.status = 0x0400 + if _, err := r.call(t, "printer.print", map[string]any{"object": dk}); err == nil || !strings.Contains(err.Error(), "0x0400") { + t.Errorf("a refused job: error = %v", err) + } + // A kept object that is not a document the printer takes is not sent. + fake.status, fake.body = 0, nil + r.objects.Put(t.Context(), key, "text/plain", []byte("hello")) + if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") || fake.body != nil { + t.Errorf("a text object: error = %v, sent %d bytes", err, len(fake.body)) + } + un := newRig(t, `{}`, "pw") + if _, err := un.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "printer is not set") || un.bus.asks() != 0 { + t.Errorf("no printer: error = %v", err) + } +} + +// The Print-Job request is the one RFC 8011 gives: header, operation attributes in order, the job's own, the +// end-of-attributes tag, then the document. +func TestThePrintJobRequestIsWellFormed(t *testing.T) { + doc := []byte("%PDF-1.4 hello") + b := EncodePrintJob("ipp://192.0.2.10/ipp/port1", PrintOptions{Format: "application/pdf", Copies: 3, Sides: "one-sided", Media: DefaultMedia, Name: "n"}, doc) + if !bytes.Equal(b[:8], []byte{2, 0, 0, 2, 0, 0, 0, 1}) || b[8] != ippOperation { + t.Errorf("header = %x", b[:9]) + } + a, off, err := decodeIPP(b) + if err != nil || !bytes.Equal(b[off:], doc) { + t.Fatalf("decode: %v, document %q", err, b[off:]) + } + if first := string(b[9:]); !strings.HasPrefix(first[3:], "attributes-charset") { + t.Errorf("the first attribute is not attributes-charset") + } + if len(a.Attributes["copies"]) != 1 || a.Attributes["copies"][0].Integer != 3 { + t.Errorf("copies = %v", a.Attributes["copies"]) + } +} + +// 1453: a scan and a print are acknowledged before the work (a slow one is not delivered again and done twice); +// the other operations are acknowledged after it. +func TestAScanAndAPrintAreAcknowledgedBeforeTheWork(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + r, _ := scanRig(t, f, Settings{Crop: true}) + body, _ := json.Marshal(Ask{ID: "s1", Op: OpScan, At: fixedNow}) + calls := 0 + var postsAtAck int + act, _ := r.worker.Handle(body, func() { calls++; postsAtAck = f.posts }) + if calls != 1 || postsAtAck != 0 || act != actionSettled || f.posts != 1 { + t.Errorf("early called %d times with %d scans begun; action %v; %d scans in all", calls, postsAtAck, act, f.posts) + } + body, _ = json.Marshal(Ask{ID: "s2", Op: OpSettings, Page: DefaultPage, At: fixedNow}) + calls = 0 + if act, _ := r.worker.Handle(body, func() { calls++ }); calls != 0 || act != actionAck { + t.Errorf("settings: early called %d times, action %v", calls, act) + } +} diff --git a/modules/brother/cmd/brother/ipp.go b/modules/brother/cmd/brother/ipp.go index 306f6ad1..5296650c 100644 --- a/modules/brother/cmd/brother/ipp.go +++ b/modules/brother/cmd/brother/ipp.go @@ -12,16 +12,20 @@ import ( "time" ) -// IPP (RFC 8010, RFC 8011) by hand: one operation, Get-Printer-Attributes, which needs no login. +// IPP (RFC 8010, RFC 8011) by hand: Get-Printer-Attributes and Print-Job, neither of which needs a login. const ( ippGetPrinterAttributes = 0x000B + ippPrintJob = 0x0002 ippOperation = 0x01 + ippJob = 0x02 ippEnd = 0x03 ippInteger = 0x21 ippEnum = 0x23 + ippNameNoLang = 0x42 ippKeyword = 0x44 ippURItag = 0x45 + ippMimeType = 0x49 ippCharset = 0x47 ippLanguage = 0x48 ) @@ -94,9 +98,15 @@ type IPPAnswer struct { // DecodeIPP reads a response. It reads only what Get-Printer-Attributes answers hold: groups of attributes of // integers, enums and strings, whose additional values have an empty name. func DecodeIPP(b []byte) (IPPAnswer, error) { + a, _, err := decodeIPP(b) + return a, err +} + +// decodeIPP is DecodeIPP and the offset after the end-of-attributes tag, where a request's document begins. +func decodeIPP(b []byte) (IPPAnswer, int, error) { a := IPPAnswer{Attributes: map[string][]IPPValue{}} if len(b) < 9 { - return a, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b)) + return a, 0, fmt.Errorf("the IPP answer is %d bytes, shorter than its header", len(b)) } a.Status = int(binary.BigEndian.Uint16(b[2:4])) i := 8 @@ -105,26 +115,26 @@ func DecodeIPP(b []byte) (IPPAnswer, error) { tag := b[i] i++ if tag == ippEnd { - return a, nil + return a, i, nil } if tag <= 0x05 { // a group's start last = "" continue } if i+2 > len(b) { - return a, fmt.Errorf("the IPP answer ends inside an attribute") + return a, 0, fmt.Errorf("the IPP answer ends inside an attribute") } nl := int(binary.BigEndian.Uint16(b[i:])) i += 2 if i+nl+2 > len(b) { - return a, fmt.Errorf("the IPP answer ends inside an attribute name") + return a, 0, fmt.Errorf("the IPP answer ends inside an attribute name") } name := string(b[i : i+nl]) i += nl vl := int(binary.BigEndian.Uint16(b[i:])) i += 2 if i+vl > len(b) { - return a, fmt.Errorf("the IPP answer ends inside the value of %q", name) + return a, 0, fmt.Errorf("the IPP answer ends inside the value of %q", name) } raw := b[i : i+vl] i += vl @@ -140,7 +150,7 @@ func DecodeIPP(b []byte) (IPPAnswer, error) { } a.Attributes[name] = append(a.Attributes[name], v) } - return a, fmt.Errorf("the IPP answer has no end-of-attributes tag") + return a, 0, fmt.Errorf("the IPP answer has no end-of-attributes tag") } func (a IPPAnswer) texts(name string) []string { @@ -234,3 +244,102 @@ func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) { } return ans.State() } + +// PrintOptions are what a Print-Job asks of the printer. +type PrintOptions struct { + Format string // document-format, application/pdf or image/jpeg + Copies int + Sides string + Media string + Name string // job-name +} + +// SniffFormat is the document format of the bytes the printer accepts: PDF or JPEG, by their first bytes, and +// otherwise an error. +func SniffFormat(doc []byte) (format, ext string, err error) { + switch { + case bytes.HasPrefix(doc, []byte("%PDF-")): + return "application/pdf", "pdf", nil + case len(doc) > 3 && doc[0] == 0xFF && doc[1] == 0xD8 && doc[2] == 0xFF: + return "image/jpeg", "jpg", nil + } + return "", "", fmt.Errorf("the document is neither a PDF nor a JPEG: the printer is sent those two only") +} + +// EncodePrintJob is the Print-Job request (RFC 8011 §4.2.1): the operation attributes in the order the standard +// requires, the job's copies, sides and media, and the document after the end-of-attributes tag. +func EncodePrintJob(printerURI string, o PrintOptions, doc []byte) []byte { + b := []byte{0x02, 0x00, 0x00, byte(ippPrintJob), 0, 0, 0, 1, ippOperation} + b = appendAttr(b, ippCharset, "attributes-charset", "utf-8") + b = appendAttr(b, ippLanguage, "attributes-natural-language", "en") + b = appendAttr(b, ippURItag, "printer-uri", printerURI) + b = appendAttr(b, ippNameNoLang, "requesting-user-name", "brother") + b = appendAttr(b, ippNameNoLang, "job-name", o.Name) + b = appendAttr(b, ippMimeType, "document-format", o.Format) + b = append(b, ippJob) + b = appendAttr(b, ippInteger, "copies", string(binary.BigEndian.AppendUint32(nil, uint32(o.Copies)))) + b = appendAttr(b, ippKeyword, "sides", o.Sides) + b = appendAttr(b, ippKeyword, "media", o.Media) + b = append(b, ippEnd) + return append(b, doc...) +} + +// jobStates are the values RFC 8011 gives job-state. +var jobStates = map[int]string{3: "pending", 4: "pending-held", 5: "processing", 6: "processing-stopped", 7: "canceled", 8: "aborted", 9: "completed"} + +// PrintResult is what a Print-Job answered: the job's id and state. +type PrintResult struct { + JobID int `json:"job-id"` + State string `json:"job-state"` + StateCode int `json:"job-state-code"` + Reasons []string `json:"job-state-reasons"` +} + +// PrintResult reads a Print-Job answer; a failing status is an error naming it. +func (a IPPAnswer) PrintResult() (PrintResult, error) { + if a.Status >= 0x0100 { + return PrintResult{}, fmt.Errorf("the printer refused the print job with IPP status 0x%04x", a.Status) + } + id := a.Attributes["job-id"] + if len(id) == 0 { + return PrintResult{}, fmt.Errorf("the printer's answer to the print job names no job-id") + } + r := PrintResult{JobID: id[0].Integer, Reasons: a.texts("job-state-reasons")} + if r.Reasons == nil { + r.Reasons = []string{} + } + if st := a.Attributes["job-state"]; len(st) > 0 { + r.StateCode = st[0].Integer + r.State = jobStates[r.StateCode] + if r.State == "" { + r.State = fmt.Sprintf("unknown (%d)", r.StateCode) + } + } + return r, nil +} + +// PrintJob sends the document to the printer's IPP service as a Print-Job, and answers the job's id and state. +func PrintJob(ctx context.Context, host string, o PrintOptions, doc []byte) (PrintResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host), bytes.NewReader(EncodePrintJob(ippPrinterURI(host), o, doc))) + if err != nil { + return PrintResult{}, err + } + req.Header.Set("Content-Type", "application/ipp") + resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req) + if err != nil { + return PrintResult{}, fmt.Errorf("the printer's IPP service does not answer: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return PrintResult{}, fmt.Errorf("the printer's IPP answer cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return PrintResult{}, fmt.Errorf("the printer's IPP service answered %s", resp.Status) + } + ans, err := DecodeIPP(body) + if err != nil { + return PrintResult{}, err + } + return ans.PrintResult() +} diff --git a/modules/brother/cmd/brother/ipp_test.go b/modules/brother/cmd/brother/ipp_test.go index 0a25934f..2fbf35df 100644 --- a/modules/brother/cmd/brother/ipp_test.go +++ b/modules/brother/cmd/brother/ipp_test.go @@ -91,7 +91,7 @@ func TestPrinterStatusToolGivesTheIPPAndESCLState(t *testing.T) { ippURL = func(string) string { return url } t.Cleanup(func() { ippURL = old }) f := newFakeScanner(t, nil) - tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), nil, time.Now) + tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), time.Now) got, err := tool(t, tl, "printer.status").Run(nil) if err != nil { t.Fatal(err) @@ -121,7 +121,7 @@ func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) { ippURL = func(string) string { return "http://127.0.0.1:1/ipp/port1" } t.Cleanup(func() { ippURL = old }) f := newFakeScanner(t, nil) - tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), nil, time.Now) + tl := tools(settingsFile(t, `{"printer":"192.0.2.10","scanner":"`+f.base()+`"}`), time.Now) got, err := tool(t, tl, "printer.status").Run(nil) if err != nil { t.Fatal(err) @@ -135,7 +135,7 @@ func TestPrinterStatusSaysWhatDoesNotAnswerAndWhatIsUnset(t *testing.T) { {`{"printer":"192.0.2.10"}`, "scanner is not set"}, {`{}`, "printer and scanner"}, } { - tl := tools(settingsFile(t, c.js), nil, time.Now) + tl := tools(settingsFile(t, c.js), time.Now) if _, err := tool(t, tl, "printer.status").Run(nil); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: error = %v, want %q", c.js, err, c.want) } diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index 22802571..e463b621 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -13,29 +13,131 @@ func main() { if len(os.Args) > 1 && os.Args[1] == "device" { os.Exit(device()) } - if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), emit, time.Now)); err != nil { + if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), time.Now)); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } -// emit says scan.added on the bus, under the file's sha256 as its message id. -func emit(body map[string]any, id string) error { - return stdio.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id}) +// intArg reads a whole number out of a tool's arguments: a JSON number, which arrives as a float64. +func intArg(args map[string]any, name string) (int, error) { + v, ok := args[name] + if !ok { + return 0, nil + } + switch n := v.(type) { + case float64: + if n != float64(int(n)) { + return 0, fmt.Errorf("%s is a whole number", name) + } + return int(n), nil + case int: + return n, nil + } + return 0, fmt.Errorf("%s is a whole number", name) } -// tools are the scanner seat's three verbs, served as . (ADR 0467 clause 2). The settings are read at each call, so a changed config value is the next -// call's. -func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool { +func strArg(args map[string]any, name string) (string, error) { + v, ok := args[name] + if !ok { + return "", nil + } + s, isStr := v.(string) + if !isStr { + return "", fmt.Errorf("%s is a string", name) + } + return s, nil +} + +// askFrom reads the arguments an ask takes out of a tool's. +func askFrom(args map[string]any) (Ask, error) { + var a Ask + var err error + for name, dst := range map[string]*string{"page": &a.Page, "object": &a.Object, "upload": &a.Upload, "sha256": &a.SHA256, + "data": &a.Data, "sides": &a.Sides, "media": &a.Media} { + if *dst, err = strArg(args, name); err != nil { + return a, err + } + } + for name, dst := range map[string]*int{"part": &a.Part, "parts": &a.Parts, "copies": &a.Copies} { + if *dst, err = intArg(args, name); err != nil { + return a, err + } + } + return a, nil +} + +// tools are the scanner seat's and the printer seat's verbs, served as . (ADR 0467 clause 2, ADR 0468). +// The settings are read at each call, so a changed config value is the next call's. None opens a credential: what +// needs one is asked of the device process. +func tools(settingsFile string, now func() time.Time) []stdio.Tool { service := func() (*Service, error) { s, err := LoadSettings(settingsFile) if err != nil { return nil, err } - return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say, Asks: askBus, - AnswerWait: answerWait, AnswerPoll: answerPoll}, nil + return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus, + AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll}, nil } + // ask is a tool that reads its arguments, and answers what the service gives. + ask := func(run func(s *Service, ctx context.Context, a Ask) (map[string]any, error), bound time.Duration) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + a, err := askFrom(args) + if err != nil { + return nil, err + } + s, err := service() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), bound) + defer cancel() + return run(s, ctx, a) + } + } + str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} } + num := func(d string) map[string]any { return map[string]any{"type": "integer", "description": d} } return []stdio.Tool{ + { + Name: "printer.upload", + Description: "Send one part of a document to the device process, to be printed later (printer.print with the upload id). " + + "Cut the document into parts of at most 700 KiB (768 KiB is the limit), number them from 1, and send each with the " + + "whole document's SHA-256. When the last part has come and the joined SHA-256 matches, the document is stored as " + + "uploads/.pdf or .jpg and the answer says complete; a wrong SHA-256 stores nothing. PDF and JPEG only.", + Input: map[string]any{"type": "object", "required": []string{"upload", "part", "parts", "sha256", "data"}, "properties": map[string]any{ + "upload": str("an id of your choosing: letters, digits, - and _, at most 64"), + "part": num("this part's number, from 1"), + "parts": num("how many parts the document has"), + "sha256": str("the whole document's SHA-256, lower-case hex"), + "data": str("this part's bytes, base64"), + }}, + Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterUpload(ctx, a) }, 2*time.Minute), + }, + { + Name: "printer.print", + Description: "Print a document on the printer: a scan kept in the bucket (object) or a completed upload (upload). The device " + + "process sends it as an IPP Print-Job in its own format (PDF or JPEG) and answers the job's id and state.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "object": str("a scan's key, as scan.added names it"), + "upload": str("a completed upload's id"), + "copies": num("1 to 99 (default 1)"), + "sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"), + "media": str("the paper's size name (default " + DefaultMedia + ")"), + }}, + Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { return s.PrinterPrint(ctx, a) }, 2*time.Minute), + }, + { + Name: "scanner.document", + Description: "One part of a scan kept in the bucket, by the key scan.added names (object): answers part, parts (how many), " + + "size, sha256 (of the whole document) and data (this part, base64). Join the parts and check the SHA-256.", + Input: map[string]any{"type": "object", "required": []string{"object", "part"}, "properties": map[string]any{ + "object": str("the scan's key"), + "part": num("the part's number, from 1"), + }}, + Run: ask(func(s *Service, ctx context.Context, a Ask) (map[string]any, error) { + return s.ScanDocument(ctx, a.Object, a.Part) + }, 2*time.Minute), + }, { Name: "printer.status", Description: "The printer's state without a login: over IPP its state (idle, processing or stopped), its state " + @@ -86,16 +188,19 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool }, { Name: "scanner.scan", - Description: "Scan the glass of the configured network scanner, cut the document out of the platen's white, " + - "file it as a one-page PDF in the scan folder and say scan.added on the bus. Answers the event's body: " + - "the file's path, its size and sha256, its real size in millimetres, the resolution the scanner " + - "really gave, whether it was cropped. The image itself is never sent.", + Description: "Scan the glass of the configured network scanner: the device process cuts the document out of the platen's " + + "white, keeps it as a one-page PDF in the module's bucket under its sha256 and says scan.added on the bus; this " + + "tool then fetches it and files it in the scan folder. Waits up to three minutes. Answers the event's body: " + + "the object's key, its size and sha256, its real size in millimetres, the resolution the scanner really gave, " + + "whether it was cropped, and the file's path. The image itself is never sent.", Run: func(map[string]any) (any, error) { s, err := service() if err != nil { return nil, err } - return s.ScanNow(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute) + defer cancel() + return s.ScanNow(ctx) }, }, { diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 270ead7e..ada6b1c3 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "reflect" "regexp" + "slices" + "sort" "strings" "testing" ) @@ -62,15 +64,30 @@ func TestTheScanFolderIsNoManifestResource(t *testing.T) { // 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named. func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) { m := readManifest(t) - if !reflect.DeepEqual(m.Emits, []string{EventAdded}) { - t.Errorf("emits = %v, want [%s]", m.Emits, EventAdded) + if len(m.Emits) != 0 { + t.Errorf("the module's own emits = %v: scan.added is said by the device process, whose bus block declares it", m.Emits) + } + bus, _ := resource(t, m, "device")["bus"].(map[string]any) + if !reflect.DeepEqual(bus["emits"], []any{EventAdded}) { + t.Errorf("the device process's bus emits = %v, want [%s]", bus["emits"], EventAdded) } var served []string - for _, tl := range tools("", nil, nil) { + for _, tl := range tools("", nil) { served = append(served, tl.Name) } - if !reflect.DeepEqual(m.Tools, served) { - t.Errorf("manifest tools = %v, the bundle serves %v", m.Tools, served) + got := append([]string{}, m.Tools...) + sort.Strings(got) + sort.Strings(served) + if !reflect.DeepEqual(got, served) { + t.Errorf("manifest tools = %v, the bundle serves %v", got, served) + } + // Every verb of a seat is a tool named .. + for _, st := range m.Claims { + for _, v := range st.Serves { + if !slices.Contains(served, st.Name+"."+v) { + t.Errorf("the seat %s serves %s, but no tool %s.%s is served", st.Name, v, st.Name, v) + } + } } } @@ -142,9 +159,9 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { func TestTheManifestDeclaresAndClaimsTheScannerAndPrinterSeats(t *testing.T) { m := readManifest(t) for _, want := range []seat{ - {Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}}, + {Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list", "document"}}, // restart is round 2 of issue 1453, and is not declared yet. - {Name: "printer", Scope: "mesh", Serves: []string{"status", "settings"}}, + {Name: "printer", Scope: "mesh", Serves: []string{"status", "settings", "print", "upload"}}, } { for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} { found := false diff --git a/modules/brother/cmd/brother/parts.go b/modules/brother/cmd/brother/parts.go new file mode 100644 index 00000000..4fa4d983 --- /dev/null +++ b/modules/brother/cmd/brother/parts.go @@ -0,0 +1,59 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// Documents cross the bus in parts (novox/hq ADR 0468 clause 3): the bus refuses a message over 1 MiB, and a +// part travels base64-encoded inside a JSON message. +const ( + // MaxPart is the most raw bytes a part may hold; a part of more is refused. + MaxPart = 768 << 10 + // PartSize is how large the parts this module cuts are: below MaxPart, so that the part's base64 text and + // the message around it stay under the bus's 1 MiB. + PartSize = 700 << 10 + // MostParts bounds how many parts one document has (a 100 MiB document is the most). + MostParts = 150 +) + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// partCount is how many parts of PartSize hold size bytes: at least one. +func partCount(size int) int { + if size <= 0 { + return 1 + } + return (size + PartSize - 1) / PartSize +} + +// cutPart is part number part (counted from 1) of data, and how many parts there are. +func cutPart(data []byte, part int) ([]byte, int, error) { + n := partCount(len(data)) + if part < 1 || part > n { + return nil, n, fmt.Errorf("part %d is out of range: the document has parts 1 to %d", part, n) + } + lo := (part - 1) * PartSize + hi := min(lo+PartSize, len(data)) + return data[lo:hi], n, nil +} + +// joinParts is the parts in order as one document, once its SHA-256 is the one named. +func joinParts(parts [][]byte, wantSHA string) ([]byte, error) { + var n int + for _, p := range parts { + n += len(p) + } + out := make([]byte, 0, n) + for _, p := range parts { + out = append(out, p...) + } + if got := sha256Hex(out); got != wantSHA { + return nil, fmt.Errorf("the joined parts' SHA-256 is %s, not the %s named: refused", got, wantSHA) + } + return out, nil +} diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index 29436fcb..b6cf5b02 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -3,10 +3,12 @@ package main import ( "context" "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" "regexp" + "slices" "strings" "time" @@ -20,10 +22,9 @@ var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`) // DefaultPage is the page printer.settings reads when none is asked for. const DefaultPage = "/general/status.html" -// The ask printer.settings puts to the device process (ADR 0467 clause 4; the shape of ADR 0436's mail-sends): -// the tool never holds the admin password, so it queues the ask on the work-queue stream device-asks and the -// device process, an account of its own that alone reads the password, answers under the ask's id in the state -// device-answers. +// The asks the verbs put to the device process (ADR 0468 clause 1; the shape of ADR 0436's mail-sends): the tools +// never hold a credential, so each verb queues an ask on the work-queue stream device-asks, and the device process, +// an account of its own, answers under the ask's id in the state device-answers. const ( // EventAsked is the event on the work queue; the stream device-asks carries it. EventAsked = "device.asked" @@ -35,21 +36,42 @@ const ( // DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller // delivers its bus account as the own secret broker-device. DeviceProcessID = "device" - // AnswerWait is how long printer.settings waits for the device process. + // AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does. AnswerWait = 60 * time.Second - // StaleAfter is how old an ask may be when the device process takes it: older than twice the wait, nobody waits - // for it any more, so the printer is not contacted for it. - StaleAfter = 2 * AnswerWait + ScanWait = 180 * time.Second + // StaleAfter is how old an ask may be when the device process takes it: older than twice the longest wait, + // nobody waits for it any more, so the device is not touched for it. + StaleAfter = 2 * ScanWait ) -// Ask is what the tool queues: which page, and when it was asked. +// The operations of an ask. +const ( + OpSettings = "settings" + OpScan = "scan" + OpDocument = "document" + OpUpload = "upload" + OpPrint = "print" +) + +// Ask is what a verb queues: the operation, when it was asked, and the operation's own arguments. type Ask struct { - ID string `json:"id"` - Page string `json:"page"` - At time.Time `json:"asked-at"` + ID string `json:"id"` + Op string `json:"op"` + At time.Time `json:"asked-at"` + + Page string `json:"page,omitempty"` // settings + Object string `json:"object,omitempty"` // document, print: a scan's key + Upload string `json:"upload,omitempty"` // upload, print: an upload's id + Part int `json:"part,omitempty"` // document, upload: counted from 1 + Parts int `json:"parts,omitempty"` // upload + SHA256 string `json:"sha256,omitempty"` // upload: the whole document's + Data string `json:"data,omitempty"` // upload: the part, base64 + Copies int `json:"copies,omitempty"` // print + Sides string `json:"sides,omitempty"` // print + Media string `json:"media,omitempty"` // print } -// AskBus is how the tool reaches the device process: it queues an ask and reads an answer back. +// AskBus is how the tools reach the device process: they queue an ask and read an answer back. type AskBus interface { Publish(id string, body any) error Answer(id string) (json.RawMessage, error) @@ -69,9 +91,10 @@ func (sdkAskBus) Answer(id string) (json.RawMessage, error) { return e.Value, nil } -// answerWait and answerPoll are what the tools wait with; a test shortens them. +// answerWait, scanWait and answerPoll are what the tools wait with; a test shortens them. var ( answerWait = AnswerWait + scanWait = ScanWait answerPoll = 500 * time.Millisecond ) @@ -84,27 +107,17 @@ func newAskID() string { return hex.EncodeToString(b) } -// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for -// its answer. It checks the page and that printer and printer-certificate are set, and reads, opens and sends -// nothing of the admin password (ADR 0467 clause 4; ADR 0436 §12): nothing is queued before those checks pass. -func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) { - if page == "" { - page = DefaultPage - } - if !validPage(page) { - return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page) - } - if err := s.Settings.MissingPrinter(); err != nil { - return nil, err - } - wait, poll := s.AnswerWait, s.AnswerPoll +// call queues the ask and waits for the device process's answer, at most wait. An answer that is an error is +// the error. Nothing here reads, opens or sends a credential (ADR 0436 §12). +func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[string]any, error) { + poll := s.AnswerPoll if wait <= 0 { wait = AnswerWait } if poll <= 0 { poll = 500 * time.Millisecond } - ask := Ask{ID: newAskID(), Page: page, At: s.Now().UTC()} + ask.ID, ask.At = newAskID(), s.Now().UTC() if err := s.Asks.Publish(ask.ID, ask); err != nil { return nil, fmt.Errorf("the ask for the device process could not be queued: %w", err) } @@ -127,17 +140,142 @@ func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string] } select { case <-ctx.Done(): - return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the printer is slow (ask %s)", + return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the device is slow (ask %s)", wait, ask.ID) case <-time.After(poll): } } } +// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for +// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set. +func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) { + if page == "" { + page = DefaultPage + } + if !validPage(page) { + return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page) + } + if err := s.Settings.MissingPrinter(); err != nil { + return nil, err + } + return s.call(ctx, Ask{Op: OpSettings, Page: page}, s.AnswerWait) +} + func validPage(page string) bool { return pagePattern.MatchString(page) && !strings.Contains(page, "..") && !strings.Contains(page, "//") } +var ( + scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`) + uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`) + sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +// Sides are the values the printer's sides take. +var sidesValues = []string{"one-sided", "two-sided-long-edge", "two-sided-short-edge"} + +// DefaultMedia is the paper printer.print takes when none is named. +const DefaultMedia = "iso_a4_210x297mm" + +// checkDocument is what is wrong with an ask for a scan's part, or nil. +func checkDocument(a Ask) error { + if !scanKeyPattern.MatchString(a.Object) { + return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object) + } + if a.Part < 1 { + return fmt.Errorf("part is a whole number from 1") + } + return nil +} + +// checkUpload is what is wrong with an ask for an upload's part, or nil: the numbers, the SHA-256, the size. +func checkUpload(a Ask) error { + if !uploadPattern.MatchString(a.Upload) { + return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload) + } + if a.Parts < 1 || a.Parts > MostParts { + return fmt.Errorf("parts is a whole number from 1 to %d, not %d", MostParts, a.Parts) + } + if a.Part < 1 || a.Part > a.Parts { + return fmt.Errorf("part %d is out of range: the upload has parts 1 to %d", a.Part, a.Parts) + } + if !sha256Pattern.MatchString(a.SHA256) { + return fmt.Errorf("sha256 is the whole document's SHA-256, 64 lower-case hex digits") + } + raw, err := base64.StdEncoding.DecodeString(a.Data) + if err != nil { + return fmt.Errorf("data is not base64: %v", err) + } + if len(raw) > MaxPart { + return fmt.Errorf("a part is at most %d bytes (768 KiB), not %d", MaxPart, len(raw)) + } + return nil +} + +// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in. +func checkPrint(a *Ask) error { + if (a.Object == "") == (a.Upload == "") { + return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither") + } + if a.Object != "" && !scanKeyPattern.MatchString(a.Object) { + return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object) + } + if a.Upload != "" && !uploadPattern.MatchString(a.Upload) { + return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload) + } + if a.Copies == 0 { + a.Copies = 1 + } + if a.Copies < 1 || a.Copies > 99 { + return fmt.Errorf("copies is a whole number from 1 to 99, not %d", a.Copies) + } + if a.Sides == "" { + a.Sides = "one-sided" + } + if !slices.Contains(sidesValues, a.Sides) { + return fmt.Errorf("sides is one of %s, not %q", strings.Join(sidesValues, ", "), a.Sides) + } + if a.Media == "" { + a.Media = DefaultMedia + } + if !mediaPattern.MatchString(a.Media) { + return fmt.Errorf("media %q is not a media size name such as %s", a.Media, DefaultMedia) + } + return nil +} + +// ScanDocument asks for one part of a scan kept in the bucket. +func (s *Service) ScanDocument(ctx context.Context, object string, part int) (map[string]any, error) { + a := Ask{Op: OpDocument, Object: object, Part: part} + if err := checkDocument(a); err != nil { + return nil, err + } + return s.call(ctx, a, s.AnswerWait) +} + +// PrinterUpload sends one part of a document to be printed later, and says whether the upload is complete. +func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, error) { + a.Op = OpUpload + if err := checkUpload(a); err != nil { + return nil, err + } + return s.call(ctx, a, s.AnswerWait) +} + +// PrinterPrint asks the device process to print a kept scan or a completed upload. +func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) { + a.Op = OpPrint + if err := checkPrint(&a); err != nil { + return nil, err + } + if s.Settings.Printer == "" { + return nil, unsetError("printer") + } + return s.call(ctx, a, s.AnswerWait) +} + // PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login // (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and // scanner is not set is said by name, and nothing is contacted. @@ -150,8 +288,7 @@ func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) { unset = append(unset, "scanner") } if len(unset) > 0 { - return nil, fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", - strings.Join(unset, " and ")) + return nil, unsetError(unset...) } out := map[string]any{} if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil { diff --git a/modules/brother/cmd/brother/printer_test.go b/modules/brother/cmd/brother/printer_test.go index 679c6f2c..45dad26c 100644 --- a/modules/brother/cmd/brother/printer_test.go +++ b/modules/brother/cmd/brother/printer_test.go @@ -6,119 +6,12 @@ import ( "os" "path/filepath" "strings" - "sync" "testing" "time" - stdio "git.novox.be/novox/mesh-sdk/go" - "brother/cmd/brother/internal/secret" ) -// memAnswers is the state device-answers, in memory. -type memAnswers struct { - mu sync.Mutex - m map[string]json.RawMessage - // fail, when set, is the error every Create answers. - fail error -} - -func (a *memAnswers) Create(key string, v any) (uint64, error) { - a.mu.Lock() - defer a.mu.Unlock() - if a.fail != nil { - return 0, a.fail - } - if _, ok := a.m[key]; ok { - return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) - } - if a.m == nil { - a.m = map[string]json.RawMessage{} - } - raw, _ := json.Marshal(v) - a.m[key] = raw - return 1, nil -} - -func (a *memAnswers) get(key string) json.RawMessage { - a.mu.Lock() - defer a.mu.Unlock() - return a.m[key] -} - -// fakeAskBus is the bus between the tool and the device process: an ask published on it is handed to the -// worker, as the work queue hands it to the device process, and the worker's answers are what the tool reads. -type fakeAskBus struct { - worker *deviceWorker // nil: nobody consumes the queue - answers *memAnswers - mu sync.Mutex - published []Ask -} - -func (b *fakeAskBus) Publish(id string, body any) error { - raw, _ := json.Marshal(body) - var ask Ask - json.Unmarshal(raw, &ask) - b.mu.Lock() - b.published = append(b.published, ask) - b.mu.Unlock() - if b.worker != nil { - go b.worker.Handle(raw) - } - return nil -} - -func (b *fakeAskBus) Answer(id string) (json.RawMessage, error) { return b.answers.get(id), nil } - -func (b *fakeAskBus) asks() int { - b.mu.Lock() - defer b.mu.Unlock() - return len(b.published) -} - -// printerRig is the tool, the bus and the admin worker over a settings file for the fake printer, with the -// secret in a file the way the mesh writes it (a trailing newline included); an empty password writes none. -type printerRig struct { - run func(args map[string]any) (any, error) - bus *fakeAskBus - worker *deviceWorker - answers *memAnswers -} - -func newRig(t *testing.T, settings, password string) *printerRig { - t.Helper() - secretFile := filepath.Join(t.TempDir(), "admin-password.secret") - if password != "" { - if err := os.WriteFile(secretFile, []byte(password+"\n"), 0o600); err != nil { - t.Fatal(err) - } - } - file := settingsFile(t, settings) - answers := &memAnswers{} - w := &deviceWorker{ - Settings: func() (Settings, error) { return LoadSettings(file) }, - Password: func() (string, error) { return secret.Read(secretFile) }, - Answers: answers, - Now: time.Now, - } - bus := &fakeAskBus{worker: w, answers: answers} - old, oldWait, oldPoll := askBus, answerWait, answerPoll - askBus, answerWait, answerPoll = bus, 5*time.Second, 5*time.Millisecond - t.Cleanup(func() { askBus, answerWait, answerPoll = old, oldWait, oldPoll }) - tl := tools(file, nil, time.Now) - return &printerRig{run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers} -} - -func printerSettingsJSON(f *fakePrinter) string { - return `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}` -} - -func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) { - t.Helper() - js := printerSettingsJSON(f) - return js, newRig(t, js, password).run -} - // 1453/AC3: printer.settings logs in by the field name the form gives, sends the password from the secret // file, follows the session cookie, and returns the page's title, links and form fields; a password field's // value is never returned, and the session is logged out. The page is SYNTHETIC (testdata/admin/ @@ -296,25 +189,25 @@ func TestTheAdminWorkerSettlesEachAsk(t *testing.T) { f := newFakePrinter(t, "B1891", "pw") r := newRig(t, printerSettingsJSON(f), "pw") w := r.worker - if act, _ := w.Handle([]byte("not json")); act != actionTerm { + if act, _ := w.Handle([]byte("not json"), nil); act != actionTerm { t.Errorf("an unreadable ask: action %v, want term", act) } - old, _ := json.Marshal(Ask{ID: "old", Page: DefaultPage, At: time.Now().Add(-StaleAfter - time.Second)}) - if act, _ := w.Handle(old); act != actionAck || r.answers.get("old") != nil || f.conns != 0 { + old, _ := json.Marshal(Ask{ID: "old", Op: OpSettings, Page: DefaultPage, At: fixedNow.Add(-StaleAfter - time.Second)}) + if act, _ := w.Handle(old, nil); act != actionAck || r.answers.get("old") != nil || f.conns != 0 { t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.conns) } - fresh, _ := json.Marshal(Ask{ID: "a1", Page: DefaultPage, At: time.Now()}) - if act, _ := w.Handle(fresh); act != actionAck || r.answers.get("a1") == nil { + fresh, _ := json.Marshal(Ask{ID: "a1", Op: OpSettings, Page: DefaultPage, At: fixedNow}) + if act, _ := w.Handle(fresh, nil); act != actionAck || r.answers.get("a1") == nil { t.Errorf("a fresh ask: action %v", act) } conns := f.conns - if act, _ := w.Handle(fresh); act != actionAck { + if act, _ := w.Handle(fresh, nil); act != actionAck { t.Errorf("an ask already answered: action %v, want ack", act) } _ = conns r.answers.fail = fmt.Errorf("the bus is away") - again, _ := json.Marshal(Ask{ID: "a2", Page: DefaultPage, At: time.Now()}) - if act, delay := w.Handle(again); act != actionRetry || delay <= 0 { + again, _ := json.Marshal(Ask{ID: "a2", Op: OpSettings, Page: DefaultPage, At: fixedNow}) + if act, delay := w.Handle(again, nil); act != actionRetry || delay <= 0 { t.Errorf("an answer that cannot be written: action %v delay %v, want retry", act, delay) } } diff --git a/modules/brother/cmd/brother/rig_test.go b/modules/brother/cmd/brother/rig_test.go new file mode 100644 index 00000000..0d2f0d23 --- /dev/null +++ b/modules/brother/cmd/brother/rig_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "brother/cmd/brother/internal/secret" +) + +// fixedNow is the time the tools and the device worker of a rig read. +var fixedNow = time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + +// memAnswers is the state device-answers, in memory. +type memAnswers struct { + mu sync.Mutex + m map[string]json.RawMessage + // fail, when set, is the error every Create answers. + fail error +} + +func (a *memAnswers) Create(key string, v any) (uint64, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.fail != nil { + return 0, a.fail + } + if _, ok := a.m[key]; ok { + return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged) + } + if a.m == nil { + a.m = map[string]json.RawMessage{} + } + raw, _ := json.Marshal(v) + if len(raw) > 1<<20 { + return 0, fmt.Errorf("an answer of %d bytes is over the bus's 1 MiB", len(raw)) + } + a.m[key] = raw + return 1, nil +} + +func (a *memAnswers) get(key string) json.RawMessage { + a.mu.Lock() + defer a.mu.Unlock() + return a.m[key] +} + +// fakeAskBus is the bus between the tools and the device process: an ask published on it is handed to the +// worker, as the work queue hands it to the device process, and the worker's answers are what the tools read. +type fakeAskBus struct { + worker *deviceWorker // nil: nobody consumes the queue + answers *memAnswers + mu sync.Mutex + published []Ask +} + +func (b *fakeAskBus) Publish(id string, body any) error { + raw, _ := json.Marshal(body) + if len(raw) > 1<<20 { + return fmt.Errorf("a message of %d bytes is over the bus's 1 MiB", len(raw)) + } + var ask Ask + json.Unmarshal(raw, &ask) + b.mu.Lock() + b.published = append(b.published, ask) + b.mu.Unlock() + if b.worker != nil { + go b.worker.Handle(raw, nil) + } + return nil +} + +func (b *fakeAskBus) Answer(id string) (json.RawMessage, error) { return b.answers.get(id), nil } + +func (b *fakeAskBus) asks() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.published) +} + +func (b *fakeAskBus) ops() []string { + b.mu.Lock() + defer b.mu.Unlock() + var o []string + for _, a := range b.published { + o = append(o, a.Op) + } + return o +} + +// printerRig is the tools, the bus and the device worker over a settings file, with the secret in a file the way +// the mesh writes it (a trailing newline included; an empty password writes none) and the bucket in memory. +type printerRig struct { + tl []stdio.Tool + run func(args map[string]any) (any, error) // printer.settings + bus *fakeAskBus + worker *deviceWorker + answers *memAnswers + objects *memObjects + said *[]said + mu *sync.Mutex +} + +// call runs a tool by name. +func (r *printerRig) call(t *testing.T, name string, args map[string]any) (any, error) { + t.Helper() + return tool(t, r.tl, name).Run(args) +} + +func newRig(t *testing.T, settings, password string) *printerRig { + t.Helper() + secretFile := filepath.Join(t.TempDir(), "admin-password.secret") + if password != "" { + if err := os.WriteFile(secretFile, []byte(password+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + file := settingsFile(t, settings) + answers, objects := &memAnswers{}, newMemObjects() + var out []said + var mu sync.Mutex + w := &deviceWorker{ + Settings: func() (Settings, error) { return LoadSettings(file) }, + Password: func() (string, error) { return secret.Read(secretFile) }, + Objects: func() (Objects, error) { return objects, nil }, + Emit: func(body map[string]any, id string) error { + mu.Lock() + defer mu.Unlock() + _, err := objects.Get(t.Context(), body["object"].(string)) + out = append(out, said{body, id, err == nil}) + return nil + }, + Answers: answers, + Node: "shanks", + Now: func() time.Time { return fixedNow }, + } + bus := &fakeAskBus{worker: w, answers: answers} + old, oldWait, oldScan, oldPoll := askBus, answerWait, scanWait, answerPoll + askBus, answerWait, scanWait, answerPoll = bus, 5*time.Second, 5*time.Second, 5*time.Millisecond + t.Cleanup(func() { askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll }) + tl := tools(file, func() time.Time { return fixedNow }) + return &printerRig{tl: tl, run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers, objects: objects, said: &out, mu: &mu} +} + +func printerSettingsJSON(f *fakePrinter) string { + return `{"printer":"` + f.host() + `","printer-certificate":"` + f.print() + `"}` +} + +func printerTools(t *testing.T, f *fakePrinter, password string) (settings string, run func(args map[string]any) (any, error)) { + t.Helper() + js := printerSettingsJSON(f) + return js, newRig(t, js, password).run +} diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index b10279cf..97b07928 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -2,85 +2,91 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" + "encoding/base64" "fmt" - "math" "time" ) -// EventAdded is the module's event: a scan was filed (ADR 0464). Consumers declare brother.scan.added; it -// travels as mesh.mod.brother.event.scan.added. It names the file and never carries the image. +// EventAdded is the module's event: a scan was kept in the bucket (ADR 0464, ADR 0468 clause 2), said by the device +// process. Consumers declare brother.scan.added; it +// travels as mesh.mod.brother.event.scan.added. It names the object and never carries the image. const EventAdded = "scan.added" -// Emitter says an event on the bus under a message id, so a repeated publish is kept to one (ADR 0436). -type Emitter func(body map[string]any, messageID string) error - -// Service is what the tools do, over one reading of the settings. +// Service is what the tools do, over one reading of the settings. The device process's own scan is Capture's. type Service struct { Settings Settings // Node is this machine's name, for the event. Node string Now func() time.Time - Emit Emitter - // Asks is how printer.settings reaches the device process, which alone logs in to the printer. + // Asks is how the verbs reach the device process, which alone holds a credential. Asks AskBus - // AnswerWait is how long printer.settings waits for the device process's answer; AnswerPoll how often it looks. - AnswerWait, AnswerPoll time.Duration + // AnswerWait is how long a verb waits for the device process's answer (ScanWait for a scan); AnswerPoll how + // often it looks. + AnswerWait, ScanWait, AnswerPoll time.Duration } -func mm(pixels, dpi int) float64 { - return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10 -} - -// ScanNow scans the glass, crops, files and says so. Nothing is written and nothing is said when the scanner -// does not give a page; the error says why. The event follows the rename, once (1440/AC6). +// ScanNow asks the device process to scan, crop, build the PDF, keep it in the bucket and say scan.added, waits for +// the stored scan's key, fetches its parts through the device process, joins them, checks their SHA-256 and files +// the PDF in the scan folder (temp and rename, no overwrite). It answers the event's body and the path. Nothing is +// asked before scanner and scan-folder are known to be set (1440/AC7); the tool never reads a credential. func (s *Service) ScanNow(ctx context.Context) (map[string]any, error) { if err := s.Settings.Missing(); err != nil { return nil, err } - at := s.Now() - scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode) + ans, err := s.call(ctx, Ask{Op: OpScan}, s.ScanWait) if err != nil { return nil, err } - // The resolution is the image's own, never the one asked (1440/AC2). - dpi, err := Density(scan) + key, _ := ans["key"].(string) + sum, _ := ans["sha256"].(string) + parts, _ := ans["parts"].(float64) + if key == "" || sum == "" || parts < 1 || parts > MostParts { + return nil, fmt.Errorf("the device process's answer to the scan names no stored scan: %v", ans) + } + got := make([][]byte, 0, int(parts)) + for i := 1; i <= int(parts); i++ { + p, err := s.fetchPart(ctx, key, i) + if err != nil { + return nil, fmt.Errorf("the scan is kept as %s, but its part %d could not be fetched: %w", key, i, err) + } + got = append(got, p) + } + pdf, err := joinParts(got, sum) if err != nil { - return nil, err + return nil, fmt.Errorf("the scan is kept as %s: %w", key, err) } - cut, err := Crop(scan, s.Settings.Crop) + at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"])) if err != nil { - return nil, err + at = s.Now() } - pdf, err := PDF(cut.JPEG, dpi) + path, err := File(s.Settings.Folder, pdf, at.Local()) if err != nil { - return nil, err + return nil, fmt.Errorf("the scan is kept as %s, but could not be filed: %w", key, err) } - path, err := File(s.Settings.Folder, pdf, at) - if err != nil { - return nil, err - } - sum := sha256.Sum256(pdf) - id := hex.EncodeToString(sum[:]) - body := map[string]any{ - "path": path, - "node": s.Node, - "bytes": len(pdf), - "sha256": id, - "width-mm": mm(cut.Width, dpi), - "height-mm": mm(cut.Height, dpi), - "resolution": dpi, - "color-mode": s.Settings.ColorMode, - "cropped": cut.Cropped, - "scanned-at": at.UTC().Format(time.RFC3339), - } - if err := s.Emit(body, id); err != nil { - return nil, fmt.Errorf("the scan is filed at %s, but %s could not be said on the bus: %w", path, EventAdded, err) + body := map[string]any{} + for k, v := range ans { + if k != "key" && k != "size" && k != "parts" { + body[k] = v + } } + body["path"] = path return body, nil } +// fetchPart is one part of a stored object, asked of the device process and decoded. +func (s *Service) fetchPart(ctx context.Context, object string, part int) ([]byte, error) { + ans, err := s.call(ctx, Ask{Op: OpDocument, Object: object, Part: part}, s.AnswerWait) + if err != nil { + return nil, err + } + data, _ := ans["data"].(string) + raw, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, fmt.Errorf("the part is not base64: %w", err) + } + return raw, nil +} + // ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an // answer, not an error: that is how the module's health is read. func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) { diff --git a/modules/brother/cmd/brother/service_test.go b/modules/brother/cmd/brother/service_test.go index 94b7f43c..6b0bb01a 100644 --- a/modules/brother/cmd/brother/service_test.go +++ b/modules/brother/cmd/brother/service_test.go @@ -5,7 +5,7 @@ import ( "context" "crypto/sha256" "encoding/hex" - "image/jpeg" + "encoding/json" "os" "path/filepath" "strings" @@ -18,13 +18,14 @@ import ( type said struct { body map[string]any id string - // seen is whether the filed scan was there when it was said. + // seen is whether the scan was kept in the bucket when it was said. seen bool } -func service(t *testing.T, f *fakeScanner, s Settings) (*Service, *[]said) { +// scanRig is the tools, the device worker and a bucket in memory over a settings file for the fake scanner and a +// scan folder of the test's own, with the real defaults laid under what s sets. +func scanRig(t *testing.T, f *fakeScanner, s Settings) (*printerRig, string) { t.Helper() - var out []said if s.Scanner == "" { s.Scanner = f.base() } @@ -37,24 +38,30 @@ func service(t *testing.T, f *fakeScanner, s Settings) (*Service, *[]said) { if s.ColorMode == "" { s.ColorMode = "color" } - return &Service{Settings: s, Node: "shanks", Now: func() time.Time { return time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) }, - Emit: func(body map[string]any, id string) error { - _, err := os.Stat(body["path"].(string)) - out = append(out, said{body, id, err == nil}) - return nil - }}, &out + js, _ := json.Marshal(map[string]any{"scanner": s.Scanner, "scan-folder": s.Folder, "resolution": s.Resolution, + "color-mode": s.ColorMode, "crop": s.Crop}) + return newRig(t, string(js), "pw"), s.Folder +} + +func (r *printerRig) scan(t *testing.T) (map[string]any, error) { + t.Helper() + got, err := r.call(t, "scanner.scan", map[string]any{}) + if err != nil { + return nil, err + } + return got.(map[string]any), nil } // 1440/AC2: asked 300 dpi, the scanner gave 200: the event's resolution and millimetres, and the PDF's page, // are the image's own. func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) - svc, _ := service(t, f, Settings{Crop: false, Resolution: 300}) - body, err := svc.ScanNow(context.Background()) + r, _ := scanRig(t, f, Settings{Crop: false, Resolution: 300}) + body, err := r.scan(t) if err != nil { t.Fatal(err) } - if body["resolution"] != 200 { + if body["resolution"] != float64(200) { t.Errorf("resolution = %v, want the image's 200 (300 was asked)", body["resolution"]) } if body["width-mm"] != 215.4 || body["height-mm"] != 279.8 { @@ -66,100 +73,127 @@ func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) { } } -// 1440/AC6: one scan.added, after the rename, with every key and never the image, under the sha256. -func TestScanNowEmitsOneEventAfterTheRenameUnderTheSha256(t *testing.T) { +// 1440/AC6 and 1453/AC9: one scan.added, said by the device process after the PDF is kept in the bucket under its +// sha256, with every key, the object's key and never the image; the tool then files the PDF. +func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) - svc, out := service(t, f, Settings{Crop: true}) - body, err := svc.ScanNow(context.Background()) + r, folder := scanRig(t, f, Settings{Crop: true}) + body, err := r.scan(t) if err != nil { t.Fatal(err) } - if len(*out) != 1 { - t.Fatalf("%d events, want 1", len(*out)) + out := *r.said + if len(out) != 1 { + t.Fatalf("%d events, want 1", len(out)) } - ev := (*out)[0] + ev := out[0] if !ev.seen { - t.Error("the event was said before the file was in place") + t.Error("the event was said before the PDF was kept in the bucket") } - for _, k := range []string{"path", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} { + for _, k := range []string{"object", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} { if _, ok := ev.body[k]; !ok { t.Errorf("the event lacks %s", k) } } if len(ev.body) != 10 { - t.Errorf("the event has %d keys, want the ten named", len(ev.body)) + t.Errorf("the event has %d keys, want the ten named (no path: filing is the tool's)", len(ev.body)) } - pdf, err := os.ReadFile(ev.body["path"].(string)) + pdf, err := os.ReadFile(body["path"].(string)) if err != nil { t.Fatal(err) } sum := sha256.Sum256(pdf) - if ev.body["sha256"] != hex.EncodeToString(sum[:]) || ev.id != ev.body["sha256"] { - t.Errorf("sha256 %v / message id %v, want the file's %x", ev.body["sha256"], ev.id, sum) + want := hex.EncodeToString(sum[:]) + if ev.body["sha256"] != want || ev.id != want || ev.body["object"] != want+".pdf" { + t.Errorf("sha256 %v / message id %v / object %v, want the file's %s", ev.body["sha256"], ev.id, ev.body["object"], want) + } + if kept, err := r.objects.Get(t.Context(), want+".pdf"); err != nil || !bytes.Equal(kept, pdf) { + t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err) } if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" { - t.Errorf("body = %v", ev.body) + t.Errorf("event = %v", ev.body) } - if want := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local).UTC().Format(time.RFC3339); ev.body["scanned-at"] != want { + if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want { t.Errorf("scanned-at = %v, want %v", ev.body["scanned-at"], want) } - if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 10 { - t.Error("the body holds more than the ten keys") + if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 11 { + t.Errorf("the tool's answer has %d keys, want the event's ten and the path", len(body)) + } + if _, has := body["key"]; has { + t.Errorf("the device process's own answer keys leak into the tool's: %v", body) } // The cropped scan is the receipt, about 80-84 mm wide (1440/AC3). if w := ev.body["width-mm"].(float64); w < 80 || w > 84 { t.Errorf("width = %v mm", w) } - img, _ := os.ReadDir(filepath.Dir(ev.body["path"].(string))) - if len(img) != 1 { - t.Errorf("folder holds %d files, want the one scan", len(img)) + files, _ := os.ReadDir(folder) + if len(files) != 1 || filepath.Join(folder, files[0].Name()) != body["path"] { + t.Errorf("folder holds %v, want the one scan", files) } } -// 1440/AC6: a scanner that refuses writes nothing, says nothing, and the error says why. -func TestAScannerRefusalWritesAndSaysNothing(t *testing.T) { +// 1453/AC9: the same scan twice is one object in the bucket: a key is kept once. +func TestTheSameScanTwiceIsOneObject(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + r, _ := scanRig(t, f, Settings{Crop: true}) + if _, err := r.scan(t); err != nil { + t.Fatal(err) + } + f.mu.Lock() + f.documents = 0 + f.mu.Unlock() + if _, err := r.scan(t); err != nil { + t.Fatal(err) + } + if r.objects.puts != 1 || len(r.objects.keys()) != 1 { + t.Errorf("%d puts, objects %v: want the one", r.objects.puts, r.objects.keys()) + } +} + +// 1440/AC6: a scanner that refuses keeps nothing, writes nothing, says nothing, and the error says why. +func TestAScannerRefusalKeepsWritesAndSaysNothing(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) f.refuse = 503 - svc, out := service(t, f, Settings{}) - _, err := svc.ScanNow(context.Background()) + r, folder := scanRig(t, f, Settings{}) + _, err := r.scan(t) if err == nil || !strings.Contains(err.Error(), "503") { t.Fatalf("error = %v, want the refusal said", err) } - if len(*out) != 0 { - t.Errorf("%d events after a refusal", len(*out)) + if len(*r.said) != 0 || len(r.objects.keys()) != 0 { + t.Errorf("%d events, objects %v after a refusal", len(*r.said), r.objects.keys()) } - if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) { + if _, err := os.Stat(folder); !os.IsNotExist(err) { t.Errorf("the folder exists after a refusal: %v", err) } } // 1440/AC6: a job that gives no page (the second NextDocument's 404) is the same. -func TestAJobThatGivesNoPageWritesAndSaysNothing(t *testing.T) { +func TestAJobThatGivesNoPageKeepsWritesAndSaysNothing(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) f.noPage = true - svc, out := service(t, f, Settings{}) - if _, err := svc.ScanNow(context.Background()); err == nil || !strings.Contains(err.Error(), "404") { + r, folder := scanRig(t, f, Settings{}) + if _, err := r.scan(t); err == nil || !strings.Contains(err.Error(), "404") { t.Fatalf("error = %v", err) } - if len(*out) != 0 { - t.Errorf("%d events", len(*out)) + if len(*r.said) != 0 || len(r.objects.keys()) != 0 { + t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys()) } - if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) { + if _, err := os.Stat(folder); !os.IsNotExist(err) { t.Errorf("the folder exists: %v", err) } } -// 1440/AC6: an image that is no JPEG (a scanner that sent something else) files nothing. -func TestAnImageThatIsNotAJPEGFilesNothing(t *testing.T) { +// 1440/AC6: an image that is no JPEG (a scanner that sent something else) keeps and files nothing. +func TestAnImageThatIsNotAJPEGKeepsAndFilesNothing(t *testing.T) { f := newFakeScanner(t, []byte("not an image")) - svc, out := service(t, f, Settings{}) - if _, err := svc.ScanNow(context.Background()); err == nil { + r, folder := scanRig(t, f, Settings{}) + if _, err := r.scan(t); err == nil { t.Fatal("a non-image was filed") } - if len(*out) != 0 { - t.Errorf("%d events", len(*out)) + if len(*r.said) != 0 || len(r.objects.keys()) != 0 { + t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys()) } - if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) { + if _, err := os.Stat(folder); !os.IsNotExist(err) { t.Errorf("the folder exists: %v", err) } } @@ -167,11 +201,11 @@ func TestAnImageThatIsNotAJPEGFilesNothing(t *testing.T) { // 1440/AC11: the first scan into a folder that is not there makes it. func TestTheFirstScanMakesTheFolder(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) - svc, _ := service(t, f, Settings{}) - if _, err := svc.ScanNow(context.Background()); err != nil { + r, folder := scanRig(t, f, Settings{}) + if _, err := r.scan(t); err != nil { t.Fatal(err) } - info, err := os.Stat(svc.Settings.Folder) + info, err := os.Stat(folder) if err != nil || info.Mode().Perm() != 0o755 { t.Fatalf("folder %v %v", info, err) } @@ -207,16 +241,16 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) { } { folder := filepath.Join(t.TempDir(), "scans") js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder) - var out []said - tl := tools(settingsFile(t, js), func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now) + r := newRig(t, js, "pw") + out := *r.said for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} { - _, err := tool(t, tl, name).Run(map[string]any{}) + _, err := tool(t, r.tl, name).Run(map[string]any{}) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s with %s: error = %v, want it to name %q", name, c.json, err, c.want) } } - if f.posts != 0 || f.documents != 0 || len(out) != 0 { - t.Errorf("the scanner was asked or an event said: %d posts, %d documents, %d events", f.posts, f.documents, len(out)) + if f.posts != 0 || f.documents != 0 || len(out) != 0 || r.bus.asks() != 0 || len(r.objects.keys()) != 0 { + t.Errorf("the scanner was asked, an ask queued or an event said: %d posts, %d documents, %d events, %d asks", f.posts, f.documents, len(out), r.bus.asks()) } if _, err := os.Stat(folder); !os.IsNotExist(err) { t.Errorf("the folder was made: %v", err) @@ -226,7 +260,7 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) { // 1440/AC7: the settings file is not there at all. func TestAMissingSettingsFileIsSaid(t *testing.T) { - tl := tools(filepath.Join(t.TempDir(), "none.json"), nil, time.Now) + tl := tools(filepath.Join(t.TempDir(), "none.json"), time.Now) if _, err := tool(t, tl, "scanner.status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") { t.Fatalf("error = %v", err) } @@ -235,7 +269,7 @@ func TestAMissingSettingsFileIsSaid(t *testing.T) { // 1440/AC8: scan_status from the captured idle status; a scanner that does not answer is answers false. func TestScanStatusSaysStateAndAdfState(t *testing.T) { f := newFakeScanner(t, nil) - svc, _ := service(t, f, Settings{}) + svc := &Service{Settings: Settings{Scanner: f.base(), Folder: "/x"}} got, err := svc.ScanStatus(context.Background()) if err != nil { t.Fatal(err) @@ -259,7 +293,7 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) { os.WriteFile(p, []byte("x"), 0o644) os.Chtimes(p, base.Add(time.Duration(i)*time.Hour), base.Add(time.Duration(i)*time.Hour)) } - tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), nil, time.Now) + tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), time.Now) got, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(5)}) if err != nil { t.Fatal(err) @@ -277,18 +311,13 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) { func TestScanNowToolScansWithTheFileSettings(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) folder := filepath.Join(t.TempDir(), "scans") - var out []said - tl := tools(settingsFile(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`), - func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now) - got, err := tool(t, tl, "scanner.scan").Run(map[string]any{}) + r := newRig(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`, "pw") + got, err := r.call(t, "scanner.scan", map[string]any{}) if err != nil { t.Fatal(err) } - if len(out) != 1 || got.(map[string]any)["cropped"] != true { - t.Fatalf("%d events, body %v", len(out), got) - } - if _, err := jpeg.DecodeConfig(bytes.NewReader(fixture(t, "scan.jpg"))); err != nil { - t.Fatal(err) + if len(*r.said) != 1 || got.(map[string]any)["cropped"] != true { + t.Fatalf("%d events, body %v", len(*r.said), got) } } diff --git a/modules/brother/cmd/brother/settings.go b/modules/brother/cmd/brother/settings.go index bbaad577..b8f9c616 100644 --- a/modules/brother/cmd/brother/settings.go +++ b/modules/brother/cmd/brother/settings.go @@ -86,6 +86,12 @@ func (s Settings) Missing() error { strings.Join(unset, " and ")) } +// unsetError says which config values are not set. +func unsetError(names ...string) error { + return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node", + strings.Join(names, " and ")) +} + // MissingPrinter is nil when the two values the printer's verbs need are set, and otherwise says which is not: // nothing is contacted before that (1453/AC4). func (s Settings) MissingPrinter() error { diff --git a/modules/brother/module.json b/modules/brother/module.json index bdd76ba9..0d2e0432 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -10,9 +10,6 @@ "secrets": { "s3-bucket": "${dir:state}/store.secret" }, - "emits": [ - "scan.added" - ], "config": { "scanner": { "type": "string", @@ -108,7 +105,8 @@ "serves": [ "scan", "status", - "list" + "list", + "document" ] }, { @@ -116,7 +114,9 @@ "scope": "mesh", "serves": [ "status", - "settings" + "settings", + "print", + "upload" ] } ], @@ -127,7 +127,8 @@ "serves": [ "scan", "status", - "list" + "list", + "document" ] }, { @@ -135,16 +136,21 @@ "scope": "mesh", "serves": [ "status", - "settings" + "settings", + "print", + "upload" ] } ], "tools": [ - "printer.status", - "printer.settings", "scanner.scan", "scanner.status", - "scanner.list" + "scanner.list", + "scanner.document", + "printer.status", + "printer.settings", + "printer.print", + "printer.upload" ], "resources": [ { @@ -213,6 +219,9 @@ ], "state": [ "device-answers" + ], + "emits": [ + "scan.added" ] }, "health": { From b430b35633532b6e424d1676287e716c8c138ed1 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:31:06 +0200 Subject: [PATCH 12/14] brother: README for the bucket, the documents in parts, printing and the device process (issue 1453) --- modules/brother/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/modules/brother/README.md b/modules/brother/README.md index c70de68f..439bebc6 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -2,7 +2,7 @@ One module for one device (novox/hq ADR 0467, issue 1453): the scanner and the printer are one machine on the network, so one module serves both. It claims two seats, each held once for the mesh: `scanner` (verbs `scan`, -`status`, `list`) and `printer` (verbs `status`, `settings`; `restart` follows in a later round). It replaces the +`status`, `list`, `document`) and `printer` (verbs `status`, `settings`, `print`, `upload`; `restart` follows in a later round). It replaces the `scan` module of issue 1440 (ADR 0464), which ran on no node and is gone from the catalogue. ## The scanner seat @@ -50,7 +50,8 @@ makes it (mode 0755) when it files the first scan. | verb (tool) | does | |---|---| -| `scanner.scan` | scans, crops, files and emits; answers the event's body | +| `scanner.scan` | asks the device process to scan, crop, keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` | +| `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 | | `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | | `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only | @@ -59,6 +60,8 @@ makes it (mode 0755) when it files the first scan. | verb (tool) | does | |---|---| | `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` | +| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing | +| `printer.print` | prints a kept scan (`object`) or a completed upload (`upload`) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state | | `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title, links (href and text) and forms with their fields (name, type, value, label); a password field's value is never given. Read-only: it sends the login and the logout and nothing else | ### Trust and the password @@ -79,7 +82,8 @@ is not given, and the printer is not contacted. ### The device process `brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a -seat. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and +seat, and does all the device and bucket work: the scan (and `scan.added`, emitted by it with the object's key and no +path, since filing in the scan folder is the tool's), the documents, the printing, and the admin pages. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and writes each answer, once, into the state `device-answers` under the ask's id (ten minutes' life): the page's title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three From 02cb424d5c3b91ddb83d744a48254ea0359f9ffa Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:37:07 +0200 Subject: [PATCH 13/14] brother: emits declared in the module's own list too, parts of 700 KiB at most, a duplicates window on device-asks, and the built binary ignored (issue 1453) --- .gitignore | 1 + modules/brother/README.md | 8 +++++++ modules/brother/cmd/brother/documents_test.go | 23 ++++++++++++++++++- modules/brother/cmd/brother/main.go | 2 +- modules/brother/cmd/brother/manifest_test.go | 10 ++++++-- modules/brother/cmd/brother/parts.go | 9 ++++---- modules/brother/cmd/brother/printer.go | 2 +- modules/brother/module.json | 9 ++++++-- 8 files changed, 53 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index ea861d09..ab0142b4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ modules/mesh-files/cmd/mesh-files/mesh-files # The provisioner client built in place (issue 671). modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client +modules/brother/cmd/brother/brother diff --git a/modules/brother/README.md b/modules/brother/README.md index 439bebc6..50089e55 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -79,6 +79,14 @@ it for the device process alone; the tools' bundle is given no such name, and `a no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret is not given, and the printer is not contacted. +### Documents in parts + +The bus refuses a message over 1 MiB, so a document crosses it in parts of at most 700 KiB (both the size this module +cuts and the most it accepts; ADR 0468 clause 3 says 768 KiB, which is 1 MiB of base64 before the message around it), +counted from 1, each carried base64-encoded in JSON. A caller joins the parts and checks the SHA-256. The bucket +(`s3-bucket`, from `minio`) is spoken to by the device process alone, from `store.json` (owner `brother-device`, mode +0600), with the S3 client mesh-mail uses; a scan is kept once as `.pdf` and an upload as `uploads/.`. + ### The device process `brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go index fae36de4..ae111a76 100644 --- a/modules/brother/cmd/brother/documents_test.go +++ b/modules/brother/cmd/brother/documents_test.go @@ -24,7 +24,7 @@ func pdfOf(size int) []byte { return b } -// 1453/AC10: a document is cut into parts of at most PartSize, under the 768 KiB limit, and the parts joined in +// 1453/AC10: a document is cut into parts of at most PartSize, under the limit, and the parts joined in // order are the document, with the SHA-256 checked; a wrong SHA-256 or a part missing is refused; a part out of // range is refused naming the range. func TestADocumentIsCutIntoPartsAndJoinedWithItsSha256Checked(t *testing.T) { @@ -438,3 +438,24 @@ func TestAScanAndAPrintAreAcknowledgedBeforeTheWork(t *testing.T) { t.Errorf("settings: early called %d times, action %v", calls, act) } } + +// 1453/AC11: a part of 700 KiB is taken and one of a byte more is refused, by the module's own message naming +// 700 KiB, before it is queued; the limit and the size the module cuts are one. +func TestAPartOverSevenHundredKiBIsRefusedByTheModulesOwnMessage(t *testing.T) { + if MaxPart != PartSize || PartSize != 700<<10 { + t.Fatalf("MaxPart %d, PartSize %d: both are 700 KiB", MaxPart, PartSize) + } + r := newRig(t, `{}`, "pw") + big := make([]byte, 700<<10+1) + _, err := upload(t, r, "over", big, sha256Hex(big), 1, 1, big) + if err == nil || !strings.Contains(err.Error(), "700 KiB") { + t.Fatalf("a part of 700 KiB + 1 byte: error = %v, want the module's own message naming 700 KiB", err) + } + if r.bus.asks() != 0 { + t.Errorf("an oversized part was queued") + } + ok := pdfOf(700 << 10) + if _, err := upload(t, r, "exact", ok, sha256Hex(ok), 1, 1, ok); err != nil { + t.Errorf("a part of exactly 700 KiB: %v", err) + } +} diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index e463b621..41a75d78 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -101,7 +101,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool { { Name: "printer.upload", Description: "Send one part of a document to the device process, to be printed later (printer.print with the upload id). " + - "Cut the document into parts of at most 700 KiB (768 KiB is the limit), number them from 1, and send each with the " + + "Cut the document into parts of at most 700 KiB (700 KiB is the limit), number them from 1, and send each with the " + "whole document's SHA-256. When the last part has come and the joined SHA-256 matches, the document is stored as " + "uploads/.pdf or .jpg and the answer says complete; a wrong SHA-256 stores nothing. PDF and JPEG only.", Input: map[string]any{"type": "object", "required": []string{"upload", "part", "parts", "sha256", "data"}, "properties": map[string]any{ diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index ada6b1c3..425ba98d 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -64,8 +64,11 @@ func TestTheScanFolderIsNoManifestResource(t *testing.T) { // 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named. func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) { m := readManifest(t) - if len(m.Emits) != 0 { - t.Errorf("the module's own emits = %v: scan.added is said by the device process, whose bus block declares it", m.Emits) + // Declared in both places, as mesh-mail declares its sender's events: the module's own, and the device + // process's bus block (the controller refuses a process's emit the module does not declare as its own, + // ADR 0436 §1). + if !reflect.DeepEqual(m.Emits, []string{EventAdded}) { + t.Errorf("the module's own emits = %v, want [%s]", m.Emits, EventAdded) } bus, _ := resource(t, m, "device")["bus"].(map[string]any) if !reflect.DeepEqual(bus["emits"], []any{EventAdded}) { @@ -312,6 +315,9 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) { !reflect.DeepEqual(st["events"], []any{EventAsked}) { t.Errorf("stream = %v", st) } + if st["duplicates"] != float64(24) { + t.Errorf("the stream has no duplicates window like mail-sends': %v", st["duplicates"]) + } if st["max-age-days"] == nil || st["max-mib"] == nil { t.Errorf("the stream is bounded: %v", st) } diff --git a/modules/brother/cmd/brother/parts.go b/modules/brother/cmd/brother/parts.go index 4fa4d983..114e5df0 100644 --- a/modules/brother/cmd/brother/parts.go +++ b/modules/brother/cmd/brother/parts.go @@ -9,11 +9,12 @@ import ( // Documents cross the bus in parts (novox/hq ADR 0468 clause 3): the bus refuses a message over 1 MiB, and a // part travels base64-encoded inside a JSON message. const ( - // MaxPart is the most raw bytes a part may hold; a part of more is refused. - MaxPart = 768 << 10 - // PartSize is how large the parts this module cuts are: below MaxPart, so that the part's base64 text and - // the message around it stay under the bus's 1 MiB. + // PartSize is how large the parts this module cuts are, and MaxPart the most raw bytes a part may hold; a part + // of more is refused. They are one: 700 KiB, so that a part's base64 text (about 933 KB) and the message around + // it stay under the bus's 1 MiB. (ADR 0468 clause 3 says at most 768 KiB, which is 1 MiB of base64 before the + // message around it, and so over the bus's limit.) PartSize = 700 << 10 + MaxPart = PartSize // MostParts bounds how many parts one document has (a 100 MiB document is the most). MostParts = 150 ) diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index b6cf5b02..a755b937 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -209,7 +209,7 @@ func checkUpload(a Ask) error { return fmt.Errorf("data is not base64: %v", err) } if len(raw) > MaxPart { - return fmt.Errorf("a part is at most %d bytes (768 KiB), not %d", MaxPart, len(raw)) + return fmt.Errorf("a part is at most %d bytes (700 KiB), not %d", MaxPart, len(raw)) } return nil } diff --git a/modules/brother/module.json b/modules/brother/module.json index 0d2e0432..023e2d12 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -89,7 +89,8 @@ "max-age-days": 1, "max-mib": 8, "retention": "work-queue", - "max-deliveries": 3 + "max-deliveries": 3, + "duplicates": 24 } ], "state": [ @@ -142,6 +143,9 @@ ] } ], + "emits": [ + "scan.added" + ], "tools": [ "scanner.scan", "scanner.status", @@ -183,7 +187,8 @@ "crop" ], "merge": "json", - "template": "files/settings.json" + "template": "files/settings.json", + "trusted": true }, { "id": "store", From 2f6086120c46a7e7754a6f87e3951d0240db0a25 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 21:39:27 +0200 Subject: [PATCH 14/14] issue 1453: read the fake printer's connection count under its lock, which the race detector requires; say 700 KiB as ADR 0468 does --- modules/brother/README.md | 2 +- modules/brother/cmd/brother/admin_test.go | 6 ++++++ modules/brother/cmd/brother/parts.go | 3 +-- modules/brother/cmd/brother/printer_test.go | 18 +++++++++--------- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/modules/brother/README.md b/modules/brother/README.md index 50089e55..4183fa17 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -82,7 +82,7 @@ is not given, and the printer is not contacted. ### Documents in parts The bus refuses a message over 1 MiB, so a document crosses it in parts of at most 700 KiB (both the size this module -cuts and the most it accepts; ADR 0468 clause 3 says 768 KiB, which is 1 MiB of base64 before the message around it), +cuts and the most it accepts, as ADR 0468 clause 3 says), counted from 1, each carried base64-encoded in JSON. A caller joins the parts and checks the SHA-256. The bucket (`s3-bucket`, from `minio`) is spoken to by the device process alone, from `store.json` (owner `brother-device`, mode 0600), with the S3 client mesh-mail uses; a scan is kept once as `.pdf` and an upload as `uploads/.`. diff --git a/modules/brother/cmd/brother/admin_test.go b/modules/brother/cmd/brother/admin_test.go index c78a61e1..f51f3465 100644 --- a/modules/brother/cmd/brother/admin_test.go +++ b/modules/brother/cmd/brother/admin_test.go @@ -119,6 +119,12 @@ func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https:// // print is the fingerprint of the certificate the server serves. func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) } +func (f *fakePrinter) connsNow() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.conns +} + func (f *fakePrinter) loggedInNow() int { f.mu.Lock() defer f.mu.Unlock() diff --git a/modules/brother/cmd/brother/parts.go b/modules/brother/cmd/brother/parts.go index 114e5df0..df357ded 100644 --- a/modules/brother/cmd/brother/parts.go +++ b/modules/brother/cmd/brother/parts.go @@ -11,8 +11,7 @@ import ( const ( // PartSize is how large the parts this module cuts are, and MaxPart the most raw bytes a part may hold; a part // of more is refused. They are one: 700 KiB, so that a part's base64 text (about 933 KB) and the message around - // it stay under the bus's 1 MiB. (ADR 0468 clause 3 says at most 768 KiB, which is 1 MiB of base64 before the - // message around it, and so over the bus's limit.) + // it stay under the bus's 1 MiB. (ADR 0468 clause 3.) PartSize = 700 << 10 MaxPart = PartSize // MostParts bounds how many parts one document has (a 100 MiB document is the most). diff --git a/modules/brother/cmd/brother/printer_test.go b/modules/brother/cmd/brother/printer_test.go index 45dad26c..fc73230d 100644 --- a/modules/brother/cmd/brother/printer_test.go +++ b/modules/brother/cmd/brother/printer_test.go @@ -119,8 +119,8 @@ func TestPrinterSettingsSaysWhichConfigIsUnsetAndQueuesNothing(t *testing.T) { t.Errorf("%s: an ask was queued", c.name) } } - if f.conns != 0 || len(f.requests) != 0 { - t.Errorf("the printer was contacted: %d connections, %v", f.conns, f.requests) + if f.connsNow() != 0 || len(f.requests) != 0 { + t.Errorf("the printer was contacted: %d connections, %v", f.connsNow(), f.requests) } } @@ -141,8 +141,8 @@ func TestPrinterSettingsSaysTheSecretIsNotGivenAndContactsNothing(t *testing.T) if _, err := r.run(nil); err == nil || !strings.Contains(err.Error(), "admin-password is not given") { t.Errorf("empty secret: error = %v", err) } - if f.conns != 0 || len(f.requests) != 0 { - t.Errorf("the printer was contacted: %d connections, %v", f.conns, f.requests) + if f.connsNow() != 0 || len(f.requests) != 0 { + t.Errorf("the printer was contacted: %d connections, %v", f.connsNow(), f.requests) } } @@ -162,8 +162,8 @@ func TestPrinterSettingsRefusesAPageOutsideTheSite(t *testing.T) { if _, err := r.run(map[string]any{"page": 5}); err == nil { t.Errorf("a page that is no string was accepted") } - if r.bus.asks() != 0 || f.conns != 0 { - t.Errorf("%d asks queued, the printer contacted %d times", r.bus.asks(), f.conns) + if r.bus.asks() != 0 || f.connsNow() != 0 { + t.Errorf("%d asks queued, the printer contacted %d times", r.bus.asks(), f.connsNow()) } } @@ -193,14 +193,14 @@ func TestTheAdminWorkerSettlesEachAsk(t *testing.T) { t.Errorf("an unreadable ask: action %v, want term", act) } old, _ := json.Marshal(Ask{ID: "old", Op: OpSettings, Page: DefaultPage, At: fixedNow.Add(-StaleAfter - time.Second)}) - if act, _ := w.Handle(old, nil); act != actionAck || r.answers.get("old") != nil || f.conns != 0 { - t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.conns) + if act, _ := w.Handle(old, nil); act != actionAck || r.answers.get("old") != nil || f.connsNow() != 0 { + t.Errorf("a stale ask: action %v, answer %s, %d connections", act, r.answers.get("old"), f.connsNow()) } fresh, _ := json.Marshal(Ask{ID: "a1", Op: OpSettings, Page: DefaultPage, At: fixedNow}) if act, _ := w.Handle(fresh, nil); act != actionAck || r.answers.get("a1") == nil { t.Errorf("a fresh ask: action %v", act) } - conns := f.conns + conns := f.connsNow() if act, _ := w.Handle(fresh, nil); act != actionAck { t.Errorf("an ask already answered: action %v, want ack", act) }