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
413 lines
19 KiB
Go
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)
|
|
},
|
|
},
|
|
}
|
|
}
|