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 }