Телевізор нікуди не залогиниш — зранку на стіні висітиме форма входу замість карти мережі. Тому /tv/<токен> розгалужується до перевірки сесії, а доступ дає токен у посиланні. Публічний зріз навмисно вузький: розкладка, алерти, хости й метрики ЛИШЕ тих хостів, які показані на цьому дашборді. Ширший доступ був би простішим у коді й перетворив би забутий лінк на ключ до кабінету. Плитки ті самі, що в кабінеті: джерело даних підмінюється контекстом. Дорогою знайдено дві помилки. Вбудовані icmp-тригери мали умову у формі, якої движок не розуміє, і мовчали, засипаючи журнал помилками — виправлено міграцією 0026 і в редакторі. База стенда виявилась у SQL_ASCII: тепер міграція відмовляється накочуватись на не-UTF8, а compose задає кодування явно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
8 KiB
Go
212 lines
8 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/auth"
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Режим NOC TV: дашборд на телевізорі в диспетчерській.
|
||
//
|
||
// Телевізор нікуди не залогиниш. Сесія протухне, браузер оновиться, і о
|
||
// шостій ранку на стіні висітиме форма входу замість карти мережі — саме
|
||
// тоді, коли на неї дивляться. Тому доступ дає токен у посиланні, а не
|
||
// вхід.
|
||
//
|
||
// Що це коштує з погляду безпеки й чому так:
|
||
//
|
||
// - токен дає ЛИШЕ читання і ЛИШЕ того, що показує цей дашборд:
|
||
// його плитки, алерти тенанта й метрики тих хостів, які на ньому
|
||
// згадані. Підбором ідентифікаторів чужий хост не дістати;
|
||
// - жодного запису, жодних налаштувань, жодних секретів;
|
||
// - посилання відкликається одним рухом, і старе одразу мертве.
|
||
//
|
||
// Ширший доступ (повноцінна сесія тільки для читання) був би простішим
|
||
// у коді й значно гіршим за наслідками: лінк, залишений у відкритому
|
||
// браузері, перетворився б на ключ до всього кабінету.
|
||
|
||
// publicDashboard — розібраний токен: кому належить і що показує.
|
||
type publicDashboard struct {
|
||
TenantID string
|
||
Dashboard *store.Dashboard
|
||
// Хости, згадані плитками. Порожня мапа означає дашборд без метрик.
|
||
Devices map[string]bool
|
||
}
|
||
|
||
func (s *Server) resolveTV(w http.ResponseWriter, r *http.Request) (*publicDashboard, bool) {
|
||
token := strings.TrimSpace(r.PathValue("token"))
|
||
tenantID, d, err := s.store.DashboardByPublicToken(r.Context(), token)
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
// Однакова відповідь на неіснуючий і на відкликаний токен:
|
||
// різниця між ними — це підказка тому, хто перебирає.
|
||
writeError(w, http.StatusNotFound, "not_found", "посилання недійсне")
|
||
return nil, false
|
||
}
|
||
if err != nil {
|
||
s.log.Error("публічний дашборд", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return nil, false
|
||
}
|
||
|
||
devices, err := s.store.DashboardWidgetDevices(r.Context(), tenantID, d.ID)
|
||
if err != nil {
|
||
s.log.Error("хости публічного дашборда", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return nil, false
|
||
}
|
||
|
||
// Кеш браузера тут шкідливий: телевізор має показувати стан мережі,
|
||
// а не те, яким він був на момент відкриття вкладки.
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
return &publicDashboard{TenantID: tenantID, Dashboard: d, Devices: devices}, true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Керування посиланням
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleCreatePublicLink(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:write") {
|
||
return
|
||
}
|
||
token, err := auth.NewToken("np_tv_")
|
||
if err != nil {
|
||
s.log.Error("токен телевізора", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if err := s.store.SetDashboardPublicToken(r.Context(), p.TenantID, r.PathValue("id"), token); err != nil {
|
||
s.writeStoreError(w, "посилання для телевізора", err)
|
||
return
|
||
}
|
||
// Шлях, а не повний URL: сервер за проксі не знає свого зовнішнього
|
||
// імені, і вгадування дало б посилання, яке нікуди не веде.
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"token": token,
|
||
"path": "/tv/" + token,
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleRevokePublicLink(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:write") {
|
||
return
|
||
}
|
||
if err := s.store.SetDashboardPublicToken(r.Context(), p.TenantID, r.PathValue("id"), ""); err != nil {
|
||
s.writeStoreError(w, "посилання для телевізора", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Публічне читання
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleTVDashboard(w http.ResponseWriter, r *http.Request) {
|
||
tv, ok := s.resolveTV(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, tv.Dashboard)
|
||
}
|
||
|
||
func (s *Server) handleTVAlerts(w http.ResponseWriter, r *http.Request) {
|
||
tv, ok := s.resolveTV(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
f := store.AlertFilter{
|
||
MinSeverity: r.URL.Query().Get("min_severity"),
|
||
States: []string{"firing", "acknowledged"},
|
||
Scope: store.Scope{Unrestricted: true},
|
||
Limit: 200,
|
||
}
|
||
alerts, err := s.store.ListAlerts(r.Context(), tv.TenantID, f)
|
||
if err != nil {
|
||
s.log.Error("алерти телевізора", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
if alerts == nil {
|
||
alerts = []store.Alert{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
|
||
}
|
||
|
||
func (s *Server) handleTVDevices(w http.ResponseWriter, r *http.Request) {
|
||
tv, ok := s.resolveTV(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
devices, err := s.store.ListDevices(r.Context(), tv.TenantID, store.Scope{Unrestricted: true})
|
||
if err != nil {
|
||
s.log.Error("хости телевізора", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
|
||
}
|
||
|
||
func (s *Server) handleTVMetrics(w http.ResponseWriter, r *http.Request) {
|
||
tv, ok := s.resolveTV(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
deviceID := r.PathValue("device")
|
||
if !tv.Devices[deviceID] {
|
||
// Хост, якого немає на дашборді, публічному токену не належить.
|
||
// 404, а не 403: існування чужого хоста — теж відомість.
|
||
writeError(w, http.StatusNotFound, "not_found", "цього хоста немає на дашборді")
|
||
return
|
||
}
|
||
|
||
q := r.URL.Query()
|
||
var ids []int64
|
||
for _, raw := range strings.Split(q.Get("series"), ",") {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
continue
|
||
}
|
||
id, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_series", "ідентифікатор ряду має бути числом")
|
||
return
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
if len(ids) == 0 || len(ids) > 20 {
|
||
writeError(w, http.StatusBadRequest, "bad_series", "потрібен від одного до двадцяти рядів")
|
||
return
|
||
}
|
||
|
||
mq := store.MetricQuery{DeviceID: deviceID, SeriesIDs: ids}
|
||
d, err := parseRange(orDefault(q.Get("range"), "6h"))
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_range", err.Error())
|
||
return
|
||
}
|
||
mq.To = time.Now()
|
||
mq.From = mq.To.Add(-d)
|
||
|
||
res, err := s.store.QueryMetrics(r.Context(), tv.TenantID, mq)
|
||
if err != nil {
|
||
s.log.Error("метрики телевізора", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
func orDefault(v, def string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return def
|
||
}
|
||
return v
|
||
}
|