Журнали файлами, тонший штрих мінікарти
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m10s
CI / server (push) Successful in 1m41s
CI / agent (push) Successful in 59s

ФАЙЛИ В /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:
byrsapty 2026-08-27 22:55:29 +03:00
parent c7fecfba0b
commit 04e1242c52
11 changed files with 1704 additions and 7 deletions

View 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
View 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
}

View 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
}

View file

@ -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))
}

View file

@ -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 розбирає ключі шифрування.

View file

@ -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))

View 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)
}

View 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
}

View 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 — це десь 150200
// байтів; один рядок із текстом помилки 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
}

View 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)
}
}
}

View file

@ -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