diff --git a/modules/brother/cmd/brother/deviceproc.go b/modules/brother/cmd/brother/deviceproc.go index b5c65ea5..cc2e412d 100644 --- a/modules/brother/cmd/brother/deviceproc.go +++ b/modules/brother/cmd/brother/deviceproc.go @@ -69,6 +69,8 @@ type deviceWorker struct { Node string // Capture scans; nil is the device's own (a test puts a stand-in). Capture func(ctx context.Context, req ScanRequest) ([]Capture, error) + // Fetcher is the guarded fetch of a web link (fetch.go); it reads its facts when it is first needed. + Fetcher func() (*Fetcher, error) // answerPart is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger). answerPart int Now func() time.Time @@ -383,7 +385,19 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro return nil, err } key := ask.Object - if key == "" { + var doc []byte + switch { + case ask.URL != "": + fetched, err := w.fetch(ctx, ask.URL) + if err != nil { + return nil, err + } + // The fetched document is kept once under its SHA-256, as a scan is, and printed as that object. + doc, key = fetched.Body, sha256Hex(fetched.Body) + if err := Store(ctx, objects, key, fetched.ContentType, doc); err != nil { + return nil, err + } + case key == "": for _, ext := range []string{"pdf", "jpg"} { k := "uploads/" + ask.Upload + "." + ext if has, err := objects.Has(ctx, k); err != nil { @@ -396,10 +410,12 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro if key == "" { return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload) } - } - doc, err := objects.Get(ctx, key) - if err != nil { - return nil, err + fallthrough + default: + var err error + if doc, err = objects.Get(ctx, key); err != nil { + return nil, err + } } format, _, err := SniffFormat(doc) if err != nil { @@ -422,6 +438,18 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro "job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}, nil } +// fetch gets a web link under the rules of fetch.go. +func (w *deviceWorker) fetch(ctx context.Context, link string) (*Fetched, error) { + if w.Fetcher == nil { + return nil, errors.New("this process has no way to fetch a web link") + } + f, err := w.Fetcher() + if err != nil { + return nil, err + } + return f.Fetch(ctx, link) +} + // 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). @@ -519,7 +547,7 @@ func nonNilForms(f []Form) []Form { // It refuses to start naming what is missing when the account cannot be read, and never logs a password. func device() int { settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER") - store := os.Getenv("BROTHER_STORE") + store, fetchFile := os.Getenv("BROTHER_STORE"), os.Getenv("BROTHER_FETCH") if brokerFile == "" { fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file") return 2 @@ -541,6 +569,13 @@ func device() int { } return NewS3(facts), nil }, + Fetcher: func() (*Fetcher, error) { + r, err := ReadFetchFacts(fetchFile) + if err != nil { + return nil, err + } + return NewFetcher(r), nil + }, Now: time.Now, Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) }, } diff --git a/modules/brother/cmd/brother/fetch.go b/modules/brother/cmd/brother/fetch.go new file mode 100644 index 00000000..17b6c108 --- /dev/null +++ b/modules/brother/cmd/brother/fetch.go @@ -0,0 +1,227 @@ +package main + +// A web link the device process fetches (novox/hq ADR 0468 clause 6, issue 1599). The process holds the bucket's key and +// the printer's password and sits inside the mesh, so a URL from any caller would otherwise reach the store, the +// printer's admin pages or the controller. Only http and https are taken. The fetch takes at most 60 s, 100 MiB and 5 +// redirects. Each host is resolved once, every address it gives is checked, and the connection is made to an address +// that was checked, never to a second lookup's. Loopback, private (RFC 1918, RFC 4193), link-local, shared (RFC 6598), +// unspecified, multicast and the mesh's own tunnel range are refused, after every redirect too. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/objectlink" +) + +const ( + // FetchTime, FetchMost and FetchRedirects are the fetch's bounds (ADR 0468 clause 6). + FetchTime = 60 * time.Second + FetchMost = objectlink.MaxDocument + FetchRedirects = 5 + // MostURL is the longest address printer.print takes. + MostURL = 2048 +) + +// FetchFacts is the file the mesh writes for the fetch (files/fetch.json): the tunnel's range, which a module cannot +// know (mesh-sdk go/catalogue/machine_into_files.go, the fact `mesh-range`). +type FetchFacts struct { + MeshRange string `json:"mesh-range"` +} + +// ReadFetchFacts reads the fetch's facts; a file that names no range is refused, since a fetch that does not know the +// mesh's range could reach it. +func ReadFetchFacts(path string) (netip.Prefix, error) { + if path == "" { + return netip.Prefix{}, errors.New("the module was started without the fetch's facts (BROTHER_FETCH)") + } + raw, err := os.ReadFile(path) + if err != nil { + return netip.Prefix{}, fmt.Errorf("the fetch's facts cannot be read: %w", err) + } + var f FetchFacts + if err := json.Unmarshal(raw, &f); err != nil { + return netip.Prefix{}, fmt.Errorf("the fetch's facts are not the module's: %v", err) + } + p, err := netip.ParsePrefix(strings.TrimSpace(f.MeshRange)) + if err != nil { + return netip.Prefix{}, fmt.Errorf("the fetch's facts name no mesh range (%q): %v", f.MeshRange, err) + } + return p, nil +} + +// CheckURLScheme is what the tool side can say of an address without looking at the network: it is http or https. +func CheckURLScheme(raw string) error { + if len(raw) > MostURL { + return fmt.Errorf("url is at most %d characters", MostURL) + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return fmt.Errorf("url %q is not an address", raw) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("url %q has the scheme %q: only http and https are fetched (novox/hq ADR 0468 clause 6)", raw, u.Scheme) + } + return nil +} + +var refusedRanges = []struct { + prefix netip.Prefix + class string +}{ + {netip.MustParsePrefix("0.0.0.0/8"), "an unspecified address"}, + {netip.MustParsePrefix("10.0.0.0/8"), "a private address (RFC 1918)"}, + {netip.MustParsePrefix("100.64.0.0/10"), "a shared address (RFC 6598)"}, + {netip.MustParsePrefix("127.0.0.0/8"), "a loopback address"}, + {netip.MustParsePrefix("169.254.0.0/16"), "a link-local address"}, + {netip.MustParsePrefix("172.16.0.0/12"), "a private address (RFC 1918)"}, + {netip.MustParsePrefix("192.168.0.0/16"), "a private address (RFC 1918)"}, + {netip.MustParsePrefix("224.0.0.0/4"), "a multicast address"}, + {netip.MustParsePrefix("::/128"), "an unspecified address"}, + {netip.MustParsePrefix("::1/128"), "a loopback address"}, + {netip.MustParsePrefix("fc00::/7"), "a private address (RFC 4193)"}, + {netip.MustParsePrefix("fe80::/10"), "a link-local address"}, + {netip.MustParsePrefix("ff00::/8"), "a multicast address"}, +} + +// Refusal says why an address may not be fetched from, or "" when it may. +func (f *Fetcher) Refusal(a netip.Addr) string { + a = a.Unmap() + for _, r := range refusedRanges { + if r.prefix.Contains(a) { + return r.class + } + } + if f.MeshRange.IsValid() && f.MeshRange.Contains(a) { + return "an address of the mesh's own network" + } + return "" +} + +// Fetcher fetches a web link under the rules above. Resolve and Dial are what a test replaces. +type Fetcher struct { + MeshRange netip.Prefix + Resolve func(ctx context.Context, host string) ([]netip.Addr, error) + Dial func(ctx context.Context, network, address string) (net.Conn, error) + Timeout time.Duration + Most int64 + Redirects int +} + +// NewFetcher is a fetcher with the real resolver and dialer and the bounds of the ADR. +func NewFetcher(meshRange netip.Prefix) *Fetcher { + d := &net.Dialer{Timeout: 15 * time.Second} + return &Fetcher{MeshRange: meshRange, Dial: d.DialContext, Timeout: FetchTime, Most: FetchMost, Redirects: FetchRedirects, + Resolve: func(ctx context.Context, host string) ([]netip.Addr, error) { + return net.DefaultResolver.LookupNetIP(ctx, "ip", host) + }} +} + +// Fetched is a document fetched: its bytes and the media type the server gave. +type Fetched struct { + Body []byte + ContentType string +} + +// Fetch gets the document at the address. Every refusal names why and happens before a connection to the refused +// address is made; a body over the limit or a fetch over the time stores nothing, since nothing is returned. +func (f *Fetcher) Fetch(ctx context.Context, raw string) (*Fetched, error) { + ctx, cancel := context.WithTimeout(ctx, f.Timeout) + defer cancel() + current := raw + for hop := 0; ; hop++ { + if err := CheckURLScheme(current); err != nil { + return nil, err + } + u, _ := url.Parse(current) + addr, err := f.checked(ctx, u.Hostname()) + if err != nil { + return nil, fmt.Errorf("%s: %w", current, err) + } + port := u.Port() + if port == "" { + port = map[string]string{"http": "80", "https": "443"}[u.Scheme] + } + target := net.JoinHostPort(addr.String(), port) + tr := &http.Transport{ + Proxy: nil, // never the environment's proxy: the address checked is the address dialled + DisableKeepAlives: true, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return f.Dial(ctx, network, target) + }, + } + client := &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil) + if err != nil { + tr.CloseIdleConnections() + return nil, err + } + resp, err := client.Do(req) + if err != nil { + tr.CloseIdleConnections() + return nil, fmt.Errorf("%s could not be fetched: %w", current, err) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.Header.Get("Location") != "" { + loc, lerr := u.Parse(resp.Header.Get("Location")) + resp.Body.Close() + tr.CloseIdleConnections() + if lerr != nil { + return nil, fmt.Errorf("%s redirects to an address that is not one: %v", current, lerr) + } + if hop >= f.Redirects { + return nil, fmt.Errorf("%s is redirected more than %d times", raw, f.Redirects) + } + current = loc.String() + continue + } + defer tr.CloseIdleConnections() + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", current, resp.Status) + } + if resp.ContentLength > f.Most { + return nil, fmt.Errorf("%s is %d bytes, over the %d a document may be (novox/hq ADR 0468 clause 6)", current, resp.ContentLength, f.Most) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, f.Most+1)) + if err != nil { + return nil, fmt.Errorf("%s could not be read within %s: %w", current, f.Timeout, err) + } + if int64(len(body)) > f.Most { + return nil, fmt.Errorf("%s is over the %d bytes a document may be (novox/hq ADR 0468 clause 6)", current, f.Most) + } + return &Fetched{Body: body, ContentType: resp.Header.Get("Content-Type")}, nil + } +} + +// checked resolves the host once, refuses it when any address it gives is refused, and answers the address to dial. +func (f *Fetcher) checked(ctx context.Context, host string) (netip.Addr, error) { + var addrs []netip.Addr + if ip, err := netip.ParseAddr(host); err == nil { + addrs = []netip.Addr{ip} + } else { + found, err := f.Resolve(ctx, host) + if err != nil { + return netip.Addr{}, fmt.Errorf("the host %s cannot be resolved: %w", host, err) + } + addrs = found + } + if len(addrs) == 0 { + return netip.Addr{}, fmt.Errorf("the host %s resolves to no address", host) + } + for _, a := range addrs { + if class := f.Refusal(a); class != "" { + return netip.Addr{}, fmt.Errorf("refused: %s is %s, and the device process fetches nothing inside the mesh or the machine (novox/hq ADR 0468 clause 6)", a, class) + } + } + return addrs[0].Unmap(), nil +} diff --git a/modules/brother/cmd/brother/fetch_test.go b/modules/brother/cmd/brother/fetch_test.go new file mode 100644 index 00000000..434b5041 --- /dev/null +++ b/modules/brother/cmd/brother/fetch_test.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// A fetch rig: names resolve as the test says, and every dial goes to the test's server whatever address was asked, +// after the fetcher has checked the address it was about to dial and told the dial what that was. +type fetchRig struct { + f *Fetcher + mu sync.Mutex + dialed []string + lookups map[string]int + names map[string][]netip.Addr + // after is what a host answers from its second lookup on, when it answers differently (a rebinding). + after map[string][]netip.Addr + srv *httptest.Server +} + +func newFetchRig(t *testing.T, h http.HandlerFunc) *fetchRig { + t.Helper() + r := &fetchRig{lookups: map[string]int{}, names: map[string][]netip.Addr{}, after: map[string][]netip.Addr{}} + r.srv = httptest.NewServer(h) + t.Cleanup(r.srv.Close) + r.f = &Fetcher{MeshRange: netip.MustParsePrefix("10.10.0.0/24"), Timeout: 5 * time.Second, Most: 1 << 20, Redirects: FetchRedirects, + Resolve: func(_ context.Context, host string) ([]netip.Addr, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.lookups[host]++ + a, ok := r.names[host] + if !ok { + return nil, errors.New("no such host") + } + if again, rebinds := r.after[host]; rebinds && r.lookups[host] > 1 { + return again, nil + } + return a, nil + }, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + r.mu.Lock() + r.dialed = append(r.dialed, address) + r.mu.Unlock() + return (&net.Dialer{}).DialContext(ctx, network, r.srv.Listener.Addr().String()) + }} + return r +} + +func addrs(s ...string) []netip.Addr { + var out []netip.Addr + for _, a := range s { + out = append(out, netip.MustParseAddr(a)) + } + return out +} + +// 1599/AC1: a url with a scheme other than http or https is refused by name. +func TestAURLOfAnotherSchemeIsRefused(t *testing.T) { + r := newFetchRig(t, func(http.ResponseWriter, *http.Request) {}) + for _, u := range []string{"file:///etc/passwd", "ftp://example.com/a.pdf", "gopher://example.com/", "javascript:alert(1)", "//example.com/a.pdf"} { + _, err := r.f.Fetch(t.Context(), u) + if err == nil || (!strings.Contains(err.Error(), "only http and https") && !strings.Contains(err.Error(), "is not an address")) { + t.Errorf("%s: error = %v", u, err) + } + if err := CheckURLScheme(u); err == nil { + t.Errorf("%s: the tool side took it", u) + } + } + if len(r.dialed) != 0 { + t.Errorf("dialled %v", r.dialed) + } +} + +// 1599/AC2: a host that is, or resolves to, a loopback, private, link-local, shared, unspecified or multicast address, +// or one in the mesh's range, is refused naming the class, and no connection is made. +func TestAddressesInsideTheMeshAndTheMachineAreRefused(t *testing.T) { + r := newFetchRig(t, func(http.ResponseWriter, *http.Request) { t.Error("a request reached the server") }) + cases := map[string]string{ + "127.0.0.1": "loopback", "::1": "loopback", "10.0.0.1": "private address (RFC 1918)", "192.168.1.1": "private address (RFC 1918)", + "172.16.0.1": "private address (RFC 1918)", "fd00::1": "private address (RFC 4193)", "169.254.1.1": "link-local", "fe80::1": "link-local", + "100.64.0.1": "shared address (RFC 6598)", "0.0.0.0": "unspecified", "::": "unspecified", "224.0.0.1": "multicast", "ff02::1": "multicast", + "10.10.0.7": "private address (RFC 1918)", "::ffff:127.0.0.1": "loopback", + } + for ip, class := range cases { + host := ip + if strings.Contains(ip, ":") { + host = "[" + ip + "]" + } + _, err := r.f.Fetch(t.Context(), "http://"+host+"/a.pdf") + if err == nil || !strings.Contains(err.Error(), class) { + t.Errorf("%s: error = %v, want the class %q", ip, err, class) + } + // The same address behind a name. + r.names["inside.test"] = addrs("203.0.113.9", ip) + if _, err := r.f.Fetch(t.Context(), "http://inside.test/a.pdf"); err == nil || !strings.Contains(err.Error(), class) { + t.Errorf("inside.test -> %s: error = %v", ip, err) + } + } + // The mesh's range from fetch.json, when it is not a private range. + r.f.MeshRange = netip.MustParsePrefix("203.0.113.0/24") + if _, err := r.f.Fetch(t.Context(), "http://203.0.113.5/a.pdf"); err == nil || !strings.Contains(err.Error(), "the mesh's own network") { + t.Errorf("the mesh range: error = %v", err) + } + if len(r.dialed) != 0 { + t.Errorf("a connection was made to %v", r.dialed) + } +} + +// 1599/AC3: a redirect to such an address is refused; a sixth redirect is refused; a redirect to a public address is followed. +func TestRedirectsAreCheckedAndBounded(t *testing.T) { + hops := 0 + r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/to-loopback": + http.Redirect(w, req, "http://127.0.0.1/secret", http.StatusFound) + case "/to-name": + http.Redirect(w, req, "http://inside.test/x", http.StatusFound) + case "/loop": + hops++ + http.Redirect(w, req, "/loop", http.StatusFound) + case "/ok": + http.Redirect(w, req, "http://public.test/final.pdf", http.StatusMovedPermanently) + default: + w.Write([]byte("%PDF-1.7 final")) + } + }) + r.names["public.test"] = addrs("203.0.113.9") + r.names["inside.test"] = addrs("192.168.0.9") + if _, err := r.f.Fetch(t.Context(), "http://public.test/to-loopback"); err == nil || !strings.Contains(err.Error(), "loopback") { + t.Errorf("redirect to loopback: error = %v", err) + } + if _, err := r.f.Fetch(t.Context(), "http://public.test/to-name"); err == nil || !strings.Contains(err.Error(), "private address (RFC 1918)") { + t.Errorf("redirect to a name: error = %v", err) + } + if _, err := r.f.Fetch(t.Context(), "http://public.test/loop"); err == nil || !strings.Contains(err.Error(), "redirected more than 5 times") { + t.Errorf("a loop: error = %v", err) + } + if hops != 6 { + t.Errorf("the loop was followed %d times, want the first request and 5 redirects (6 requests)", hops) + } + got, err := r.f.Fetch(t.Context(), "http://public.test/ok") + if err != nil || string(got.Body) != "%PDF-1.7 final" { + t.Errorf("a redirect to a public address: %v, %v", got, err) + } +} + +// 1599/AC4: a body over the limit and a fetch over the time are refused, and nothing is returned to be stored. +func TestABodyOverTheLimitAndAFetchOverTheTimeAreRefused(t *testing.T) { + r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/big": + w.Write([]byte(strings.Repeat("x", 2000))) + case "/chunked": + w.(http.Flusher).Flush() // no length is declared + w.Write([]byte(strings.Repeat("x", 2000))) + case "/slow": + time.Sleep(500 * time.Millisecond) + w.Write([]byte("late")) + } + }) + r.names["public.test"] = addrs("203.0.113.9") + r.f.Most = 1000 + for _, p := range []string{"/big", "/chunked"} { + if got, err := r.f.Fetch(t.Context(), "http://public.test"+p); err == nil || got != nil || !strings.Contains(err.Error(), "over the") { + t.Errorf("%s: %v, %v", p, got, err) + } + } + r.f.Timeout = 100 * time.Millisecond + if got, err := r.f.Fetch(t.Context(), "http://public.test/slow"); err == nil || got != nil { + t.Errorf("a slow fetch: %v, %v", got, err) + } +} + +// 1599/AC5: the connection goes to the address that was checked: a name that answers differently on a second lookup +// is not dialled again, and each host is looked up once. +func TestTheCheckedAddressIsTheOneDialled(t *testing.T) { + r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("%PDF-1.7 x")) }) + r.names["rebind.test"] = addrs("203.0.113.9") + r.after["rebind.test"] = addrs("127.0.0.1") // a second lookup would say loopback + if _, err := r.f.Fetch(t.Context(), "http://rebind.test:8080/a.pdf"); err != nil { + t.Fatal(err) + } + if len(r.dialed) != 1 || r.dialed[0] != "203.0.113.9:8080" { + t.Errorf("dialled %v, want the checked address and the url's port", r.dialed) + } + if r.lookups["rebind.test"] != 1 { + t.Errorf("the host was looked up %d times", r.lookups["rebind.test"]) + } +} + +// 1599/AC6: a fetched PDF is stored under its SHA-256 and printed as that object. +func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) { + fake := newIPPFake(t, 41, 5) + r := newRig(t, `{"printer":"192.0.2.10"}`, "pw") + doc := pdfOf(4000) + web := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + w.Write(doc) + }) + web.names["public.test"] = addrs("203.0.113.9") + r.worker.Fetcher = func() (*Fetcher, error) { return web.f, nil } + got, err := r.call(t, "printer.print", map[string]any{"url": "http://public.test/letter.pdf"}) + if err != nil { + t.Fatal(err) + } + m := got.(map[string]any) + key := sha256Hex(doc) + if m["object"] != key || m["job-id"] != float64(41) || m["format"] != "application/pdf" { + t.Errorf("answer = %v", m) + } + if stored, err := r.objects.Get(t.Context(), key); err != nil || string(stored) != string(doc) { + t.Errorf("stored under the hash: %v", err) + } + if _, sent := fake.request(t); string(sent) != string(doc) { + t.Error("the printer was not sent the fetched document") + } + queued := r.bus.published[0] + if queued.URL != "http://public.test/letter.pdf" || queued.Object != "" { + t.Errorf("queued %+v", queued) + } + if err := CheckURLScheme("https://public.test/letter.pdf"); err != nil { + t.Errorf("https refused: %v", err) + } + // A refusal inside the mesh is answered, and nothing is stored nor printed. + fake.body = nil + before := len(r.objects.keys()) + if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") { + t.Errorf("a loopback address: error = %v", err) + } + if len(r.objects.keys()) != before || fake.body != nil { + t.Error("something was stored or printed for a refused address") + } +} + +// The fetch's facts are the file the mesh writes (files/fetch.json) from the `mesh-range` machine fact, and the device +// process reads them. +func TestTheFetchFactsAreTheMeshRange(t *testing.T) { + raw, err := os.ReadFile("../../files/fetch.json") + if err != nil || !strings.Contains(string(raw), `"mesh-range": "${machine:mesh-range}"`) { + t.Fatalf("files/fetch.json = %s, %v", raw, err) + } + file := filepath.Join(t.TempDir(), "fetch.json") + os.WriteFile(file, []byte(`{"mesh-range": "10.10.0.0/24"}`), 0o600) + p, err := ReadFetchFacts(file) + if err != nil || p.String() != "10.10.0.0/24" { + t.Errorf("prefix %v, %v", p, err) + } + os.WriteFile(file, []byte(`{}`), 0o600) + if _, err := ReadFetchFacts(file); err == nil { + t.Error("facts with no range were taken") + } +} diff --git a/modules/brother/cmd/brother/manifest_test.go b/modules/brother/cmd/brother/manifest_test.go index 1e61a776..044686dd 100644 --- a/modules/brother/cmd/brother/manifest_test.go +++ b/modules/brother/cmd/brother/manifest_test.go @@ -291,6 +291,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) { "BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"], "BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"], "BROTHER_STORE": "${dir:state}/store.json", + "BROTHER_FETCH": "${dir:state}/fetch.json", // 1599: the tunnel's range, which the fetch never reaches } if !reflect.DeepEqual(env, want) { t.Errorf("env = %v, want %v", env, want) diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index 67b8268a..9d67399d 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -89,11 +89,12 @@ type Ask struct { Document string `json:"document,omitempty"` // scan: start, or an open document's id: a glass page into it Finish bool `json:"finish,omitempty"` // scan: close the document named in Document into one PDF - // Path with Node, and URL, are what printer.print is asked: the tool side turns them into an Object before - // anything is queued, so the queued ask carries a key and the options and never these (novox/hq ADR 0468 clause 5). - Path string `json:"-"` // print: a file on the machine Node - Node string `json:"-"` // print: the machine Path is on - URL string `json:"-"` // print: a web page or document to fetch (issue 1599) + // Path with Node are what printer.print is asked for a file of a machine: the tool side turns them into an Object + // before anything is queued, so the queued ask carries a key and the options and never these (ADR 0468 clause 5). + // A URL is queued as given: the device process alone fetches it (clause 6). + Path string `json:"-"` // print: a file on the machine Node + Node string `json:"-"` // print: the machine Path is on + URL string `json:"url,omitempty"` // print: a web document the device process fetches (issue 1599) } // AskBus is how the tools reach the device process: they queue an ask and read an answer back. @@ -362,7 +363,9 @@ func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, erro from := map[string]any{} switch { case a.URL != "": - return nil, fmt.Errorf("printing a url is not served yet (issue 1599)") + if err := CheckURLScheme(a.URL); err != nil { + return nil, err + } case a.Path != "": if s.Upload == nil { return nil, fmt.Errorf("this module has no way to ask node-files for an upload") diff --git a/modules/brother/files/fetch.json b/modules/brother/files/fetch.json new file mode 100644 index 00000000..abef425f --- /dev/null +++ b/modules/brother/files/fetch.json @@ -0,0 +1,3 @@ +{ + "mesh-range": "${machine:mesh-range}" +} diff --git a/modules/brother/module.json b/modules/brother/module.json index 2e12f09c..3a637020 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -219,6 +219,14 @@ "mode": "0644", "template": "files/bucket.json" }, + { + "id": "fetch", + "type": "file", + "path": "${dir:state}/fetch.json", + "owner": "brother-device", + "mode": "0600", + "template": "files/fetch.json" + }, { "id": "device", "type": "process", @@ -233,11 +241,13 @@ "BROTHER_SETTINGS": "${dir:state}/settings.json", "BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret", "BROTHER_BROKER": "${dir:state}/broker-device.secret", - "BROTHER_STORE": "${dir:state}/store.json" + "BROTHER_STORE": "${dir:state}/store.json", + "BROTHER_FETCH": "${dir:state}/fetch.json" }, "restart-on": [ "settings", - "store" + "store", + "fetch" ], "bus": { "queues": [