Files
vpp-maglev/internal/prober/http.go

162 lines
4.8 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package prober
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd"
"git.ipng.ch/ipng/vpp-maglev/internal/health"
)
var userAgent = "maglev-healthchecker/" + buildinfo.Version() + " (+https://git.ipng.ch/ipng/vpp-maglev)"
// HTTPProbe sends a plain HTTP GET to cfg.Target inside the healthcheck netns.
func HTTPProbe(ctx context.Context, cfg ProbeConfig) health.ProbeResult {
return doHTTPProbe(ctx, cfg, false)
}
// HTTPSProbe sends an HTTP GET over TLS to cfg.Target inside the healthcheck netns.
func HTTPSProbe(ctx context.Context, cfg ProbeConfig) health.ProbeResult {
return doHTTPProbe(ctx, cfg, true)
}
func doHTTPProbe(ctx context.Context, cfg ProbeConfig, useTLS bool) health.ProbeResult {
if cfg.HTTP == nil {
return health.ProbeResult{OK: false, Layer: health.LayerUnknown, Code: "UNKNOWN", Detail: "missing HTTP params"}
}
p := cfg.HTTP
port := cfg.Port
if port == 0 {
if useTLS {
port = 443
} else {
port = 80
}
}
// Always use "http" scheme: TLS (if any) is already applied to conn during
// the netns dial phase. Using "https" here would cause http.Transport to
// wrap conn in a second TLS layer, producing "http: server gave HTTP
// response to HTTPS client".
target := fmt.Sprintf("http://%s%s", net.JoinHostPort(cfg.Target.String(), strconv.Itoa(int(port))), p.Path)
hostHeader := p.Host
if hostHeader == "" {
hostHeader = cfg.Target.String()
}
// Dial (and optionally handshake) inside the healthcheck netns.
// The socket retains its netns after creation, so HTTP can be done outside.
var conn net.Conn
dialErr := inNetns(cfg.HealthCheckNetns, func() error {
dialer := &net.Dialer{Timeout: cfg.Timeout}
if cfg.ProbeSrc != nil {
dialer.LocalAddr = &net.TCPAddr{IP: cfg.ProbeSrc}
}
c, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(cfg.Target.String(), strconv.Itoa(int(port))))
if err != nil {
return err
}
if useTLS {
tlsConn := tls.Client(c, tlsConfig(p.ServerName, p.InsecureSkipVerify))
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = c.Close()
return err
}
conn = tlsConn
} else {
conn = c
}
return nil
})
if dialErr != nil {
if isTimeout(dialErr) {
return health.ProbeResult{OK: false, Layer: health.LayerL4, Code: "L4TOUT", Detail: dialErr.Error()}
}
// Distinguish TLS handshake failures (L6) from TCP connect failures (L4).
// conn is non-nil only when TCP succeeded but TLS handshake failed.
if useTLS && conn == nil && isTLSError(dialErr) {
if isTimeout(dialErr) {
return health.ProbeResult{OK: false, Layer: health.LayerL6, Code: "L6TOUT", Detail: dialErr.Error()}
}
return health.ProbeResult{OK: false, Layer: health.LayerL6, Code: "L6RSP", Detail: dialErr.Error()}
}
return health.ProbeResult{OK: false, Layer: health.LayerL4, Code: "L4CON", Detail: dialErr.Error()}
}
defer func() { _ = conn.Close() }()
transport := &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return conn, nil
},
DisableKeepAlives: true,
}
client := &http.Client{
Transport: transport,
Timeout: cfg.Timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse // never follow redirects
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return health.ProbeResult{OK: false, Layer: health.LayerL7, Code: "L7RSP", Detail: err.Error()}
}
req.Host = hostHeader
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
if isTimeout(err) {
return health.ProbeResult{OK: false, Layer: health.LayerL7, Code: "L7TOUT", Detail: err.Error()}
}
return health.ProbeResult{OK: false, Layer: health.LayerL7, Code: "L7RSP", Detail: err.Error()}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < p.ResponseCodeMin || resp.StatusCode > p.ResponseCodeMax {
return health.ProbeResult{
OK: false,
Layer: health.LayerL7,
Code: "L7STS",
Detail: fmt.Sprintf("HTTP %d (want %d-%d)", resp.StatusCode, p.ResponseCodeMin, p.ResponseCodeMax),
}
}
if p.ResponseRegexp != nil {
body, err := io.ReadAll(resp.Body)
if err != nil {
return health.ProbeResult{OK: false, Layer: health.LayerL7, Code: "L7TOUT", Detail: err.Error()}
}
if !p.ResponseRegexp.Match(body) {
return health.ProbeResult{
OK: false,
Layer: health.LayerL7,
Code: "L7RSP",
Detail: fmt.Sprintf("body did not match regexp %q", p.ResponseRegexp),
}
}
}
return health.ProbeResult{OK: true, Layer: health.LayerL7, Code: "L7OK"}
}
// isTLSError returns true if err originated from the TLS layer.
func isTLSError(err error) bool {
if err == nil {
return false
}
_, ok := err.(tls.AlertError)
return ok || strings.Contains(err.Error(), "tls:")
}