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:
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+28
-8
@@ -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" +
|
||||||
|
|||||||
@@ -4,25 +4,32 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -98,8 +109,12 @@ func renderStatusPage(w http.ResponseWriter, title string) {
|
|||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user