Newer
Older
status / server.go
package main

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func runServer() {
	cfg := loadServerConfig()
	db, err := NewDB(cfg.DBDsn)
	if err != nil {
		log.Fatalf("DB: %v", err)
	}
	log.Printf("status server on %s", cfg.HTTPAddr)

	// Инициализация телеграм бота
	var bot *NotificationBot
	if cfg.TelegramToken != "" && len(cfg.TelegramChatIDs) > 0 {
		log.Printf("Initializing Telegram bot with interval 30 seconds...")
		bot, err = NewNotificationBot(TelegramBotConfig{
			Token:   cfg.TelegramToken,
			ChatIDs: cfg.TelegramChatIDs,
		}, db)
		if err != nil {
			log.Printf("Warning: failed to initialize telegram bot: %v", err)
		} else {
			log.Printf("Telegram bot initialized, sending updates every 30 seconds")
			go func() {
				ctx := context.Background()
				bot.Start(ctx, 30) 
			}()
		}
	} else {
		log.Printf("Telegram bot not configured (token or chat IDs missing)")
	}

	s := &srv{db: db, cfg: cfg, bot: bot}

	mux := http.NewServeMux()

	mux.HandleFunc("/api/v1/servers", s.apiServers)
	mux.HandleFunc("/api/v1/results", s.apiResults)

	mux.HandleFunc("/public", s.webPublic)

	mux.HandleFunc("/", s.webAuth(s.webDashboard))
	mux.HandleFunc("/servers/add", s.webAuth(s.webAddServer))
	mux.HandleFunc("/servers/toggle", s.webAuth(s.webToggleServer))
	mux.HandleFunc("/servers/delete", s.webAuth(s.webDeleteServer))
	mux.HandleFunc("/agents/add", s.webAuth(s.webAddAgent))
	mux.HandleFunc("/agents/delete", s.webAuth(s.webDeleteAgent))
	mux.HandleFunc("/chart/", s.webAuth(s.webChartPage))
	mux.HandleFunc("/api/chart-data/", s.webAuth(s.webChartData))

	if err := http.ListenAndServe(cfg.HTTPAddr, mux); err != nil {
		log.Fatal(err)
	}
}

func maskToken(token string) string {
	if len(token) < 10 {
		return "***"
	}
	return token[:5] + "..." + token[len(token)-5:]
}

type srv struct {
	db  *DB
	cfg ServerConfig
	bot *NotificationBot
}

func (s *srv) apiServers(w http.ResponseWriter, r *http.Request) {
	agent := s.tokenAuth(w, r)
	if agent == nil {
		return
	}
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	_ = s.db.UpdateAgentSeen(agent.ID, realIP(r))

	servers, err := s.db.ListServers(true)
	if err != nil {
		log.Printf("ListServers: %v", err)
		http.Error(w, "internal error", 500)
		return
	}
	type dto struct {
		ID           int    `json:"id"`
		URL          string `json:"url"`
		Insecure     bool   `json:"insecure"`
		ExpectedCode int    `json:"expected_code"`
		ExpectedBody string `json:"expected_body"`
	}
	out := make([]dto, len(servers))
	for i, sv := range servers {
		out[i] = dto{sv.ID, sv.URL, sv.Insecure, sv.ExpectedCode, sv.ExpectedBody}
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(out)
}

type resultPayload struct {
	Results []struct {
		ServerID  int    `json:"server_id"`
		Success   bool   `json:"success"`
		HTTPCode  int    `json:"http_code"`
		ConnectMS int    `json:"connect_ms"`
		TTFBMS    int    `json:"ttfb_ms"`
		TotalMS   int    `json:"total_ms"`
		CheckedAt string `json:"checked_at"`
	} `json:"results"`
	AgentIP string `json:"agent_ip"`
}

func (s *srv) apiResults(w http.ResponseWriter, r *http.Request) {
	agent := s.tokenAuth(w, r)
	if agent == nil {
		return
	}
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}
	var p resultPayload
	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	ip := p.AgentIP
	if ip == "" {
		ip = realIP(r)
	}
	_ = s.db.UpdateAgentSeen(agent.ID, ip)

	var results []CheckResult
	for _, rr := range p.Results {
		t := time.Now()
		if rr.CheckedAt != "" {
			if pt, err := time.Parse(time.RFC3339, rr.CheckedAt); err == nil {
				t = pt
			}
		}
		cr := CheckResult{
			AgentID:   agent.ID,
			ServerID:  rr.ServerID,
			CheckedAt: t,
			Success:   rr.Success,
			HTTPCode:  rr.HTTPCode,
		}
		if rr.Success {
			cr.ConnectMS = rr.ConnectMS
			cr.TTFBMS = rr.TTFBMS
			cr.TotalMS = rr.TotalMS
		}
		results = append(results, cr)
	}
	if err := s.db.SaveResults(results); err != nil {
		log.Printf("SaveResults: %v", err)
		http.Error(w, "internal error", 500)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

type serverDash struct {
	Server
	Total       int
	Successes   int
	UptimePct   float64
	UptimeClass string
	AvgTTFBMS   int
}

type agentDash struct {
	Agent
	Online      bool
	LastSeenStr string
}

type dashData struct {
	Servers []serverDash
	Agents  []agentDash
}

func (s *srv) webDashboard(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}
	servers, err := s.db.ListServers(false)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	agents, err := s.db.ListAgents()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	stats, _ := s.db.ServerStats(time.Now().Add(-24 * time.Hour))

	var sd []serverDash
	for _, sv := range servers {
		d := serverDash{Server: sv}
		if st, ok := stats[sv.ID]; ok {
			d.Total = st.Total
			d.Successes = st.Successes
			d.AvgTTFBMS = st.AvgTTFBMS
			if st.Total > 0 {
				d.UptimePct = float64(st.Successes) / float64(st.Total) * 100
			}
		}
		switch {
		case !sv.Active:
			d.UptimeClass = "disabled"
		case d.Total == 0:
			d.UptimeClass = "nodata"
		case d.UptimePct >= 99:
			d.UptimeClass = "ok"
		case d.UptimePct >= 95:
			d.UptimeClass = "warn"
		default:
			d.UptimeClass = "err"
		}
		sd = append(sd, d)
	}

	var ad []agentDash
	for _, ag := range agents {
		d := agentDash{Agent: ag}
		if ag.LastSeen != nil {
			d.Online = time.Since(*ag.LastSeen) < 10*time.Minute
			d.LastSeenStr = ag.LastSeen.Format("2006-01-02 15:04:05")
		} else {
			d.LastSeenStr = "never"
		}
		ad = append(ad, d)
	}

	renderTemplate(w, dashboardTmpl, dashData{Servers: sd, Agents: ad})
}

func (s *srv) webAddServer(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	url := strings.TrimSpace(r.FormValue("url"))
	name := strings.TrimSpace(r.FormValue("name"))
	anonName := strings.TrimSpace(r.FormValue("anon_name"))
	insecure := r.FormValue("insecure") == "1"
	expectedCode, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("expected_code")))
	if expectedCode == 0 {
		expectedCode = 200
	}
	expectedBody := r.FormValue("expected_body")
	if url == "" {
		http.Error(w, "url required", 400)
		return
	}
	if err := s.db.AddServer(url, name, anonName, insecure, expectedCode, expectedBody); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}

	// Отправляем уведомление о добавлении сервера
	if s.bot != nil {
		newServer := Server{
			URL:          url,
			Name:         name,
			AnonName:     anonName,
			ExpectedCode: expectedCode,
			Active:       true,
		}
		s.bot.NotifyServerAdded(newServer)
	}

	http.Redirect(w, r, "/", http.StatusSeeOther)
}


func (s *srv) webToggleServer(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	id, _ := strconv.Atoi(r.FormValue("id"))
	active := r.FormValue("active") == "1"
	if err := s.db.SetServerActive(id, active); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *srv) webDeleteServer(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	id, _ := strconv.Atoi(r.FormValue("id"))

	// Получаем информацию о сервере перед удалением
	var serverName, serverURL string
	if s.bot != nil {
		servers, _ := s.db.ListServers(false)
		for _, sv := range servers {
			if sv.ID == id {
				serverName = sv.Name
				if serverName == "" {
					serverName = sv.AnonName
				}
				serverURL = sv.URL
				break
			}
		}
	}

	if err := s.db.DeleteServer(id); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}

	// Отправляем уведомление об удалении
	if s.bot != nil && serverURL != "" {
		s.bot.NotifyServerRemoved(id, serverName, serverURL)
	}

	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *srv) webAddAgent(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	name := strings.TrimSpace(r.FormValue("name"))
	anonName := strings.TrimSpace(r.FormValue("anon_name"))
	if name == "" {
		http.Error(w, "name required", 400)
		return
	}
	token, err := generateToken()
	if err != nil {
		http.Error(w, "token generation failed", 500)
		return
	}
	if err := s.db.AddAgent(name, anonName, token); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	renderTemplate(w, tokenTmpl, map[string]string{"Name": name, "Token": token})
}

func (s *srv) webDeleteAgent(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	id, _ := strconv.Atoi(r.FormValue("id"))
	if err := s.db.DeleteAgent(id); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

type chartPageData struct {
	Server *Server
	Agents []Agent
}

func (s *srv) webChartPage(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, "/chart/")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "bad id", 400)
		return
	}
	servers, err := s.db.ListServers(false)
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	var target *Server
	for i := range servers {
		if servers[i].ID == id {
			target = &servers[i]
			break
		}
	}
	if target == nil {
		http.NotFound(w, r)
		return
	}
	agents, err := s.db.ListAgents()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	renderTemplate(w, chartTmpl, chartPageData{Server: target, Agents: agents})
}

func (s *srv) webChartData(w http.ResponseWriter, r *http.Request) {
	idStr := strings.TrimPrefix(r.URL.Path, "/api/chart-data/")
	id, err := strconv.Atoi(idStr)
	if err != nil {
		http.Error(w, "bad id", 400)
		return
	}
	hours := 24
	if h := r.URL.Query().Get("hours"); h != "" {
		if hh, err := strconv.Atoi(h); err == nil && hh > 0 {
			hours = hh
		}
	}
	agentID, _ := strconv.Atoi(r.URL.Query().Get("agent"))
	points, err := s.db.ServerChartData(id, agentID, time.Now().Add(-time.Duration(hours)*time.Hour))
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	type pt struct {
		T       string `json:"t"`
		Success bool   `json:"s"`
		TotalMS int    `json:"ms"`
		TTFBMS  int    `json:"ttfb"`
		AgentID int    `json:"aid"`
	}
	out := make([]pt, len(points))
	for i, p := range points {
		out[i] = pt{p.T.Format(time.RFC3339), p.Success, p.TotalMS, p.TTFBMS, p.AgentID}
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(out)
}

type publicServerRow struct {
	AnonName    string
	Successes   int
	UptimePct   float64
	UptimeClass string
	AvgTTFBMS   int
}

type publicAgentRow struct {
	AnonName string
	Online   bool
}

type publicData struct {
	Servers []publicServerRow
	Agents  []publicAgentRow
}

func (s *srv) webPublic(w http.ResponseWriter, r *http.Request) {
	servers, err := s.db.ListServers(false)
	if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	agents, err := s.db.ListAgents()
	if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	stats, _ := s.db.ServerStats(time.Now().Add(-24 * time.Hour))

	var sd []publicServerRow
	for _, sv := range servers {
		if sv.AnonName == "" || !sv.Active {
			continue
		}
		row := publicServerRow{AnonName: sv.AnonName}
		if st, ok := stats[sv.ID]; ok {
			row.Successes = st.Successes
			row.AvgTTFBMS = st.AvgTTFBMS
			if st.Total > 0 {
				row.UptimePct = float64(st.Successes) / float64(st.Total) * 100
			}
		}
		switch {
		case row.Successes == 0:
			row.UptimeClass = "nodata"
		case row.UptimePct >= 99:
			row.UptimeClass = "ok"
		case row.UptimePct >= 95:
			row.UptimeClass = "warn"
		default:
			row.UptimeClass = "err"
		}
		sd = append(sd, row)
	}

	var ad []publicAgentRow
	for _, ag := range agents {
		if ag.AnonName == "" {
			continue
		}
		online := ag.LastSeen != nil && time.Since(*ag.LastSeen) < 10*time.Minute
		ad = append(ad, publicAgentRow{AnonName: ag.AnonName, Online: online})
	}

	renderTemplate(w, publicTmpl, publicData{Servers: sd, Agents: ad})
}

func (s *srv) tokenAuth(w http.ResponseWriter, r *http.Request) *Agent {
	token := r.Header.Get("X-Agent-Token")
	if token == "" {
		token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	}
	if token == "" {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return nil
	}
	agent, err := s.db.AgentByToken(token)
	if err != nil || agent == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return nil
	}
	return agent
}

func (s *srv) webAuth(next http.HandlerFunc) http.HandlerFunc {
	if s.cfg.WebUser == "" && s.cfg.WebPass == "" {
		return next
	}
	return func(w http.ResponseWriter, r *http.Request) {
		user, pass, ok := r.BasicAuth()
		if !ok || user != s.cfg.WebUser || pass != s.cfg.WebPass {
			w.Header().Set("WWW-Authenticate", `Basic realm="status"`)
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func renderTemplate(w http.ResponseWriter, tmplStr string, data interface{}) {
	tmpl, err := template.New("t").Funcs(template.FuncMap{
		"printf": func(format string, args ...interface{}) string {
			return fmt.Sprintf(format, args...)
		},
	}).Parse(tmplStr)
	if err != nil {
		http.Error(w, "template error: "+err.Error(), 500)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := tmpl.Execute(w, data); err != nil {
		log.Printf("template execute: %v", err)
	}
}

func generateToken() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(b), nil
}

func realIP(r *http.Request) string {
	if ip := r.Header.Get("X-Real-IP"); ip != "" {
		return ip
	}
	if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
		return strings.SplitN(ip, ",", 2)[0]
	}
	addr := r.RemoteAddr
	if i := strings.LastIndex(addr, ":"); i != -1 {
		return addr[:i]
	}
	return addr
}