1
0
mirror of https://github.com/Luzifer/webcheck.git synced 2024-09-16 14:18:26 +00:00
webcheck/main.go

228 lines
5.3 KiB
Go

package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"github.com/sirupsen/logrus"
"github.com/Luzifer/rconfig/v2"
)
const (
cleanupInterval = 10 * time.Second
logFolderPerms = 0o750
)
var (
cfg = struct {
DisableLog bool `flag:"no-log" default:"false" description:"Disable response body logging"`
Interval time.Duration `flag:"interval,i" default:"1s" description:"Check interval"`
LogDir string `flag:"log-dir,l" default:"/tmp/resp-log/" description:"Directory to log non-matched requests to"`
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
LogRetention time.Duration `flag:"log-retention" default:"24h" description:"When to clean up file from log-dir"`
Match string `flag:"match,m" default:".*" description:"RegExp to match the response body against to validate it"`
Timeout time.Duration `flag:"timeout,t" default:"30s" description:"Timeout for the request"`
URL string `flag:"url,u" default:"" description:"URL to query" validate:"nonzero"`
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
}{}
version = "dev"
)
func initApp() error {
rconfig.AutoEnv(true)
if err := rconfig.ParseAndValidate(&cfg); err != nil {
return fmt.Errorf("parsing cli options: %w", err)
}
l, err := logrus.ParseLevel(cfg.LogLevel)
if err != nil {
return fmt.Errorf("parsing log-level: %w", err)
}
logrus.SetLevel(l)
return nil
}
func main() {
var err error
if err = initApp(); err != nil {
logrus.WithError(err).Fatal("initializing app")
}
if cfg.VersionAndExit {
logrus.WithField("version", version).Info("webcheck")
os.Exit(0)
}
matcher, err := regexp.Compile(cfg.Match)
if err != nil {
logrus.WithError(err).Fatal("compiling matcher RegExp")
}
lastResult := newCheckResult(statusUnknown, "Uninitialized", 0)
go cleanupLogFiles()
for range time.Tick(cfg.Interval) {
var (
body io.ReadWriter
result *checkResult
)
if !cfg.DisableLog {
body = new(bytes.Buffer)
}
result = doCheck(cfg.URL, matcher, body)
if !result.Equals(lastResult) {
fmt.Println() //nolint:forbidigo
lastResult = result
if result.Status == statusFailed {
fn, err := dumpRequest(body)
if err != nil {
logrus.WithError(err).Fatal("logging request")
}
lastResult.DumpFile = fn
}
} else {
lastResult.AddDuration(result.Durations.GetCurrent())
}
if err = lastResult.Print(); err != nil {
logrus.WithError(err).Fatal("displaying status")
}
}
}
func cleanupLogFiles() {
for range time.Tick(cleanupInterval) {
if info, err := os.Stat(cfg.LogDir); err != nil || !info.IsDir() {
continue
}
if err := filepath.Walk(cfg.LogDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && time.Since(info.ModTime()) > cfg.LogRetention {
return os.Remove(path)
}
return nil
}); err != nil {
fmt.Println() //nolint:forbidigo
logrus.WithError(err).Error("cleaning up logs")
}
}
}
func doCheck(url string, match *regexp.Regexp, responseBody io.Writer) *checkResult {
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
respStart := time.Now()
resp, err := http.DefaultClient.Do(req)
respDuration := time.Since(respStart)
if err != nil {
return newCheckResult(
statusFailed,
fmt.Sprintf("HTTP request failed: %s", err),
respDuration,
)
}
defer func() {
if err := resp.Body.Close(); err != nil {
logrus.WithError(err).Error("closing response body (leaked fd)")
}
}()
body := new(bytes.Buffer)
if _, err = io.Copy(body, resp.Body); err != nil {
return newCheckResult(
statusFailed,
"Was not able to read response body",
respDuration,
)
}
if responseBody != nil {
if err = resp.Header.Write(responseBody); err != nil {
return newCheckResult(
statusFailed,
"Was not able to copy headers",
respDuration,
)
}
if _, err = responseBody.Write(append([]byte{'\n'}, body.Bytes()...)); err != nil {
return newCheckResult(
statusFailed,
"Was not able to copy body",
respDuration,
)
}
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return newCheckResult(
statusFailed,
fmt.Sprintf("Status code was != 2xx: %d", resp.StatusCode),
respDuration,
)
}
if !match.Match(body.Bytes()) {
return newCheckResult(
statusFailed,
"Response body does not match regexp",
respDuration,
)
}
return newCheckResult(
statusOk,
fmt.Sprintf("Status was %d and text matched", resp.StatusCode),
respDuration,
)
}
func dumpRequest(body io.Reader) (string, error) {
if body == nil {
return "", nil
}
if err := os.MkdirAll(cfg.LogDir, logFolderPerms); err != nil {
return "", fmt.Errorf("creating log folder: %w", err)
}
f, err := os.CreateTemp(cfg.LogDir, "resp")
if err != nil {
return "", fmt.Errorf("creating log file: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
logrus.WithError(err).Error("closing log file (leaked fd)")
}
}()
if _, err = io.Copy(f, body); err != nil {
return "", fmt.Errorf("copying request body: %w", err)
}
return f.Name(), nil
}