Журнали файлами, тонший штрих мінікарти
ФАЙЛИ В /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/crypto"
|
||||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||||
"github.com/netpulse/netpulse/server/internal/httpapi"
|
"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/internal/store"
|
||||||
"github.com/netpulse/netpulse/server/webui"
|
"github.com/netpulse/netpulse/server/webui"
|
||||||
)
|
)
|
||||||
|
|
@ -71,6 +72,16 @@ func run() error {
|
||||||
telegramBot = flag.Bool("telegram-callbacks",
|
telegramBot = flag.Bool("telegram-callbacks",
|
||||||
os.Getenv("NETPULSE_TELEGRAM_CALLBACKS") != "0",
|
os.Getenv("NETPULSE_TELEGRAM_CALLBACKS") != "0",
|
||||||
"приймати натискання кнопок під сповіщеннями Telegram (довге опитування)")
|
"приймати натискання кнопок під сповіщеннями Telegram (довге опитування)")
|
||||||
|
// Типове значення — ім'я служби з docker-compose, тобто рівно та
|
||||||
|
// адреса, за якою колектор стоїть у кожній інсталяції, зробленій
|
||||||
|
// установником. Порожнє значення тут коштувало б дорожче:
|
||||||
|
// сторінка журналу з коробки показувала б половину системи, і
|
||||||
|
// побачити другу половину змогли б лише ті, хто прочитав про
|
||||||
|
// цей прапорець. Там, де топологія інша, ім'я не розв'язується
|
||||||
|
// за мілісекунди, і сторінка чесно каже, що саме налаштувати.
|
||||||
|
collectorLog = flag.String("collector-log",
|
||||||
|
envOr("NETPULSE_COLLECTOR_LOG_URL", "http://collector:9444"),
|
||||||
|
"адреса внутрішньої ручки журналу колектора; порожньо — не питати")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -78,7 +89,24 @@ func run() error {
|
||||||
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
|
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)
|
api := httpapi.New(st, signer, log)
|
||||||
|
// Сторінка «Журнал сервера». Токен внутрішньої ручки колектора
|
||||||
|
// виводиться зі спеки ключів шифрування — єдиного секрету, який в
|
||||||
|
// обох процесів уже спільний; чому саме так — у logbuf/http.go.
|
||||||
|
// Без -dek токен порожній, і сторінка так і скаже.
|
||||||
|
api = api.WithServerLog(logRing, *collectorLog, logbuf.InternalToken(*keysFlag))
|
||||||
// Ключі шифрування потрібні не лише каналам сповіщень: секрет
|
// Ключі шифрування потрібні не лише каналам сповіщень: секрет
|
||||||
// дзеркала конфігів лежить у тому самому core.secrets, а движок
|
// дзеркала конфігів лежить у тому самому core.secrets, а движок
|
||||||
// алертів на інсталяції може бути вимкнений.
|
// алертів на інсталяції може бути вимкнений.
|
||||||
|
|
@ -308,7 +341,13 @@ func envOr(key, def string) string {
|
||||||
return def
|
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
|
lv := slog.LevelInfo
|
||||||
switch level {
|
switch level {
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|
@ -318,5 +357,6 @@ func newLogger(level string) *slog.Logger {
|
||||||
case "error":
|
case "error":
|
||||||
lv = slog.LevelError
|
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"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
@ -22,6 +23,7 @@ import (
|
||||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||||||
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
"github.com/netpulse/netpulse/server/internal/grpcapi"
|
||||||
|
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||||
"github.com/netpulse/netpulse/server/internal/store"
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
|
|
@ -53,6 +55,12 @@ func run() error {
|
||||||
"корінь сховища версій конфігів; порожньо — без Git")
|
"корінь сховища версій конфігів; порожньо — без Git")
|
||||||
controlEndpoint = flag.String("control-endpoint", os.Getenv("NETPULSE_CONTROL_ENDPOINT"),
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -60,7 +68,22 @@ func run() error {
|
||||||
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
|
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)
|
ring, err := buildKeyring(*keysFlag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -154,6 +177,15 @@ func run() error {
|
||||||
|
|
||||||
log.Info("запуск", "version", version, "listen", *listen, "tls", !*insecure)
|
log.Info("запуск", "version", version, "listen", *listen, "tls", !*insecure)
|
||||||
|
|
||||||
|
// Внутрішня ручка журналу — окремим слухачем і НЕ фатально.
|
||||||
|
//
|
||||||
|
// Зайнятий порт, відсутній -dek, будь-яка інша причина — усе це має
|
||||||
|
// коштувати рівно одного попередження й порожньої половини на
|
||||||
|
// сторінці. Збір даних із мережі не повинен не піднятись через
|
||||||
|
// зручність перегляду власного журналу; той самий принцип, що й у
|
||||||
|
// перевірці ліцензії в REST-процесі.
|
||||||
|
serveInternalLog(ctx, logRing, *internalListen, logbuf.InternalToken(*keysFlag), log)
|
||||||
|
|
||||||
// Диспетчер збору конфігів: черга наповнюється REST-процесом, а
|
// Диспетчер збору конфігів: черга наповнюється REST-процесом, а
|
||||||
// живі сесії зондів тримає саме цей.
|
// живі сесії зондів тримає саме цей.
|
||||||
go svc.DispatchConfigJobs(ctx, 5*time.Second)
|
go svc.DispatchConfigJobs(ctx, 5*time.Second)
|
||||||
|
|
@ -212,7 +244,13 @@ func envOr(key, def string) string {
|
||||||
return def
|
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
|
lv := slog.LevelInfo
|
||||||
switch level {
|
switch level {
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|
@ -222,7 +260,53 @@ func newLogger(level string) *slog.Logger {
|
||||||
case "error":
|
case "error":
|
||||||
lv = slog.LevelError
|
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 розбирає ключі шифрування.
|
// buildKeyring розбирає ключі шифрування.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/netpulse/netpulse/server/internal/alerting"
|
"github.com/netpulse/netpulse/server/internal/alerting"
|
||||||
"github.com/netpulse/netpulse/server/internal/auth"
|
"github.com/netpulse/netpulse/server/internal/auth"
|
||||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||||
|
"github.com/netpulse/netpulse/server/internal/logbuf"
|
||||||
"github.com/netpulse/netpulse/server/internal/store"
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,6 +42,14 @@ type Server struct {
|
||||||
// каже, що перевірити ключ нічим. Мовчазна відмова тут була б
|
// каже, що перевірити ключ нічим. Мовчазна відмова тут була б
|
||||||
// гіршою за відсутність можливості.
|
// гіршою за відсутність можливості.
|
||||||
license *store.LicenseVerifier
|
license *store.LicenseVerifier
|
||||||
|
|
||||||
|
// Кільце останніх рядків власного журналу цього процесу й адреса
|
||||||
|
// такого самого кільця в колекторі. nil і порожньо — робочий стан:
|
||||||
|
// сторінка журналу тоді чесно каже, чого саме бракує. Див.
|
||||||
|
// serverlog.go.
|
||||||
|
logRing *logbuf.Ring
|
||||||
|
collectorLogURL string
|
||||||
|
collectorLogToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New створює сервер. signer може бути nil лише в тестах, які не
|
// 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("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))
|
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", s.authenticated(s.handleListAudit))
|
||||||
mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta))
|
mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta))
|
||||||
mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent))
|
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 ROW_PX = 3
|
||||||
const BAR_PX = 2
|
|
||||||
|
/**
|
||||||
|
* Товщина штриха рядка. Один піксель, а не два — і це не косметика.
|
||||||
|
*
|
||||||
|
* При двох просвіт між сусідніми рядками лишався один піксель. На екрані
|
||||||
|
* зі звичайним масштабом це ще працювало, а на дробовому (1.25, 1.5 —
|
||||||
|
* тобто на більшості ноутбуків) єдиний піксель просвіту губився при
|
||||||
|
* округленні, і рядки злипались у суцільну пляму. Мінікарта показувала
|
||||||
|
* не форму конфігу, а сірий прямокутник — тобто рівно те, заради чого
|
||||||
|
* її не варто малювати взагалі.
|
||||||
|
*
|
||||||
|
* Один піксель штриха при трьох на рядок дає просвіт удвічі товщий за
|
||||||
|
* сам штрих. Він переживає будь-яке округлення: навіть якщо штрих і
|
||||||
|
* просвіт з'їдуть на півпікселя, між рядками лишиться видима межа.
|
||||||
|
*
|
||||||
|
* Мінікарта від цього стає блідішою — і це правильний бік розміну.
|
||||||
|
* Блідий, але читабельний малюнок кращий за яскраву пляму: на неї
|
||||||
|
* дивляться, щоб побачити СТРУКТУРУ (де розділи, де порожні рядки, де
|
||||||
|
* знайдене), а не щоб прочитати текст.
|
||||||
|
*/
|
||||||
|
const BAR_PX = 1
|
||||||
|
|
||||||
/** Ширина колонки позначок по всьому документу (режим прокрутки). */
|
/** Ширина колонки позначок по всьому документу (режим прокрутки). */
|
||||||
const GUTTER_W = 6
|
const GUTTER_W = 6
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue