Продукт показував syslog З ПРИСТРОЇВ, аудит, черги, стенограми команд — усе про мережу, і нічого про себе. Щоб дізнатись, на що лається сам NetPulse, треба було заходити по ssh. Буфер у памяті, не в базі: журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres — у найцікавіший момент. Стеля в БАЙТАХ (8 МіБ на процес), а не в рядках: один рядок із текстом SQL-помилки буває довшим за сотню звичайних, тож «5000 рядків» означало б непередбачувані десятки мегабайтів. Два процеси — один перелік із позначкою джерела, а не дві вкладки: людина знає симптом («о третій ночі перестали йти сповіщення»), а не те, який із двох процесів за це відповідає. Маскування — наявним gitstore.Scrub, не своїм: паролі DSN, матеріал DEK, секрет підпису сесій. Це другий рубіж — відомі шляхи вже почищені в місці народження, але кільце робить журнал видимим у браузері й вивантажуваним у файл, що піде в тікет. Межі написані НА СТОРІНЦІ, а не лише в документації: журнал не переживає перезапуску й не покаже причини падіння бази. Поруч — скільки записів витіснено: без цього числа не відрізнити «нічого не сталося» від «сталося стільки, що початок уже не влазить».
176 lines
10 KiB
Go
176 lines
10 KiB
Go
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
|
||
}
|