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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user