mirror of
https://github.com/Luzifer/webcheck.git
synced 2024-11-08 14:40:01 +00:00
227 lines
5.3 KiB
Go
227 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
|
|
}
|