From 6d92df4f461ae463cc7666d153a7400d60a624d6 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 17:30:17 -0400 Subject: [PATCH] feat(importer): add Uptime Kuma backup converter with CLI and API Convert Kuma monitorList/notificationList to go-upkeep Backup format. Maps all monitor types (http, ping, port, dns, group), ntfy notifications with auth, parent IDs, and alert assignments. Available via --import-kuma flag and POST /api/import/kuma endpoint. --- cmd/goupkeep/main.go | 16 ++++ internal/importer/kuma.go | 186 ++++++++++++++++++++++++++++++++++++++ internal/server/server.go | 26 +++++- 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/importer/kuma.go diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 0d8e7b9..0ae9cb0 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "go-upkeep/internal/cluster" + "go-upkeep/internal/importer" "go-upkeep/internal/models" "go-upkeep/internal/monitor" "go-upkeep/internal/server" @@ -75,6 +76,7 @@ func main() { flagDBType := flag.String("db-type", dbType, "Database type") flagDSN := flag.String("dsn", dbDSN, "Database DSN") demo := flag.Bool("demo", false, "Seed demo data") + importKuma := flag.String("import-kuma", "", "Import Uptime Kuma backup JSON file") flag.Parse() var s store.Store @@ -96,6 +98,20 @@ func main() { seedDemoData(s) } + if *importKuma != "" { + kb, err := importer.LoadKumaFile(*importKuma) + if err != nil { + fmt.Printf("Kuma import error: %v\n", err) + os.Exit(1) + } + backup := importer.ConvertKuma(kb) + if err := s.ImportData(backup); err != nil { + fmt.Printf("Import failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) + } + monitor.StartEngine() server.Start(server.ServerConfig{ diff --git a/internal/importer/kuma.go b/internal/importer/kuma.go new file mode 100644 index 0000000..d2fd2aa --- /dev/null +++ b/internal/importer/kuma.go @@ -0,0 +1,186 @@ +package importer + +import ( + "encoding/json" + "fmt" + "go-upkeep/internal/models" + "os" + "strings" +) + +type KumaBackup struct { + Version string `json:"version"` + MonitorList []KumaMonitor `json:"monitorList"` + NotificationList []KumaNotifEntry `json:"notificationList"` +} + +type KumaMonitor struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + URL string `json:"url"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Type string `json:"type"` + Interval int `json:"interval"` + Timeout int `json:"timeout"` + MaxRetries int `json:"maxretries"` + Method string `json:"method"` + AcceptedCodes []string `json:"accepted_statuscodes"` + IgnoreTLS bool `json:"ignoreTls"` + Parent int `json:"parent"` + Active bool `json:"active"` + DNSResolveType string `json:"dns_resolve_type"` + DNSResolveServer string `json:"dns_resolve_server"` + NotificationIDs map[string]bool `json:"notificationIDList"` + ExpiryNotif bool `json:"expiryNotification"` + Tags []json.RawMessage `json:"tags"` +} + +type KumaNotifEntry struct { + ID int `json:"id"` + Name string `json:"name"` + Config string `json:"config"` +} + +type KumaNotifConfig struct { + Type string `json:"type"` + URL string `json:"ntfyserverurl"` + Topic string `json:"ntfytopic"` + Priority int `json:"ntfyPriority"` + AuthMode string `json:"ntfyAuthenticationMethod"` + Username string `json:"ntfyusername"` + Password string `json:"ntfypassword"` +} + +func LoadKumaFile(path string) (*KumaBackup, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + var backup KumaBackup + if err := json.Unmarshal(data, &backup); err != nil { + return nil, fmt.Errorf("parse JSON: %w", err) + } + return &backup, nil +} + +func ConvertKuma(kb *KumaBackup) models.Backup { + alertMap := convertKumaNotifications(kb.NotificationList) + + var alerts []models.AlertConfig + for _, a := range alertMap { + alerts = append(alerts, a) + } + + kumaToUpkeepAlert := make(map[int]int) + for _, n := range kb.NotificationList { + if a, ok := alertMap[n.ID]; ok { + kumaToUpkeepAlert[n.ID] = a.ID + } + } + + var sites []models.Site + for _, m := range kb.MonitorList { + site := convertKumaMonitor(m, kumaToUpkeepAlert) + sites = append(sites, site) + } + + return models.Backup{ + Sites: sites, + Alerts: alerts, + } +} + +func convertKumaNotifications(entries []KumaNotifEntry) map[int]models.AlertConfig { + result := make(map[int]models.AlertConfig) + for _, entry := range entries { + var cfg KumaNotifConfig + json.Unmarshal([]byte(entry.Config), &cfg) + + alert := models.AlertConfig{ + ID: entry.ID, + Name: entry.Name, + Settings: make(map[string]string), + } + + switch cfg.Type { + case "ntfy": + alert.Type = "ntfy" + alert.Settings["url"] = strings.TrimRight(cfg.URL, "/") + alert.Settings["topic"] = cfg.Topic + alert.Settings["priority"] = fmt.Sprintf("%d", cfg.Priority) + if cfg.AuthMode == "usernamePassword" { + alert.Settings["username"] = cfg.Username + alert.Settings["password"] = cfg.Password + } + case "discord": + alert.Type = "discord" + alert.Settings["url"] = cfg.URL + case "slack": + alert.Type = "slack" + alert.Settings["url"] = cfg.URL + default: + alert.Type = "webhook" + alert.Settings["url"] = cfg.URL + } + + result[entry.ID] = alert + } + return result +} + +func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site { + site := models.Site{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + Type: m.Type, + Interval: m.Interval, + Timeout: m.Timeout, + MaxRetries: m.MaxRetries, + Method: m.Method, + Hostname: m.Hostname, + Port: m.Port, + IgnoreTLS: m.IgnoreTLS, + ParentID: m.Parent, + } + + if len(m.AcceptedCodes) > 0 { + site.AcceptedCodes = strings.Join(m.AcceptedCodes, ",") + } + + site.DNSResolveType = m.DNSResolveType + site.DNSServer = m.DNSResolveServer + + switch m.Type { + case "http": + site.URL = m.URL + site.CheckSSL = m.ExpiryNotif + case "ping": + if m.Hostname != "" { + site.Hostname = m.Hostname + } + case "port": + if m.Hostname != "" { + site.Hostname = m.Hostname + } + case "dns": + if m.Hostname != "" { + site.Hostname = m.Hostname + } + case "group": + // groups are organizational only + } + + for nidStr := range m.NotificationIDs { + var nid int + fmt.Sscanf(nidStr, "%d", &nid) + if upkeepID, ok := alertMap[nid]; ok { + site.AlertID = upkeepID + break + } + } + + return site +} diff --git a/internal/server/server.go b/internal/server/server.go index d7f16b7..6e857b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "fmt" + "go-upkeep/internal/importer" "go-upkeep/internal/models" "go-upkeep/internal/monitor" "go-upkeep/internal/store" @@ -81,7 +82,30 @@ func Start(cfg ServerConfig) { w.Write([]byte("Import Successful")) }) - // 5. Status Page + // 5. Kuma Import + mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) { + 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 + } + var kb importer.KumaBackup + if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { + http.Error(w, "Invalid Kuma JSON: "+err.Error(), 400) + return + } + backup := importer.ConvertKuma(&kb) + if err := store.Get().ImportData(backup); err != nil { + http.Error(w, "Import Failed: "+err.Error(), 500) + return + } + w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) + }) + + // 6. Status Page 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) {