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.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go-upkeep/internal/cluster"
|
"go-upkeep/internal/cluster"
|
||||||
|
"go-upkeep/internal/importer"
|
||||||
"go-upkeep/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"go-upkeep/internal/monitor"
|
"go-upkeep/internal/monitor"
|
||||||
"go-upkeep/internal/server"
|
"go-upkeep/internal/server"
|
||||||
@@ -75,6 +76,7 @@ func main() {
|
|||||||
flagDBType := flag.String("db-type", dbType, "Database type")
|
flagDBType := flag.String("db-type", dbType, "Database type")
|
||||||
flagDSN := flag.String("dsn", dbDSN, "Database DSN")
|
flagDSN := flag.String("dsn", dbDSN, "Database DSN")
|
||||||
demo := flag.Bool("demo", false, "Seed demo data")
|
demo := flag.Bool("demo", false, "Seed demo data")
|
||||||
|
importKuma := flag.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var s store.Store
|
var s store.Store
|
||||||
@@ -96,6 +98,20 @@ func main() {
|
|||||||
seedDemoData(s)
|
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()
|
monitor.StartEngine()
|
||||||
|
|
||||||
server.Start(server.ServerConfig{
|
server.Start(server.ServerConfig{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/importer"
|
||||||
"go-upkeep/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"go-upkeep/internal/monitor"
|
"go-upkeep/internal/monitor"
|
||||||
"go-upkeep/internal/store"
|
"go-upkeep/internal/store"
|
||||||
@@ -81,7 +82,30 @@ func Start(cfg ServerConfig) {
|
|||||||
w.Write([]byte("Import Successful"))
|
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 {
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user