Netpulse_SasS/server/internal/store/ncm_repos.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

639 lines
29 KiB
Go
Raw Permalink 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 store
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/gitstore"
)
// Дзеркало локального сховища версій на зовнішній Git.
//
// Рядок ncm.repos існував із першої міграції NCM разом із полями
// remote_url / remote_secret_id / mirror_enabled, і його не читав ніхто.
// Цей файл — той, хто нарешті читає.
//
// Межа відповідальності проста: тут усе, що знає БАЗА про дзеркало —
// налаштування, секрет і слід останньої спроби. Сам push живе в
// gitstore/mirror.go, розклад — у grpcapi/ncm_mirror.go. Три шари, і
// жоден із них не знає токена довше, ніж триває одна спроба.
// RepoName — ім'я каталогу репозиторію тенанта під коренем сховища.
//
// Одне місце на весь продукт: до цього рядок "<tenant>.git" складався
// в трьох різних файлах, і розійтись їм заважала лише уважність.
func RepoName(tenantID string) string { return tenantID + ".git" }
// GitMirror — стан дзеркала для сторінки й для фонового такту.
//
// Токена тут немає й бути не може: структура віддається в API як є.
// Про його наявність каже HasSecret — це факт про налаштування, а не
// сам секрет.
type GitMirror struct {
RepoID string `json:"repo_id"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
// Auth: none | https | ssh
Auth string `json:"auth"`
User string `json:"user"`
HostKey string `json:"host_key,omitempty"`
HostKeyFP string `json:"host_key_fp,omitempty"`
HasSecret bool `json:"has_secret"`
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"`
LastOkAt *time.Time `json:"last_ok_at,omitempty"`
LastError string `json:"last_error,omitempty"`
FailCount int `json:"fail_count"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
PushedRefs int `json:"pushed_refs"`
ForceOnce bool `json:"force_once"`
DurationMs int `json:"duration_ms"`
// Локальні факти. Сторінка має відповідати не «дзеркало
// налаштоване», а «стільки гілок є тут, стільки поїхало туди».
LocalRefs int `json:"local_refs"`
LocalError string `json:"local_error,omitempty"`
Pending bool `json:"pending"`
Versions int `json:"versions"`
Devices int `json:"devices"`
StorageSize int64 `json:"storage_bytes"`
stateHash string
// Публічна половина ключа розгортання — та, яку несуть у
// репозиторій. Порожньо: дзеркало ходить не по SSH, ключа ще немає
// або він у форматі, з якого її не вивести.
PublicKey string `json:"public_key,omitempty"`
}
// MirrorInput — те, що надсилає форма.
type MirrorInput struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
User string `json:"user"`
HostKey string `json:"host_key"`
// Secret порожній означає «лишити збережений». Показати наявний
// неможливо навіть теоретично: розшифрувати заради показу — це
// віддати його туди, звідки він уже не повернеться. Той самий
// висновок, що й для токенів каналів сповіщень.
Secret string `json:"secret"`
}
var ErrNoMirrorSecret = errors.New("не збережено ні токена, ні ключа")
// EnsureRepo повертає рядок ncm.repos тенанта, створюючи його за
// потреби.
//
// Потрібен саме тут: репозиторій заводиться при першому бекапі
// (див. StoreConfig), а дзеркало налаштовують ДО нього — інсталяція,
// яка щойно піднялась, ще не зібрала жодного конфігу, але вже хоче
// знати, куди їх складати.
func (s *Store) EnsureRepo(ctx context.Context, tenantID string) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
SELECT id::text FROM ncm.repos WHERE tenant_id = $1 ORDER BY created_at LIMIT 1
`, tenantID).Scan(&id)
if !isNoRows(err) {
return err
}
// $1 і як uuid, і як частина текстового шляху — те саме
// приведення, що й у StoreConfig, і з тієї ж причини.
return tx.QueryRow(ctx, `
INSERT INTO ncm.repos (tenant_id, name, storage_path)
VALUES ($1::uuid, 'default', '/var/lib/netpulse/git/' || $1 || '.git')
RETURNING id::text
`, tenantID).Scan(&id)
})
return id, err
}
// GetMirror — налаштування дзеркала разом із тим, що з ним відбувається.
func (s *Store) GetMirror(ctx context.Context, tenantID string) (*GitMirror, error) {
repoID, err := s.EnsureRepo(ctx, tenantID)
if err != nil {
return nil, err
}
m := &GitMirror{RepoID: repoID, Auth: gitstore.AuthNone}
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var secretID *string
if err := tx.QueryRow(ctx, `
SELECT mirror_enabled, COALESCE(remote_url,''), remote_auth,
COALESCE(remote_user,''), COALESCE(remote_host_key,''),
remote_secret_id::text,
mirror_last_attempt_at, mirror_last_ok_at,
COALESCE(mirror_last_error,''), mirror_fail_count,
mirror_next_attempt_at, COALESCE(mirror_state_hash,''),
mirror_refs, COALESCE(mirror_duration_ms,0), size_bytes,
mirror_force_once
FROM ncm.repos WHERE id = $1 AND tenant_id = $2
`, repoID, tenantID).Scan(&m.Enabled, &m.URL, &m.Auth, &m.User, &m.HostKey,
&secretID, &m.LastAttemptAt, &m.LastOkAt, &m.LastError, &m.FailCount,
&m.NextAttemptAt, &m.stateHash, &m.PushedRefs, &m.DurationMs,
&m.StorageSize, &m.ForceOnce); err != nil {
return err
}
m.HasSecret = secretID != nil
// Скільки версій і хостів зараз в архіві. Це те число, яке
// поїде першим повним push, і людина має бачити його ДО
// вмикання, а не дізнаватись із логів через півгодини.
return tx.QueryRow(ctx, `
SELECT count(*)::int, count(DISTINCT device_id)::int
FROM ncm.configs WHERE tenant_id = $1
`, tenantID).Scan(&m.Versions, &m.Devices)
})
if err != nil {
return nil, err
}
if m.HostKey != "" {
m.HostKeyFP = gitstore.Fingerprint(m.HostKey)
}
// Локальні гілки читаються з диска, а не з бази: база знає, що
// НАЛАШТОВАНО, і не знає, що є насправді. Різниця між цими двома
// відповідями — це і є «дзеркало відстало».
if s.git != nil {
refs, hash, err := s.git.LocalState(RepoName(tenantID))
switch {
case err == nil:
m.LocalRefs = refs
m.Pending = m.Enabled && hash != m.stateHash
case errors.Is(err, gitstore.ErrNoRepo):
// Жодного конфігу ще не зібрано — не помилка.
default:
m.LocalError = err.Error()
}
} else {
m.LocalError = "версіювання в Git вимкнено на цьому сервері"
}
return m, nil
}
// SaveMirror зберігає налаштування.
//
// Секрет кладеться в core.secrets тим самим механізмом, що й паролі від
// обладнання: AES-GCM активним ключем кільця, з AAD, який прив'язує
// шифротекст до тенанта й до цього конкретного репозиторію. Без AAD
// рядок можна було б прямим UPDATE переставити на чужий репозиторій, і
// розшифрування б відпрацювало — тобто чужим токеном пушило б наше
// дзеркало.
func (s *Store) SaveMirror(ctx context.Context, tenantID string, in MirrorInput, ring *crypto.Keyring) error {
repoID, err := s.EnsureRepo(ctx, tenantID)
if err != nil {
return err
}
in.URL = strings.TrimSpace(in.URL)
in.User = strings.TrimSpace(in.User)
in.HostKey = strings.TrimSpace(in.HostKey)
auth := gitstore.AuthNone
if in.URL != "" {
if err := gitstore.ValidateRemoteURL(in.URL); err != nil {
return err
}
auth = gitstore.AuthHTTPS
if strings.HasPrefix(in.URL, "ssh://") || !strings.Contains(in.URL, "://") {
auth = gitstore.AuthSSH
}
}
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var oldSecret *string
var oldURL, oldAuth string
if err := tx.QueryRow(ctx, `
SELECT remote_secret_id::text, COALESCE(remote_url,''), remote_auth
FROM ncm.repos WHERE id = $1 AND tenant_id = $2
`, repoID, tenantID).Scan(&oldSecret, &oldURL, &oldAuth); err != nil {
return err
}
secretID := any(nil)
if oldSecret != nil {
secretID = *oldSecret
}
if in.Secret != "" {
if ring == nil {
return errors.New("сервер запущено без ключа шифрування — зберегти токен ніяк")
}
aad := tenantID + "|ncm.mirror|" + repoID
sec, err := ring.Encrypt([]byte(in.Secret), aad)
if err != nil {
return err
}
kind := "api_token"
if auth == gitstore.AuthSSH {
kind = "ssh_key"
}
var sid string
if err := tx.QueryRow(ctx, `
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
RETURNING id::text
`, tenantID, kind, sec.KeyID, sec.Nonce, sec.Ciphertext,
sec.AuthTag, aad).Scan(&sid); err != nil {
return err
}
// Старий прибираємо лише після того, як новий ліг:
// зворотний порядок на помилці шифрування лишив би дзеркало
// без доступу — і виявилось би це вночі.
if oldSecret != nil {
if _, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *oldSecret); err != nil {
return err
}
}
secretID = sid
}
// Зміна адреси або способу входу обнуляє слід останньої спроби.
// Інакше на новому сервері висіла б помилка від старого, і
// перший же погляд на сторінку збрехав би.
reset := in.URL != oldURL || auth != oldAuth
_, err := tx.Exec(ctx, `
UPDATE ncm.repos SET
mirror_enabled = $3,
remote_url = NULLIF($4,''),
remote_auth = $5,
remote_user = NULLIF($6,''),
remote_host_key = NULLIF($7,''),
remote_secret_id = $8,
mirror_last_error = CASE WHEN $9 THEN NULL ELSE mirror_last_error END,
mirror_fail_count = CASE WHEN $9 THEN 0 ELSE mirror_fail_count END,
mirror_state_hash = CASE WHEN $9 THEN NULL ELSE mirror_state_hash END,
mirror_last_ok_at = CASE WHEN $9 THEN NULL ELSE mirror_last_ok_at END,
-- Наступна спроба — негайно. Людина щойно натиснула
-- «зберегти» й дивиться на екран; чекати до кінця
-- поточної витримки повтору означало б показати їй
-- «нічого не відбувається».
mirror_next_attempt_at = NULL
WHERE id = $1 AND tenant_id = $2
`, repoID, tenantID, in.Enabled && in.URL != "", in.URL, auth,
in.User, in.HostKey, secretID, reset)
return err
})
}
// ForgetMirrorSecret прибирає збережений токен або ключ.
//
// Окремою дією, а не порожнім полем у формі: порожнє поле означає
// «лишити як є», і зробити його ще й способом видалення означало б, що
// звичайне збереження форми мовчки знищує доступ.
func (s *Store) ForgetMirrorSecret(ctx context.Context, tenantID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
WITH gone AS (
UPDATE ncm.repos SET remote_secret_id = NULL, mirror_enabled = false
WHERE tenant_id = $1 AND remote_secret_id IS NOT NULL
RETURNING remote_secret_id
)
DELETE FROM core.secrets WHERE id IN (SELECT remote_secret_id FROM gone)
`, tenantID)
return err
})
}
// MirrorTarget — усе, що потрібно фоновому такту, щоб зробити один
// push: разом із розшифрованим секретом.
//
// Живе рівно стільки, скільки триває спроба. У журнал не пишеться, в
// API не віддається, у структуру відповіді не входить.
type MirrorTarget struct {
TenantID string
RepoID string
Remote gitstore.Remote
StateHash string
LastOkAt *time.Time
FailCount int
}
// MirrorTargets — тенанти з увімкненим дзеркалом, яким уже час.
//
// Відбір за mirror_next_attempt_at робить БАЗА, а не такт у пам'яті:
// колектор перезапускається (релізи, oom, міграції), і витримка
// повтору, що живе лише в пам'яті процесу, після кожного перезапуску
// починалася б спочатку — тобто недоступний сервер отримував би шквал
// спроб рівно тоді, коли ми його щойно перезапустили.
func (s *Store) MirrorTargets(ctx context.Context, ring *crypto.Keyring) ([]MirrorTarget, error) {
rows, err := s.bg.Query(ctx, `
SELECT r.id::text, r.tenant_id::text, COALESCE(r.remote_url,''), r.remote_auth,
COALESCE(r.remote_user,''), COALESCE(r.remote_host_key,''),
COALESCE(r.mirror_state_hash,''), r.mirror_last_ok_at, r.mirror_fail_count,
r.mirror_force_once,
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM ncm.repos r
LEFT JOIN core.secrets s ON s.id = r.remote_secret_id
JOIN core.tenants t ON t.id = r.tenant_id AND t.deleted_at IS NULL
WHERE r.mirror_enabled
AND r.remote_url IS NOT NULL
AND (r.mirror_next_attempt_at IS NULL OR r.mirror_next_attempt_at <= now())
ORDER BY r.mirror_last_ok_at NULLS FIRST
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MirrorTarget
for rows.Next() {
var t MirrorTarget
var keyID, aad *string
var nonce, ct, tag []byte
if err := rows.Scan(&t.RepoID, &t.TenantID, &t.Remote.URL, &t.Remote.Auth,
&t.Remote.User, &t.Remote.HostKey, &t.StateHash, &t.LastOkAt,
&t.FailCount, &t.Remote.Force, &keyID, &nonce, &ct, &tag, &aad); err != nil {
return nil, err
}
if keyID != nil {
if ring == nil {
return nil, errors.New("процес запущено без ключа шифрування — токен дзеркала не прочитати")
}
plain, err := ring.Decrypt(&crypto.Secret{
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
}, derefStr(aad))
if err != nil {
// Нечитабельний секрет одного тенанта не має зупиняти
// дзеркалення решти. Помилка поїде в його ж поле стану
// на наступному кроці такту.
t.Remote.Secret = ""
} else {
t.Remote.Secret = string(plain)
}
}
out = append(out, t)
}
return out, rows.Err()
}
// MirrorOutcome — що записати після спроби.
type MirrorOutcome struct {
OK bool
Err string
Refs int
StateHash string
Duration time.Duration
// NextAt — коли пробувати знову. Нуль означає «за звичайним тактом».
NextAt time.Time
}
// MarkMirror зберігає слід спроби.
//
// Пишеться ЗАВЖДИ — і на успіх, і на відмову. Мовчання після невдалої
// спроби — найгірший із можливих станів: сторінка показувала б час
// останнього успіху, і за тиждень простою вона казала б рівно те саме,
// що й за хвилину до нього.
func (s *Store) MarkMirror(ctx context.Context, tenantID, repoID string, o MirrorOutcome) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var next any
if !o.NextAt.IsZero() {
next = o.NextAt
}
_, err := tx.Exec(ctx, `
UPDATE ncm.repos SET
mirror_last_attempt_at = now(),
mirror_last_ok_at = CASE WHEN $3 THEN now() ELSE mirror_last_ok_at END,
mirror_last_error = CASE WHEN $3 THEN NULL ELSE NULLIF($4,'') END,
mirror_fail_count = CASE WHEN $3 THEN 0 ELSE mirror_fail_count + 1 END,
mirror_state_hash = CASE WHEN $3 THEN NULLIF($5,'') ELSE mirror_state_hash END,
mirror_refs = CASE WHEN $3 THEN $6 ELSE mirror_refs END,
mirror_duration_ms = $7,
mirror_next_attempt_at = $8,
-- Дозвіл на перезапис гасне після першого ж успіху:
-- інакше інсталяція тихо жила б у режимі, у якому
-- дзеркало щоразу затирає віддалену історію.
mirror_force_once = CASE WHEN $3 THEN false ELSE mirror_force_once END
WHERE id = $1 AND tenant_id = $2
`, repoID, tenantID, o.OK, o.Err, o.StateHash, o.Refs,
int(o.Duration.Milliseconds()), next)
return err
})
}
// RequestMirrorPush просить фоновий такт піти в мережу негайно.
//
// Скидається і витримка повтору, і відбиток останнього надісланого
// стану. Друге важливіше за перше: без нього такт побачив би, що
// локально нічого не змінилось, і чесно нічого не зробив би — а людина
// натискає «надіслати зараз» саме тоді, коли сумнівається не в
// локальному стані, а в тому, чи ціле дзеркало.
func (s *Store) RequestMirrorPush(ctx context.Context, tenantID string, force bool) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx, `
UPDATE ncm.repos
SET mirror_next_attempt_at = NULL, mirror_state_hash = NULL,
mirror_force_once = $2
WHERE tenant_id = $1 AND mirror_enabled
`, tenantID, force)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return errors.New("дзеркало вимкнено — надсилати нікуди")
}
return nil
})
}
// MirrorDedupKey — ключ дедуплікації алерту про зламане дзеркало.
func MirrorDedupKey(repoID string) string { return "ncm.mirror:" + repoID }
// RaiseMirrorAlert піднімає алерт про те, що копія не їде.
//
// Навіщо алерт, коли є сторінка зі станом. Тому що на сторінку дзеркала
// не заходять: її відкривають один раз, коли налаштовують, і більше
// ніколи. Дзеркало ж заводять рівно для того дня, коли локального диска
// не стане, — і дізнатись у той день, що воно мовчало тиждень, означає
// не мати його взагалі. Алерт кладе цей факт туди, куди дивляться
// щодня, і в лічильник у шапці.
//
// rule_id порожній навмисно: правила тут немає й не має бути. Умова
// «копія не поїхала» не має ні порога, ні селектора хостів — це не те,
// що інженер налаштовує, а те, що система знає про себе.
func (s *Store) RaiseMirrorAlert(ctx context.Context, tenantID, repoID, severity, title, message string) error {
_, err := s.RaiseAlert(ctx, tenantID, Alert{
Severity: severity,
Title: title,
Message: message,
DedupKey: MirrorDedupKey(repoID),
})
return err
}
// ResolveMirrorAlert закриває його після першого ж вдалого push.
func (s *Store) ResolveMirrorAlert(ctx context.Context, tenantID, repoID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `
WITH closed AS (
UPDATE alr.alerts
SET state = 'resolved', resolved_at = now()
WHERE tenant_id = $1 AND dedup_key = $2
AND state IN ('firing','acknowledged','suppressed')
RETURNING id
)
INSERT INTO core.event_outbox (tenant_id, topic, payload)
SELECT $1, 'alert.resolved',
jsonb_build_object('alert_id', id::text, 'reason', 'mirror.ok')
FROM closed
`, tenantID, MirrorDedupKey(repoID))
return err
})
}
// PushMirror виконує один push репозиторію тенанта.
//
// Тонка обгортка навмисно: gitstore нічого не знає про базу, а store —
// про протокол Git. Єдине, що додається тут, — ім'я репозиторію, яке
// обидва боки мусять розуміти однаково.
func (s *Store) PushMirror(ctx context.Context, tenantID string, r gitstore.Remote) (gitstore.PushStat, error) {
if s.git == nil {
return gitstore.PushStat{}, gitstore.ErrDisabled
}
return s.git.Mirror(ctx, RepoName(tenantID), r)
}
// LocalMirrorState — скільки гілок і який їхній відбиток зараз на диску.
func (s *Store) LocalMirrorState(tenantID string) (int, string, error) {
if s.git == nil {
return 0, "", gitstore.ErrDisabled
}
return s.git.LocalState(RepoName(tenantID))
}
// MirrorRemote збирає Remote із збереженого — з розшифрованим секретом.
//
// Потрібно ручним діям: «перевірити з'єднання» й «надіслати зараз».
// Секрет живе рівно в межах виклику.
func (s *Store) MirrorRemote(ctx context.Context, tenantID string, ring *crypto.Keyring) (gitstore.Remote, error) {
var r gitstore.Remote
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var keyID, aad *string
var nonce, ct, tag []byte
err := tx.QueryRow(ctx, `
SELECT COALESCE(r.remote_url,''), r.remote_auth, COALESCE(r.remote_user,''),
COALESCE(r.remote_host_key,''),
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
FROM ncm.repos r
LEFT JOIN core.secrets s ON s.id = r.remote_secret_id
WHERE r.tenant_id = $1
ORDER BY r.created_at LIMIT 1
`, tenantID).Scan(&r.URL, &r.Auth, &r.User, &r.HostKey,
&keyID, &nonce, &ct, &tag, &aad)
if err != nil {
return err
}
if keyID == nil {
return nil
}
if ring == nil {
return errors.New("сервер запущено без ключа шифрування — токен не прочитати")
}
plain, err := ring.Decrypt(&crypto.Secret{
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
}, derefStr(aad))
if err != nil {
return fmt.Errorf("секрет дзеркала: %w", err)
}
r.Secret = string(plain)
return nil
})
return r, err
}
// GenerateMirrorDeployKey створює ключ розгортання й зберігає його.
//
// Повертає ПУБЛІЧНУ половину — саме її людина віднесе в репозиторій.
// Приватна лягає зашифрованою тим самим шляхом, що й токен, і назовні
// не виходить ніколи: у неї немає жодного застосування поза цим
// сервером, тож і показувати її нема кому.
//
// Ключ розгортання відкриває ОДИН репозиторій, на відміну від
// особистого токена, який відкриває всі репозиторії власника. Для
// системи, що щоночі складає туди конфіги всієї мережі, це різниця між
// інцидентом і катастрофою.
func (s *Store) GenerateMirrorDeployKey(ctx context.Context, tenantID, url string, ring *crypto.Keyring) (string, error) {
if ring == nil {
return "", errors.New("сервер запущено без ключа шифрування — зберегти ключ ніяк")
}
cur, err := s.GetMirror(ctx, tenantID)
if err != nil {
return "", err
}
// Адреса з форми має пріоритет над збереженою.
//
// Без цього виходила пастка: людина міняє адресу на SSH, бачить
// блок ключа й тисне «створити» — а ключ лягає з типом, виведеним
// зі СТАРОЇ, ще https-адреси, тобто зберігається як токен. Дзеркало
// потім чесно шле приватний ключ туди, де чекають на токен, і
// падає з незрозумілим «доступ заборонено».
url = strings.TrimSpace(url)
if url == "" {
url = cur.URL
}
if url == "" || (!strings.HasPrefix(url, "ssh://") && strings.Contains(url, "://")) {
return "", fmt.Errorf("%w: ключ розгортання працює лише з SSH-адресою "+
"(ssh://… або git@хост:шлях). Для https-адреси потрібен токен", ErrInvalid)
}
priv, pub, err := gitstore.GenerateDeployKey(mirrorKeyComment)
if err != nil {
return "", err
}
// Зберігаємо через звичайний шлях збереження: інакше з'явився б
// другий спосіб покласти секрет дзеркала, і вони розійшлися б на
// першій же правці одного з них.
in := MirrorInput{
Enabled: cur.Enabled,
URL: url,
User: cur.User,
HostKey: cur.HostKey,
Secret: priv,
}
if err := s.SaveMirror(ctx, tenantID, in, ring); err != nil {
return "", err
}
return pub, nil
}
// mirrorKeyComment — підпис ключа в переліку ключів репозиторію.
//
// Без нього там стоїть безіменний рядок, і за півроку ніхто не згадає,
// чий він і чи можна його прибрати.
const mirrorKeyComment = "netpulse-mirror"
// MirrorPublicKey — публічна половина збереженого ключа.
//
// Виводиться з приватної щоразу, а не зберігається окремо: друга копія
// тієї самої істини рано чи пізно розійдеться з першою. Порожньо —
// дзеркало ходить не по SSH або ключа ще немає.
func (s *Store) MirrorPublicKey(ctx context.Context, tenantID string, ring *crypto.Keyring) (string, error) {
m, err := s.GetMirror(ctx, tenantID)
if err != nil || m.Auth != gitstore.AuthSSH || !m.HasSecret || ring == nil {
return "", err
}
// Беремо той самий шлях, яким секрет читає саме дзеркалення:
// другий спосіб розшифрувати означав би другу можливість
// помилитись у AAD і не помітити цього до нічного бекапу.
rem, err := s.MirrorRemote(ctx, tenantID, ring)
if err != nil || rem.Secret == "" {
return "", err
}
pub, err := gitstore.PublicKeyOf(rem.Secret, mirrorKeyComment)
if err != nil {
// Ключ міг бути введений руками й мати інший формат. Це не
// привід ламати сторінку: дзеркало від показу не залежить.
return "", nil
}
return pub, nil
}