diff --git a/README.md b/README.md index e69de29..faf2e00 100644 --- a/README.md +++ b/README.md @@ -0,0 +1 @@ +curl -fsSL https://raw.githubusercontent.com/TestSender/status/refs/heads/main/install.sh | bash diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..c3c44f4 --- /dev/null +++ b/agent.go @@ -0,0 +1,238 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httptrace" + "strings" + "time" +) + +func runAgent() { + cfg := loadAgentConfig() + log.Printf("Agent starting -> %s, interval %ds", cfg.ServerURL, cfg.CheckInterval) + + secureClient := &http.Client{Timeout: 10 * time.Second} + insecureClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + apiClient := &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + // preserve original method on redirect (Go changes POST→GET on 301/302) + req.Method = via[0].Method + return nil + }, + } + + var servers []serverDTO + var lastFetch time.Time + var cachedIP string + + for { + if len(servers) == 0 || time.Since(lastFetch) >= 10*time.Minute { + fetched, err := fetchServers(apiClient, cfg) + if err != nil { + log.Printf("fetchServers: %v", err) + if len(servers) == 0 { + log.Printf("No servers, retry in 1 min") + time.Sleep(time.Minute) + continue + } + log.Printf("Keeping old server list (%d servers)", len(servers)) + } else { + servers = fetched + lastFetch = time.Now() + log.Printf("Server list refreshed: %d servers", len(servers)) + if ip := getMyIP(); ip != "" { + cachedIP = ip + } + } + } + + results := checkAll(secureClient, insecureClient, servers) + log.Printf("Checked %d servers, sending results", len(results)) + if err := sendResults(apiClient, cfg, results, cachedIP); err != nil { + log.Printf("sendResults: %v", err) + } + + time.Sleep(time.Duration(cfg.CheckInterval) * time.Second) + } +} + +type serverDTO struct { + ID int `json:"id"` + URL string `json:"url"` + Insecure bool `json:"insecure"` + ExpectedCode int `json:"expected_code"` + ExpectedBody string `json:"expected_body"` +} + +func fetchServers(client *http.Client, cfg AgentConfig) ([]serverDTO, error) { + req, err := http.NewRequest(http.MethodGet, cfg.ServerURL+"/api/v1/servers", nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Agent-Token", cfg.Token) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned %d", resp.StatusCode) + } + var out []serverDTO + return out, json.NewDecoder(resp.Body).Decode(&out) +} + +type agentResult 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"` +} + +func checkAll(secure, insecure *http.Client, servers []serverDTO) []agentResult { + results := make([]agentResult, 0, len(servers)) + for _, sv := range servers { + client := secure + if sv.Insecure { + client = insecure + } + results = append(results, checkOne(client, sv)) + } + return results +} + +func checkOne(client *http.Client, sv serverDTO) agentResult { + expectedCode := sv.ExpectedCode + if expectedCode == 0 { + expectedCode = 200 + } + r := agentResult{ + ServerID: sv.ID, + CheckedAt: time.Now().UTC().Format(time.RFC3339), + } + + var connectStart, connectDone, firstByte time.Time + start := time.Now() + + trace := &httptrace.ClientTrace{ + ConnectStart: func(_, _ string) { connectStart = time.Now() }, + ConnectDone: func(_, _ string, _ error) { connectDone = time.Now() }, + GotFirstResponseByte: func() { firstByte = time.Now() }, + } + + req, err := http.NewRequest(http.MethodGet, sv.URL, nil) + if err != nil { + log.Printf("[check] %s: %v", sv.URL, err) + return r + } + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + + resp, err := client.Do(req) + if err != nil { + log.Printf("[check] %s: %v", sv.URL, err) + r.TotalMS = int(time.Since(start).Milliseconds()) + return r + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + r.TotalMS = int(time.Since(start).Milliseconds()) + r.HTTPCode = resp.StatusCode + + if !connectStart.IsZero() && !connectDone.IsZero() { + r.ConnectMS = int(connectDone.Sub(connectStart).Milliseconds()) + } + if !firstByte.IsZero() { + r.TTFBMS = int(firstByte.Sub(start).Milliseconds()) + } + + r.Success = resp.StatusCode == expectedCode + if r.Success && sv.ExpectedBody != "" { + r.Success = bytes.Contains(body, []byte(sv.ExpectedBody)) + } + if !r.Success { + r.ConnectMS = 0 + r.TTFBMS = 0 + r.TotalMS = 0 + } + log.Printf("[check] %s -> %d success=%v total=%dms", sv.URL, resp.StatusCode, r.Success, r.TotalMS) + return r +} + +func sendResults(client *http.Client, cfg AgentConfig, results []agentResult, ip string) error { + payload := map[string]interface{}{ + "results": results, + "agent_ip": ip, + } + b, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/api/v1/results", bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("X-Agent-Token", cfg.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server returned %d: %s", resp.StatusCode, body) + } + return nil +} + +func getMyIP() string { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.ipify.org") + if err != nil { + log.Printf("getMyIP: %v", err) + return "" + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + ip := strings.TrimSpace(string(b)) + if isPrivateIP(ip) { + log.Printf("getMyIP: got private IP %s, ignoring", ip) + return "" + } + return ip +} + +func isPrivateIP(s string) bool { + ip := net.ParseIP(s) + if ip == nil { + return true + } + private := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128", "fc00::/7"} + for _, cidr := range private { + _, block, _ := net.ParseCIDR(cidr) + if block.Contains(ip) { + return true + } + } + return false +} diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..c88d9b0 --- /dev/null +++ b/bot.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "fmt" + "log" + "sync" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// TelegramBotConfig - конфигурация для телеграм бота +type TelegramBotConfig struct { + Token string + ChatIDs []int64 +} + +// NotificationBot - бот для отправки уведомлений +type NotificationBot struct { + api *tgbotapi.BotAPI + chatIDs []int64 + mu sync.RWMutex + lastSummaryMsg map[int64]int // chatID -> last summary message ID + db *DB +} + +// NewNotificationBot создает новый экземпляр бота +func NewNotificationBot(cfg TelegramBotConfig, db *DB) (*NotificationBot, error) { + api, err := tgbotapi.NewBotAPI(cfg.Token) + if err != nil { + return nil, fmt.Errorf("failed to create bot: %w", err) + } + + log.Printf("Telegram bot authorized as %s", api.Self.UserName) + + return &NotificationBot{ + api: api, + chatIDs: cfg.ChatIDs, + db: db, + lastSummaryMsg: make(map[int64]int), + }, nil +} + +// Start запускает отправку сводок с заданным интервалом +func (b *NotificationBot) Start(ctx context.Context, intervalSeconds int) { + ticker := time.NewTicker(time.Duration(intervalSeconds) * time.Second) + defer ticker.Stop() + + // Отправляем приветствие при старте + b.sendStartupMessage() + + // Отправляем первую сводку сразу + b.SendSummary() + + for { + select { + case <-ctx.Done(): + log.Println("Telegram bot stopping...") + return + case <-ticker.C: + b.SendSummary() + } + } +} + +func (b *NotificationBot) sendStartupMessage() { + msg := "🤖 *Status Monitor Bot Started*\n\n" + + "Sending status updates every 30 seconds.\n" + + "Messages will be updated automatically." + + for _, chatID := range b.chatIDs { + b.sendMessage(chatID, msg, true) + } +} + +// SendSummary отправляет текущую сводку по всем серверам +func (b *NotificationBot) SendSummary() { + servers, err := b.db.ListServers(true) + if err != nil { + log.Printf("Failed to list servers: %v", err) + return + } + + // Получаем статистику за последние 5 минут + stats, err := b.db.ServerStats(time.Now().Add(-5 * time.Minute)) + if err != nil { + log.Printf("Failed to get stats: %v", err) + return + } + + // Формируем сообщение + message := b.formatSummary(servers, stats) + + // Отправляем или обновляем сообщение в каждом чате + for _, chatID := range b.chatIDs { + b.updateOrSendMessage(chatID, message) + } +} + +func (b *NotificationBot) formatSummary(servers []Server, stats map[int]ServerStat) string { + var upCount, downCount, degradedCount int + var totalResponseTime int + var serversWithData int + + // Строим таблицу статусов + statusTable := "" + + for _, sv := range servers { + var status string + var statusIcon string + var responseTime int + + if st, ok := stats[sv.ID]; ok && st.Total > 0 { + uptime := float64(st.Successes) / float64(st.Total) * 100 + responseTime = st.AvgTTFBMS + totalResponseTime += responseTime + serversWithData++ + + switch { + case uptime >= 99: + status = "UP" + statusIcon = "✅" + upCount++ + case uptime >= 95: + status = "DEGRADED" + statusIcon = "⚠️" + degradedCount++ + default: + status = "DOWN" + statusIcon = "❌" + downCount++ + } + + name := b.getShortName(sv) + statusTable += fmt.Sprintf("%s *%s* - %s (%.1f%%, %dms)\n", + statusIcon, name, status, uptime, responseTime) + } else { + statusTable += fmt.Sprintf("❓ *%s* - NO DATA\n", b.getShortName(sv)) + downCount++ + } + } + + // Средний response time + avgResponseTime := 0 + if serversWithData > 0 { + avgResponseTime = totalResponseTime / serversWithData + } + + // Определяем общий статус + overallStatus := "HEALTHY" + overallIcon := "🟢" + if downCount > 0 { + overallStatus = "CRITICAL" + overallIcon = "🔴" + } else if degradedCount > 0 { + overallStatus = "DEGRADED" + overallIcon = "🟡" + } + + // Формируем полное сообщение + message := fmt.Sprintf(`🏥 *STATUS MONITOR* %s + +📊 *Status:* %s %s +📈 *Servers:* %d total | 🟢 %d | 🟡 %d | 🔴 %d +⏱️ *Avg Response:* %d ms +🕐 *Updated:* %s + +━━━━━━━━━━━━━━━━━━━━━ +*DETAILS:* +%s +━━━━━━━━━━━━━━━━━━━━━ +_Updates every 30 seconds_`, + overallIcon, + overallStatus, overallIcon, + len(servers), upCount, degradedCount, downCount, + avgResponseTime, + time.Now().Format("15:04:05"), + statusTable, + ) + + return message +} + +func (b *NotificationBot) getShortName(server Server) string { + if server.Name != "" { + if len(server.Name) > 30 { + return server.Name[:27] + "..." + } + return server.Name + } + if server.AnonName != "" { + if len(server.AnonName) > 30 { + return server.AnonName[:27] + "..." + } + return server.AnonName + } + url := server.URL + if len(url) > 40 { + url = url[:37] + "..." + } + return url +} + +// updateOrSendMessage обновляет существующее сообщение или отправляет новое +func (b *NotificationBot) updateOrSendMessage(chatID int64, message string) { + b.mu.RLock() + lastMsgID, exists := b.lastSummaryMsg[chatID] + b.mu.RUnlock() + + if exists && lastMsgID > 0 { + // Пытаемся отредактировать существующее сообщение + editMsg := tgbotapi.NewEditMessageText(chatID, lastMsgID, message) + editMsg.ParseMode = "Markdown" + _, err := b.api.Send(editMsg) + if err != nil { + // Если не удалось отредактировать, отправляем новое + log.Printf("Failed to edit message, sending new: %v", err) + sentMsg := b.sendMessage(chatID, message, true) + if sentMsg != nil { + b.mu.Lock() + b.lastSummaryMsg[chatID] = sentMsg.MessageID + b.mu.Unlock() + } + } + } else { + // Отправляем новое сообщение + sentMsg := b.sendMessage(chatID, message, true) + if sentMsg != nil { + b.mu.Lock() + b.lastSummaryMsg[chatID] = sentMsg.MessageID + b.mu.Unlock() + } + } +} + +func (b *NotificationBot) sendMessage(chatID int64, text string, markdown bool) *tgbotapi.Message { + msg := tgbotapi.NewMessage(chatID, text) + if markdown { + msg.ParseMode = "Markdown" + } + sent, err := b.api.Send(msg) + if err != nil { + log.Printf("Failed to send message to chat %d: %v", chatID, err) + return nil + } + log.Printf("Sent message to chat %d", chatID) + return &sent +} + +// NotifyServerAdded уведомляет о добавлении нового сервера +func (b *NotificationBot) NotifyServerAdded(server Server) { + message := fmt.Sprintf("➕ *Server Added*\n\n*Name:* %s\n*URL:* %s\n*Now monitoring*", + b.getShortName(server), server.URL) + + for _, chatID := range b.chatIDs { + b.sendMessage(chatID, message, true) + } + // Сразу отправляем обновленную сводку + b.SendSummary() +} + +// NotifyServerRemoved уведомляет об удалении сервера +func (b *NotificationBot) NotifyServerRemoved(serverID int, serverName, serverURL string) { + message := fmt.Sprintf("❌ *Server Removed*\n\n*Name:* %s\n*Monitoring stopped*", serverName) + + for _, chatID := range b.chatIDs { + b.sendMessage(chatID, message, true) + } + // Сразу отправляем обновленную сводку + b.SendSummary() +} \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..6982601 --- /dev/null +++ b/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash +build_for_arch() { + local goos="linux" + local goarch=$1 + local suffix=$2 + + echo "Building for ${goos}/${goarch}..." + GOOS=${goos} GOARCH=${goarch} go build -trimpath -ldflags="-s -w" -o status-${suffix} . +} + +build_for_arch "amd64" "linux-x86_64" +build_for_arch "386" "linux-x86" +build_for_arch "arm64" "linux-aarch64" +build_for_arch "arm" "linux-arm" +build_for_arch "mips" "linux-mips" diff --git a/config.go b/config.go new file mode 100644 index 0000000..185d7f9 --- /dev/null +++ b/config.go @@ -0,0 +1,100 @@ +package main + +import ( + "os" + "strconv" + "strings" +) + +type ServerConfig struct { + DBDsn string + HTTPAddr string + WebUser string + WebPass string + TelegramToken string + TelegramChatIDs []int64 +} + +type AgentConfig struct { + ServerURL string + Token string + CheckInterval int +} + +func loadServerConfig() ServerConfig { + addr := os.Getenv("HTTP_ADDR") + if addr == "" { + addr = ":8080" + } + + // Парсим chat IDs - разрешаем отрицательные значения (для групп) + var chatIDs []int64 + if chatIDsStr := os.Getenv("TELEGRAM_CHAT_IDS"); chatIDsStr != "" { + for _, s := range strings.Split(chatIDsStr, ",") { + s = strings.TrimSpace(s) + // Убираем условие id > 0, разрешаем любые ненулевые значения + if id, err := strconv.ParseInt(s, 10, 64); err == nil && id != 0 { + chatIDs = append(chatIDs, id) + } + } + } + + return ServerConfig{ + DBDsn: mustEnv("DB_DSN"), + HTTPAddr: addr, + WebUser: os.Getenv("WEB_USER"), + WebPass: os.Getenv("WEB_PASSWORD"), + TelegramToken: os.Getenv("TELEGRAM_TOKEN"), + TelegramChatIDs: chatIDs, + } +} + +type ExporterConfig struct { + DBDsn string + VMURL string + Interval int + Batch int +} + +func loadExporterConfig() ExporterConfig { + interval := 60 + if s := os.Getenv("EXPORT_INTERVAL_SEC"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + interval = v + } + } + batch := 5000 + if s := os.Getenv("EXPORT_BATCH"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + batch = v + } + } + return ExporterConfig{ + DBDsn: mustEnv("DSN"), + VMURL: strings.TrimRight(mustEnv("VMURL"), "/"), + Interval: interval, + Batch: batch, + } +} + +func loadAgentConfig() AgentConfig { + interval := 60 + if s := os.Getenv("CHECK_INTERVAL_SEC"); s != "" { + if v, err := strconv.Atoi(s); err == nil && v > 0 { + interval = v + } + } + return AgentConfig{ + ServerURL: strings.TrimRight(mustEnv("SERVER_URL"), "/"), + Token: mustEnv("AGENT_TOKEN"), + CheckInterval: interval, + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + panic("required env not set: " + key) + } + return v +} \ No newline at end of file diff --git a/db.go b/db.go new file mode 100644 index 0000000..8f73f39 --- /dev/null +++ b/db.go @@ -0,0 +1,432 @@ +package main + +import ( + "database/sql" + "fmt" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +type DB struct { + conn *sql.DB +} + +type Server struct { + ID int + URL string + Name string + AnonName string + Active bool + Insecure bool + ExpectedCode int + ExpectedBody string + CreatedAt time.Time +} + +type Agent struct { + ID int + Name string + AnonName string + Token string + LastIP string + LastSeen *time.Time + CreatedAt time.Time +} + +type CheckResult struct { + AgentID int + ServerID int + CheckedAt time.Time + Success bool + HTTPCode int + ConnectMS int + TTFBMS int + TotalMS int +} + +type ServerStat struct { + Total int + Successes int + AvgTTFBMS int +} + +type ChartPoint struct { + T time.Time + Success bool + TotalMS int + TTFBMS int + AgentID int +} + +func ensureParseTime(dsn string) string { + if strings.Contains(dsn, "parseTime=") { + return dsn + } + if strings.Contains(dsn, "?") { + return dsn + "&parseTime=true" + } + return dsn + "?parseTime=true" +} + +func NewDB(dsn string) (*DB, error) { + conn, err := sql.Open("mysql", ensureParseTime(dsn)) + if err != nil { + return nil, err + } + conn.SetMaxOpenConns(10) + conn.SetMaxIdleConns(5) + if err := conn.Ping(); err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + d := &DB{conn: conn} + if err := d.initSchema(); err != nil { + return nil, fmt.Errorf("initSchema: %w", err) + } + return d, nil +} + +func (d *DB) initSchema() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS servers ( + id INT AUTO_INCREMENT PRIMARY KEY, + url VARCHAR(512) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL DEFAULT '', + anon_name VARCHAR(255) NOT NULL DEFAULT '', + active TINYINT(1) NOT NULL DEFAULT 1, + insecure TINYINT(1) NOT NULL DEFAULT 0, + expected_code INT NOT NULL DEFAULT 200, + expected_body VARCHAR(512) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS agents ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + anon_name VARCHAR(255) NOT NULL DEFAULT '', + token VARCHAR(128) NOT NULL UNIQUE, + last_ip VARCHAR(45), + last_seen DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + } + for _, s := range stmts { + if _, err := d.conn.Exec(s); err != nil { + return err + } + } + if err := d.migrateAddColumn("servers", "anon_name", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name"); err != nil { + return err + } + if err := d.migrateAddColumn("agents", "anon_name", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name"); err != nil { + return err + } + if err := d.migrateAddColumn("servers", "insecure", "TINYINT(1) NOT NULL DEFAULT 0 AFTER active"); err != nil { + return err + } + if err := d.migrateAddColumn("servers", "expected_code", "INT NOT NULL DEFAULT 200 AFTER insecure"); err != nil { + return err + } + if err := d.migrateAddColumn("servers", "expected_body", "VARCHAR(512) NOT NULL DEFAULT '' AFTER expected_code"); err != nil { + return err + } + return d.EnsureResultsTable(time.Now()) +} + +// migrateAddColumn adds a column to a table only if it doesn't already exist, +// checking information_schema to avoid any locking on already-migrated tables. +func (d *DB) migrateAddColumn(table, column, definition string) error { + var count int + err := d.conn.QueryRow( + `SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + table, column, + ).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return nil // already exists + } + _, err = d.conn.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)) + return err +} + +func ResultsTableName(t time.Time) string { + return fmt.Sprintf("results_%04d_%02d", t.Year(), t.Month()) +} + +func (d *DB) EnsureResultsTable(t time.Time) error { + name := ResultsTableName(t) + q := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + agent_id INT NOT NULL, + server_id INT NOT NULL, + checked_at DATETIME NOT NULL, + success TINYINT(1) NOT NULL, + http_code INT NOT NULL DEFAULT 0, + connect_ms INT NOT NULL DEFAULT 0, + ttfb_ms INT NOT NULL DEFAULT 0, + total_ms INT NOT NULL DEFAULT 0, + INDEX idx_server_time (server_id, checked_at), + INDEX idx_agent_time (agent_id, checked_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, name) + _, err := d.conn.Exec(q) + return err +} + +func (d *DB) resultsTables() ([]string, error) { + rows, err := d.conn.Query("SHOW TABLES LIKE 'results_%'") + if err != nil { + return nil, err + } + defer rows.Close() + var tables []string + for rows.Next() { + var t string + if err := rows.Scan(&t); err != nil { + return nil, err + } + tables = append(tables, t) + } + return tables, rows.Err() +} + +func (d *DB) ListServers(activeOnly bool) ([]Server, error) { + q := "SELECT id, url, name, anon_name, active, insecure, expected_code, expected_body, created_at FROM servers" + if activeOnly { + q += " WHERE active = 1" + } + q += " ORDER BY id" + rows, err := d.conn.Query(q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Server + for rows.Next() { + var s Server + if err := rows.Scan(&s.ID, &s.URL, &s.Name, &s.AnonName, &s.Active, &s.Insecure, &s.ExpectedCode, &s.ExpectedBody, &s.CreatedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (d *DB) AddServer(url, name, anonName string, insecure bool, expectedCode int, expectedBody string) error { + if expectedCode == 0 { + expectedCode = 200 + } + _, err := d.conn.Exec( + "INSERT INTO servers (url, name, anon_name, insecure, expected_code, expected_body) VALUES (?, ?, ?, ?, ?, ?)", + url, name, anonName, insecure, expectedCode, expectedBody, + ) + return err +} + +func (d *DB) SetServerActive(id int, active bool) error { + v := 0 + if active { + v = 1 + } + _, err := d.conn.Exec("UPDATE servers SET active = ? WHERE id = ?", v, id) + return err +} + +func (d *DB) DeleteServer(id int) error { + _, err := d.conn.Exec("DELETE FROM servers WHERE id = ?", id) + return err +} + +func (d *DB) ListAgents() ([]Agent, error) { + rows, err := d.conn.Query( + "SELECT id, name, anon_name, token, COALESCE(last_ip,''), last_seen, created_at FROM agents ORDER BY id", + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Agent + for rows.Next() { + var a Agent + if err := rows.Scan(&a.ID, &a.Name, &a.AnonName, &a.Token, &a.LastIP, &a.LastSeen, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (d *DB) AddAgent(name, anonName, token string) error { + _, err := d.conn.Exec("INSERT INTO agents (name, anon_name, token) VALUES (?, ?, ?)", name, anonName, token) + return err +} + +func (d *DB) DeleteAgent(id int) error { + _, err := d.conn.Exec("DELETE FROM agents WHERE id = ?", id) + return err +} + +func (d *DB) AgentByToken(token string) (*Agent, error) { + var a Agent + err := d.conn.QueryRow( + "SELECT id, name, anon_name, token, COALESCE(last_ip,''), last_seen, created_at FROM agents WHERE token = ?", token, + ).Scan(&a.ID, &a.Name, &a.AnonName, &a.Token, &a.LastIP, &a.LastSeen, &a.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &a, nil +} + +func (d *DB) UpdateAgentSeen(id int, ip string) error { + _, err := d.conn.Exec("UPDATE agents SET last_ip = ?, last_seen = NOW() WHERE id = ?", ip, id) + return err +} + +func (d *DB) SaveResults(results []CheckResult) error { + if len(results) == 0 { + return nil + } + now := time.Now() + if err := d.EnsureResultsTable(now); err != nil { + return err + } + table := ResultsTableName(now) + tx, err := d.conn.Begin() + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.Prepare(fmt.Sprintf( + "INSERT INTO %s (agent_id, server_id, checked_at, success, http_code, connect_ms, ttfb_ms, total_ms) VALUES (?,?,?,?,?,?,?,?)", + table, + )) + if err != nil { + return err + } + defer stmt.Close() + for _, r := range results { + t := r.CheckedAt + if t.IsZero() { + t = now + } + if _, err := stmt.Exec(r.AgentID, r.ServerID, t, r.Success, r.HTTPCode, r.ConnectMS, r.TTFBMS, r.TotalMS); err != nil { + return err + } + } + return tx.Commit() +} + +func (d *DB) ServerStats(since time.Time) (map[int]ServerStat, error) { + tables, err := d.resultsTables() + if err != nil { + return nil, err + } + out := map[int]ServerStat{} + for _, tbl := range tables { + q := fmt.Sprintf( + "SELECT server_id, COUNT(*), SUM(success), AVG(NULLIF(ttfb_ms,0)) FROM %s WHERE checked_at >= ? GROUP BY server_id", + tbl, + ) + rows, err := d.conn.Query(q, since) + if err != nil { + continue + } + for rows.Next() { + var sid, total int + var succ sql.NullInt64 + var avgTTFB sql.NullFloat64 + if err := rows.Scan(&sid, &total, &succ, &avgTTFB); err != nil { + rows.Close() + return nil, err + } + cur := out[sid] + cur.Total += total + cur.Successes += int(succ.Int64) + // weighted average across tables + if avgTTFB.Valid && total > 0 { + cur.AvgTTFBMS = int((float64(cur.AvgTTFBMS*cur.Total) + avgTTFB.Float64*float64(total)) / float64(cur.Total+total)) + } + out[sid] = cur + } + rows.Close() + } + return out, nil +} + +// ServerChartData returns chart points for a server. agentID=0 means all agents. +func (d *DB) ServerChartData(serverID int, agentID int, since time.Time) ([]ChartPoint, error) { + tables, err := d.resultsTables() + if err != nil { + return nil, err + } + var points []ChartPoint + for _, tbl := range tables { + var q string + var args []interface{} + if agentID > 0 { + q = fmt.Sprintf( + "SELECT checked_at, success, total_ms, ttfb_ms, agent_id FROM %s WHERE server_id = ? AND agent_id = ? AND checked_at >= ? ORDER BY checked_at", + tbl, + ) + args = []interface{}{serverID, agentID, since} + } else { + q = fmt.Sprintf( + "SELECT checked_at, success, total_ms, ttfb_ms, agent_id FROM %s WHERE server_id = ? AND checked_at >= ? ORDER BY checked_at", + tbl, + ) + args = []interface{}{serverID, since} + } + rows, err := d.conn.Query(q, args...) + if err != nil { + continue + } + for rows.Next() { + var p ChartPoint + if err := rows.Scan(&p.T, &p.Success, &p.TotalMS, &p.TTFBMS, &p.AgentID); err != nil { + rows.Close() + return nil, err + } + points = append(points, p) + } + rows.Close() + } + return points, nil +} + +func (d *DB) GetLatestServerResult(serverID int) (*CheckResult, error) { + tables, err := d.resultsTables() + if err != nil { + return nil, err + } + + var latest *CheckResult + latestTime := time.Time{} + + for _, tbl := range tables { + query := fmt.Sprintf(` + SELECT agent_id, checked_at, success, http_code, connect_ms, ttfb_ms, total_ms + FROM %s + WHERE server_id = ? + ORDER BY checked_at DESC + LIMIT 1`, tbl) + + var cr CheckResult + err := d.conn.QueryRow(query, serverID).Scan( + &cr.AgentID, &cr.CheckedAt, &cr.Success, &cr.HTTPCode, + &cr.ConnectMS, &cr.TTFBMS, &cr.TotalMS, + ) + if err == nil { + if latest == nil || cr.CheckedAt.After(latestTime) { + latest = &cr + latestTime = cr.CheckedAt + } + } + } + + return latest, nil +} diff --git a/exporter.go b/exporter.go new file mode 100644 index 0000000..2a36d9f --- /dev/null +++ b/exporter.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strings" + "time" +) + +func runExporter() { + cfg := loadExporterConfig() + log.Printf("Exporter starting -> VM %s, interval %ds", cfg.VMURL, cfg.Interval) + + db, err := NewDB(cfg.DBDsn) + if err != nil { + log.Fatalf("db: %v", err) + } + if err := db.ensureExportStateTable(); err != nil { + log.Fatalf("export state: %v", err) + } + + tick := time.NewTicker(time.Duration(cfg.Interval) * time.Second) + defer tick.Stop() + + for { + if err := exportOnce(db, cfg); err != nil { + log.Printf("export: %v", err) + } + if err := dropOldTables(db); err != nil { + log.Printf("drop old: %v", err) + } + <-tick.C + } +} + +func (d *DB) ensureExportStateTable() error { + _, err := d.conn.Exec(`CREATE TABLE IF NOT EXISTS vm_export_state ( + table_name VARCHAR(64) NOT NULL PRIMARY KEY, + last_id BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + return err +} + +func (d *DB) getExportCursor(table string) (int64, error) { + var id int64 + err := d.conn.QueryRow("SELECT last_id FROM vm_export_state WHERE table_name = ?", table).Scan(&id) + if err == sql.ErrNoRows { + return 0, nil + } + return id, err +} + +func (d *DB) setExportCursor(table string, id int64) error { + _, err := d.conn.Exec( + "INSERT INTO vm_export_state (table_name, last_id) VALUES (?, ?) ON DUPLICATE KEY UPDATE last_id = VALUES(last_id)", + table, id, + ) + return err +} + +func (d *DB) serverLabels() (map[int]map[string]string, error) { + rows, err := d.conn.Query("SELECT id, url, name, anon_name FROM servers") + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]map[string]string{} + for rows.Next() { + var id int + var url, name, anon string + if err := rows.Scan(&id, &url, &name, &anon); err != nil { + return nil, err + } + out[id] = map[string]string{"server_url": url, "server_name": name, "server_anon": anon} + } + return out, rows.Err() +} + +func (d *DB) agentLabels() (map[int]map[string]string, error) { + rows, err := d.conn.Query("SELECT id, name, anon_name FROM agents") + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]map[string]string{} + for rows.Next() { + var id int + var name, anon string + if err := rows.Scan(&id, &name, &anon); err != nil { + return nil, err + } + out[id] = map[string]string{"agent_name": name, "agent_anon": anon} + } + return out, rows.Err() +} + +type vmLine struct { + Metric map[string]string `json:"metric"` + Values []float64 `json:"values"` + Timestamps []int64 `json:"timestamps"` +} + +func exportOnce(d *DB, cfg ExporterConfig) error { + tables, err := d.resultsTables() + if err != nil { + return err + } + sort.Strings(tables) + servers, err := d.serverLabels() + if err != nil { + return err + } + agents, err := d.agentLabels() + if err != nil { + return err + } + + for _, tbl := range tables { + for { + cursor, err := d.getExportCursor(tbl) + if err != nil { + return fmt.Errorf("%s cursor: %w", tbl, err) + } + rows, err := d.conn.Query(fmt.Sprintf( + "SELECT id, agent_id, server_id, checked_at, success, http_code, connect_ms, ttfb_ms, total_ms FROM %s WHERE id > ? ORDER BY id LIMIT ?", + tbl, + ), cursor, cfg.Batch) + if err != nil { + return fmt.Errorf("%s select: %w", tbl, err) + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + var maxID int64 + var count int + for rows.Next() { + var id int64 + var agentID, serverID, httpCode, connectMS, ttfbMS, totalMS int + var checkedAt time.Time + var success bool + if err := rows.Scan(&id, &agentID, &serverID, &checkedAt, &success, &httpCode, &connectMS, &ttfbMS, &totalMS); err != nil { + rows.Close() + return err + } + ts := checkedAt.UnixMilli() + base := map[string]string{ + "server_id": fmt.Sprintf("%d", serverID), + "agent_id": fmt.Sprintf("%d", agentID), + } + for k, v := range servers[serverID] { + base[k] = v + } + for k, v := range agents[agentID] { + base[k] = v + } + succ := 0.0 + if success { + succ = 1.0 + } + for metric, val := range map[string]float64{ + "status_check_success": succ, + "status_check_http_code": float64(httpCode), + "status_check_connect_ms": float64(connectMS), + "status_check_ttfb_ms": float64(ttfbMS), + "status_check_total_ms": float64(totalMS), + } { + labels := copyLabels(base) + labels["__name__"] = metric + if err := enc.Encode(vmLine{Metric: labels, Values: []float64{val}, Timestamps: []int64{ts}}); err != nil { + rows.Close() + return err + } + } + if id > maxID { + maxID = id + } + count++ + } + rows.Close() + if count == 0 { + break + } + if err := postVM(cfg.VMURL, &buf); err != nil { + return fmt.Errorf("%s post: %w", tbl, err) + } + if err := d.setExportCursor(tbl, maxID); err != nil { + return fmt.Errorf("%s cursor update: %w", tbl, err) + } + log.Printf("exported %d rows from %s (cursor=%d)", count, tbl, maxID) + if count < cfg.Batch { + break + } + } + } + return nil +} + +func copyLabels(m map[string]string) map[string]string { + out := make(map[string]string, len(m)+1) + for k, v := range m { + out[k] = v + } + return out +} + +func postVM(vmURL string, body io.Reader) error { + url := vmURL + "/api/v1/import" + req, err := http.NewRequest("POST", url, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/stream+json") + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("vm status %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +// dropOldTables drops results_YYYY_MM tables older than the current month, +// but only once the current month is at least 48 hours old (so any late-arriving +// data for the previous month has been flushed and exported). +func dropOldTables(d *DB) error { + now := time.Now() + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + if now.Sub(startOfMonth) < 48*time.Hour { + return nil + } + tables, err := d.resultsTables() + if err != nil { + return err + } + current := ResultsTableName(now) + for _, tbl := range tables { + if tbl >= current { + continue + } + cursor, err := d.getExportCursor(tbl) + if err != nil { + return err + } + var maxID sql.NullInt64 + if err := d.conn.QueryRow(fmt.Sprintf("SELECT MAX(id) FROM %s", tbl)).Scan(&maxID); err != nil { + return err + } + if maxID.Valid && cursor < maxID.Int64 { + log.Printf("skip drop %s: export incomplete (cursor=%d, max=%d)", tbl, cursor, maxID.Int64) + continue + } + if _, err := d.conn.Exec(fmt.Sprintf("DROP TABLE %s", tbl)); err != nil { + return fmt.Errorf("drop %s: %w", tbl, err) + } + if _, err := d.conn.Exec("DELETE FROM vm_export_state WHERE table_name = ?", tbl); err != nil { + return err + } + log.Printf("dropped old table %s", tbl) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ef755f5 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/TestSender/status + +go 1.22 + +require github.com/go-sql-driver/mysql v1.7.1 + +require github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ebde66f --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..87c3a0f --- /dev/null +++ b/install.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Installer for status-status +# Usage: +# curl -fsSL https://raw.githubusercontent.com/dimonyga/status-status/main/install.sh | bash +# curl -fsSL .../install.sh | bash -s -- --version v1.2.3 --prefix /usr/local/bin +# +# On systems with systemd, also installs unit files to /lib/systemd/system and +# sysconfig templates to /etc/sysconfig (without overwriting existing ones). +# Disable with --no-systemd or STATUS_NO_SYSTEMD=1. +set -euo pipefail + +REPO="${STATUS_REPO:-TestSender/status}" +VERSION="${STATUS_VERSION:-latest}" +PREFIX="${STATUS_PREFIX:-/usr/local/bin}" +NO_SYSTEMD="${STATUS_NO_SYSTEMD:-0}" +BIN_NAME="status" + +UNITS=(status-server.service status-agent.service status-exporter.service) +SYSCONFIGS=(sysconfig-server sysconfig-agent sysconfig-exporter) + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --no-systemd) NO_SYSTEMD=1; shift ;; + -h|--help) + sed -n '2,10p' "$0"; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 1 ;; + esac +done + +err() { echo "error: $*" >&2; exit 1; } + +if command -v curl >/dev/null 2>&1; then + DL="curl -fsSL" +elif command -v wget >/dev/null 2>&1; then + DL="wget -qO-" +else + err "need curl or wget" +fi + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +[ "$os" = "linux" ] || err "unsupported OS: $os (linux only)" + +raw_arch="$(uname -m)" +case "$raw_arch" in + x86_64|amd64) suffix="linux-x86_64" ;; + i386|i686|x86) suffix="linux-x86" ;; + aarch64|arm64) suffix="linux-aarch64" ;; + armv7l|armv6l|arm) suffix="linux-arm" ;; + mips) suffix="linux-mips" ;; + *) err "unsupported arch: $raw_arch" ;; +esac + +if [ "$VERSION" = "latest" ]; then + tag="$($DL "https://api.github.com/repos/$REPO/releases/latest" \ + | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1)" + [ -n "$tag" ] || err "failed to resolve latest release" +else + tag="$VERSION" +fi + +asset="${BIN_NAME}-${suffix}" +bin_url="https://github.com/${REPO}/releases/download/${tag}/${asset}" +# systemd units and sysconfig templates are taken from the repo at the same tag +raw_base="https://raw.githubusercontent.com/${REPO}/${tag}" + +echo "Installing ${BIN_NAME} ${tag} (${suffix}) -> ${PREFIX}/${BIN_NAME}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +$DL "$bin_url" > "$tmp/$BIN_NAME" || err "download failed: $bin_url" +[ -s "$tmp/$BIN_NAME" ] || err "empty download from $bin_url" +chmod +x "$tmp/$BIN_NAME" + +SUDO="" +maybe_sudo() { + # pick SUDO once for any privileged write below + if [ -z "${SUDO+x}" ] || [ "$SUDO" = "" ]; then + if [ "$(id -u)" -ne 0 ]; then + if command -v sudo >/dev/null 2>&1; then SUDO="sudo" + else err "root privileges required (install sudo or run as root)" + fi + fi + fi +} + +if [ ! -w "$PREFIX" ]; then maybe_sudo; fi +$SUDO mkdir -p "$PREFIX" +$SUDO install -m 0755 "$tmp/$BIN_NAME" "$PREFIX/$BIN_NAME" +echo "Installed binary: $PREFIX/$BIN_NAME" + +# --- systemd units + sysconfig ------------------------------------------- +install_systemd() { + [ "$NO_SYSTEMD" = "1" ] && { echo "systemd install skipped (--no-systemd)"; return; } + command -v systemctl >/dev/null 2>&1 || { echo "systemd not detected, skipping unit install"; return; } + + maybe_sudo + + unit_dir="/lib/systemd/system" + [ -d "$unit_dir" ] || unit_dir="/etc/systemd/system" + sysconf_dir="/etc/sysconfig" + + $SUDO mkdir -p "$unit_dir" "$sysconf_dir" + + for u in "${UNITS[@]}"; do + $DL "$raw_base/$u" > "$tmp/$u" || err "download failed: $raw_base/$u" + [ -s "$tmp/$u" ] || err "empty unit: $u" + # point ExecStart at the prefix we actually installed to + sed -i "s|/usr/bin/${BIN_NAME}|${PREFIX}/${BIN_NAME}|g" "$tmp/$u" + $SUDO install -m 0644 "$tmp/$u" "$unit_dir/$u" + echo "Installed unit: $unit_dir/$u" + done + + for s in "${SYSCONFIGS[@]}"; do + dest="$sysconf_dir/status-${s#sysconfig-}" + if $SUDO test -e "$dest"; then + echo "Kept existing: $dest" + continue + fi + $DL "$raw_base/$s" > "$tmp/$s" || err "download failed: $raw_base/$s" + [ -s "$tmp/$s" ] || err "empty sysconfig: $s" + $SUDO install -m 0640 "$tmp/$s" "$dest" + echo "Installed config: $dest" + done + + # create 'status' system user referenced by the units, if missing + if ! getent passwd status >/dev/null 2>&1; then + $SUDO useradd -r -s /sbin/nologin -d /dev/null -c "status-status service" status \ + && echo "Created user: status" + fi + + $SUDO systemctl daemon-reload || true + + cat </dev/null || useradd -r -s /sbin/nologin -d /dev/null -c "status service" status diff --git a/server.go b/server.go new file mode 100644 index 0000000..792aee7 --- /dev/null +++ b/server.go @@ -0,0 +1,587 @@ +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 +} diff --git a/status-agent.service b/status-agent.service new file mode 100644 index 0000000..f08e7cf --- /dev/null +++ b/status-agent.service @@ -0,0 +1,22 @@ +[Unit] +Description=status agent +After=network.target + +[Service] +Type=simple +User=status +Group=status +ExecStart=/usr/bin/status +Restart=on-failure +RestartSec=10 + +EnvironmentFile=/etc/sysconfig/status-agent + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/status-exporter.service b/status-exporter.service new file mode 100644 index 0000000..2609f80 --- /dev/null +++ b/status-exporter.service @@ -0,0 +1,23 @@ +[Unit] +Description=status exporter (MySQL -> VictoriaMetrics) +After=network.target mysql.service +Wants=mysql.service + +[Service] +Type=simple +User=status +Group=status +ExecStart=/usr/bin/status +Restart=on-failure +RestartSec=5 + +EnvironmentFile=/etc/sysconfig/status-exporter + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/status-server.service b/status-server.service new file mode 100644 index 0000000..1dbbbf6 --- /dev/null +++ b/status-server.service @@ -0,0 +1,23 @@ +[Unit] +Description=status server +After=network.target mysql.service +Wants=mysql.service + +[Service] +Type=simple +User=status +Group=status +ExecStart=/usr/bin/status +Restart=on-failure +RestartSec=5 + +EnvironmentFile=/etc/sysconfig/status-server + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/sysconfig-agent b/sysconfig-agent new file mode 100644 index 0000000..4618246 --- /dev/null +++ b/sysconfig-agent @@ -0,0 +1,4 @@ +MODE=agent +SERVER_URL=http://yourserver:8080 +AGENT_TOKEN=your-agent-token-here +CHECK_INTERVAL_SEC=60 diff --git a/sysconfig-exporter b/sysconfig-exporter new file mode 100644 index 0000000..3f4d220 --- /dev/null +++ b/sysconfig-exporter @@ -0,0 +1,5 @@ +MODE=exporter +DSN=status:password@tcp(127.0.0.1:3306)/status?parseTime=true +VMURL=http://127.0.0.1:8428 +EXPORT_INTERVAL_SEC=60 +EXPORT_BATCH=5000 diff --git a/sysconfig-server b/sysconfig-server new file mode 100644 index 0000000..d9679de --- /dev/null +++ b/sysconfig-server @@ -0,0 +1,7 @@ +MODE=server +DB_DSN=status:password@tcp(127.0.0.1:3306)/status?charset=utf8mb4&parseTime=true +HTTP_ADDR=:8080 +WEB_USER=admin +WEB_PASSWORD=changeme +TELEGRAM_TOKEN=your_bot_token_here +TELEGRAM_CHAT_IDS=123456789,987654321 # ID чатов, разделенные запятой \ No newline at end of file diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..014e13f --- /dev/null +++ b/templates.go @@ -0,0 +1,469 @@ +package main + +const dashboardTmpl = ` + + + + +status dashboard + + + +

status

+ +

Servers (24h uptime)

+ + + + + + + + + + + +{{range .Servers}} + + + + + + + + + + +{{else}} + +{{end}} +
URLNameAnon NameOK ChecksUptimeAvg TTFBStatusActions
{{.URL}}{{.Name}}{{if .AnonName}}{{.AnonName}}{{else}}{{end}}{{.Successes}} + {{if gt .Total 0}} + + {{printf "%.1f" .UptimePct}}% + {{else}} + + + {{end}} + + {{if gt .AvgTTFBMS 0}}{{.AvgTTFBMS}} ms{{else}}—{{end}} + + {{if eq .UptimeClass "ok"}}OK + {{else if eq .UptimeClass "warn"}}DEGRADED + {{else if eq .UptimeClass "err"}}DOWN + {{else if eq .UptimeClass "disabled"}}DISABLED + {{else}}NO DATA{{end}} + + {{if .Active}} +
+ + + +
+ {{else}} +
+ + + +
+ {{end}} +
+ + +
+
No servers configured
+ +
+
+ + + +
+
+ + + + +
+
+ +

Agents

+ + + + + + + + + +{{range .Agents}} + + + + + + + + +{{else}} + +{{end}} +
NameAnon NameIPLast SeenStatusActions
{{.Name}}{{if .AnonName}}{{.AnonName}}{{else}}{{end}}{{if .LastIP}}{{.LastIP}}{{else}}{{end}}{{.LastSeenStr}} + {{if .Online}}ONLINE + {{else}}OFFLINE{{end}} + +
+ + +
+
No agents registered
+ +
+
+ + + +
+
+ + +` + +const tokenTmpl = ` + + + +Agent Created — status + + + +

Agent Created

+
+
Agent: {{index . "Name"}}
+
+ ⚠ Save this token now — it will NOT be shown again.
+ Set it as the AGENT_TOKEN environment variable on the agent host. +
+
Agent Token:
+
{{index . "Token"}}
+ ← Back to dashboard +
+ +` + +const publicTmpl = ` + + + + +Status + + + +

Status

+ +

Services (24h)

+ + + + + + + + +{{range .Servers}} + + + + + + + +{{else}} + +{{end}} +
NameOK ChecksUptimeAvg TTFBStatus
{{.AnonName}}{{.Successes}} + {{if gt .Successes 0}} + + {{printf "%.1f" .UptimePct}}% + {{else}} + + + {{end}} + + {{if gt .AvgTTFBMS 0}}{{.AvgTTFBMS}} ms{{else}}—{{end}} + + {{if eq .UptimeClass "ok"}}OK + {{else if eq .UptimeClass "warn"}}DEGRADED + {{else if eq .UptimeClass "err"}}DOWN + {{else}}NO DATA{{end}} +
No data available
+ +

Nodes

+ + + + + +{{range .Agents}} + + + + +{{else}} + +{{end}} +
NameStatus
{{.AnonName}} + {{if .Online}}ONLINE + {{else}}OFFLINE{{end}} +
No nodes
+ + +` + +const chartTmpl = ` + + + + +{{.Server.URL}} — status + + + +
← Dashboard
+
+

{{.Server.URL}}

+
{{if .Server.Name}}{{.Server.Name}}{{else}}(no name){{end}}
+ +
+ Range: + + + + + | + Agent: + + +
+ +
+
Availability
+ +
+
+
Response Time (ms)
+ +
+ + + + + +`