Журнали файлами, тонший штрих мінікарти
ФАЙЛИ В /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, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
This commit is contained in:
parent
c7fecfba0b
commit
04e1242c52
11 changed files with 1704 additions and 7 deletions
78
deploy/docker-compose.journald.yml
Normal file
78
deploy/docker-compose.journald.yml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Журнали в journald замість файлів докера.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f deploy/docker-compose.journald.yml up -d
|
||||
#
|
||||
# ЩО ЦЕ ДАЄ
|
||||
#
|
||||
# * `docker logs` ПРОДОВЖУЄ працювати. Це головна причина обрати
|
||||
# journald, а не syslog: драйвер syslog віддає рядки назовні й
|
||||
# нічого не лишає докеру, тож `docker logs` після нього мовчить —
|
||||
# і людина, яка про це не знає, вирішує, що служба нічого не пише.
|
||||
# * Історія переживає перестворення контейнера. Типовий json-file
|
||||
# живе всередині контейнера: `docker compose up -d` після оновлення
|
||||
# стирає весь журнал рівно тоді, коли він потрібен — щоб порівняти
|
||||
# «до» і «після».
|
||||
# * Ротацію робить systemd, а не ми. Одним місцем менше.
|
||||
#
|
||||
# ЧОГО НЕ ДАЄ
|
||||
#
|
||||
# Простих текстових файлів у /var/log/netpulse. journald тримає свій
|
||||
# формат, і читають його journalctl-ом:
|
||||
#
|
||||
# journalctl CONTAINER_NAME=netpulse-api-1 -f
|
||||
# journalctl CONTAINER_NAME=netpulse-collector-1 --since "2 hours ago"
|
||||
# journalctl CONTAINER_NAME=netpulse-api-1 -p err --since today
|
||||
#
|
||||
# Це той самий tail і grep, лише іншою командою. Кому потрібні саме
|
||||
# файли — ставить rsyslog і додає одне правило; ми цього не робимо за
|
||||
# замовчуванням, бо пакет на чужому сервері ставить власник, а не
|
||||
# продукт.
|
||||
#
|
||||
# ЧОМУ ОКРЕМИЙ ФАЙЛ, А НЕ ЗМІННА
|
||||
#
|
||||
# Опції max-size і max-file існують ЛИШЕ в json-file. Передати їх
|
||||
# journald означає «unknown log opt» і контейнер, який не стартує.
|
||||
# Прибрати ключ умовно compose не вміє, тож перемикач змінною тут
|
||||
# зробив би нерозв'язний стан: драйвер новий, опції старі.
|
||||
#
|
||||
# СТЕЛЯ МІСЦЯ задається не тут, а в journald (він спільний для машини):
|
||||
#
|
||||
# /etc/systemd/journald.conf → SystemMaxUse=1G
|
||||
# systemctl restart systemd-journald
|
||||
#
|
||||
# Типово journald бере до 10% файлової системи — на диску 30 ГБ це
|
||||
# 3 ГБ, що більше, ніж потрібно, і саме тому варто задати явно.
|
||||
|
||||
x-journald: &journald
|
||||
driver: journald
|
||||
options:
|
||||
# Тег — саме ІМ'Я контейнера, без префікса зі скісною.
|
||||
#
|
||||
# Спершу стояло "netpulse/{{.Name}}", і це виглядало охайніше. Але
|
||||
# rsyslog обриває властивість programname на скісній: для всіх
|
||||
# контейнерів вона ставала просто "netpulse", а ім'я служби лишалось
|
||||
# усередині syslogtag. Правило «писати кожній службі свій файл» через
|
||||
# це не спрацьовувало жодного разу — каталог створювався й лишався
|
||||
# порожнім, тобто поломка виглядала як «rsyslog не працює».
|
||||
#
|
||||
# Імена контейнерів і так починаються з імені проєкту (netpulse-api-1),
|
||||
# тож префікс нічого не додавав, окрім цієї халепи.
|
||||
tag: "{{.Name}}"
|
||||
|
||||
services:
|
||||
db:
|
||||
logging: *journald
|
||||
cache:
|
||||
logging: *journald
|
||||
migrate:
|
||||
logging: *journald
|
||||
cli:
|
||||
logging: *journald
|
||||
api:
|
||||
logging: *journald
|
||||
collector:
|
||||
logging: *journald
|
||||
proxy:
|
||||
logging: *journald
|
||||
agent:
|
||||
logging: *journald
|
||||
39
deploy/logrotate-netpulse
Normal file
39
deploy/logrotate-netpulse
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Ротація журналів NetPulse. Ставиться в /etc/logrotate.d/netpulse.
|
||||
#
|
||||
# Без цього файла попередній стає нескінченним: rsyslog пише й пише, а
|
||||
# диск закінчується мовчки. Саме так уже було з json-file докера —
|
||||
# драйвер стояв без max-size і не крутив нічого.
|
||||
|
||||
/var/log/netpulse/*.log {
|
||||
# Щодня, і 14 копій — два тижні історії.
|
||||
#
|
||||
# Не «за розміром»: розмір ротації відповідає на питання «скільки
|
||||
# місця», а на питання «що було в ніч на вівторок» відповідає дата.
|
||||
# Друге питання ставлять частіше.
|
||||
daily
|
||||
rotate 14
|
||||
|
||||
# Стеля на випадок шторму. Два тижні звичайних журналів — десятки
|
||||
# мегабайтів, але трап-шторм або цикл перезапусків дає гігабайти за
|
||||
# години, і тоді ротація за датою не встигає.
|
||||
maxsize 50M
|
||||
|
||||
compress
|
||||
# Найсвіжіший стиснутий файл лишається як є: `zgrep` по вчорашньому
|
||||
# потрібен частіше за економію кількох мегабайтів.
|
||||
delaycompress
|
||||
|
||||
missingok
|
||||
notifempty
|
||||
# Файл створює rsyslog; без create після ротації він писав би у
|
||||
# видалений inode, і журнал зникав би до перезапуску демона.
|
||||
create 0640 root adm
|
||||
|
||||
sharedscripts
|
||||
postrotate
|
||||
# HUP, а не restart: перезапуск демона губить рядки, які
|
||||
# надійшли в мить перезапуску, — тобто рівно під час події, яка
|
||||
# найімовірніше й спричинила ріст журналу.
|
||||
/usr/lib/rsyslog/rsyslog-rotate 2>/dev/null || systemctl kill -s HUP rsyslog.service 2>/dev/null || true
|
||||
endscript
|
||||
}
|
||||
74
deploy/rsyslog-netpulse.conf
Normal file
74
deploy/rsyslog-netpulse.conf
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Журнали NetPulse звичайними файлами в /var/log/netpulse/.
|
||||
#
|
||||
# Ставиться в /etc/rsyslog.d/30-netpulse.conf
|
||||
# Разом із deploy/logrotate-netpulse і накладкою
|
||||
# deploy/docker-compose.journald.yml.
|
||||
#
|
||||
# ЧОМУ ЧИТАЄМО ЖУРНАЛ, А НЕ ПЕРЕХОПЛЮЄМО ПОТІК
|
||||
#
|
||||
# Очевидний шлях — драйвер `syslog` у докера: він шле рядки просто в
|
||||
# rsyslog. Але тоді докер їх у себе НЕ КЛАДЕ, і `docker logs` замовкає
|
||||
# назавжди. Людина, яка про це не знає, робить із мовчання висновок
|
||||
# «служба нічого не пише» — а це найгірший різновид помилки, бо він
|
||||
# виглядає як факт.
|
||||
#
|
||||
# Тому драйвер лишається journald, а rsyslog читає вже записане
|
||||
# (imjournal). Обидва шляхи працюють одночасно: `docker logs`, journalctl
|
||||
# і файли показують те саме.
|
||||
#
|
||||
# Ціна: рядок лежить у двох місцях — у журналі systemd і у файлі. На
|
||||
# стелі 1 ГБ у journald і ротації нижче це десятки мегабайтів, тобто
|
||||
# дешевше за годину пошуку причини без журналу.
|
||||
|
||||
module(load="imjournal" StateFile="imjournal-netpulse.state" Ratelimit.Interval="0")
|
||||
|
||||
# Розбираємо ТЕГ, а не метадані журналу.
|
||||
#
|
||||
# Перша спроба матчила $!CONTAINER_NAME — поле, яке journald справді
|
||||
# кладе (journalctl його бачить), але до правил rsyslog воно доходить
|
||||
# не завжди: залежить від того, чи прийшов рядок через imjournal, чи
|
||||
# через imuxsock, куди journald форвардить syslog. Каталог у нас
|
||||
# створився, правило не спрацювало жодного разу, і зовні це виглядало
|
||||
# як «rsyslog не працює» — хоча він працював, просто умова не збігалась.
|
||||
#
|
||||
# Тег `netpulse/<ім'я>` ми задаємо самі в накладці journald, і він
|
||||
# приїжджає звичайним syslog-тегом обома шляхами. Тобто умова тепер
|
||||
# спирається на те, що ми контролюємо, а не на те, що може не доїхати.
|
||||
#
|
||||
# `:10:$` бере тегу з десятого символу до кінця — рівно те, що після
|
||||
# `netpulse/`.
|
||||
template(name="netpulseLogFile" type="string"
|
||||
string="/var/log/netpulse/%programname%.log")
|
||||
|
||||
# Формат: час у ISO, ім'я служби, саме повідомлення.
|
||||
#
|
||||
# %msg% без опцій навмисно. Спершу стояло
|
||||
# `%msg:::sp-if-no-1st-sp,drop-last-lf%` — і воно давало ПОРОЖНІЙ текст:
|
||||
# файли з'являлись, рядки в них були, але після імені служби не було
|
||||
# нічого. Тобто журнал виглядав робочим і не містив жодного слова —
|
||||
# найгірший різновид поломки. Пробіл між назвою й текстом ставимо
|
||||
# самі: journald його не лишає, і без нього рядок злипається в
|
||||
# «netpulse-api-1{"time":...» — читабельно для jq, але не для ока.
|
||||
#
|
||||
# Повідомлення в NetPulse — уже JSON від slog, тож обгортати його ще
|
||||
# однією структурою не треба: `jq` має отримати рядок, який він уміє
|
||||
# прочитати. Час і служба стоять ПЕРЕД ним, щоб `tail` лишався
|
||||
# читабельним для людини, а `cut -d" " -f3-` віддавав чистий JSON.
|
||||
template(name="netpulseLine" type="string"
|
||||
string="%timereported:::date-rfc3339% %programname% %msg%\n")
|
||||
|
||||
# Умова — на ІМЕНІ КОНТЕЙНЕРА, яке докер кладе тегом (див. накладку
|
||||
# deploy/docker-compose.journald.yml). Метадані журналу ($!CONTAINER_NAME)
|
||||
# для цього не годяться: до правил rsyslog вони доходять не завжди, і
|
||||
# перша спроба на них мовчки не спрацювала.
|
||||
if ($programname startswith "netpulse-") then {
|
||||
action(type="omfile"
|
||||
dynaFile="netpulseLogFile"
|
||||
template="netpulseLine"
|
||||
dirCreateMode="0750"
|
||||
fileCreateMode="0640"
|
||||
dynaFileCacheSize="20")
|
||||
# stop, щоб ті самі рядки не поїхали ще й у /var/log/syslog: там
|
||||
# вони перемішаються з усім іншим на машині, а користі не додадуть.
|
||||
stop
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/httpapi"
|
||||
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"github.com/netpulse/netpulse/server/webui"
|
||||
)
|
||||
|
|
@ -71,6 +72,16 @@ func run() error {
|
|||
telegramBot = flag.Bool("telegram-callbacks",
|
||||
os.Getenv("NETPULSE_TELEGRAM_CALLBACKS") != "0",
|
||||
"приймати натискання кнопок під сповіщеннями Telegram (довге опитування)")
|
||||
// Типове значення — ім'я служби з docker-compose, тобто рівно та
|
||||
// адреса, за якою колектор стоїть у кожній інсталяції, зробленій
|
||||
// установником. Порожнє значення тут коштувало б дорожче:
|
||||
// сторінка журналу з коробки показувала б половину системи, і
|
||||
// побачити другу половину змогли б лише ті, хто прочитав про
|
||||
// цей прапорець. Там, де топологія інша, ім'я не розв'язується
|
||||
// за мілісекунди, і сторінка чесно каже, що саме налаштувати.
|
||||
collectorLog = flag.String("collector-log",
|
||||
envOr("NETPULSE_COLLECTOR_LOG_URL", "http://collector:9444"),
|
||||
"адреса внутрішньої ручки журналу колектора; порожньо — не питати")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
|
|
@ -78,7 +89,24 @@ func run() error {
|
|||
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
|
||||
}
|
||||
|
||||
log := newLogger(*logLevel)
|
||||
// Кільце останніх рядків власного журналу — щоб сторінку «Журнал
|
||||
// сервера» можна було відкрити в браузері замість ssh. Створюється
|
||||
// ДО логера й до всього іншого: рядки про невдалий старт (немає
|
||||
// -jwt-secret, не розібрано ключі) цікаві найбільше, а їх пише
|
||||
// найперший код.
|
||||
//
|
||||
// Секрети реєструються тут же й усі, які цей процес узагалі знає.
|
||||
// Ідея не в тому, що вони точно потраплять у журнал, — відомі шляхи
|
||||
// вже почищено в джерелі. Ідея в тому, що кільце робить журнал
|
||||
// видимим у браузері й вивантажуваним у файл, і покладатись на
|
||||
// повноту переліку місць, де хтось не забув почистити, тут не можна.
|
||||
logRing := logbuf.New(logbuf.DefaultMaxBytes)
|
||||
logRing.Mask(logbuf.DSNSecrets(*dsn)...)
|
||||
logRing.Mask(logbuf.DSNSecrets(*dsnWorker)...)
|
||||
logRing.Mask(logbuf.KeySecrets(*keysFlag)...)
|
||||
logRing.Mask(*jwtKey)
|
||||
|
||||
log := newLogger(*logLevel, logRing)
|
||||
|
||||
// Секрет обовʼязковий і не генерується автоматично: випадковий
|
||||
// ключ при кожному старті означав би, що будь-який перезапуск
|
||||
|
|
@ -122,6 +150,11 @@ func run() error {
|
|||
}
|
||||
|
||||
api := httpapi.New(st, signer, log)
|
||||
// Сторінка «Журнал сервера». Токен внутрішньої ручки колектора
|
||||
// виводиться зі спеки ключів шифрування — єдиного секрету, який в
|
||||
// обох процесів уже спільний; чому саме так — у logbuf/http.go.
|
||||
// Без -dek токен порожній, і сторінка так і скаже.
|
||||
api = api.WithServerLog(logRing, *collectorLog, logbuf.InternalToken(*keysFlag))
|
||||
// Ключі шифрування потрібні не лише каналам сповіщень: секрет
|
||||
// дзеркала конфігів лежить у тому самому core.secrets, а движок
|
||||
// алертів на інсталяції може бути вимкнений.
|
||||
|
|
@ -308,7 +341,13 @@ func envOr(key, def string) string {
|
|||
return def
|
||||
}
|
||||
|
||||
func newLogger(level string) *slog.Logger {
|
||||
// newLogger збирає логер процесу.
|
||||
//
|
||||
// Кільце ДОПОВНЮЄ вивід, а не заміняє його: JSON у stderr лишається
|
||||
// таким самим, як був, і його так само забирає docker. Це не дрібниця —
|
||||
// stderr переживає падіння процесу, а кільце ні, тож заміна одного
|
||||
// другим позбавила б інсталяцію єдиного джерела про причини аварії.
|
||||
func newLogger(level string, ring *logbuf.Ring) *slog.Logger {
|
||||
lv := slog.LevelInfo
|
||||
switch level {
|
||||
case "debug":
|
||||
|
|
@ -318,5 +357,6 @@ func newLogger(level string) *slog.Logger {
|
|||
case "error":
|
||||
lv = slog.LevelError
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lv}))
|
||||
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lv})
|
||||
return slog.New(ring.Wrap(h))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
|
@ -22,6 +23,7 @@ import (
|
|||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
||||
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
|
@ -53,6 +55,12 @@ func run() error {
|
|||
"корінь сховища версій конфігів; порожньо — без Git")
|
||||
controlEndpoint = flag.String("control-endpoint", os.Getenv("NETPULSE_CONTROL_ENDPOINT"),
|
||||
"адреса, за якою зонди мають підключатись; порожньо — та, якою вони прийшли")
|
||||
// Внутрішня ручка журналу. Порт сусідній із gRPC і назовні не
|
||||
// публікується — так само, як 9443: до нього ходить лише
|
||||
// REST-процес усередині мережі, і лише з токеном.
|
||||
internalListen = flag.String("internal-listen",
|
||||
envOr("NETPULSE_INTERNAL_LISTEN", ":9444"),
|
||||
"адреса внутрішньої ручки журналу для REST-процесу; порожньо — не слухати")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
|
|
@ -60,7 +68,22 @@ func run() error {
|
|||
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
|
||||
}
|
||||
|
||||
log := newLogger(*logLevel)
|
||||
// Кільце останніх рядків власного журналу — джерело для сторінки
|
||||
// «Журнал сервера». Саме в цьому процесі крутяться такти, які й
|
||||
// лаються вночі: диспетчер збору конфігів, розклад бекапів, обидва
|
||||
// прибиральники, закриття періодів SLA й дзеркалення на Git. Без
|
||||
// цієї половини сторінка показувала б тишу там, де щогодини падає
|
||||
// push у Forgejo.
|
||||
//
|
||||
// Секрети реєструються всі, які цей процес знає: кільце робить
|
||||
// журнал видимим у браузері й вивантажуваним у файл, і другий рубіж
|
||||
// маскування тут не зайвий — див. logbuf.Ring.Mask.
|
||||
logRing := logbuf.New(logbuf.DefaultMaxBytes)
|
||||
logRing.Mask(logbuf.DSNSecrets(*dsn)...)
|
||||
logRing.Mask(logbuf.DSNSecrets(*dsnWorker)...)
|
||||
logRing.Mask(logbuf.KeySecrets(*keysFlag)...)
|
||||
|
||||
log := newLogger(*logLevel, logRing)
|
||||
|
||||
ring, err := buildKeyring(*keysFlag)
|
||||
if err != nil {
|
||||
|
|
@ -154,6 +177,15 @@ func run() error {
|
|||
|
||||
log.Info("запуск", "version", version, "listen", *listen, "tls", !*insecure)
|
||||
|
||||
// Внутрішня ручка журналу — окремим слухачем і НЕ фатально.
|
||||
//
|
||||
// Зайнятий порт, відсутній -dek, будь-яка інша причина — усе це має
|
||||
// коштувати рівно одного попередження й порожньої половини на
|
||||
// сторінці. Збір даних із мережі не повинен не піднятись через
|
||||
// зручність перегляду власного журналу; той самий принцип, що й у
|
||||
// перевірці ліцензії в REST-процесі.
|
||||
serveInternalLog(ctx, logRing, *internalListen, logbuf.InternalToken(*keysFlag), log)
|
||||
|
||||
// Диспетчер збору конфігів: черга наповнюється REST-процесом, а
|
||||
// живі сесії зондів тримає саме цей.
|
||||
go svc.DispatchConfigJobs(ctx, 5*time.Second)
|
||||
|
|
@ -212,7 +244,13 @@ func envOr(key, def string) string {
|
|||
return def
|
||||
}
|
||||
|
||||
func newLogger(level string) *slog.Logger {
|
||||
// newLogger збирає логер процесу.
|
||||
//
|
||||
// Кільце ДОПОВНЮЄ вивід, а не заміняє його: JSON у stderr лишається
|
||||
// таким самим, як був, і його так само забирає docker. Це не дрібниця —
|
||||
// stderr переживає падіння процесу, а кільце ні, тож заміна одного
|
||||
// другим позбавила б інсталяцію єдиного джерела про причини аварії.
|
||||
func newLogger(level string, ring *logbuf.Ring) *slog.Logger {
|
||||
lv := slog.LevelInfo
|
||||
switch level {
|
||||
case "debug":
|
||||
|
|
@ -222,7 +260,53 @@ func newLogger(level string) *slog.Logger {
|
|||
case "error":
|
||||
lv = slog.LevelError
|
||||
}
|
||||
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lv}))
|
||||
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lv})
|
||||
return slog.New(ring.Wrap(h))
|
||||
}
|
||||
|
||||
// serveInternalLog піднімає внутрішню ручку журналу.
|
||||
//
|
||||
// Мовчазна відмова тут неприпустима, тому кожна причина «не піднялось»
|
||||
// їде в журнал окремим рядком: сторінка покаже «джерело недоступне», і
|
||||
// пояснення до цього має бути хоч десь. А от зупиняти процес жодна з
|
||||
// них не має права.
|
||||
func serveInternalLog(ctx context.Context, ring *logbuf.Ring, addr, token string,
|
||||
log *slog.Logger) {
|
||||
|
||||
if addr == "" {
|
||||
log.Info("внутрішня ручка журналу вимкнена (-internal-listen порожній)")
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
log.Warn("внутрішня ручка журналу не піднята: немає -dek, " +
|
||||
"а токен виводиться саме з нього")
|
||||
return
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Warn("внутрішня ручка журналу не піднята", "addr", addr, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(logbuf.InternalPath, ring.HTTPHandler("collector", token))
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := srv.Serve(lis); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Warn("внутрішня ручка журналу зупинилась", "err", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = srv.Close()
|
||||
}()
|
||||
|
||||
log.Info("внутрішня ручка журналу", "addr", addr, "path", logbuf.InternalPath)
|
||||
}
|
||||
|
||||
// buildKeyring розбирає ключі шифрування.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||
"github.com/netpulse/netpulse/server/internal/auth"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -41,6 +42,14 @@ type Server struct {
|
|||
// каже, що перевірити ключ нічим. Мовчазна відмова тут була б
|
||||
// гіршою за відсутність можливості.
|
||||
license *store.LicenseVerifier
|
||||
|
||||
// Кільце останніх рядків власного журналу цього процесу й адреса
|
||||
// такого самого кільця в колекторі. nil і порожньо — робочий стан:
|
||||
// сторінка журналу тоді чесно каже, чого саме бракує. Див.
|
||||
// serverlog.go.
|
||||
logRing *logbuf.Ring
|
||||
collectorLogURL string
|
||||
collectorLogToken string
|
||||
}
|
||||
|
||||
// New створює сервер. signer може бути nil лише в тестах, які не
|
||||
|
|
@ -372,6 +381,12 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("GET /api/v1/sla/targets/{id}/report.csv", s.authenticated(s.handleSLAReportCSV))
|
||||
mux.Handle("POST /api/v1/sla/targets/{id}/close", s.authenticated(s.handleCloseSLAPeriod))
|
||||
|
||||
// Журнал самого сервера — те, на що лається NetPulse, а не мережа.
|
||||
// Під settings:write: це системний журнал з адресами, іменами
|
||||
// хостів і текстами помилок SQL, і бачити його має рівно той, хто й
|
||||
// так бачить усе. Див. serverlog.go.
|
||||
mux.Handle("GET /api/v1/server-log", s.authenticated(s.handleServerLog))
|
||||
|
||||
mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit))
|
||||
mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta))
|
||||
mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent))
|
||||
|
|
|
|||
247
server/internal/httpapi/serverlog.go
Normal file
247
server/internal/httpapi/serverlog.go
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||
)
|
||||
|
||||
// Журнал самого сервера: на що лається NetPulse, коли лається він, а не
|
||||
// мережа.
|
||||
//
|
||||
// ПРО ПРАВА
|
||||
//
|
||||
// settings:write, і нового права не заводиться.
|
||||
//
|
||||
// Звірено з наявним переліком (core.permissions, міграції 0010 і 0053).
|
||||
// Придатних кандидатів там рівно два. audit:read — «читати журнал
|
||||
// аудиту», і воно НЕ підходить: аудит відповідає на питання «хто що
|
||||
// зробив», його дають внутрішньому контролеру, і давати разом із ним
|
||||
// тексти помилок SQL і внутрішні адреси означало б розширити те право
|
||||
// мовчки. settings:write — «Змінювати налаштування організації», і воно
|
||||
// є лише у власника й адміна (0064 спирається на це прямо). Той, хто
|
||||
// може змінити строки зберігання даних і викинути піврічну історію,
|
||||
// уже бачить усе, що можна побачити; додати йому системний журнал —
|
||||
// не розширення доступу, а виправлення того, що він досі не міг
|
||||
// подивитись, не маючи ssh.
|
||||
//
|
||||
// Право на ЗАПИС для сторінки, яка лише читає, виглядає дивно рівно
|
||||
// доти, доки не спитати, що воно насправді означає в цьому продукті:
|
||||
// «це власник інсталяції». Заводити читальний двійник settings:read
|
||||
// заради одної сторінки — це нове право в переліку ролей, нова галочка
|
||||
// в редакторі ролей і нова міграція; коштує це дорожче, ніж дає.
|
||||
//
|
||||
// ЧОГО ТУТ НЕМАЄ Й НЕ БУДЕ
|
||||
//
|
||||
// Ані запису в базу, ані фільтра по кабінету. Журнал процесу — це
|
||||
// журнал ІНСТАЛЯЦІЇ, а не кабінету: один такт прибиральника обслуговує
|
||||
// всі кабінети одразу, і розділити його рядки між ними неможливо. Тому
|
||||
// сторінка доступна власникові кабінету, але показує спільне — і саме
|
||||
// тому нею не можна користуватись на спільному хостингу кількох
|
||||
// клієнтів. На інсталяції в клієнта (а це і є коробковий продукт)
|
||||
// кабінет один, і питання не виникає.
|
||||
|
||||
const (
|
||||
// serverLogPerm — див. розбір прав вище.
|
||||
serverLogPerm = "settings:write"
|
||||
|
||||
// Скільки рядків віддавати за раз.
|
||||
//
|
||||
// Фільтр накладається на сервері ДО стелі, і це не оптимізація, а
|
||||
// сенс: «останні 500 рядків, серед яких три помилки» і «останні 500
|
||||
// помилок» — різні відповіді, а людина, яка обрала рівень «error»,
|
||||
// питає друге.
|
||||
serverLogLimit = 500
|
||||
serverLogMaxLimit = 5000
|
||||
|
||||
// Скільки чекати на сусідній процес. Три секунди: більше — і
|
||||
// сторінка підвисає рівно тоді, коли по неї прийшли; менше — і
|
||||
// завантажений колектор не встигав би відповісти.
|
||||
serverLogTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// serverLogSource — стан одного джерела для сторінки.
|
||||
//
|
||||
// Помилка живе тут, а не в загальному полі відповіді, з тієї самої
|
||||
// причини, з якої в StorageAlertState є Blind: «джерело мовчить» і
|
||||
// «джерело недоступне» на екрані виглядають однаково, а означають
|
||||
// протилежне. Без цього поля порожній колектор читався б як «у
|
||||
// колекторі все тихо».
|
||||
type serverLogSource struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
Records int `json:"records"`
|
||||
Bytes int `json:"bytes"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
Dropped uint64 `json:"dropped"`
|
||||
Since time.Time `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// WithServerLog вмикає сторінку журналу сервера.
|
||||
//
|
||||
// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі
|
||||
// шифрування чи ліцензія: тести й частина інсталяцій кільця не мають, і
|
||||
// сторінка тоді має чесно відповісти 503, а не впасти.
|
||||
//
|
||||
// collectorURL порожній — робочий стан: журнал колектора просто не
|
||||
// показується, і сторінка каже, чому саме.
|
||||
func (s *Server) WithServerLog(ring *logbuf.Ring, collectorURL, token string) *Server {
|
||||
s.logRing = ring
|
||||
s.collectorLogURL = strings.TrimSpace(collectorURL)
|
||||
s.collectorLogToken = token
|
||||
return s
|
||||
}
|
||||
|
||||
// handleServerLog віддає злитий журнал обох процесів, найсвіжіші зверху.
|
||||
func (s *Server) handleServerLog(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, serverLogPerm) {
|
||||
return
|
||||
}
|
||||
if s.logRing == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "no_log_buffer",
|
||||
"кільцевий буфер журналу не увімкнено в цій збірці")
|
||||
return
|
||||
}
|
||||
|
||||
minLevel, levelOK := parseLogLevel(r.URL.Query().Get("level"))
|
||||
if !levelOK {
|
||||
writeError(w, http.StatusBadRequest, "bad_level", "рівень має бути error, warn або info")
|
||||
return
|
||||
}
|
||||
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
limit := serverLogLimit
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
|
||||
limit = min(v, serverLogMaxLimit)
|
||||
}
|
||||
|
||||
snaps := []logbuf.Snapshot{s.logRing.Snapshot("api")}
|
||||
sources := []serverLogSource{describeSource("api", "API", snaps[0], nil)}
|
||||
|
||||
// Колектор питається завжди, коли адреса є: кешувати тут нема чого,
|
||||
// сторінку відкриває одна людина й дивиться на неї хвилину, а
|
||||
// застарілий на п'ять секунд журнал у момент аварії — це рівно ті
|
||||
// п'ять секунд, яких бракує.
|
||||
if s.collectorLogURL != "" {
|
||||
snap, err := logbuf.Fetch(r.Context(), s.collectorLogURL, s.collectorLogToken, serverLogTimeout)
|
||||
sources = append(sources, describeSource("collector", "Колектор", snap, err))
|
||||
if err == nil {
|
||||
snaps = append(snaps, snap)
|
||||
} else {
|
||||
// У власний журнал це не пишеться навмисно: сторінку
|
||||
// оновлюють раз на кілька секунд, і недоступний сусід за
|
||||
// хвилину залив би кільце сотнею однакових рядків — тобто
|
||||
// витіснив би саме те, по що людина прийшла.
|
||||
s.log.Debug("журнал колектора недоступний", "url", s.collectorLogURL, "err", err)
|
||||
}
|
||||
} else {
|
||||
sources = append(sources, serverLogSource{
|
||||
Key: "collector", Label: "Колектор",
|
||||
Error: "адресу колектора не налаштовано (-collector-log або NETPULSE_COLLECTOR_LOG_URL)",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"records": mergeLog(snaps, minLevel, query, limit),
|
||||
"sources": sources,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// describeSource зводить знімок і помилку в один опис для сторінки.
|
||||
func describeSource(key, label string, snap logbuf.Snapshot, err error) serverLogSource {
|
||||
if err != nil {
|
||||
return serverLogSource{Key: key, Label: label, Error: err.Error()}
|
||||
}
|
||||
return serverLogSource{
|
||||
Key: key, Label: label, OK: true,
|
||||
Records: len(snap.Records),
|
||||
Bytes: snap.Bytes,
|
||||
MaxBytes: snap.MaxBytes,
|
||||
Dropped: snap.Dropped,
|
||||
Since: snap.Since,
|
||||
}
|
||||
}
|
||||
|
||||
// parseLogLevel читає фільтр рівня як НИЖНЮ МЕЖУ.
|
||||
//
|
||||
// Саме межу, а не точний збіг: людина, яка обрала «warn», питає «що
|
||||
// пішло не так», і ховати від неї error було б буквальним виконанням
|
||||
// букви запиту проти його змісту.
|
||||
//
|
||||
// Порожнє значення — «усе», і сюди ж потрапляє debug: він вмикається
|
||||
// прапорцем процесу, а не сторінкою, і якщо вже ввімкнений — має бути
|
||||
// видним.
|
||||
func parseLogLevel(s string) (slog.Level, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "all", "debug":
|
||||
return slog.LevelDebug, true
|
||||
case "info":
|
||||
return slog.LevelInfo, true
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, true
|
||||
case "error":
|
||||
return slog.LevelError, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// mergeLog зливає знімки процесів в один перелік, найсвіжіші зверху.
|
||||
//
|
||||
// Порядок — за часом, а не за джерелом: людина шукає, ЩО сталося о
|
||||
// третій ночі, а не що робив кожен процес окремо. Номер запису
|
||||
// розв'язує збіги в межах одного джерела: два рядки в одну наносекунду
|
||||
// цілком трапляються, і без цього вони переставлялися б місцями на
|
||||
// кожному оновленні сторінки — рух, який виглядає як нові події.
|
||||
func mergeLog(snaps []logbuf.Snapshot, minLevel slog.Level, query string, limit int) []logbuf.Record {
|
||||
out := make([]logbuf.Record, 0, limit)
|
||||
for _, snap := range snaps {
|
||||
for _, rec := range snap.Records {
|
||||
if !matchLog(rec, minLevel, query) {
|
||||
continue
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if !out[i].Time.Equal(out[j].Time) {
|
||||
return out[i].Time.After(out[j].Time)
|
||||
}
|
||||
if out[i].Source != out[j].Source {
|
||||
return out[i].Source < out[j].Source
|
||||
}
|
||||
return out[i].Seq > out[j].Seq
|
||||
})
|
||||
|
||||
// Стеля накладається після злиття й сортування: інакше «останні 500»
|
||||
// означало б 500 з кожного процесу, тобто тисячу, і найсвіжіші рядки
|
||||
// одного могли б випасти на користь старих рядків другого.
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchLog — чи проходить запис фільтр.
|
||||
//
|
||||
// Пошук іде і по повідомленню, і по атрибутах: імена хостів, адреси й
|
||||
// тексти помилок живуть саме в атрибутах, а шукають зазвичай саме їх.
|
||||
func matchLog(rec logbuf.Record, minLevel slog.Level, query string) bool {
|
||||
var lv slog.Level
|
||||
if err := lv.UnmarshalText([]byte(rec.Level)); err == nil && lv < minLevel {
|
||||
return false
|
||||
}
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(rec.Msg), query) ||
|
||||
strings.Contains(strings.ToLower(rec.Attrs), query)
|
||||
}
|
||||
165
server/internal/logbuf/http.go
Normal file
165
server/internal/logbuf/http.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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 не дає з
|
||||
// нього дістати сам ключ.
|
||||
|
||||
// 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
|
||||
}
|
||||
480
server/internal/logbuf/logbuf.go
Normal file
480
server/internal/logbuf/logbuf.go
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
// 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
|
||||
}
|
||||
455
server/internal/logbuf/logbuf_test.go
Normal file
455
server/internal/logbuf/logbuf_test.go
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
package logbuf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ І ЧОМУ САМЕ ЦЕ
|
||||
//
|
||||
// Урок проєкту, дослівно: зелена перевірка доводить рівно те, що вона
|
||||
// перевіряє. Тест ізоляції RLS був правильний і зелений — і пропустив
|
||||
// зламаний вхід, бо перевіряв «чи не видно чужого» тоді, коли зламалось
|
||||
// «чи видно своє».
|
||||
//
|
||||
// Для кільця дзеркальна пастка виглядає так: тест «старі записи
|
||||
// витісняються» зелений і на кільці, яке викидає ВСЕ; тест «секрет
|
||||
// замаскований» зелений і на кільці, яке не пише нічого. Тому кожна
|
||||
// перевірка нижче має обидві половини — що зникло І що лишилось.
|
||||
//
|
||||
// Найважливіший тест у файлі — TestWrapKeepsStderr. Кільце має
|
||||
// ДОПОВНЮВАТИ вивід, а не заміняти його: stderr переживає падіння
|
||||
// процесу, кільце ні. Поломка «журнал є на сторінці, зник у docker
|
||||
// logs» виглядає як робоча система рівно доти, доки хтось не піде
|
||||
// шукати причину аварії — тобто виявиться в найгіршу мить.
|
||||
|
||||
// collect — логер, який пише і в кільце, і в перевіряний буфер.
|
||||
func collect(t *testing.T, max int) (*slog.Logger, *Ring, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
ring := New(max)
|
||||
h := slog.NewJSONHandler(&out, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
return slog.New(ring.Wrap(h)), ring, &out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Витіснення
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestEvictsOldestOnOverflow(t *testing.T) {
|
||||
// Кільце рівно на MaxRecordBytes: беремо мінімум, який дозволяє New,
|
||||
// щоб переповнити його десятком коротких рядків, а не мільйоном.
|
||||
log, ring, _ := collect(t, MaxRecordBytes)
|
||||
|
||||
// Один запис важить len(msg)+len(attrs)+recordOverhead. При
|
||||
// повідомленні на ~10 байтів це ~140, тобто в 8 КіБ їх влазить
|
||||
// близько шістдесяти. Пишемо помітно більше.
|
||||
const n = 400
|
||||
for i := range n {
|
||||
log.Info("рядок", "i", i)
|
||||
}
|
||||
|
||||
snap := ring.Snapshot("test")
|
||||
if len(snap.Records) == 0 {
|
||||
t.Fatal("кільце порожнє: витіснення викинуло все — це не витіснення, а стирання")
|
||||
}
|
||||
if snap.Dropped == 0 {
|
||||
t.Fatal("нічого не витіснено, хоча записів свідомо більше за стелю")
|
||||
}
|
||||
if snap.Bytes > snap.MaxBytes {
|
||||
t.Fatalf("стелю перевищено: %d > %d", snap.Bytes, snap.MaxBytes)
|
||||
}
|
||||
if int(snap.Dropped)+len(snap.Records) != n {
|
||||
t.Fatalf("записи загубились: витіснено %d + лишилось %d ≠ %d",
|
||||
snap.Dropped, len(snap.Records), n)
|
||||
}
|
||||
|
||||
// ПЕРША ПОЛОВИНА: найсвіжіший рядок на місці. Без цієї перевірки
|
||||
// зеленим був би й буфер, який викидає найновіше замість найстарішого.
|
||||
if got := snap.Records[0].Attrs; got != "i=399" {
|
||||
t.Fatalf("зверху має бути найсвіжіший запис, а там %q", got)
|
||||
}
|
||||
// ДРУГА ПОЛОВИНА: найстаріший зник саме тому, що не влазить.
|
||||
last := snap.Records[len(snap.Records)-1]
|
||||
if last.Attrs == "i=0" {
|
||||
t.Fatal("найстаріший запис не витіснено, хоча стеля перевищена")
|
||||
}
|
||||
|
||||
// Порядок усередині знімка — суворо від нового до старого.
|
||||
for i := 1; i < len(snap.Records); i++ {
|
||||
if snap.Records[i-1].Seq <= snap.Records[i].Seq {
|
||||
t.Fatalf("порядок порушено на %d: %d після %d",
|
||||
i, snap.Records[i].Seq, snap.Records[i-1].Seq)
|
||||
}
|
||||
}
|
||||
|
||||
// Since має переїхати на найстаріший із тих, що лишились: інакше
|
||||
// сторінка казала б «журнал від старту процесу» там, де початок уже
|
||||
// витіснено.
|
||||
if !snap.Since.Equal(last.Time) {
|
||||
t.Fatalf("Since не переїхав: %v замість %v", snap.Since, last.Time)
|
||||
}
|
||||
}
|
||||
|
||||
// Один довжелезний рядок не має виносити з кільця все інше — заради
|
||||
// цього й існує MaxRecordBytes.
|
||||
func TestHugeRecordDoesNotEmptyRing(t *testing.T) {
|
||||
log, ring, _ := collect(t, 64<<10)
|
||||
|
||||
for i := range 20 {
|
||||
log.Info("звичайний", "i", i)
|
||||
}
|
||||
log.Error("велика помилка", "sql", strings.Repeat("x", 4<<20))
|
||||
|
||||
snap := ring.Snapshot("test")
|
||||
if len(snap.Records) < 10 {
|
||||
t.Fatalf("один довгий рядок вимів кільце: лишилось %d", len(snap.Records))
|
||||
}
|
||||
top := snap.Records[0]
|
||||
if !strings.HasSuffix(top.Attrs, truncMark) {
|
||||
t.Fatalf("довгий запис не обрізано: %d байтів без позначки", len(top.Attrs))
|
||||
}
|
||||
if len(top.Msg)+len(top.Attrs) > MaxRecordBytes {
|
||||
t.Fatalf("обрізано не до стелі: %d", len(top.Msg)+len(top.Attrs))
|
||||
}
|
||||
// Обрізаний запис має лишатись корисним: повідомлення ціле, і з
|
||||
// нього видно, ЩО сталося.
|
||||
if top.Msg != "велика помилка" {
|
||||
t.Fatalf("повідомлення постраждало: %q", top.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Вивід у stderr не зникає
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestWrapKeepsStderr(t *testing.T) {
|
||||
log, ring, out := collect(t, DefaultMaxBytes)
|
||||
|
||||
log.With("процес", "api").WithGroup("db").Info("привіт", "хост", "db:5432")
|
||||
|
||||
// Нижній обробник відпрацював і віддав свій JSON — рівно те, що
|
||||
// забирає docker.
|
||||
var line map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &line); err != nil {
|
||||
t.Fatalf("stderr не отримав JSON: %v (%q)", err, out.String())
|
||||
}
|
||||
if line["msg"] != "привіт" {
|
||||
t.Fatalf("stderr отримав не те: %v", line)
|
||||
}
|
||||
if line["процес"] != "api" {
|
||||
t.Fatalf("атрибути WithAttrs не доїхали до stderr: %v", line)
|
||||
}
|
||||
if g, ok := line["db"].(map[string]any); !ok || g["хост"] != "db:5432" {
|
||||
t.Fatalf("група не доїхала до stderr: %v", line)
|
||||
}
|
||||
|
||||
// І та сама подія лежить у кільці — тобто це доповнення, а не заміна.
|
||||
snap := ring.Snapshot("api")
|
||||
if len(snap.Records) != 1 {
|
||||
t.Fatalf("у кільці %d записів замість одного", len(snap.Records))
|
||||
}
|
||||
rec := snap.Records[0]
|
||||
if rec.Msg != "привіт" || rec.Level != "INFO" || rec.Source != "api" {
|
||||
t.Fatalf("запис зіпсовано: %+v", rec)
|
||||
}
|
||||
if rec.Attrs != `процес=api db.хост=db:5432` {
|
||||
t.Fatalf("атрибути зведено неправильно: %q", rec.Attrs)
|
||||
}
|
||||
}
|
||||
|
||||
// Помилка нижнього обробника не має ані ковтатись, ані заважати кільцю.
|
||||
func TestWrapPropagatesHandlerError(t *testing.T) {
|
||||
ring := New(DefaultMaxBytes)
|
||||
want := errors.New("stderr зайнято")
|
||||
h := ring.Wrap(failingHandler{err: want})
|
||||
|
||||
var rec slog.Record
|
||||
rec = slog.NewRecord(time.Now(), slog.LevelWarn, "щось", 0)
|
||||
if err := h.Handle(context.Background(), rec); !errors.Is(err, want) {
|
||||
t.Fatalf("помилку нижнього обробника проковтнуто: %v", err)
|
||||
}
|
||||
if got := len(ring.Snapshot("x").Records); got != 1 {
|
||||
t.Fatalf("запис не потрапив у кільце попри збій stderr: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type failingHandler struct{ err error }
|
||||
|
||||
func (h failingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
func (h failingHandler) Handle(context.Context, slog.Record) error { return h.err }
|
||||
func (h failingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
||||
func (h failingHandler) WithGroup(string) slog.Handler { return h }
|
||||
|
||||
// Логери, породжені від одного, не мають затирати атрибути один одному.
|
||||
// Це класична пастка спільного хвоста слайса: помилка тиха й з'являється
|
||||
// лише тоді, коли з базового логера зробили двох дітей.
|
||||
func TestWithAttrsDoesNotShareTail(t *testing.T) {
|
||||
log, ring, _ := collect(t, DefaultMaxBytes)
|
||||
|
||||
base := log.With("спільне", "так")
|
||||
base.With("гілка", "перша").Info("а")
|
||||
base.With("гілка", "друга").Info("б")
|
||||
|
||||
snap := ring.Snapshot("test")
|
||||
if len(snap.Records) != 2 {
|
||||
t.Fatalf("записів %d", len(snap.Records))
|
||||
}
|
||||
if snap.Records[0].Attrs != "спільне=так гілка=друга" {
|
||||
t.Fatalf("другий запис: %q", snap.Records[0].Attrs)
|
||||
}
|
||||
if snap.Records[1].Attrs != "спільне=так гілка=перша" {
|
||||
t.Fatalf("перший запис: %q", snap.Records[1].Attrs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Маскування
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestMaskHidesSecretsKeepsContext(t *testing.T) {
|
||||
const pw = "s3cret-pass-word"
|
||||
const dsn = "postgres://netpulse:" + pw + "@db:5432/netpulse?sslmode=disable"
|
||||
const dek = "k1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
log, ring, out := collect(t, DefaultMaxBytes)
|
||||
ring.Mask(DSNSecrets(dsn)...)
|
||||
ring.Mask(KeySecrets(dek)...)
|
||||
|
||||
log.Error("підключення до БД", "dsn", dsn,
|
||||
"err", "cannot parse `"+dsn+"`: invalid port")
|
||||
log.Error("дзеркало", "url", "https://netpulse:glpat-AbCdEf0123456789@forgejo.example/np.git")
|
||||
log.Error("ключі", "spec", dek)
|
||||
|
||||
snap := ring.Snapshot("test")
|
||||
all := ""
|
||||
for _, rec := range snap.Records {
|
||||
all += rec.Msg + " " + rec.Attrs + "\n"
|
||||
}
|
||||
|
||||
// ПЕРША ПОЛОВИНА: секретів немає.
|
||||
for _, bad := range []string{pw, "glpat-AbCdEf0123456789",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"} {
|
||||
if strings.Contains(all, bad) {
|
||||
t.Fatalf("секрет %q протік у кільце:\n%s", bad, all)
|
||||
}
|
||||
}
|
||||
|
||||
// ДРУГА ПОЛОВИНА, і без неї тест зелений на кільці, яке взагалі
|
||||
// нічого не пише: контекст, заради якого журнал і читають, лишився.
|
||||
for _, want := range []string{"db:5432", "invalid port", "forgejo.example/np.git",
|
||||
"підключення до БД", "дзеркало"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Fatalf("маскування знесло потрібне (%q):\n%s", want, all)
|
||||
}
|
||||
}
|
||||
|
||||
// Маскування стосується КІЛЬЦЯ, а не stderr: у docker logs журнал
|
||||
// має лишитись таким, яким був. Інакше ця зміна тихо переписала б
|
||||
// поведінку, на яку ніхто не підписувався.
|
||||
if !strings.Contains(out.String(), pw) {
|
||||
t.Fatal("stderr теж почистили — це вже не доповнення, а зміна поведінки")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSNSecrets(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"postgres://u:pa55word@db/netpulse", "pa55word"},
|
||||
{"host=db user=netpulse password=pa55word dbname=netpulse", "pa55word"},
|
||||
{"postgres://u@db/netpulse", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := DSNSecrets(c.in)
|
||||
if c.want == "" {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("%q: вигадано секрет %v", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) != 1 || got[0] != c.want {
|
||||
t.Fatalf("%q: %v замість [%q]", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Одночасний запис і читання
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Гонка тут не гіпотетична: у кільце пишуть усі такти обох процесів, а
|
||||
// читає його HTTP-обробник у чужій горутині — тобто одночасний доступ є
|
||||
// в найпершу ж секунду роботи.
|
||||
//
|
||||
// Прогін під -race доводить відсутність гонки; без нього (CGO_ENABLED=0,
|
||||
// збірка без компілятора C) цей тест усе одно ловить головне: зрив
|
||||
// інваріантів. Незамкнений доступ до слайса, що ріжеться з голови,
|
||||
// дає або паніку на індексі, або знімок із порожніми записами, або
|
||||
// розбіжність обліку байтів.
|
||||
func TestConcurrentWriteAndRead(t *testing.T) {
|
||||
ring := New(64 << 10)
|
||||
log := slog.New(ring.Wrap(slog.NewJSONHandler(discard{}, nil)))
|
||||
|
||||
const writers, perWriter, readers = 8, 500, 4
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for w := range writers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range perWriter {
|
||||
log.Info("такт", "writer", w, "i", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for range readers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
snap := ring.Snapshot("test")
|
||||
var sum int
|
||||
for i, rec := range snap.Records {
|
||||
if rec.Seq == 0 || rec.Level == "" {
|
||||
t.Errorf("порожній запис у знімку на позиції %d", i)
|
||||
return
|
||||
}
|
||||
if i > 0 && snap.Records[i-1].Seq <= rec.Seq {
|
||||
t.Errorf("порядок порушено: %d після %d",
|
||||
rec.Seq, snap.Records[i-1].Seq)
|
||||
return
|
||||
}
|
||||
sum += rec.size()
|
||||
}
|
||||
// Облік байтів має збігатися з тим, що справді лежить у
|
||||
// кільці: розбіжність означала б, що витіснення й
|
||||
// додавання бачать різні стани.
|
||||
if sum != snap.Bytes {
|
||||
t.Errorf("облік байтів розійшовся: %d за записами, %d у лічильнику",
|
||||
sum, snap.Bytes)
|
||||
return
|
||||
}
|
||||
if snap.Bytes > snap.MaxBytes {
|
||||
t.Errorf("стелю перевищено під навантаженням: %d > %d",
|
||||
snap.Bytes, snap.MaxBytes)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Читачі спиняються після письменників, а не разом із ними: останній
|
||||
// знімок має застати кільце вже в спокої.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
close(stop)
|
||||
}()
|
||||
<-done
|
||||
|
||||
snap := ring.Snapshot("test")
|
||||
if int(snap.Dropped)+len(snap.Records) != writers*perWriter {
|
||||
t.Fatalf("записи загубились: %d + %d ≠ %d",
|
||||
snap.Dropped, len(snap.Records), writers*perWriter)
|
||||
}
|
||||
}
|
||||
|
||||
type discard struct{}
|
||||
|
||||
func (discard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Внутрішня ручка
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestInternalHandlerRequiresToken(t *testing.T) {
|
||||
ring := New(DefaultMaxBytes)
|
||||
slog.New(ring.Wrap(slog.NewJSONHandler(discard{}, nil))).Info("такт")
|
||||
|
||||
const dek = "k1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
token := InternalToken(dek)
|
||||
if token == "" {
|
||||
t.Fatal("токен не виведено")
|
||||
}
|
||||
if InternalToken("") != "" {
|
||||
t.Fatal("порожній секрет має давати порожній токен — інакше це відчинені двері")
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(ring.HTTPHandler("collector", token))
|
||||
defer srv.Close()
|
||||
|
||||
// БЕЗ токена — відмова.
|
||||
res, err := http.Get(srv.URL + InternalPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("без токена віддано %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// З ЧУЖИМ токеном — теж відмова.
|
||||
if _, err := Fetch(context.Background(), srv.URL, InternalToken("k1=beef"), time.Second); err == nil {
|
||||
t.Fatal("чужий токен прийнято")
|
||||
}
|
||||
|
||||
// ЗІ СВОЇМ — знімок доїжджає цілим.
|
||||
snap, err := Fetch(context.Background(), srv.URL, token, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("свій токен відхилено: %v", err)
|
||||
}
|
||||
if snap.Source != "collector" || len(snap.Records) != 1 || snap.Records[0].Msg != "такт" {
|
||||
t.Fatalf("знімок доїхав зіпсованим: %+v", snap)
|
||||
}
|
||||
if snap.MaxBytes != DefaultMaxBytes {
|
||||
t.Fatalf("стеля не доїхала: %d", snap.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Ручка без токена має бути зачиненою, а не відчиненою: інсталяція без
|
||||
// -dek існує, і мовчазне зняття перевірки на ній було б найгіршим із
|
||||
// можливих наслідків порожньої змінної.
|
||||
func TestInternalHandlerWithoutTokenIsClosed(t *testing.T) {
|
||||
ring := New(DefaultMaxBytes)
|
||||
srv := httptest.NewServer(ring.HTTPHandler("collector", ""))
|
||||
defer srv.Close()
|
||||
|
||||
for _, auth := range []string{"", "Bearer ", "Bearer щось"} {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+InternalPath, nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("з %q віддано %d", auth, res.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,7 +80,27 @@ export const MAP_W = 180
|
|||
* кориснішою від того, що вміщає більше.
|
||||
*/
|
||||
const ROW_PX = 3
|
||||
const BAR_PX = 2
|
||||
|
||||
/**
|
||||
* Товщина штриха рядка. Один піксель, а не два — і це не косметика.
|
||||
*
|
||||
* При двох просвіт між сусідніми рядками лишався один піксель. На екрані
|
||||
* зі звичайним масштабом це ще працювало, а на дробовому (1.25, 1.5 —
|
||||
* тобто на більшості ноутбуків) єдиний піксель просвіту губився при
|
||||
* округленні, і рядки злипались у суцільну пляму. Мінікарта показувала
|
||||
* не форму конфігу, а сірий прямокутник — тобто рівно те, заради чого
|
||||
* її не варто малювати взагалі.
|
||||
*
|
||||
* Один піксель штриха при трьох на рядок дає просвіт удвічі товщий за
|
||||
* сам штрих. Він переживає будь-яке округлення: навіть якщо штрих і
|
||||
* просвіт з'їдуть на півпікселя, між рядками лишиться видима межа.
|
||||
*
|
||||
* Мінікарта від цього стає блідішою — і це правильний бік розміну.
|
||||
* Блідий, але читабельний малюнок кращий за яскраву пляму: на неї
|
||||
* дивляться, щоб побачити СТРУКТУРУ (де розділи, де порожні рядки, де
|
||||
* знайдене), а не щоб прочитати текст.
|
||||
*/
|
||||
const BAR_PX = 1
|
||||
|
||||
/** Ширина колонки позначок по всьому документу (режим прокрутки). */
|
||||
const GUTTER_W = 6
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue