Netpulse_SasS/server/internal/logbuf/http.go
byrsapty e6b585dd4c
All checks were successful
CI / hygiene (push) Successful in 11s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m55s
CI / agent (push) Successful in 1m0s
Сторінка «Журнал сервера»: кільцевий буфер у памяті
Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд —
усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам
NetPulse, треба було заходити по ssh.

Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно
тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ
(8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки
буває довшим за сотню звичайних, тож «5000 рядків» означало б
непередбачувані десятки мегабайтів.

Два процеси — один перелік із позначкою джерела, а не дві вкладки:
людина знає симптом («о третій ночі перестали йти сповіщення»), а не
те, який із двох процесів за це відповідає.

Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал
DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені
в місці народження, але кільце робить журнал видимим у браузері й
вивантажуваним у файл, що піде в тікет.

Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не
переживає перезапуску й не покаже причини падіння бази. Поруч —
скільки записів витіснено: без цього числа не відрізнити «нічого не
сталося» від «сталося стільки, що початок уже не влазить».
2026-08-27 23:50:03 +03:00

176 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package logbuf
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ДВА ПРОЦЕСИ — ДВА КІЛЬЦЯ, І ОДНЕ ВІКНО НА ОБИДВА
//
// Кільце живе в пам'яті процесу, а процесів у продукті два:
// netpulse-api (REST і WebSocket) і netpulse-server (колектор зондів).
// Пам'яті одного другий не бачить у принципі.
//
// Показувати саму лише половину з боку API не варіант, і не з міркувань
// повноти. Подивіться, ЩО саме крутиться в колекторі: диспетчер збору
// конфігів, розклад бекапів, звірка планів, два прибиральники за
// строками зберігання, закриття періодів SLA, дзеркалення на зовнішній
// Git. Тобто рівно ті такти, які й лаються вночі й про які потім
// питають. Сторінка без них показувала б тишу там, де насправді
// щогодини падає push у Forgejo.
//
// Тому внутрішня ручка на колекторі, яку REST-процес проксіює, і один
// спільний перелік із позначкою джерела в кожному рядку. Вибір саме на
// користь спільного переліку, а не двох вкладок: людина, яка прийшла
// сюди, знає симптом («о третій ночі перестали йти сповіщення») і НЕ
// знає, який із двох процесів за це відповідає. Дві вкладки змушують її
// вгадати відповідь на питання, заради якого вона й прийшла, — і
// половину часу вона вгадає неправильно й піде з висновком «нічого не
// було».
//
// ЦІНА, ЯКУ ЦЕ КОШТУЄ
//
// Ще один слухач у колекторі й ще одна залежність REST-процесу від
// мережі. Обидві мають бути безпечними за замовчуванням:
//
// - слухач НЕ валить колектор, якщо порт зайнятий, — журнал не варте
// того, щоб через нього не піднявся збір даних;
// - запит до колектора має короткий строк і НЕ валить сторінку: якщо
// сусід недоступний, у відповіді їде своя половина плюс чесний
// рядок про те, чому другої немає. Порожньої вкладки без пояснення
// тут бути не може.
//
// ПРО ДОСТУП ДО ВНУТРІШНЬОЇ РУЧКИ
//
// Це системний журнал: адреси, імена хостів, тексти помилок SQL. Порт
// назовні не публікується (як і gRPC колектора), але «воно всередині
// мережі» в цьому продукті не вважається доступом ніде: до Postgres
// ходять із паролем, до колектора — з токеном зонда. Тому й тут токен.
//
// Нового секрету при цьому не заводиться, і це головне: секрет, який
// треба роздати двом контейнерам, — це ще одна змінна оточення, яку
// половина інсталяцій не виставить, і ручка тихо перестане працювати.
// Токен виводиться HMAC-ом із того, що в обох процесів уже є спільного
// й секретного, — зі спеки ключів шифрування (-dek). Мітка робить
// виведене значення непридатним ні для чого іншого, а HMAC не дає з
// нього дістати сам ключ.
//
// ЧОГО ЦЕЙ ЗАХИСТ НЕ ДАЄ, І ЦЕ ТРЕБА ЗНАТИ
//
// Канал відкритий: токен і сам журнал ідуть звичайним HTTP. У типовому
// розгортанні обидва процеси стоять у одній мережі docker на одній
// машині, і це та сама межа довіри, у якій уже ходить h2c до колектора
// (див. docker-compose.yml). Але інсталяція, що рознесе процеси на різні
// машини через мережу, якій не довіряє, отримає токен відкритим текстом
// на дроті. Для такої топології правильний крок — не пускати 9444 повз
// периметр і лишити -collector-log порожнім; сторінка тоді чесно
// покаже лише половину, а не тихо винесе журнал у мережу.
// tokenLabel — розділювач призначень. Без нього виведене значення
// формально було б «ще однією функцією від ключа шифрування», а таких
// збігів у криптографії уникають за звичкою, а не за потребою.
const tokenLabel = "netpulse/serverlog/v1"
// InternalPath — шлях внутрішньої ручки. Під /internal/, а не під
// /api/, щоб було видно з першого погляду: це не частина публічного
// API, і зворотний проксі не має її проксіювати.
const InternalPath = "/internal/serverlog"
// InternalToken виводить токен внутрішньої ручки зі спільного секрету.
//
// Порожній секрет дає порожній токен — це «ручки немає». Саме так, а не
// «ручка без пароля»: інсталяція без -dek існує (REST-процес уміє
// працювати без ключів), і мовчазне зняття перевірки на ній було б
// найгіршим із можливих наслідків порожньої змінної.
func InternalToken(shared string) string {
shared = strings.TrimSpace(shared)
if shared == "" {
return ""
}
mac := hmac.New(sha256.New, []byte(shared))
mac.Write([]byte(tokenLabel))
return hex.EncodeToString(mac.Sum(nil))
}
// HTTPHandler — внутрішня ручка, що віддає знімок кільця.
//
// Порожній token означає «нікому не відповідати»: викликати цю функцію
// без токена — помилка налаштування, і поводитись вона має як закриті
// двері, а не як відчинені.
func (r *Ring) HTTPHandler(source, token string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if token == "" || !tokenOK(req, token) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(r.Snapshot(source))
})
}
// tokenOK — порівняння за сталий час.
//
// hmac.Equal, а не ==: токен тут перевіряється на кожному запиті, і
// різниця в часі відповіді на перший розбіжний байт — рівно те, з чого
// його підбирають.
func tokenOK(req *http.Request, want string) bool {
got, _ := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ")
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(got)), []byte(want)) == 1
}
// Fetch забирає знімок у сусіднього процесу.
//
// Строк короткий навмисно: сторінку журналу відкривають тоді, коли вже
// щось не так, і чекати на недоступного сусіда пів хвилини означає
// підвісити єдину сторінку, що ще працює.
func Fetch(ctx context.Context, base, token string, timeout time.Duration) (Snapshot, error) {
var out Snapshot
if base == "" {
return out, fmt.Errorf("адресу сусіднього процесу не вказано")
}
if token == "" {
return out, fmt.Errorf("немає спільного секрету (-dek) — звернутись нічим")
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(base, "/")+InternalPath, nil)
if err != nil {
return out, err
}
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
// Тіло не показуємо: на тому кінці міг відповісти зовсім не наш
// процес (чужий сервіс на тому ж порту), і пересилати його HTML
// на сторінку журналу — це показати сміття замість пояснення.
return out, fmt.Errorf("відповідь %d", res.StatusCode)
}
// Стеля на розмір відповіді: без неї сусід, який зійшов з розуму,
// або чужий сервіс на тому порту складали б у пам'ять REST-процесу
// стільки, скільки надішлють. Два кільця по 8 МіБ плюс службові
// поля JSON із запасом не переростуть цієї межі.
dec := json.NewDecoder(io.LimitReader(res.Body, 64<<20))
if err := dec.Decode(&out); err != nil {
return out, fmt.Errorf("нерозбірлива відповідь: %w", err)
}
return out, nil
}