brother: the admin process alone reads the admin password and logs in; printer.settings queues an ask and reads its answer (issue 1453)

This commit is contained in:
2026-10-20 21:20:16 +02:00
parent 58c0c0682c
commit 29107b0d93
11 changed files with 668 additions and 111 deletions
@@ -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)
}
}
}
+195
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
+8 -4
View File
@@ -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.",
+106 -49
View File
@@ -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
+197 -37
View File
@@ -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)
}
}
+4 -2
View File
@@ -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 {
-19
View File
@@ -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
}
+9
View File
@@ -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
)
+24
View File
@@ -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=