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() }