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=