Files
smallwebwaf/internal/sww/healthcheck.go
clawbot f7e3190654
check / check (push) Waiting to run
Rename discriminator to waf everywhere (closes #312)
The command is now `sww waf`, and the package is `internal/waf`. Types,
functions, files, tests, comments, docs, the Dockerfile, the runit
script, `script/`, compose examples, the log field `waf_line` and the
`X-From-Waf` test header follow. No behaviour changes, and no setting
changes name: rproxy already finds the waf through `SWRP_WAF_BACKEND`.

Done with one `sed -i` (Discriminator and DISCRIMINATOR to WAF,
discriminator to waf) over the files `git grep -i discriminator`
listed, as sneak allowed for this rename, then `git mv` for the
package and files. By hand afterwards: the header's spelling, which
the linter requires, and the padding after `waf` in `sww`'s usage
text.

Model: opus-5-5
2026-11-11 02:43:16 +01:00

146 lines
3.7 KiB
Go

package sww
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
// healthCheckTimeout bounds the whole health check.
const healthCheckTimeout = 5 * time.Second
var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In waf's container, where
// SWW_COMMAND is waf, it returns 0 while waf answers its health
// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container,
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
// any other value of SWW_COMMAND included, it writes why to stderr and
// returns 1. It reads no other setting, nor a file that another names, so neither
// can fail it.
// args are the arguments after `healthcheck`; it takes none, and given
// one it names it on stderr and returns 1 without checking anything.
func HealthCheck(
ctx context.Context, args []string, lookupEnv func(string) (string, bool),
stderr io.Writer,
) int {
if len(args) > 0 {
_, _ = fmt.Fprintf(stderr,
"sww healthcheck: unexpected argument %q\n", args[0])
return 1
}
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel()
command, err := config.Command(lookupEnv)
switch {
case err != nil:
err = fmt.Errorf("invalid setting: %w", err)
case command == "waf":
err = checkWAF(ctx, lookupEnv)
default:
err = checkRProxyAndApp(ctx, lookupEnv)
}
if err != nil {
_, _ = fmt.Fprintln(stderr, "unhealthy:", err)
return 1
}
return 0
}
// checkWAF asks waf's health endpoint.
func checkWAF(
ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, err := config.WAFListenAddr(lookupEnv)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
err = askHealthEndpoint(ctx, listenAddr)
if err != nil {
return fmt.Errorf("ask waf: %w", err)
}
return nil
}
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
func checkRProxyAndApp(
ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
err = askHealthEndpoint(ctx, listenAddr)
if err != nil {
return fmt.Errorf("ask rproxy: %w", err)
}
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", appAddress(upstreamURL))
if err != nil {
return fmt.Errorf("connect to the app: %w", err)
}
_ = conn.Close()
return nil
}
// askHealthEndpoint asks the health endpoint on 127.0.0.1 at the port of
// listenAddr, which the settings have checked has one.
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
_, port, _ := net.SplitHostPort(listenAddr)
health := "http://" + net.JoinHostPort("127.0.0.1", port) + waf.HealthPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
if err != nil {
return fmt.Errorf("make the request: %w", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("send the request: %w", err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w %s", errHealthEndpoint, res.Status)
}
return nil
}
// appAddress is the host and port of the app's URL, the port being the
// scheme's own when the URL names none.
func appAddress(app *url.URL) string {
port := app.Port()
if port == "" {
port = "80"
if app.Scheme == "https" {
port = "443"
}
}
return net.JoinHostPort(app.Hostname(), port)
}