Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
329 lines
14 KiB
Go
329 lines
14 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/gitstore"
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Дзеркалення архіву конфігів на зовнішній Git.
|
||
//
|
||
// Чотири дії, і межа між ними проходить по тому, ХТО чекає.
|
||
//
|
||
// Читання стану й збереження налаштувань — миттєві, їх робить цей
|
||
// процес. Перевірка з'єднання теж: вона коротка (одне ls-remote) і саме
|
||
// заради неї людина зараз сидить перед екраном.
|
||
//
|
||
// А от сам push цей процес не робить НІКОЛИ. «Надіслати зараз»
|
||
// прибирає витримку повтору й позначку останнього стану — і фоновий
|
||
// такт колектора підхоплює це протягом хвилини. Причина та сама, що й
|
||
// у прибиральника версій: REST — шлях запитів людини, його масштабують
|
||
// копіями, а перший повний push інсталяції з річною історією — це
|
||
// десятки хвилин і гігабайти. Тримати таке в обробнику HTTP означало б
|
||
// або обірваний запит, або зайнятий надовго процес, який зараз комусь
|
||
// малює сторінку.
|
||
|
||
// handleGetMirror — стан дзеркала.
|
||
//
|
||
// Право ncm:read, а не ncm:mirror, і це навмисно: помітити, що копія
|
||
// тиждень не їде, має могти той, хто щодня дивиться на конфіги, а не
|
||
// лише власник кабінету. Налаштувати — інша річ, і право там інше.
|
||
func (s *Server) handleGetMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "ncm:read") {
|
||
return
|
||
}
|
||
m, err := s.store.GetMirror(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||
return
|
||
}
|
||
// Публічна половина ключа їде разом зі станом: людина заводить його
|
||
// в репозиторії не тієї ж хвилини, коли створює, і повертатись сюди
|
||
// по неї має бути куди. Помилка тут не ламає сторінку — без ключа
|
||
// вона просто не покаже блока з ним.
|
||
if pub, err := s.store.MirrorPublicKey(r.Context(), p.TenantID, s.keyring); err == nil {
|
||
m.PublicKey = pub
|
||
}
|
||
writeJSON(w, http.StatusOK, m)
|
||
}
|
||
|
||
// handleSetMirror зберігає налаштування.
|
||
func (s *Server) handleSetMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "ncm:mirror") {
|
||
return
|
||
}
|
||
var in store.MirrorInput
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if s.keyring == nil && strings.TrimSpace(in.Secret) != "" {
|
||
writeError(w, http.StatusServiceUnavailable, "no_keyring",
|
||
"сервер запущено без ключа шифрування — зберегти токен ніде")
|
||
return
|
||
}
|
||
|
||
// Увімкнути дзеркало без адреси неможливо: інакше на сторінці
|
||
// стояло б «увімкнено», а такт мовчки нічого не робив би.
|
||
if in.Enabled && strings.TrimSpace(in.URL) == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"щоб увімкнути дзеркало, потрібна адреса віддаленого репозиторію")
|
||
return
|
||
}
|
||
|
||
if err := s.store.SaveMirror(r.Context(), p.TenantID, in, s.keyring); err != nil {
|
||
if isMirrorInputError(err) {
|
||
writeError(w, http.StatusBadRequest, "bad_remote", err.Error())
|
||
return
|
||
}
|
||
s.writeStoreError(w, "збереження дзеркала", err)
|
||
return
|
||
}
|
||
|
||
m, err := s.store.GetMirror(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||
return
|
||
}
|
||
|
||
// У журналі — намір і адреса, ніколи токен. RedactURL прибирає
|
||
// логін із адреси на випадок, коли людина вставила рядок виду
|
||
// https://токен@forgejo/…, скопійований із чужої інструкції.
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionMirrorUpdate,
|
||
ObjectType: store.AuditObjectMirror,
|
||
ObjectID: m.RepoID,
|
||
Meta: map[string]any{
|
||
"enabled": m.Enabled,
|
||
"url": gitstore.RedactURL(m.URL),
|
||
"auth": m.Auth,
|
||
"has_secret": m.HasSecret,
|
||
"host_key_fp": m.HostKeyFP,
|
||
},
|
||
}); err != nil {
|
||
s.log.Error("аудит дзеркала", "err", err)
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, m)
|
||
}
|
||
|
||
// mirrorTestResult — відповідь на «перевірити з'єднання».
|
||
//
|
||
// Завжди 200, навіть коли не вийшло. Помилка тут — це відповідь на
|
||
// питання, а не збій запиту: людина натиснула саме для того, щоб
|
||
// дізнатись текст. HTTP-код 502 показав би їй «щось пішло не так»
|
||
// замість «токен не має права запису».
|
||
type mirrorTestResult struct {
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error,omitempty"`
|
||
Refs int `json:"refs"`
|
||
Empty bool `json:"empty"`
|
||
|
||
HostKey string `json:"host_key,omitempty"`
|
||
HostKeyFP string `json:"host_key_fp,omitempty"`
|
||
HostKeyKnown bool `json:"host_key_known"`
|
||
// NeedsPin — сервер показав ключ, якого ми ще не знаємо. Далі
|
||
// вирішує людина: закріпити цей чи зупинитись.
|
||
NeedsPin bool `json:"needs_pin"`
|
||
}
|
||
|
||
// handleTestMirror перевіряє з'єднання ДО збереження.
|
||
//
|
||
// Окрема дія існує рівно тому, що альтернатива — дізнатись про
|
||
// помилку через добу з журналу. Токен береться з тіла запиту (людина
|
||
// щойно його ввела) або зі збереженого, якщо поле лишили порожнім.
|
||
func (s *Server) handleTestMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "ncm:mirror") {
|
||
return
|
||
}
|
||
var in store.MirrorInput
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
remote := gitstore.Remote{
|
||
URL: strings.TrimSpace(in.URL),
|
||
User: strings.TrimSpace(in.User),
|
||
Secret: in.Secret,
|
||
HostKey: strings.TrimSpace(in.HostKey),
|
||
// Півхвилини: перевірка має або відповісти, поки людина
|
||
// дивиться на екран, або чесно сказати, що сервер мовчить.
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
|
||
// Порожні поля добираються зі збереженого: типовий сценарій —
|
||
// «перевір те, що вже налаштовано», без повторного введення токена,
|
||
// якого форма й не показує.
|
||
if remote.Secret == "" || remote.URL == "" {
|
||
saved, err := s.store.MirrorRemote(r.Context(), p.TenantID, s.keyring)
|
||
if err == nil {
|
||
if remote.URL == "" {
|
||
remote.URL = saved.URL
|
||
}
|
||
if remote.Secret == "" {
|
||
remote.Secret = saved.Secret
|
||
}
|
||
if remote.User == "" {
|
||
remote.User = saved.User
|
||
}
|
||
if remote.HostKey == "" {
|
||
remote.HostKey = saved.HostKey
|
||
}
|
||
}
|
||
}
|
||
|
||
stat, err := gitstore.Probe(r.Context(), remote)
|
||
res := mirrorTestResult{
|
||
Refs: stat.Refs,
|
||
Empty: stat.Empty,
|
||
HostKey: stat.HostKey,
|
||
HostKeyKnown: stat.HostKeyKnown,
|
||
}
|
||
if stat.HostKey != "" {
|
||
res.HostKeyFP = gitstore.Fingerprint(stat.HostKey)
|
||
res.NeedsPin = !stat.HostKeyKnown
|
||
}
|
||
switch {
|
||
case err != nil:
|
||
// Друга сітка поверх тієї, що вже стоїть у gitstore: текст
|
||
// звідти проходить через Scrub, але цей рядок їде до людини й
|
||
// у браузерну консоль, і платити за припущення «там уже
|
||
// почищено» довелося б токеном.
|
||
res.Error = gitstore.Scrub(err, remote.Secret).Error()
|
||
case res.NeedsPin:
|
||
res.Error = "сервер показав ключ, якого ми ще не знаємо. Звірте відбиток і збережіть — далі кожен обмін перевірятиме саме його"
|
||
default:
|
||
res.OK = true
|
||
}
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
// handlePushMirror просить надіслати негайно.
|
||
//
|
||
// Сам push тут не робиться (див. коментар угорі файлу): знімається
|
||
// витримка повтору й відбиток останнього надісланого стану, і
|
||
// найближчий такт колектора йде в мережу, навіть якщо локально нічого
|
||
// не змінилось. Відповідь — оновлений стан; результат людина побачить
|
||
// на тій самій сторінці протягом хвилини.
|
||
func (s *Server) handlePushMirror(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "ncm:mirror") {
|
||
return
|
||
}
|
||
// force — окреме, свідоме «перезаписати віддалене дерево». Потрібне
|
||
// рівно в одному випадку: локальний репозиторій відновили з бази
|
||
// (netpulse-gitsync), коміти склались наново, і звичайний push
|
||
// чесно відмовляється затирати те, що вже поїхало. Без цієї дії
|
||
// вихід із такого стану був би лише через ручний git на сервері.
|
||
var in struct {
|
||
Force bool `json:"force"`
|
||
}
|
||
if r.ContentLength > 0 && !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
if err := s.store.RequestMirrorPush(r.Context(), p.TenantID, in.Force); err != nil {
|
||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||
return
|
||
}
|
||
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionMirrorPush,
|
||
ObjectType: store.AuditObjectMirror,
|
||
Meta: map[string]any{"force": in.Force},
|
||
}); err != nil {
|
||
s.log.Error("аудит дзеркала", "err", err)
|
||
}
|
||
|
||
m, err := s.store.GetMirror(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "дзеркало архіву конфігів", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, m)
|
||
}
|
||
|
||
// handleForgetMirrorSecret прибирає збережений токен або ключ.
|
||
func (s *Server) handleForgetMirrorSecret(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "ncm:mirror") {
|
||
return
|
||
}
|
||
if err := s.store.ForgetMirrorSecret(r.Context(), p.TenantID); err != nil {
|
||
s.writeStoreError(w, "секрет дзеркала", err)
|
||
return
|
||
}
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionMirrorUpdate,
|
||
ObjectType: store.AuditObjectMirror,
|
||
Meta: map[string]any{"enabled": false, "has_secret": false},
|
||
}); err != nil {
|
||
s.log.Error("аудит дзеркала", "err", err)
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// isMirrorInputError — чи це помилка введення, а не збій сервера.
|
||
// Від відповіді залежить код: 400 просить виправити поле, 500 —
|
||
// звернутись до адміністратора.
|
||
func isMirrorInputError(err error) bool {
|
||
for _, e := range []error{
|
||
gitstore.ErrNoRemoteURL, gitstore.ErrBadRemoteURL,
|
||
gitstore.ErrUnsupportedTr, gitstore.ErrPlainHTTP,
|
||
gitstore.ErrHostKeyUnset,
|
||
} {
|
||
if errors.Is(err, e) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// handleMirrorDeployKey створює ключ розгортання й показує публічну
|
||
// половину.
|
||
//
|
||
// POST, хоч і виглядає як «дай ключ»: він створюється й замінює
|
||
// попередній. Друге натискання — це новий ключ, і старий одразу
|
||
// перестає працювати; сказати про це має інтерфейс, а не з'ясувати
|
||
// нічний бекап.
|
||
func (s *Server) handleMirrorDeployKey(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "ncm:mirror") {
|
||
return
|
||
}
|
||
// Адреса з форми: людина могла щойно замінити https на SSH і ще не
|
||
// зберегти. Без неї ключ ліг би з типом старої адреси.
|
||
var in struct {
|
||
URL string `json:"url"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||
|
||
pub, err := s.store.GenerateMirrorDeployKey(r.Context(), p.TenantID, in.URL, s.keyring)
|
||
if err != nil {
|
||
s.writeStoreError(w, "ключ розгортання", err)
|
||
return
|
||
}
|
||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||
ActorUserID: p.UserID,
|
||
ActorTokenID: p.TokenID,
|
||
ActorIP: clientIP(r),
|
||
Action: store.AuditActionMirrorUpdate,
|
||
ObjectType: store.AuditObjectMirror,
|
||
// Публічну половину — можна: вона й так їде в чужий
|
||
// репозиторій. Приватна в аудит не потрапляє ніколи.
|
||
Meta: map[string]any{"deploy_key": true, "public_key": pub},
|
||
}); err != nil {
|
||
s.log.Error("аудит дзеркала", "err", err)
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"public_key": pub})
|
||
}
|