Files
mesh-catalog/modules/brother/cmd/brother/main.go
T
jschoubben 6cf51173d5
mesh/repo-check head 6cf51173d586 merged into trunk b2f5ebb27dbf beside mesh-controller#424@550cf37d — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 6cf51173d586 merged into trunk b2f5ebb27dbf — pass: group feat/files-through-the-store composed: every machine composes with the chang…
mesh/delivery superseded: its walk was superseded and names no walk that took it over: a walk that took it over answers its merge, and it follows that wa…
mesh/delivery-group group feat/files-through-the-store closed: a member was closed before the group merged
Give the Service its own fetch client and open-document waits instead of reading package variables from background goroutines, which a test or reload changed meanwhile: data races under -race (issue 1601)
2026-10-21 18:03:58 +02:00

413 lines
19 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// uploadVia is how the tool side asks a machine's node-files for an upload; a test puts its own in its place.
var uploadVia = sdkUpload
func main() {
if len(os.Args) > 1 && os.Args[1] == "device" {
os.Exit(device())
}
if len(os.Args) > 1 && os.Args[1] == "watch" {
os.Exit(watch())
}
if err := stdio.Serve("", tools(os.Getenv("BROTHER_SETTINGS"), time.Now)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// watch is `brother watch`: the process that carries the tool check brother_health beside the device process's unit
// check, as fail2ban's watch does (ADR 0240: a tool check is only beside a check the module does not run itself).
// It does nothing itself; the node-engine asks the tool, and this stays up until it is stopped.
func watch() int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
fmt.Fprintln(os.Stderr, "[brother] watch is up; the node-engine asks brother_health")
<-ctx.Done()
return 0
}
// 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)
}
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
}
// boolArgs reads the two flags printer.settings takes: links and hidden.
func boolArgs(args map[string]any) (links, hidden bool, err error) {
for name, dst := range map[string]*bool{"links": &links, "hidden": &hidden} {
if v, ok := args[name]; ok {
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
*dst = b
}
}
return links, hidden, 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, "path": &a.Path, "node": &a.Node, "url": &a.URL, "ask": &a.Of} {
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 <seat>.<verb> (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 {
jobs := NewJobs()
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, Asks: askBus,
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll,
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs, Upload: uploadVia, Fetch: fetchClient, DocExpiry: docExpiry, DocRetry: docRetry,
Bucket: func() (string, error) { return ReadBucket(os.Getenv("BROTHER_BUCKET")) }}, 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 (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/<upload>.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, in one call: a key in the bucket (object: a scan, or a document stored there), a file on any " +
"machine of the mesh (path with node: that machine's node-files sends it into the bucket, a place it keeps a secret is " +
"refused there by name and nothing is printed), or a web link (url). Name exactly one. A Word, spreadsheet or slide " +
"document is turned into a PDF first; otherwise only PDF and JPEG, told by the bytes and not the name, and only a format " +
"the printer lists in document-format-supported. The device process sends it as an IPP Print-Job and answers the job's " +
"id and state. No document crosses the bus. A call answers within 25 s (the tool runner cuts a call at 30 s): a print not done by then goes on and is answered as started with its ask id, and printer.print with only that ask reads it.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
"path": str("an absolute path of a file on the machine node names"),
"node": str("the machine path is on, such as g14"),
"url": str("an http or https address of a document or a page"),
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
"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: func(args map[string]any) (any, error) {
a, err := askFrom(args)
if err != nil {
return nil, err
}
if a.Of != "" && len(args) > 1 {
return nil, fmt.Errorf("ask is read on its own: give the ask id alone to read an earlier print")
}
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
return s.PrinterPrint(ctx, a)
},
},
{
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 " +
"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: 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 and its forms with their fields, each answering its current value: a text, textarea or hidden " +
"field its value; a checkbox its value and checked (true or false); a select its selected option's value (the " +
"first when none is marked) and options (value, text); a radio group ONE field whose value is the checked button's " +
"and whose options are the buttons (value, label). A field's label is its <label for>, else the first cell of its " +
"table row or its <dt>. The page's links (href and text) are answered only with links: true (they repeat the " +
"printer's whole menu), hidden fields (CSRF token, page id) only with hidden: true. 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 /home/status.html)"},
"links": map[string]any{"type": "boolean", "description": "also answer the page's links (default false)"},
"hidden": map[string]any{"type": "boolean", "description": "also answer the hidden fields (default false)"},
},
},
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()
links, hidden, err := boolArgs(args)
if err != nil {
return nil, err
}
return s.PrinterSettings(ctx, page, links, hidden)
},
},
{
Name: "scanner.scan",
Description: "Scan with the configured network scanner, from the glass (source glass, the default: one page, cut out of " +
"the platen's white when crop is set) or from the document feeder (source feeder: every sheet in it, whole, up to 50; " +
"duplex true scans both sides of each sheet). The device process keeps the result as one PDF in the module's bucket " +
"under its sha256 (one page for the glass, one page per side for the feeder; with apart true and several documents lying apart on the glass, each is its own one-page PDF with its own object and scan.added) and says scan.added on the bus; this " +
"tool then fetches it and files it in the scan folder, in the background, so a scan is filed even when you stop waiting. " +
"A glass scan is waited for (up to three minutes). A feeder scan is waited for 25 seconds (the mesh console gives up on a " +
"call after 30): when it is not done by then the answer is {started: true, id, state: running}; the scan goes on (the " +
"device process lets a feeder run last 20 seconds a page, 3 to 30 minutes) and is filed when done, and " +
"scanner.scan {id} reads the outcome: state running, state done with the answer below, or state failed with why. " +
"The id is known to the tool process that started the scan; scan.added also announces it. " +
"Several pages of the glass in one PDF (a page turned over, a stapled document): document start scans page 1 and answers {document: <id>, pages: 1}; " +
"document <id> scans the next page; document <id> with finish true closes it into one PDF, filed once, answered like a scan. " +
"An open document that gets no page for 30 minutes is closed and filed so by the tool, and its pages are kept meanwhile in the bucket. Answers the event's body: " +
"the object's key, its size and sha256, the first page's real size in millimetres, the resolution the scanner really gave, " +
"whether it was cropped, the number of pages and the file's path; for apart true and several documents on the glass {documents: [that, for each], count} (a failure of one is in failed). The image itself is never sent. " +
"Duplex on the glass is refused.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"source": map[string]any{"type": "string", "enum": []string{"glass", "feeder"}, "description": "glass (default) or feeder"},
"id": map[string]any{"type": "string", "description": "read the outcome of the scan with this id (from an answer that said started) instead of scanning"},
"document": map[string]any{"type": "string", "description": "glass only, a document of several pages in one PDF: start opens one and scans its first page; the id of an open document scans the next page into it"},
"finish": map[string]any{"type": "boolean", "description": "with document: the id of an open document: close it into one PDF, filed once (nothing is scanned)"},
"duplex": map[string]any{"type": "boolean", "description": "feeder only: scan both sides of each sheet (default false)"},
"apart": map[string]any{"type": "boolean", "description": "glass only: several documents lying apart on the glass are each filed as their own PDF (default false: one scan of the glass)"},
}},
Run: func(args map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
apart := false
if v, ok := args["apart"]; ok {
if apart, ok = v.(bool); !ok {
return nil, fmt.Errorf("apart is true or false")
}
}
id, err := strArg(args, "id")
if err != nil {
return nil, err
}
if id != "" {
return s.ScanResult(id)
}
document, err := strArg(args, "document")
if err != nil {
return nil, err
}
finish := false
if v, ok := args["finish"]; ok {
if finish, ok = v.(bool); !ok {
return nil, fmt.Errorf("finish is true or false")
}
}
if document != "" || finish {
if document == "" {
return nil, fmt.Errorf("finish closes a document: name its id in document")
}
if v, _ := args["source"].(string); v == "feeder" {
return nil, fmt.Errorf("a document of several pages is scanned from the glass; the feeder takes the whole stack in one scan")
}
ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute)
defer cancel()
return s.ScanPage(ctx, document, finish)
}
source, err := strArg(args, "source")
if err != nil {
return nil, err
}
duplex := false
if v, ok := args["duplex"]; ok {
if duplex, ok = v.(bool); !ok {
return nil, fmt.Errorf("duplex is true or false")
}
}
ctx, cancel := context.WithTimeout(context.Background(), feederWait+10*time.Minute)
defer cancel()
return s.ScanNow(ctx, source, duplex, apart)
},
},
{
Name: "brother_health",
Description: "The module's health, asked by the node-engine (novox/hq ADR 0240): the certificates the printer serves, one for each " +
"key type (ECDSA and RSA), read with no login and compared with the pinned printer-certificate list. Answers healthy, why, " +
"and what was served with each fingerprint and whether it is pinned. Not healthy when a certificate served is not pinned (the " +
"printer was reset or given new certificates): why names the new fingerprint, so the pin can be changed before the admin " +
"pages are needed. A printer that does not answer is healthy, with why saying nothing was compared. Read-only.",
Run: func(map[string]any) (any, error) {
s, err := service()
if err != nil {
return nil, err
}
// The node-engine asks this every minute, so it is also what brings back, after a restart of this
// process, the expiry of the documents left open in the bucket (issue 1538).
if s.Settings.Missing() == nil && jobs.tryRecover() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := s.Recover(ctx); err != nil {
jobs.recoverFailed()
}
}()
}
if w, waits := CertWaits(s.Settings); waits {
return w, nil
}
ctx, cancel := context.WithTimeout(context.Background(), certTotal+time.Second)
defer cancel()
return CheckCertificates(ctx, s.Settings.Printer, s.Settings.PrinterCertificate), nil
},
},
{
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) {
s, err := service()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return s.ScanStatus(ctx)
},
},
{
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",
"properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many scans (default 20)"},
},
},
Run: func(args map[string]any) (any, error) {
limit := 20
if v, ok := args["limit"]; ok {
f, isNum := v.(float64)
if i, isInt := v.(int); isInt {
f, isNum = float64(i), true
}
if !isNum || f < 1 || f > 1000 || f != float64(int(f)) {
return nil, fmt.Errorf("limit is a whole number from 1 to 1000")
}
limit = int(f)
}
s, err := service()
if err != nil {
return nil, err
}
return s.ScanList(limit)
},
},
}
}