Netpulse_SasS/server/internal/logbuf/http.go
byrsapty 04e1242c52
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, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
2026-08-27 22:55:29 +03:00

165 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}