package sync import ( "context" _ "embed" "errors" "fmt" "html/template" "net" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/gin-gonic/gin" "github.com/bakito/adguardhome-sync/internal/log" "github.com/bakito/adguardhome-sync/internal/metrics" "github.com/bakito/adguardhome-sync/internal/sync/static" "github.com/bakito/adguardhome-sync/version" ) func (w *worker) handleSync(c *gin.Context) { l.With("remote-addr", c.Request.RemoteAddr).Info("Starting sync from API") w.sync() } func (w *worker) handleRoot(c *gin.Context) { total, dns, blocked, malware, adult := metrics.StatsGraph() c.HTML(http.StatusOK, "index.html", map[string]any{ "DarkMode": w.cfg.API.DarkMode, "Metrics": w.cfg.API.Metrics.Enabled, "Version": version.Version, "Build": version.Build, "SyncStatus": w.status(), "Stats": map[string]any{ "Labels": getLast24Hours(), "DNS": dns, "Blocked": blocked, "BlockedPercentage": percent(total.NumBlockedFiltering, total.NumDnsQueries), "Malware": malware, "MalwarePercentage": percent(total.NumReplacedSafebrowsing, total.NumDnsQueries), "Adult": adult, "AdultPercentage": percent(total.NumReplacedParental, total.NumDnsQueries), "TotalDNS": total.NumDnsQueries, "TotalBlocked": total.NumBlockedFiltering, "TotalMalware": total.NumReplacedSafebrowsing, "TotalAdult": total.NumReplacedParental, }, }, ) } func percent(a, b *int) string { if a == nil || b == nil || *b == 0 { return "0.00" } return fmt.Sprintf("%.2f", (float64(*a)*100.0)/float64(*b)) } func (w *worker) handleLogs(c *gin.Context) { c.Data(http.StatusOK, "text/plain", []byte(strings.Join(log.Logs(), ""))) } func (w *worker) handleClearLogs(c *gin.Context) { log.Clear() c.Status(http.StatusOK) } func (w *worker) handleStatus(c *gin.Context) { c.JSON(http.StatusOK, w.status()) } func (w *worker) handleHealthz(c *gin.Context) { status := w.status() if status.Origin.Status != "success" { c.Status(http.StatusServiceUnavailable) return } for _, replica := range status.Replicas { if replica.Status != "success" { c.Status(http.StatusServiceUnavailable) return } } c.Status(http.StatusOK) } func (w *worker) listenAndServe() { sl := l.With("port", w.cfg.API.Port) if w.cfg.API.TLS.Enabled() { c, k := w.cfg.API.TLS.Certs() sl = sl.With("tls-cert", c).With("tls-key", k) } sl.Info("Starting API server") ctx, cancel := context.WithCancel(context.Background()) gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.HEAD("/healthz", w.handleHealthz) r.GET("/healthz", w.handleHealthz) var group gin.IRouter = r if w.cfg.API.Username != "" && w.cfg.API.Password != "" { group = r.Group("/", gin.BasicAuth(map[string]string{w.cfg.API.Username: w.cfg.API.Password})) } group.POST("/api/v1/sync", w.handleSync) group.GET("/api/v1/logs", w.handleLogs) group.POST("/api/v1/clear-logs", w.handleClearLogs) group.GET("/api/v1/status", w.handleStatus) static.HandleResources(group, w.cfg.API.DarkMode) group.GET("/", w.handleRoot) if w.cfg.API.Metrics.Enabled { group.GET("/metrics", metrics.Handler()) go w.startScraping() } httpServer := &http.Server{ Addr: fmt.Sprintf(":%d", w.cfg.API.Port), Handler: r, BaseContext: func(_ net.Listener) context.Context { return ctx }, ReadHeaderTimeout: 1 * time.Second, } r.SetHTMLTemplate(template.Must(template.New("index.html").Parse(static.Index()))) go func() { var err error if w.cfg.API.TLS.Enabled() { err = httpServer.ListenAndServeTLS(w.cfg.API.TLS.Certs()) } else { err = httpServer.ListenAndServe() } if !errors.Is(err, http.ErrServerClosed) { l.With("error", err).Fatalf("HTTP server ListenAndServe") } }() signalChan := make(chan os.Signal, 1) signal.Notify( signalChan, syscall.SIGHUP, // kill -SIGHUP XXXX syscall.SIGINT, // kill -SIGINT XXXX or Ctrl+c syscall.SIGQUIT, // kill -SIGQUIT XXXX ) <-signalChan l.Info("os.Interrupt - shutting down...") go func() { <-signalChan l.Fatal("os.Kill - terminating...") }() gracefulCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second) defer cancelShutdown() if w.cron != nil { l.Info("Stopping cron") w.cron.Stop() } if err := httpServer.Shutdown(gracefulCtx); err != nil { l.With("error", err).Error("Shutdown error") defer os.Exit(1) } else { l.Info("API server stopped") } // manually cancel context if not using httpServer.RegisterOnShutdown(cancel) cancel() } type syncStatus struct { SyncRunning bool `json:"syncRunning"` Origin replicaStatus `json:"origin"` Replicas []replicaStatus `json:"replicas"` } type replicaStatus struct { Host string `json:"host"` URL string `json:"url"` Status string `json:"status"` Error string `json:"error,omitempty"` ProtectionEnabled *bool `json:"protection_enabled"` } func getLast24Hours() []string { var result []string currentTime := time.Now() // Loop to get the last 24 hours for i := range 24 { // Calculate the time for the current hour in the loop timeInstance := currentTime.Add(time.Duration(-i) * time.Hour) timeInstance = timeInstance.Truncate(time.Hour) // Format the time as "14 Dec 17:00" formattedTime := timeInstance.Format("02 Jan 15:04") result = append(result, formattedTime) } // Reverse the slice to get the correct order (from oldest to latest) for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { result[i], result[j] = result[j], result[i] } return result }