package main import ( "context" "embed" "fmt" "io/fs" "log" "net" "net/http" "os" os_signal "os/signal" "strings" "syscall" "time" "git.exozy.me/exozyme/status/core" "git.exozy.me/exozyme/status/scanner" "github.com/cbroglie/mustache" "github.com/timewasted/go-accept-headers" ) //go:embed all:public var staticAssets embed.FS //go:embed all:templates var templateAssets embed.FS func parseAndRenderTemplate(templateName, siteName, iconSrc string, data interface{}) (string, error) { rawTemplate, err := templateAssets.ReadFile(templateName) if err != nil { return "", err } tmpl, err := mustache.ParseString(string(rawTemplate)) if err != nil { log.Println("Failed to parse template:", err) return "", err } res, err := tmpl.Render(map[string]interface{}{"SiteName": siteName, "IconSrc": iconSrc, "Data": data}) if err != nil { log.Println("Failed to render template:", err) return "", err } return res, nil } func index(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { return } config_new, err := core.LoadConfig() if err != nil { log.Println("[WARN] failed to load config:", err) } else { config = config_new } // get service status' timeout := 3 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() data := scanner.CheckServiceBatch(ctx, config.Service, config.BasicAuthUsername, config.BasicAuthPassword) non_duplicates, err := dbm.StoreServiceStatusBatch(data, time.Now()) if err != nil { http.Error(w, err.Error(), 500) return } err_annouce := announce(non_duplicates) if err_annouce != nil { log.Println(err_annouce) } // don't uncomment this in production. verbose // log.Printf("%v", data) ua := r.UserAgent() prefer_text := strings.HasPrefix(ua, "curl/") header_accept := accept.Parse(r.Header.Get("Accept")) accept_text := header_accept.Accepts("text/plain") accept_html := header_accept.Accepts("text/html") if (!accept_html || prefer_text) && accept_text { indexForVT100(w, r, data) } else if accept_html { historical_data, err := dbm.QueryAllAfter(time.Now().Add(-7 * 24 * time.Hour)) if err != nil { http.Error(w, err.Error(), 500) return } indexForBrowserWithHistoricalData(w, r, historical_data, config.SiteName, config.IconSrc) } else { // when no valid content type is requested // not to-spec. still sends plain text anyway indexForVT100(w, r, data) } } func indexForVT100(w http.ResponseWriter, _ *http.Request, data scanner.ServiceStatusBatch) { w.Header().Add("Content-Type", "text/plain; charset=utf-8") parsedTemplate := formatStatusUpdateAsText(data) w.Write([]byte(parsedTemplate)) } func indexForBrowserWithHistoricalData(w http.ResponseWriter, _ *http.Request, historical_data []core.Row, siteName string, iconSrc string) { // the basic idea here: // - create new template and render it with historical_data // // reference: see `indexForBrowser` above parsedTemplate, err := parseAndRenderTemplate("templates/index.html", siteName, iconSrc, data) if err != nil { http.Error(w, err.Error(), 500) return } w.Write([]byte(parsedTemplate)) } var config *core.Config var dbm core.Database var client *core.MatrixClient func cleanup() { log.Println("Cleaning up") _ = dbm.StoreSelfStatus(core.EventSelfDown, time.Now()) _ = dbm.Persist() } func main() { var err error config, err = core.LoadConfig() if err != nil { log.Panic(err) } dbm, err = core.OpenDatabase(config.Database) if err != nil { log.Panic(err) } client, err = core.NewMatrixClient(config.Matrix) if err != nil { log.Panic(err) } http.HandleFunc("/", index) // serve public folder from embedfs htmlContent, err := fs.Sub(fs.FS(staticAssets), "public") if err != nil { log.Panic(err) } fs := http.FileServer(http.FS(htmlContent)) http.Handle("/public/", http.StripPrefix("/public/", fs)) protocol := "tcp" addr := "localhost:3333" var unix_socket_path string var is_unix bool if config.UnixSocket != "" { // get unix socket path from the config else unix_socket_path = config.UnixSocket is_unix = true } else { // fallback unix_socket_path, is_unix = os.LookupEnv("PORT") } if is_unix { protocol = "unix" addr = unix_socket_path // VnPower: is this unsafe? removeFile(addr) // ideally this should be run at program exit. sadly Go's defer doesn't care about signals or panic } srv := &http.Server{Addr: addr, Handler: nil} ln, err := net.Listen(protocol, addr) if err != nil { log.Panic(err) } if is_unix { err = os.Chmod(addr, 0660) if err != nil { log.Panic(err) } log.Printf("Listening on %v\n", addr) } else { log.Printf("Listening on http://%v/\n", addr) } // log startup and register cleanup (on SIGTERM or panic) ch_sigterm := make(chan os.Signal, 1) os_signal.Notify(ch_sigterm, syscall.SIGTERM, syscall.SIGINT) log.Print("Starting up") err = dbm.StoreSelfStatus(core.EventSelfUp, time.Now()) if err != nil { log.Panic(err) } defer cleanup() go func() { <-ch_sigterm err := srv.Shutdown(context.Background()) if err != nil { log.Printf("Error when shutting down: %v", err) } }() btime, err := core.GetBootTime() if err != nil { log.Printf("Error in core.GetBootTime: %v", err) } else { time_passed := time.Now().Sub(btime) time.Sleep(core.WaitDurationAfterBoot - time_passed) } go func() { // timed update for { err := routineCheck(5 * time.Minute) if err != nil { log.Printf("Error in routineCheck: %v", err) // idea: maybe we should delay here } } }() err = srv.Serve(ln) // serve http if err != http.ErrServerClosed { log.Panic(err) } } func routineCheck(timeout time.Duration) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() data := scanner.CheckServiceBatch(ctx, config.Service, config.BasicAuthUsername, config.BasicAuthPassword) non_duplicates, err := dbm.StoreServiceStatusBatch(data, time.Now()) if err != nil { return err } err = announce(non_duplicates) if err != nil { return err } // log.Printf("Routine check: %v", data) <-ctx.Done() return nil } func formatStatusUpdateAsText(updates []scanner.ServiceStatus) string { result := "" for _, v := range updates { var upOrDown string if v.Ok { upOrDown = "UP" } else { upOrDown = "DOWN" } serviceType := fmt.Sprintf("[%s]", v.Type) result += fmt.Sprintf("%-4s | %-24s %-7s %s\n", upOrDown, v.Name, serviceType, v.Status) } return result } var first_time_to_annouce = true func announce(updates []scanner.ServiceStatus) error { messageBody := "" if first_time_to_annouce { // only show downed services tmp := []scanner.ServiceStatus{} num_omitted := 0 for _, v := range updates { if !v.Ok { tmp = append(tmp, v) } else { num_omitted += 1 } } updates = tmp messageBody += fmt.Sprintf("UP | (%d services omitted)\n", num_omitted) } if len(updates) == 0 { return nil } if first_time_to_annouce { first_time_to_annouce = false } messageBody += formatStatusUpdateAsText(updates) _, err := client.SendNoticeHTML( "```\n"+messageBody+"```", "
"+strings.TrimRight(messageBody, "\n")+"
", &updates) log.Printf("notify: err=%v\n%v", err, updates) return err }