mesh/merge-gate head a01e1ea7fb64 merged into trunk d74c1cb995a4 — pass: builds netcheck → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head a01e1ea7fb64 merged into trunk d74c1cb995a4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
246 lines
7.7 KiB
Go
246 lines
7.7 KiB
Go
package main
|
|
|
|
// One HTTP request and its whole answer (hq issue 1643). netcheck_http answers whether a URL is up;
|
|
// this answers what it says: the status line, every response header and the body, so a service's live
|
|
// answers (a mail autoconfiguration file, an API's reply) can be captured from the machine that
|
|
// reaches it. The request is made once: redirects are reported, never followed, and only http and
|
|
// https are spoken. TLS is verified against the machine's own trust store (which holds the mesh's CA).
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Bounds: a request body of at most 256 KiB is sent, a response body of at most 1 MiB is read.
|
|
const (
|
|
MostRequestBody = 256 * 1024
|
|
MostResponseBody = 1024 * 1024
|
|
DefaultRequestTTL = 10 * time.Second
|
|
)
|
|
|
|
// RequestResult is what netcheck_request answers.
|
|
type RequestResult struct {
|
|
URL string `json:"url"`
|
|
Method string `json:"method"`
|
|
Status int `json:"status"`
|
|
StatusLine string `json:"status_line"`
|
|
Proto string `json:"proto"`
|
|
Headers map[string][]string `json:"headers"`
|
|
Redirect string `json:"redirect,omitempty"`
|
|
Body string `json:"body"`
|
|
Encoding string `json:"body_encoding"`
|
|
BodyBytes int `json:"body_bytes"`
|
|
Cut bool `json:"body_cut"`
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
}
|
|
|
|
// allowLoopback is a seam for the tests only (their servers listen on loopback); nothing sets it otherwise.
|
|
var allowLoopback = false
|
|
|
|
// refusedAddress says why an address may not be dialled, or "". The node's own loopback carries
|
|
// unauthenticated consoles (the tool console, the bus monitor), and link-local carries instance metadata;
|
|
// a caller on another node must not reach them through this tool.
|
|
func refusedAddress(ip netip.Addr) string {
|
|
ip = ip.Unmap()
|
|
switch {
|
|
case ip.IsLoopback() && !allowLoopback:
|
|
return "loopback"
|
|
case ip.IsUnspecified():
|
|
return "unspecified"
|
|
case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast():
|
|
return "link-local"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// guardDial runs on the address actually about to be connected, after name resolution, so a name that
|
|
// resolves to a refused address (or is rebound to one) is refused as well as an address in the URL.
|
|
func guardDial(_, address string, _ syscall.RawConn) error {
|
|
host, _, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return fmt.Errorf("refused: %q is not an address", address)
|
|
}
|
|
ip, err := netip.ParseAddr(strings.SplitN(host, "%", 2)[0])
|
|
if err != nil {
|
|
return fmt.Errorf("refused: %q is not an address", host)
|
|
}
|
|
if why := refusedAddress(ip); why != "" {
|
|
return fmt.Errorf("refused: %s is a %s address; this tool does not reach this machine's own services", host, why)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// shortFormAddress: a host made only of numeric labels (decimal, octal or 0x hex) that net/netip does not
|
|
// take as an address. The C resolver expands these (127.1 is 127.0.0.1, 2130706433 too), so the tool
|
|
// refuses them on its own words before any lookup rather than rely on the resolver.
|
|
func shortFormAddress(host string) bool {
|
|
if host == "" || strings.Contains(host, ":") {
|
|
return false
|
|
}
|
|
if _, err := netip.ParseAddr(host); err == nil {
|
|
return false
|
|
}
|
|
for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") {
|
|
digits := label
|
|
if len(label) > 2 && (label[:2] == "0x" || label[:2] == "0X") {
|
|
digits = label[2:]
|
|
for _, c := range digits {
|
|
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
|
|
return false
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if digits == "" {
|
|
return false
|
|
}
|
|
for _, c := range digits {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func runRequest(args map[string]any) (any, error) {
|
|
raw, err := text(args, "url")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("url %q is not one: %v", raw, err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return nil, fmt.Errorf("url %q: only http and https are spoken, not %q", raw, u.Scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return nil, fmt.Errorf("url %q has no host", raw)
|
|
}
|
|
if shortFormAddress(u.Hostname()) {
|
|
return nil, fmt.Errorf("refused: host %q is an address in a short or legacy form (as 127.1, 2130706433 or 0x7f.0.0.1), which the resolver may read as loopback, unspecified or link-local; write the full address", u.Hostname())
|
|
}
|
|
method := "GET"
|
|
if m, _ := args["method"].(string); strings.TrimSpace(m) != "" {
|
|
method = strings.ToUpper(strings.TrimSpace(m))
|
|
}
|
|
if method != "GET" && method != "HEAD" && method != "POST" {
|
|
return nil, fmt.Errorf("method %q is not GET, HEAD or POST", method)
|
|
}
|
|
body, _ := args["body"].(string)
|
|
if len(body) > MostRequestBody {
|
|
return nil, fmt.Errorf("body is %d bytes, more than the %d this tool sends", len(body), MostRequestBody)
|
|
}
|
|
timeout := DefaultRequestTTL
|
|
if _, given := args["timeout_ms"]; given {
|
|
ms, err := whole(args, "timeout_ms", int(DefaultRequestTTL/time.Millisecond))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ms < 1 {
|
|
return nil, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
|
|
}
|
|
timeout = time.Duration(ms) * time.Millisecond
|
|
if timeout > MostTimeout {
|
|
timeout = MostTimeout
|
|
}
|
|
}
|
|
var headers map[string]any
|
|
if h, given := args["headers"]; given && h != nil {
|
|
var ok bool
|
|
if headers, ok = h.(map[string]any); !ok {
|
|
return nil, errors.New("headers must be an object of header name to value")
|
|
}
|
|
}
|
|
|
|
cx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(cx, method, u.String(), reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for name, v := range headers {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("header %q must be a string", name)
|
|
}
|
|
if strings.EqualFold(name, "Host") {
|
|
req.Host = s
|
|
continue
|
|
}
|
|
req.Header.Set(name, s)
|
|
}
|
|
transport := &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: (&net.Dialer{Timeout: timeout, Control: guardDial}).DialContext,
|
|
}
|
|
defer transport.CloseIdleConnections()
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: timeout,
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
start := time.Now()
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: %v", method, raw, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, MostResponseBody+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading the answer of %s: %v", raw, err)
|
|
}
|
|
cut := len(data) > MostResponseBody
|
|
if cut {
|
|
data = data[:MostResponseBody]
|
|
}
|
|
res := RequestResult{
|
|
URL: raw, Method: method, Status: resp.StatusCode, StatusLine: resp.Status, Proto: resp.Proto,
|
|
Headers: map[string][]string(resp.Header), BodyBytes: len(data), Cut: cut, Encoding: "text",
|
|
ElapsedMS: time.Since(start).Milliseconds(),
|
|
}
|
|
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
|
if loc, err := resp.Location(); err == nil {
|
|
res.Redirect = loc.String()
|
|
if l := resp.Header.Get("Location"); !strings.Contains(l, "://") {
|
|
res.Redirect = l
|
|
}
|
|
} else {
|
|
res.Redirect = resp.Header.Get("Location")
|
|
}
|
|
}
|
|
if utf8.Valid(data) || (cut && validBeforeCut(data)) {
|
|
res.Body = string(data)
|
|
} else {
|
|
res.Body, res.Encoding = base64.StdEncoding.EncodeToString(bytes.Clone(data)), "base64"
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// validBeforeCut: a body cut inside a multi-byte character is still text.
|
|
func validBeforeCut(b []byte) bool {
|
|
for i := 0; i < utf8.UTFMax && i < len(b); i++ {
|
|
if utf8.Valid(b[:len(b)-i]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|