fix(security): harden TLS, timeouts, validation, logging, and token generation

- Default TLS verification on, opt-in UPKEEP_INSECURE_SKIP_VERIFY
- Alert webhooks use 10s timeout client, close response bodies
- URL input validates http/https scheme for HTTP monitors
- Stdlib logs route to stderr instead of discard
- Panic on crypto/rand failure in token generation
- Cluster startup warnings for non-HTTPS and missing secret
- Replace demo SMTP creds with obvious placeholders
- Color-coded log entries and scroll hints in logs tab
This commit is contained in:
2026-05-14 11:46:06 -04:00
parent b7592ee9e5
commit 11848ce674
7 changed files with 156 additions and 34 deletions
+6 -4
View File
@@ -8,7 +8,6 @@ import (
"go-upkeep/internal/server" "go-upkeep/internal/server"
"go-upkeep/internal/store" "go-upkeep/internal/store"
"go-upkeep/internal/tui" "go-upkeep/internal/tui"
"io"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -23,7 +22,7 @@ import (
) )
func main() { func main() {
log.SetOutput(io.Discard) log.SetOutput(os.Stderr)
portVal := 23234 portVal := 23234
dbType := "sqlite" dbType := "sqlite"
@@ -67,6 +66,9 @@ func main() {
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" {
clusterKey = v clusterKey = v
} }
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" {
monitor.SetInsecureSkipVerify(true)
}
port := flag.Int("port", portVal, "SSH Port") port := flag.Int("port", portVal, "SSH Port")
flagDBType := flag.String("db-type", dbType, "Database type") 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("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("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"})
s.AddAlert("Email Oncall", "email", map[string]string{ s.AddAlert("Email Oncall", "email", map[string]string{
"host": "smtp.gmail.com", "port": "587", "host": "smtp.example.com", "port": "587",
"user": "oncall@example.com", "pass": "hunter2", "user": "oncall@example.com", "pass": "replace-me",
"from": "oncall@example.com", "to": "team@example.com", "from": "oncall@example.com", "to": "team@example.com",
}) })
+29 -9
View File
@@ -7,8 +7,11 @@ import (
"go-upkeep/internal/models" "go-upkeep/internal/models"
"net/http" "net/http"
"net/smtp" "net/smtp"
"time"
) )
var alertClient = &http.Client{Timeout: 10 * time.Second}
type Provider interface { type Provider interface {
Send(title, message string) error Send(title, message string) error
} }
@@ -24,7 +27,9 @@ func GetProvider(cfg models.AlertConfig) Provider {
return &WebhookProvider{URL: cfg.Settings["url"]} return &WebhookProvider{URL: cfg.Settings["url"]}
case "email": case "email":
port := "25" port := "25"
if p, ok := cfg.Settings["port"]; ok { port = p } if p, ok := cfg.Settings["port"]; ok {
port = p
}
return &EmailProvider{ return &EmailProvider{
Host: cfg.Settings["host"], Host: cfg.Settings["host"],
Port: port, Port: port,
@@ -40,40 +45,55 @@ func GetProvider(cfg models.AlertConfig) Provider {
// --- DISCORD --- // --- DISCORD ---
type DiscordProvider struct{ URL string } type DiscordProvider struct{ URL string }
func (d *DiscordProvider) Send(title, message string) error { func (d *DiscordProvider) Send(title, message string) error {
payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)} payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)}
jsonValue, _ := json.Marshal(payload) jsonValue, _ := json.Marshal(payload)
_, err := http.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue)) resp, err := alertClient.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue))
return err if err != nil {
return err
}
resp.Body.Close()
return nil
} }
// --- SLACK --- // --- SLACK ---
type SlackProvider struct{ URL string } type SlackProvider struct{ URL string }
func (s *SlackProvider) Send(title, message string) error { func (s *SlackProvider) Send(title, message string) error {
payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)} payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)}
jsonValue, _ := json.Marshal(payload) jsonValue, _ := json.Marshal(payload)
_, err := http.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue)) resp, err := alertClient.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue))
return err if err != nil {
return err
}
resp.Body.Close()
return nil
} }
// --- GENERIC WEBHOOK --- // --- GENERIC WEBHOOK ---
type WebhookProvider struct{ URL string } type WebhookProvider struct{ URL string }
func (w *WebhookProvider) Send(title, message string) error { func (w *WebhookProvider) Send(title, message string) error {
// Sends a standard JSON payload
payload := map[string]string{ payload := map[string]string{
"title": title, "title": title,
"message": message, "message": message,
"status": "alert", "status": "alert",
} }
jsonValue, _ := json.Marshal(payload) jsonValue, _ := json.Marshal(payload)
_, err := http.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue)) resp, err := alertClient.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue))
return err if err != nil {
return err
}
resp.Body.Close()
return nil
} }
// --- EMAIL --- // --- EMAIL ---
type EmailProvider struct { type EmailProvider struct {
Host, Port, User, Pass, To, From string Host, Port, User, Pass, To, From string
} }
func (e *EmailProvider) Send(title, message string) error { func (e *EmailProvider) Send(title, message string) error {
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
msg := []byte("To: " + e.To + "\r\n" + msg := []byte("To: " + e.To + "\r\n" +
@@ -81,4 +101,4 @@ func (e *EmailProvider) Send(title, message string) error {
"\r\n" + "\r\n" +
message + "\r\n") message + "\r\n")
return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg)
} }
+15 -8
View File
@@ -4,35 +4,42 @@ import (
"fmt" "fmt"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"net/http" "net/http"
"strings"
"time" "time"
) )
type Config struct { type Config struct {
Mode string // "leader" or "follower" Mode string // "leader" or "follower"
PeerURL string // URL of the Leader (e.g., http://primary:8080) PeerURL string // URL of the Leader (e.g., http://primary:8080)
SharedKey string // Security Key SharedKey string // Security Key
} }
func Start(cfg Config) { func Start(cfg Config) {
if cfg.Mode == "leader" { if cfg.Mode == "leader" {
fmt.Println("Cluster: Running as LEADER (Active)") 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) monitor.SetEngineActive(true)
return return
} }
if cfg.Mode == "follower" { if cfg.Mode == "follower" {
fmt.Println("Cluster: Running as FOLLOWER (Passive)") 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) go runFollowerLoop(cfg)
} }
} }
func runFollowerLoop(cfg Config) { func runFollowerLoop(cfg Config) {
client := http.Client{Timeout: 2 * time.Second} client := http.Client{Timeout: 2 * time.Second}
// Failover Configuration // Failover Configuration
failures := 0 failures := 0
threshold := 3 threshold := 3
for { for {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
@@ -44,7 +51,7 @@ func runFollowerLoop(cfg Config) {
resp, err := client.Do(req) resp, err := client.Do(req)
isLeaderHealthy := false isLeaderHealthy := false
if err == nil && resp.StatusCode == 200 { if err == nil && resp.StatusCode == 200 {
isLeaderHealthy = true isLeaderHealthy = true
resp.Body.Close() resp.Body.Close()
@@ -66,4 +73,4 @@ func runFollowerLoop(cfg Config) {
} }
} }
} }
} }
+7 -1
View File
@@ -45,8 +45,14 @@ var (
// Global Switch for HA // Global Switch for HA
isActive = true isActive = true
activeMutex sync.RWMutex activeMutex sync.RWMutex
insecureSkipVerify bool
) )
func SetInsecureSkipVerify(skip bool) {
insecureSkipVerify = skip
}
func SetEngineActive(active bool) { func SetEngineActive(active bool) {
activeMutex.Lock() activeMutex.Lock()
defer activeMutex.Unlock() defer activeMutex.Unlock()
@@ -208,7 +214,7 @@ func checkPush(site models.Site) {
func checkHTTP(site models.Site) { func checkHTTP(site models.Site) {
start := time.Now() 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) resp, err := client.Get(site.URL)
latency := time.Since(start) latency := time.Since(start)
+27 -9
View File
@@ -19,14 +19,21 @@ type ServerConfig struct {
} }
func Start(cfg ServerConfig) { func Start(cfg ServerConfig) {
if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
}
mux := http.NewServeMux() mux := http.NewServeMux()
// 1. Push Heartbeat // 1. Push Heartbeat
mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") 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) { if monitor.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK); w.Write([]byte("OK")) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else { } else {
http.Error(w, "Invalid Token", 404) http.Error(w, "Invalid Token", 404)
} }
@@ -54,7 +61,10 @@ func Start(cfg ServerConfig) {
// 4. Config Import // 4. Config Import
mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { 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 { if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", 401)
return return
@@ -75,7 +85,8 @@ func Start(cfg ServerConfig) {
if cfg.EnableStatus { if cfg.EnableStatus {
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title) }) 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) { 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(monitor.LiveState) json.NewEncoder(w).Encode(monitor.LiveState)
}) })
@@ -95,11 +106,15 @@ func renderStatusPage(w http.ResponseWriter, title string) {
sites = append(sites, s) sites = append(sites, s)
} }
monitor.Mutex.RUnlock() monitor.Mutex.RUnlock()
sort.Slice(sites, func(i, j int) bool { sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status { if sites[i].Status != sites[j].Status {
if sites[i].Status == "DOWN" { return true } if sites[i].Status == "DOWN" {
if sites[j].Status == "DOWN" { return false } return true
}
if sites[j].Status == "DOWN" {
return false
}
} }
return sites[i].Name < sites[j].Name return sites[i].Name < sites[j].Name
}) })
@@ -148,6 +163,9 @@ func renderStatusPage(w http.ResponseWriter, title string) {
</html>` </html>`
t, _ := template.New("status").Parse(tpl) 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) t.Execute(w, data)
} }
+51 -2
View File
@@ -1,5 +1,54 @@
package tui package tui
func (m Model) viewLogsTab() string { import (
return "\n" + m.logViewport.View() "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")
} }
+21 -1
View File
@@ -5,6 +5,7 @@ import (
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"go-upkeep/internal/store" "go-upkeep/internal/store"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -317,7 +318,26 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
huh.NewInput().Title("URL"). huh.NewInput().Title("URL").
Placeholder("https://example.com"). Placeholder("https://example.com").
Description("Required for HTTP monitors"). 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)"). huh.NewInput().Title("Check Interval (seconds)").
Placeholder("60"). Placeholder("60").
Value(&m.siteFormData.Interval), Value(&m.siteFormData.Interval),