Initial revisin of healthchecker, inspired by HAProxy
This commit is contained in:
156
internal/prober/http.go
Normal file
156
internal/prober/http.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package prober
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ipng.ch/ipng/vpp-maglev/internal/health"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if useTLS {
|
||||
scheme = "https"
|
||||
}
|
||||
target := fmt.Sprintf("%s://%s%s", scheme, 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 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", "maglev-healthchecker/1.0")
|
||||
|
||||
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 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:")
|
||||
}
|
||||
Reference in New Issue
Block a user