Print a web link: the device process fetches it bounded, never inside the mesh or the machine, stores it under its SHA-256 and prints that (issue 1599)
This commit is contained in:
@@ -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...) },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"mesh-range": "${machine:mesh-range}"
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user