Netpulse_SasS/server/internal/httpapi/git_mirror.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
2026-08-27 17:32:49 +03:00

329 lines
14 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 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})
}