From 04e1242c528b04212213e29be49f2ae59d509fec Mon Sep 17 00:00:00 2001 From: byrsapty Date: Thu, 27 Aug 2026 22:55:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=96=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=D0=BC=D0=B8,=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BD=D1=88=D0=B8=D0=B9=20=D1=88=D1=82=D1=80=D0=B8=D1=85=20?= =?UTF-8?q?=D0=BC=D1=96=D0=BD=D1=96=D0=BA=D0=B0=D1=80=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ФАЙЛИ В /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, просвіт удвічі товщий за нього й переживає будь-яке округлення. Малюнок став блідішим — це правильний бік розміну: на мінікарту дивляться, щоб побачити структуру, а не прочитати текст. --- deploy/docker-compose.journald.yml | 78 +++++ deploy/logrotate-netpulse | 39 +++ deploy/rsyslog-netpulse.conf | 74 ++++ server/cmd/netpulse-api/main.go | 46 ++- server/cmd/netpulse-server/main.go | 90 ++++- server/internal/httpapi/server.go | 15 + server/internal/httpapi/serverlog.go | 247 +++++++++++++ server/internal/logbuf/http.go | 165 +++++++++ server/internal/logbuf/logbuf.go | 480 ++++++++++++++++++++++++++ server/internal/logbuf/logbuf_test.go | 455 ++++++++++++++++++++++++ web/src/components/Minimap.tsx | 22 +- 11 files changed, 1704 insertions(+), 7 deletions(-) create mode 100644 deploy/docker-compose.journald.yml create mode 100644 deploy/logrotate-netpulse create mode 100644 deploy/rsyslog-netpulse.conf create mode 100644 server/internal/httpapi/serverlog.go create mode 100644 server/internal/logbuf/http.go create mode 100644 server/internal/logbuf/logbuf.go create mode 100644 server/internal/logbuf/logbuf_test.go diff --git a/deploy/docker-compose.journald.yml b/deploy/docker-compose.journald.yml new file mode 100644 index 0000000..d2c67b3 --- /dev/null +++ b/deploy/docker-compose.journald.yml @@ -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 diff --git a/deploy/logrotate-netpulse b/deploy/logrotate-netpulse new file mode 100644 index 0000000..56c04d3 --- /dev/null +++ b/deploy/logrotate-netpulse @@ -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 +} diff --git a/deploy/rsyslog-netpulse.conf b/deploy/rsyslog-netpulse.conf new file mode 100644 index 0000000..aeb8889 --- /dev/null +++ b/deploy/rsyslog-netpulse.conf @@ -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 +} diff --git a/server/cmd/netpulse-api/main.go b/server/cmd/netpulse-api/main.go index d3fa184..e06e053 100644 --- a/server/cmd/netpulse-api/main.go +++ b/server/cmd/netpulse-api/main.go @@ -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)) } diff --git a/server/cmd/netpulse-server/main.go b/server/cmd/netpulse-server/main.go index 53d2a7c..3e950de 100644 --- a/server/cmd/netpulse-server/main.go +++ b/server/cmd/netpulse-server/main.go @@ -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 розбирає ключі шифрування. diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index b6cd20e..9459beb 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/httpapi/serverlog.go b/server/internal/httpapi/serverlog.go new file mode 100644 index 0000000..5bebc3e --- /dev/null +++ b/server/internal/httpapi/serverlog.go @@ -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) +} diff --git a/server/internal/logbuf/http.go b/server/internal/logbuf/http.go new file mode 100644 index 0000000..9056193 --- /dev/null +++ b/server/internal/logbuf/http.go @@ -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 +} diff --git a/server/internal/logbuf/logbuf.go b/server/internal/logbuf/logbuf.go new file mode 100644 index 0000000..bfc6e6f --- /dev/null +++ b/server/internal/logbuf/logbuf.go @@ -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=". +// Маскується і сам запис у тому вигляді, як він прийшов, і розібраний +// ключ: у журнал ключ може потрапити обома шляхами — і як шматок +// командного рядка, і як байти всередині чужої помилки. +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 +} diff --git a/server/internal/logbuf/logbuf_test.go b/server/internal/logbuf/logbuf_test.go new file mode 100644 index 0000000..722704c --- /dev/null +++ b/server/internal/logbuf/logbuf_test.go @@ -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) + } + } +} diff --git a/web/src/components/Minimap.tsx b/web/src/components/Minimap.tsx index fa09116..c7cfa4e 100644 --- a/web/src/components/Minimap.tsx +++ b/web/src/components/Minimap.tsx @@ -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