ФАЙЛИ В /var/log/netpulse. rsyslog читає journald і розкладає по файлах
на службу. Драйвер docker лишається journald, а не syslog: syslog
віддав би рядки назовні й нічого не лишив докеру, тож `docker logs`
замовк би назавжди — і мовчання виглядало б як «служба нічого не пише».
Тепер працюють усі три шляхи: docker logs, journalctl, файли.
Дорогою три власні помилки, кожна виглядала як «rsyslog не працює»:
1. Умова матчила $!CONTAINER_NAME — метадані журналу. journalctl їх
бачить, а до правил rsyslog вони доходять не завжди. Каталог
створювався й лишався порожнім.
2. Тег «netpulse/{{.Name}}» здавався охайнішим, але rsyslog обриває
programname на скісній: для ВСІХ контейнерів вона ставала просто
«netpulse». Тег тепер — саме ім'я контейнера, воно й так має
префікс проєкту.
3. Шаблон `%msg:::sp-if-no-1st-sp,drop-last-lf%` давав ПОРОЖНІЙ
текст: файли були, рядки були, слів не було. Журнал виглядав
робочим і не містив нічого — найгірший різновид поломки.
Конфігурації в deploy/, щоб їхали клієнтам, а не лишались разовим
налаштуванням одного сервера.
МІНІКАРТА. Штрих рядка був 2 px при 3 px на рядок — просвіт в один
піксель. На дробовому масштабі екрана (1.25, 1.5 — тобто на більшості
ноутбуків) він губився при округленні, і рядки злипались у суцільну
пляму: мінікарта показувала не форму конфігу, а сірий прямокутник.
Тепер штрих 1 px, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
480 lines
23 KiB
Go
480 lines
23 KiB
Go
// Package logbuf — останні рядки власного журналу процесу, у пам'яті.
|
||
package logbuf
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"log/slog"
|
||
"net/url"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||
)
|
||
|
||
// ЩО ЦЕ ЛІКУЄ
|
||
//
|
||
// Продукт показує syslog із пристроїв, журнал аудиту, черги й
|
||
// стенограми команд — тобто все про мережу й нічого про себе. Коли
|
||
// лається сам NetPulse (не поїхало дзеркало, не закрився період SLA,
|
||
// відмовила розсилка), єдиний спосіб це побачити — `docker logs`, тобто
|
||
// ssh на сервер. Людина, яка має право дивитись на моніторинг, такого
|
||
// доступу зазвичай не має й не повинна мати.
|
||
//
|
||
// Тому обгортка над slog.Handler: окрім звичайного виводу вона кладе
|
||
// запис у кільце останніх рядків, а REST віддає це кільце сторінці.
|
||
//
|
||
// ЧОМУ В ПАМ'ЯТІ, А НЕ В БАЗІ
|
||
//
|
||
// Журнал, що пише в Postgres, замовкає рівно тоді, коли ляже Postgres, —
|
||
// тобто в найцікавіший момент. Той самий такт, що мав би зберегти рядок
|
||
// «немає з'єднання з БД», сам упреться в те саме «немає з'єднання».
|
||
// Пам'ять цієї залежності не має взагалі. Саме тому тут немає й
|
||
// міграції: зберігати нема чого й нема де.
|
||
//
|
||
// Ціна відома й названа прямо на сторінці: кільце не переживає
|
||
// перезапуску процесу. Якщо процес упав, його останні слова лишились
|
||
// лише в `docker logs`. Це не недогляд, а межа механізму, і мовчати про
|
||
// неї не можна: людина, яка шукає в журналі причину падіння й нічого не
|
||
// знаходить, зробить хибний висновок «нічого не сталося».
|
||
//
|
||
// ЧОМУ СТЕЛЯ В БАЙТАХ, А НЕ В РЯДКАХ
|
||
//
|
||
// Бо рядки не однакові. Звичайний рядок рівня info — це десь 150–200
|
||
// байтів; один рядок із текстом помилки Postgres, у який укладено сам
|
||
// запит, буває довшим за сотню звичайних. «Тримати 5000 рядків» на
|
||
// потоці, де кожен третій — така помилка, означає непередбачувані
|
||
// десятки мегабайтів у процесі, який їх нікому не обіцяв. Стеля в
|
||
// байтах — це стеля, яку можна порахувати наперед і назвати в звіті.
|
||
//
|
||
// Звідси ж друга стеля, MaxRecordBytes: без неї один рядок на кілька
|
||
// мегабайтів (а конфіг маршрутизатора, який хтось поклав у атрибут,
|
||
// саме такий) виніс би з кільця геть усе, і сторінка показала б рівно
|
||
// цей один рядок. Обрізаний запис лишається корисним; порожнє кільце —
|
||
// ні.
|
||
|
||
const (
|
||
// DefaultMaxBytes — скільки байтів тексту тримає кільце одного
|
||
// процесу.
|
||
//
|
||
// 8 МіБ, і ось арифметика, з якої це число взялося. Звичайний рядок
|
||
// (повідомлення + атрибути + службові поля) важить близько 170
|
||
// байтів — це приблизно 50 000 рядків. Найгірший випадок, коли
|
||
// КОЖЕН рядок уперся в MaxRecordBytes, дає 8 Мі/(8 Кі+128) ≈ 1000
|
||
// рядків. Тобто глибина журналу гуляє в п'ятдесят разів, а пам'ять
|
||
// не гуляє взагалі — саме заради цього стеля й у байтах.
|
||
//
|
||
// Процесів два, тож інсталяція платить 16 МіБ тексту плюс службові
|
||
// поля записів (ще близько 6 МіБ у типовому випадку). Для сервера,
|
||
// який тримає Postgres із TimescaleDB, це шум; для сторінки, заради
|
||
// якої більше не треба йти по ssh, — вигідний обмін.
|
||
DefaultMaxBytes = 8 << 20
|
||
|
||
// MaxRecordBytes — стеля одного запису.
|
||
//
|
||
// 8 КіБ вистачає на будь-яку помилку з текстом SQL-запиту (їх
|
||
// бачили до ~3 КіБ) і не вистачає на конфіг пристрою — а це рівно
|
||
// та межа, яку тут і треба провести.
|
||
MaxRecordBytes = 8 << 10
|
||
|
||
// recordOverhead — скільки важить запис понад свій текст.
|
||
//
|
||
// Сама структура Record — 96 байтів на 64-бітній платформі, плюс
|
||
// вміст рядків рівня й джерела, плюс місце в слайсі. Округлено до
|
||
// 128 навмисно в більший бік: стеля має бути обіцянкою, а не
|
||
// оцінкою, і недорахувати тут гірше, ніж перерахувати.
|
||
recordOverhead = 128
|
||
|
||
// truncMark — позначка обрізаного запису.
|
||
//
|
||
// Видима навмисно: рядок, який мовчки обрізали, читають як цілий і
|
||
// шукають у ньому причину, якої в ньому вже немає.
|
||
truncMark = "…[обрізано]"
|
||
)
|
||
|
||
// Record — один рядок журналу так, як його бачить сторінка.
|
||
//
|
||
// Атрибути зведені в один рядок "ключ=значення", а не збережені
|
||
// структурою: сторінці однаково показувати їх текстом, пошук по тексту
|
||
// має бачити і ключі, і значення, а зберігати []slog.Attr означало б
|
||
// тримати живими всі об'єкти, на які вони посилаються, — тобто кільце
|
||
// на 8 МіБ тексту утримувало б у пам'яті сотні мегабайтів чужих даних.
|
||
type Record struct {
|
||
// Seq — наскрізний номер у межах процесу. Потрібен не для показу, а
|
||
// для порядку: два записи в одну наносекунду впорядкувати за часом
|
||
// не можна, і сторінка їх перемішувала б на кожному оновленні.
|
||
Seq uint64 `json:"seq"`
|
||
Time time.Time `json:"time"`
|
||
Level string `json:"level"`
|
||
Msg string `json:"msg"`
|
||
Attrs string `json:"attrs,omitempty"`
|
||
|
||
// Source — який процес це написав. Заповнюється не кільцем, а тим,
|
||
// хто знімок віддає: саме кільце свого імені не знає й знати не
|
||
// мусить.
|
||
Source string `json:"source,omitempty"`
|
||
}
|
||
|
||
func (r Record) size() int { return len(r.Msg) + len(r.Attrs) + recordOverhead }
|
||
|
||
// Snapshot — стан кільця на мить запиту.
|
||
//
|
||
// Разом із записами їдуть і числа про саме кільце. Це не діагностика
|
||
// заради діагностики: без Dropped і Since людина не відрізнить «за
|
||
// годину нічого не сталося» від «сталося стільки, що початок уже
|
||
// витіснено», а це протилежні висновки.
|
||
type Snapshot struct {
|
||
Source string `json:"source"`
|
||
Records []Record `json:"records"`
|
||
Dropped uint64 `json:"dropped"`
|
||
Since time.Time `json:"since"`
|
||
Bytes int `json:"bytes"`
|
||
MaxBytes int `json:"max_bytes"`
|
||
}
|
||
|
||
// Ring — кільце останніх рядків.
|
||
type Ring struct {
|
||
mu sync.Mutex
|
||
max int
|
||
bytes int
|
||
seq uint64
|
||
dropped uint64
|
||
since time.Time
|
||
recs []Record
|
||
|
||
// secrets — що маскувати в кожному записі; див. Mask.
|
||
secrets []string
|
||
}
|
||
|
||
// New створює кільце зі стелею max байтів тексту.
|
||
//
|
||
// Стеля, менша за MaxRecordBytes, піднімається до неї: інакше перший же
|
||
// довгий рядок витіснив би сам себе, і кільце лишалося б порожнім
|
||
// назавжди — стан, у якому сторінка бреше найпереконливіше.
|
||
func New(max int) *Ring {
|
||
if max < MaxRecordBytes {
|
||
max = MaxRecordBytes
|
||
}
|
||
return &Ring{max: max, since: time.Now()}
|
||
}
|
||
|
||
// ЧОМУ СЕКРЕТИ МАСКУЮТЬСЯ ТУТ, ЯКЩО ВОНИ ВЖЕ МАСКУЮТЬСЯ В ДЖЕРЕЛІ
|
||
//
|
||
// Бо маскування в джерелі — це набір окремих рішень у окремих файлах
|
||
// (gitstore.RedactURL для адрес дзеркала, scrubToken для бота), і
|
||
// кожне з них правильне рівно доти, доки хтось не додасть ще один
|
||
// log.Error із чужою помилкою всередині. Досі ціна такого недогляду
|
||
// була невелика: рядок їхав у stderr, звідти в docker, і читав його
|
||
// той, хто вже має ssh, тобто вже має і DSN, і ключі. Кільце міняє
|
||
// саме це — воно робить журнал видимим у браузері й вивантажуваним у
|
||
// файл, який піде в тікет. Тому тут другий, суцільний рубіж.
|
||
//
|
||
// Маскування НЕ своє: береться gitstore.Scrub — той самий, яким
|
||
// чиститься текст помилок дзеркала. Він робить дві речі, і обидві
|
||
// потрібні: замінює передані значення й вирізає логін-пароль із
|
||
// будь-якої адреси виду «схема://щось@», хоч би де вона в рядку
|
||
// трапилась. Друге важливіше за перше: воно ловить і DSN із паролем, і
|
||
// вставлений із чужої інструкції git-URL із токеном — тобто те, про що
|
||
// ми не знали заздалегідь.
|
||
//
|
||
// Значення коротші за 8 байтів Scrub ігнорує (вони збіглися б із
|
||
// випадковим підрядком і перетворили журнал на кашу) — тож коротким
|
||
// секретом цей рубіж не закрити, і покладатись на нього як на єдиний
|
||
// не можна.
|
||
|
||
// Mask реєструє секрети, які не мають потрапити в кільце.
|
||
func (r *Ring) Mask(secrets ...string) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
for _, s := range secrets {
|
||
if s = strings.TrimSpace(s); s != "" {
|
||
r.secrets = append(r.secrets, s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wrap загортає наявний обробник.
|
||
//
|
||
// Саме загортає, а не заміняє: next викликається завжди й першим, а
|
||
// кільце наповнюється після нього. Порядок тут структурний, а не
|
||
// стилістичний — він робить неможливим стан «журнал є на сторінці, але
|
||
// зник із stderr». Вивід у stderr — головний: його забирає docker, він
|
||
// переживає перезапуск процесу, і саме він лишається єдиним джерелом
|
||
// про падіння.
|
||
func (r *Ring) Wrap(next slog.Handler) slog.Handler {
|
||
if next == nil {
|
||
panic("logbuf: Wrap(nil)")
|
||
}
|
||
return &handler{next: next, ring: r}
|
||
}
|
||
|
||
// Snapshot — копія кільця, найсвіжіші перші.
|
||
//
|
||
// Порядок задано тут, а не в кожного споживача: сторінка, вивантаження
|
||
// у файл і злиття двох процесів усі хочуть свіже зверху, і забути
|
||
// розвернути десь в одному місці — питання часу.
|
||
func (r *Ring) Snapshot(source string) Snapshot {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
out := make([]Record, len(r.recs))
|
||
for i, rec := range r.recs {
|
||
rec.Source = source
|
||
out[len(r.recs)-1-i] = rec
|
||
}
|
||
return Snapshot{
|
||
Source: source,
|
||
Records: out,
|
||
Dropped: r.dropped,
|
||
Since: r.since,
|
||
Bytes: r.bytes,
|
||
MaxBytes: r.max,
|
||
}
|
||
}
|
||
|
||
// add кладе запис у кільце, витісняючи найстаріші.
|
||
func (r *Ring) add(t time.Time, level, msg, attrs string) {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
// Маскування під тим самим замком, що й запис: секрети реєструються
|
||
// при старті, але Mask лишається відкритим методом, і читати
|
||
// r.secrets без замка означало б гонку рівно на тому полі, заради
|
||
// якого все це й робиться.
|
||
msg = scrub(msg, r.secrets)
|
||
attrs = scrub(attrs, r.secrets)
|
||
|
||
// Обрізається саме хвіст атрибутів: повідомлення й перші атрибути
|
||
// кажуть, ЩО сталося, а розпух зазвичай останній — вкладений текст
|
||
// помилки чи конфігу.
|
||
if over := len(msg) + len(attrs) - MaxRecordBytes; over > 0 {
|
||
if len(attrs) > over+len(truncMark) {
|
||
attrs = attrs[:len(attrs)-over-len(truncMark)] + truncMark
|
||
} else {
|
||
attrs = truncMark
|
||
if len(msg) > MaxRecordBytes {
|
||
msg = msg[:MaxRecordBytes-len(truncMark)] + truncMark
|
||
}
|
||
}
|
||
}
|
||
|
||
r.seq++
|
||
rec := Record{Seq: r.seq, Time: t, Level: level, Msg: msg, Attrs: attrs}
|
||
r.recs = append(r.recs, rec)
|
||
r.bytes += rec.size()
|
||
|
||
for r.bytes > r.max && len(r.recs) > 1 {
|
||
r.bytes -= r.recs[0].size()
|
||
// Обнуляється навмисно: без цього рядки витісненого запису
|
||
// лишалися б живими у хвості масиву аж до наступного
|
||
// перевиділення, і кільце тримало б у пам'яті помітно більше за
|
||
// свою стелю.
|
||
r.recs[0] = Record{}
|
||
r.recs = r.recs[1:]
|
||
r.dropped++
|
||
// Since переїжджає на найстаріший із тих, що лишились. Спочатку
|
||
// це мить створення кільця («журнал від старту процесу»), після
|
||
// першого витіснення — час найстарішого запису. Різниця між
|
||
// цими двома станами і є відповіддю на питання, чи бачить
|
||
// людина початок історії, чи вже ні.
|
||
r.since = r.recs[0].Time
|
||
}
|
||
}
|
||
|
||
// scrub — маскування одного рядка наявним механізмом.
|
||
//
|
||
// gitstore.Scrub працює з помилками, а не з рядками, тому текст їде
|
||
// туди й назад через errors.New. Це дешевше, ніж мати другу копію тієї
|
||
// самої логіки: копія розійшлася б із оригіналом на першій же правці, і
|
||
// розійшлася б мовчки.
|
||
func scrub(s string, secrets []string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
return gitstore.Scrub(errors.New(s), secrets...).Error()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// slog.Handler
|
||
// ---------------------------------------------------------------------
|
||
|
||
type handler struct {
|
||
next slog.Handler
|
||
ring *Ring
|
||
|
||
// Атрибути, накопичені через WithAttrs, уже відрендерені в текст:
|
||
// логер створюють раз, а пишуть у нього тисячі разів, і рендерити
|
||
// незмінну частину на кожен рядок було б платою ні за що.
|
||
attrs []string
|
||
// Префікс поточної групи з крапкою на кінці; порожній — груп немає.
|
||
group string
|
||
}
|
||
|
||
func (h *handler) Enabled(ctx context.Context, lv slog.Level) bool {
|
||
return h.next.Enabled(ctx, lv)
|
||
}
|
||
|
||
func (h *handler) Handle(ctx context.Context, rec slog.Record) error {
|
||
// Текст збирається ДО передачі далі. Формально slog.Record можна
|
||
// читати й після, але покладатись на це не варто: обробник нижче
|
||
// має право робити з копією що завгодно, а ми залежали б від його
|
||
// внутрішньої поведінки.
|
||
attrs := h.render(rec)
|
||
|
||
// Спершу stderr — і лише потім кільце. Помилка нижнього обробника
|
||
// не скасовує запису в кільце: рядок, який не вдалося записати в
|
||
// stderr, потрібен на сторінці тим більше.
|
||
err := h.next.Handle(ctx, rec)
|
||
h.ring.add(rec.Time, rec.Level.String(), rec.Message, attrs)
|
||
return err
|
||
}
|
||
|
||
func (h *handler) WithAttrs(as []slog.Attr) slog.Handler {
|
||
if len(as) == 0 {
|
||
return h
|
||
}
|
||
n := *h
|
||
// slices.Clip обов'язковий: без нього два логери, породжені від
|
||
// одного, писали б у спільний хвіст масиву й затирали атрибути один
|
||
// одному.
|
||
n.attrs = slices.Clip(h.attrs)
|
||
for _, a := range as {
|
||
n.attrs = appendAttr(n.attrs, h.group, a)
|
||
}
|
||
n.next = h.next.WithAttrs(as)
|
||
return &n
|
||
}
|
||
|
||
func (h *handler) WithGroup(name string) slog.Handler {
|
||
// Порожнє ім'я groupʼи — «нічого не робити»: цього вимагає контракт
|
||
// slog.Handler.
|
||
if name == "" {
|
||
return h
|
||
}
|
||
n := *h
|
||
n.group = h.group + name + "."
|
||
n.next = h.next.WithGroup(name)
|
||
return &n
|
||
}
|
||
|
||
// render зводить атрибути запису в один рядок.
|
||
func (h *handler) render(rec slog.Record) string {
|
||
out := h.attrs
|
||
if rec.NumAttrs() > 0 {
|
||
out = slices.Clip(out)
|
||
rec.Attrs(func(a slog.Attr) bool {
|
||
out = appendAttr(out, h.group, a)
|
||
return true
|
||
})
|
||
}
|
||
return strings.Join(out, " ")
|
||
}
|
||
|
||
// appendAttr додає атрибут як "ключ=значення", розкриваючи групи.
|
||
func appendAttr(dst []string, prefix string, a slog.Attr) []string {
|
||
v := a.Value.Resolve()
|
||
if v.Kind() == slog.KindGroup {
|
||
g := v.Group()
|
||
// Група без атрибутів не пишеться зовсім — так само, як у
|
||
// стандартних обробниках.
|
||
if len(g) == 0 {
|
||
return dst
|
||
}
|
||
p := prefix
|
||
if a.Key != "" {
|
||
p += a.Key + "."
|
||
}
|
||
for _, sub := range g {
|
||
dst = appendAttr(dst, p, sub)
|
||
}
|
||
return dst
|
||
}
|
||
// Порожній ключ із порожнім значенням slog викидає; повторюємо.
|
||
if a.Equal(slog.Attr{}) {
|
||
return dst
|
||
}
|
||
return append(dst, prefix+a.Key+"="+quote(v.String()))
|
||
}
|
||
|
||
// quote бере значення в лапки лише тоді, коли без них рядок
|
||
// «ключ=значення» перестає читатись як пара.
|
||
func quote(s string) string {
|
||
if s == "" {
|
||
return `""`
|
||
}
|
||
if strings.ContainsAny(s, " \t\r\n\"=") {
|
||
return strconv.Quote(s)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Що саме маскувати
|
||
// ---------------------------------------------------------------------
|
||
|
||
// DSNSecrets вибирає з рядка підключення те, що не має світитись.
|
||
//
|
||
// Саме пароль, а не DSN цілком: у DSN лишається хост і база, а вони в
|
||
// журналі потрібні — «не можу підключитись до db:5432» і «не можу
|
||
// підключитись до «секрет»» різняться тим, чи можна щось зробити з
|
||
// прочитаним.
|
||
//
|
||
// Дві форми, бо pgx приймає обидві: URL (postgres://user:pass@host/db)
|
||
// і набір ключів (host=db password=xxx). Друга трапляється рідше, але
|
||
// саме її вводять руками — тобто там, де пароль найчастіше і буває
|
||
// незвичним.
|
||
func DSNSecrets(dsn string) []string {
|
||
dsn = strings.TrimSpace(dsn)
|
||
if dsn == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
if u, err := url.Parse(dsn); err == nil && u.User != nil {
|
||
if pw, ok := u.User.Password(); ok && pw != "" {
|
||
out = append(out, pw)
|
||
}
|
||
}
|
||
for _, part := range strings.Fields(dsn) {
|
||
if k, v, ok := strings.Cut(part, "="); ok {
|
||
switch strings.ToLower(strings.TrimSpace(k)) {
|
||
case "password", "sslpassword":
|
||
if v != "" {
|
||
out = append(out, v)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// KeySecrets вибирає матеріал ключів шифрування зі спеки -dek.
|
||
//
|
||
// Формат той самий, що читає crypto.ParseKeyring: "key_id=<hex|base64>".
|
||
// Маскується і сам запис у тому вигляді, як він прийшов, і розібраний
|
||
// ключ: у журнал ключ може потрапити обома шляхами — і як шматок
|
||
// командного рядка, і як байти всередині чужої помилки.
|
||
func KeySecrets(spec string) []string {
|
||
spec = strings.TrimSpace(spec)
|
||
if spec == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, part := range strings.Split(spec, ",") {
|
||
_, raw, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||
if !ok || raw == "" {
|
||
continue
|
||
}
|
||
out = append(out, raw)
|
||
if key, err := hex.DecodeString(raw); err == nil {
|
||
out = append(out, string(key))
|
||
} else if key, err := base64.StdEncoding.DecodeString(raw); err == nil {
|
||
out = append(out, string(key))
|
||
}
|
||
}
|
||
return out
|
||
}
|