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
}