228 lines
8.4 KiB
Go
228 lines
8.4 KiB
Go
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
|
|
}
|