package main
import (
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"sync"
"time"
"gopkg.awl.red/nymph/config"
)
var log = slog.New(slog.NewJSONHandler(os.Stdout, nil))
var client = http.Client{}
var redirectClient = http.Client{}
type Status struct {
Service string `json:"service"`
Component string `json:"component"`
Error string `json:"error"`
Message string `json:"message"`
Severity string `json:"severity"`
Status string `json:"status"`
}
type Statuses struct {
SeeOthers map[string]Status `json:"see_others"`
muSeeOthers sync.RWMutex
Gets map[string]Status `json:"gets"`
muGets sync.RWMutex
Commands map[string]Status `json:"commands"`
muCommands sync.RWMutex
}
var statuses = Statuses{
SeeOthers: map[string]Status{},
Gets: map[string]Status{},
Commands: map[string]Status{},
}
// key is service component error message
func handleResult(cfg config.Config, fqn string, service string, component string, message string, err error, mapp map[string]Status, mu *sync.RWMutex) error {
mu.RLock()
newStatus, hadStatus := mapp[fqn]
hasErr := err != nil
if (hadStatus && hasErr) || (!hadStatus && !hasErr) {
mu.RUnlock()
return nil
}
mu.RUnlock()
f := url.Values{}
var status string
if !hadStatus {
newStatus = Status{
Service: service,
Component: component,
Message: message,
Error: err.Error(),
Severity: "IMMEDIATE",
Status: cfg.Cicada.DownString,
}
status = newStatus.Status
} else {
status = cfg.Cicada.UpString
}
f.Set("service", newStatus.Service)
f.Set("component", newStatus.Component)
f.Set("message", newStatus.Message)
f.Set("error", newStatus.Error)
f.Set("severity", newStatus.Severity)
f.Set("status", status)
log.Info("status change", "service", newStatus.Service, "component", newStatus.Component, "status", status)
fBody := strings.NewReader(f.Encode())
req, err := http.NewRequest(http.MethodPost, cfg.Cicada.SendAlertUrl, fBody)
if err != nil {
log.Error("error making request to send cicada status", "err", err)
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", cfg.Http.Ua)
req.Header.Set("Authorization", "Bearer "+cfg.Cicada.Key)
res, err := client.Do(req)
if err != nil {
log.Error("error sending cicada status", "err", err)
return err
}
if res.StatusCode >= http.StatusBadRequest {
body := ""
resBytes, errBdy := io.ReadAll(res.Body)
if errBdy == nil {
body = string(resBytes)
}
log.Error("error sending cicada status", "err_body", body)
return err
}
mu.Lock()
if hadStatus && !hasErr {
delete(mapp, fqn)
} else if !hadStatus && hasErr {
mapp[fqn] = newStatus
}
mu.Unlock()
// no longer has status
return nil
}
func handleSeeOtherResult(cfg config.Config, fqn string, service string, component string, message string, err error) error {
errr := handleResult(cfg, fqn, service, component, message, err, statuses.SeeOthers, &statuses.muSeeOthers)
if errr != nil {
log.Error("handleResult error", "err", errr)
}
return errr
}
func handleGetResult(cfg config.Config, fqn string, service string, component string, message string, err error) error {
errr := handleResult(cfg, fqn, service, component, message, err, statuses.Gets, &statuses.muGets)
if errr != nil {
log.Error("handleResult error", "err", errr)
}
return errr
}
func handleCommandResult(cfg config.Config, fqn string, service string, component string, message string, err error) error {
errr := handleResult(cfg, fqn, service, component, message, err, statuses.Commands, &statuses.muCommands)
if errr != nil {
log.Error("handleResult error", "err", errr)
}
return errr
}
func seeOtherWorker(cfg config.Config) {
for {
bh:
for backhalf, target := range cfg.SeeOthers {
for i := 0; i <= cfg.SeeOtherRetries; i += 1 {
isLast := i == cfg.SeeOtherRetries
bhUrl, err := url.Parse(backhalf)
service := "unknown backhalf service"
message := "could not reach backhalf"
if err != nil {
if isLast {
handleSeeOtherResult(cfg, backhalf, service, "", message, err)
}
continue
}
service = bhUrl.Host
req, err := http.NewRequest(http.MethodGet, backhalf, nil)
if err != nil {
if isLast {
handleSeeOtherResult(cfg, backhalf, service, "", message, err)
}
continue
}
req.Header.Set("User-Agent", cfg.Http.Ua)
res, err := redirectClient.Do(req)
if err != nil {
if isLast {
handleSeeOtherResult(cfg, backhalf, service, "", "could not get backhalf", err)
}
continue
}
if res.StatusCode >= http.StatusBadRequest {
if isLast {
handleSeeOtherResult(cfg, backhalf, service, "", "bad status", errors.New("unexpected status "+res.Status))
}
continue
}
location := res.Header.Get("Location")
if location != target {
if isLast {
handleSeeOtherResult(cfg, backhalf, service, "", "bad location", errors.New("unexpected location "+location))
}
continue
}
handleSeeOtherResult(cfg, backhalf, "", "", "", nil)
continue bh
}
}
time.Sleep(time.Duration(cfg.SeeOtherInterval) * time.Second)
}
}
func randomizeSlice[T any](in []T) []T {
slice := slices.Clone(in)
for i := range slice {
j := rand.Intn(i + 1)
slice[i], slice[j] = slice[j], slice[i]
}
return slice
}
func getServiceWorker(cfg config.Config, serviceName string, service config.GetsSection) {
i := 0
for {
if i%60 == 0 {
log.Info("checking service", "name", serviceName)
}
urls := service.Urls
if service.PassIfOne {
urls = randomizeSlice(service.Urls)
}
outer:
for _, destination := range urls {
for i := 0; i <= cfg.SeeOtherRetries; i += 1 {
isLast := i == cfg.SeeOtherRetries
component := ""
message := "could not reach destination"
if !service.PassIfOne {
bhUrl, err := url.Parse(destination)
if err != nil {
if isLast {
handleGetResult(cfg, destination, serviceName, component, message, err)
}
continue
}
component = bhUrl.Host
}
req, err := http.NewRequest(http.MethodGet, destination, nil)
if err != nil {
if isLast {
handleGetResult(cfg, destination, serviceName, component, message, err)
}
continue
}
req.Header.Set("User-Agent", cfg.Http.Ua)
res, err := client.Do(req)
if err != nil {
if isLast {
handleGetResult(cfg, destination, serviceName, component, "could not get url", err)
}
continue
}
if res.StatusCode >= http.StatusBadRequest {
if isLast {
handleGetResult(cfg, destination, serviceName, component, "bad status", errors.New("unexpected status "+res.Status))
}
continue
}
handleGetResult(cfg, destination, "", "", "", nil)
if service.PassIfOne {
break outer
} else {
continue outer
}
}
}
time.Sleep(time.Second * time.Duration(service.Interval))
i += 1
}
}
func getsWorker(cfg config.Config) {
for name, service := range cfg.Gets {
go getServiceWorker(cfg, name, service)
}
}
func launchCommandWorkers(cfg config.Config) {
for _, command := range cfg.Commands {
go commandWorker(cfg, command)
}
}
func matchOutput(output string, command config.Command) error {
if command.ExactOutput != "" && output != command.ExactOutput {
return fmt.Errorf("expected %q, got %q", command.ExactOutput, output)
}
if len(command.MatchAny) > 0 {
foundAny := false
for _, matchPart := range command.MatchAny {
foundAny = strings.Contains(output, matchPart)
if foundAny {
break
}
}
if !foundAny {
return fmt.Errorf("expected to find one of %q, but did not", strings.Join(command.MatchAny, " @OR@ "))
}
}
for _, matchPart := range command.MatchAll {
if !strings.Contains(output, matchPart) {
return fmt.Errorf("expected to find %q, but did not", matchPart)
}
}
return nil
}
func commandWorker(cfg config.Config, command config.Command) {
fragments := strings.Split(command.Command, " | ")
loop:
for {
if len(fragments) == 1 {
exargs := strings.Split(fragments[0], " ")
executable := exargs[0]
args := []string{}
if len(exargs) > 1 {
args = exargs[1:]
}
cmd := exec.Command(executable, args...)
var stdout strings.Builder
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
handleCommandResult(cfg, command.Name, command.Service, command.Component, command.Name, err)
time.Sleep(time.Duration(command.Interval) * time.Second)
continue loop
}
out := stdout.String()
err = matchOutput(out, command)
if err != nil {
handleCommandResult(cfg, command.Name, command.Service, command.Component, command.Name, err)
time.Sleep(time.Duration(command.Interval) * time.Second)
continue loop
}
}
// TODO: support commands with pipes
var lastCmd *exec.Cmd
waits := []*exec.Cmd{}
var err error
for i, fragment := range fragments {
exargs := strings.Split(fragment, " ")
executable := exargs[0]
args := []string{}
if len(exargs) > 1 {
args = exargs[1:]
}
cmd := exec.Command(executable, args...)
if i > 0 {
cmd.Stdin, err = lastCmd.StdoutPipe()
if err != nil {
handleCommandResult(cfg, command.Name, command.Service, command.Component, "could not pipe "+strconv.Itoa(i), err)
slices.Reverse(waits)
for _, w := range waits {
_ = w.Wait()
}
time.Sleep(time.Duration(command.Interval) * time.Second)
continue loop
}
}
cmd.StdoutPipe()
}
}
}
func main() {
cfg := config.Must()
client.Timeout = time.Duration(cfg.Http.Timeout) * time.Second
redirectClient.Timeout = time.Duration(cfg.Http.Timeout) * time.Second
redirectClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
if len(cfg.SeeOthers) > 0 {
go seeOtherWorker(cfg)
}
if len(cfg.Gets) > 0 {
go getsWorker(cfg)
}
if len(cfg.Commands) > 0 {
go launchCommandWorkers(cfg)
}
var wg sync.WaitGroup
wg.Add(1)
wg.Wait()
}