diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 250cff6..be8a9ab 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -8,7 +8,6 @@ import ( "go-upkeep/internal/server" "go-upkeep/internal/store" "go-upkeep/internal/tui" - "io" "log" "os" "os/signal" @@ -23,7 +22,7 @@ import ( ) func main() { - log.SetOutput(io.Discard) + log.SetOutput(os.Stderr) portVal := 23234 dbType := "sqlite" @@ -67,6 +66,9 @@ func main() { if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { clusterKey = v } + if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" { + monitor.SetInsecureSkipVerify(true) + } port := flag.Int("port", portVal, "SSH Port") flagDBType := flag.String("db-type", dbType, "Database type") @@ -153,8 +155,8 @@ func seedDemoData(s store.Store) { s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}) s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}) s.AddAlert("Email Oncall", "email", map[string]string{ - "host": "smtp.gmail.com", "port": "587", - "user": "oncall@example.com", "pass": "hunter2", + "host": "smtp.example.com", "port": "587", + "user": "oncall@example.com", "pass": "replace-me", "from": "oncall@example.com", "to": "team@example.com", }) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 0933de2..03b943d 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -7,8 +7,11 @@ import ( "go-upkeep/internal/models" "net/http" "net/smtp" + "time" ) +var alertClient = &http.Client{Timeout: 10 * time.Second} + type Provider interface { Send(title, message string) error } @@ -24,7 +27,9 @@ func GetProvider(cfg models.AlertConfig) Provider { return &WebhookProvider{URL: cfg.Settings["url"]} case "email": port := "25" - if p, ok := cfg.Settings["port"]; ok { port = p } + if p, ok := cfg.Settings["port"]; ok { + port = p + } return &EmailProvider{ Host: cfg.Settings["host"], Port: port, @@ -40,40 +45,55 @@ func GetProvider(cfg models.AlertConfig) Provider { // --- DISCORD --- type DiscordProvider struct{ URL string } + func (d *DiscordProvider) Send(title, message string) error { payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)} jsonValue, _ := json.Marshal(payload) - _, err := http.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue)) - return err + resp, err := alertClient.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue)) + if err != nil { + return err + } + resp.Body.Close() + return nil } // --- SLACK --- type SlackProvider struct{ URL string } + func (s *SlackProvider) Send(title, message string) error { payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)} jsonValue, _ := json.Marshal(payload) - _, err := http.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue)) - return err + resp, err := alertClient.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue)) + if err != nil { + return err + } + resp.Body.Close() + return nil } // --- GENERIC WEBHOOK --- type WebhookProvider struct{ URL string } + func (w *WebhookProvider) Send(title, message string) error { - // Sends a standard JSON payload payload := map[string]string{ "title": title, "message": message, "status": "alert", } jsonValue, _ := json.Marshal(payload) - _, err := http.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue)) - return err + resp, err := alertClient.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue)) + if err != nil { + return err + } + resp.Body.Close() + return nil } // --- EMAIL --- type EmailProvider struct { Host, Port, User, Pass, To, From string } + func (e *EmailProvider) Send(title, message string) error { auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) msg := []byte("To: " + e.To + "\r\n" + @@ -81,4 +101,4 @@ func (e *EmailProvider) Send(title, message string) error { "\r\n" + message + "\r\n") return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) -} \ No newline at end of file +} diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 205039f..295443d 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -4,35 +4,42 @@ import ( "fmt" "go-upkeep/internal/monitor" "net/http" + "strings" "time" ) type Config struct { - Mode string // "leader" or "follower" - PeerURL string // URL of the Leader (e.g., http://primary:8080) - SharedKey string // Security Key + Mode string // "leader" or "follower" + PeerURL string // URL of the Leader (e.g., http://primary:8080) + SharedKey string // Security Key } func Start(cfg Config) { if cfg.Mode == "leader" { fmt.Println("Cluster: Running as LEADER (Active)") + if cfg.SharedKey != "" { + fmt.Println("WARNING: Cluster mode enabled. Ensure the HTTP server is behind a TLS-terminating proxy.") + } monitor.SetEngineActive(true) return } if cfg.Mode == "follower" { fmt.Println("Cluster: Running as FOLLOWER (Passive)") - monitor.SetEngineActive(false) // Start passive + if cfg.PeerURL != "" && !strings.HasPrefix(cfg.PeerURL, "https://") { + fmt.Println("WARNING: Cluster peer URL is not HTTPS. Cluster secret will be sent in cleartext.") + } + monitor.SetEngineActive(false) go runFollowerLoop(cfg) } } func runFollowerLoop(cfg Config) { client := http.Client{Timeout: 2 * time.Second} - + // Failover Configuration failures := 0 - threshold := 3 + threshold := 3 for { time.Sleep(5 * time.Second) @@ -44,7 +51,7 @@ func runFollowerLoop(cfg Config) { resp, err := client.Do(req) isLeaderHealthy := false - + if err == nil && resp.StatusCode == 200 { isLeaderHealthy = true resp.Body.Close() @@ -66,4 +73,4 @@ func runFollowerLoop(cfg Config) { } } } -} \ No newline at end of file +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 5b4a5c8..40d99f1 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -45,8 +45,14 @@ var ( // Global Switch for HA isActive = true activeMutex sync.RWMutex + + insecureSkipVerify bool ) +func SetInsecureSkipVerify(skip bool) { + insecureSkipVerify = skip +} + func SetEngineActive(active bool) { activeMutex.Lock() defer activeMutex.Unlock() @@ -208,7 +214,7 @@ func checkPush(site models.Site) { func checkHTTP(site models.Site) { start := time.Now() - client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}}} resp, err := client.Get(site.URL) latency := time.Since(start) diff --git a/internal/server/server.go b/internal/server/server.go index d65f224..d7f16b7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -19,14 +19,21 @@ type ServerConfig struct { } func Start(cfg ServerConfig) { + if cfg.ClusterKey == "" { + fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") + } mux := http.NewServeMux() // 1. Push Heartbeat mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") - if token == "" { http.Error(w, "Missing token", 400); return } + if token == "" { + http.Error(w, "Missing token", 400) + return + } if monitor.RecordHeartbeat(token) { - w.WriteHeader(http.StatusOK); w.Write([]byte("OK")) + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) } else { http.Error(w, "Invalid Token", 404) } @@ -54,7 +61,10 @@ func Start(cfg ServerConfig) { // 4. Config Import mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { http.Error(w, "POST required", 405); return } + if r.Method != "POST" { + http.Error(w, "POST required", 405) + return + } if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { http.Error(w, "Unauthorized", 401) return @@ -75,7 +85,8 @@ func Start(cfg ServerConfig) { if cfg.EnableStatus { mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title) }) mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { - monitor.Mutex.RLock(); defer monitor.Mutex.RUnlock() + monitor.Mutex.RLock() + defer monitor.Mutex.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(monitor.LiveState) }) @@ -95,11 +106,15 @@ func renderStatusPage(w http.ResponseWriter, title string) { sites = append(sites, s) } monitor.Mutex.RUnlock() - + sort.Slice(sites, func(i, j int) bool { if sites[i].Status != sites[j].Status { - if sites[i].Status == "DOWN" { return true } - if sites[j].Status == "DOWN" { return false } + if sites[i].Status == "DOWN" { + return true + } + if sites[j].Status == "DOWN" { + return false + } } return sites[i].Name < sites[j].Name }) @@ -148,6 +163,9 @@ func renderStatusPage(w http.ResponseWriter, title string) { ` t, _ := template.New("status").Parse(tpl) - data := struct { Title string; Sites []models.Site }{Title: title, Sites: sites} + data := struct { + Title string + Sites []models.Site + }{Title: title, Sites: sites} t.Execute(w, data) -} \ No newline at end of file +} diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index a55b51e..0302acd 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -1,5 +1,54 @@ package tui -func (m Model) viewLogsTab() string { - return "\n" + m.logViewport.View() +import ( + "fmt" + "strings" +) + +func colorizeLog(line string) string { + lower := strings.ToLower(line) + switch { + case strings.Contains(lower, "confirmed down"), + strings.Contains(lower, "is down"), + strings.Contains(lower, "missed heartbeat"), + strings.Contains(lower, "failed check"), + strings.Contains(lower, "ssl warning"): + return dangerStyle.Render(line) + case strings.Contains(lower, "recovered"), + strings.Contains(lower, "is up"), + strings.Contains(lower, "recovery"): + return specialStyle.Render(line) + case strings.Contains(lower, "engine"), + strings.Contains(lower, "cluster"): + return titleStyle.Render(line) + default: + return line + } +} + +func (m Model) viewLogsTab() string { + content := m.logViewport.View() + if strings.TrimSpace(content) == "" || content == "Waiting for logs..." { + return "\n No log entries yet. Logs appear as monitors run checks." + } + + lines := strings.Split(content, "\n") + var colored []string + for _, line := range lines { + if line == "" { + colored = append(colored, line) + continue + } + colored = append(colored, colorizeLog(line)) + } + + count := 0 + for _, l := range lines { + if strings.TrimSpace(l) != "" { + count++ + } + } + + header := subtleStyle.Render(fmt.Sprintf(" %d entries [↑/↓] Scroll [PgUp/PgDn] Page", count)) + return "\n" + header + "\n\n" + strings.Join(colored, "\n") } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 659e9f2..e565bd3 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -5,6 +5,7 @@ import ( "go-upkeep/internal/models" "go-upkeep/internal/monitor" "go-upkeep/internal/store" + "net/url" "strconv" "strings" "time" @@ -317,7 +318,26 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewInput().Title("URL"). Placeholder("https://example.com"). Description("Required for HTTP monitors"). - Value(&m.siteFormData.URL), + Value(&m.siteFormData.URL). + Validate(func(s string) error { + if m.siteFormData.SiteType == "push" { + return nil + } + if s == "" { + return fmt.Errorf("URL is required for HTTP monitors") + } + u, err := url.Parse(s) + if err != nil { + return fmt.Errorf("invalid URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL must start with http:// or https://") + } + if u.Host == "" { + return fmt.Errorf("URL must include a host") + } + return nil + }), huh.NewInput().Title("Check Interval (seconds)"). Placeholder("60"). Value(&m.siteFormData.Interval),