Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
348 lines
15 KiB
Go
348 lines
15 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/json"
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
)
|
||
|
||
// ErrTelegramLinkInvalid — коду не існує, він протух або вже зужитий.
|
||
//
|
||
// Одна помилка на всі три випадки з тієї ж причини, що й у запрошеннях
|
||
// зондів: розрізняти їх у відповіді означало б підказувати тому, хто
|
||
// перебирає коди, наскільки він близько. А перебирати тут є де —
|
||
// повідомлення боту шле хто завгодно.
|
||
var ErrTelegramLinkInvalid = errors.New("код прив'язки недійсний")
|
||
|
||
// ErrTelegramNotLinked — цей telegram-акаунт не зіставлений з жодним
|
||
// користувачем кабінету.
|
||
var ErrTelegramNotLinked = errors.New("telegram не прив'язано")
|
||
|
||
// TelegramLinkTTL — скільки живе код прив'язки. Обґрунтування строку —
|
||
// у міграції 0061, поруч із самою таблицею.
|
||
const TelegramLinkTTL = 15 * time.Minute
|
||
|
||
// TelegramAccount — прив'язаний telegram-акаунт.
|
||
type TelegramAccount struct {
|
||
ID string `json:"id"`
|
||
TenantID string `json:"-"`
|
||
UserID string `json:"user_id"`
|
||
Email string `json:"email,omitempty"`
|
||
TgUserID int64 `json:"tg_user_id"`
|
||
// Username без «@»: додавати його — справа показу, а не сховища.
|
||
TgUsername string `json:"tg_username,omitempty"`
|
||
TgName string `json:"tg_name,omitempty"`
|
||
LinkedAt time.Time `json:"linked_at"`
|
||
LastActionAt *time.Time `json:"last_action_at,omitempty"`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Канали й курсор — для приймача натискань
|
||
// ---------------------------------------------------------------------
|
||
|
||
// TelegramChannels читає УСІ увімкнені telegram-канали всіх кабінетів
|
||
// разом із розшифрованими токенами.
|
||
//
|
||
// Поза тенантною транзакцією й через пул напряму — тим самим шляхом, що
|
||
// ActiveRules: приймач натискань не обслуговує кабінет, він обслуговує
|
||
// ботів, а який кабінет за цим ботом, з'ясовується вже з чату, у якому
|
||
// натиснули кнопку. Дізнатись це наперед ніяк — оновлення ще не прийшло.
|
||
//
|
||
// Канали з нечитабельним секретом просто пропускаються, а не валять
|
||
// вибірку: один зіпсований токен не має гасити кнопки решті кабінетів.
|
||
func (s *Store) TelegramChannels(ctx context.Context, ring *crypto.Keyring) ([]Channel, error) {
|
||
if ring == nil {
|
||
return nil, nil
|
||
}
|
||
rows, err := s.bg.Query(ctx, `
|
||
SELECT c.id::text, c.tenant_id::text, c.name, c.config::text,
|
||
c.min_severity::text, c.enabled,
|
||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||
FROM alr.channels c
|
||
JOIN core.tenants t ON t.id = c.tenant_id
|
||
JOIN core.secrets s ON s.id = c.secret_id
|
||
WHERE c.kind = 'telegram' AND c.enabled
|
||
AND t.status NOT IN ('suspended','cancelled')
|
||
ORDER BY c.tenant_id, c.name
|
||
`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []Channel
|
||
for rows.Next() {
|
||
var c Channel
|
||
var cfg, keyID, aad string
|
||
var nonce, ct, tag []byte
|
||
if err := rows.Scan(&c.ID, &c.TenantID, &c.Name, &cfg,
|
||
&c.MinSeverity, &c.Enabled, &keyID, &nonce, &ct, &tag, &aad); err != nil {
|
||
return nil, err
|
||
}
|
||
c.Kind = "telegram"
|
||
c.Config = json.RawMessage(cfg)
|
||
c.HasSecret = true
|
||
|
||
plain, derr := ring.Decrypt(&crypto.Secret{
|
||
KeyID: keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||
}, aad)
|
||
if derr != nil {
|
||
continue
|
||
}
|
||
c.Secret = string(plain)
|
||
out = append(out, c)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// TelegramCursor каже, з якого update_id читати далі.
|
||
//
|
||
// Нуль означає «рядка ще немає»: перший запуск має взяти те, що
|
||
// Telegram віддасть сам, а не намагатись вгадати початок черги.
|
||
func (s *Store) TelegramCursor(ctx context.Context, botHash []byte) (int64, error) {
|
||
var next int64
|
||
err := s.bg.QueryRow(ctx,
|
||
`SELECT next_update_id FROM alr.telegram_cursors WHERE bot_hash = $1`,
|
||
botHash).Scan(&next)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return 0, nil
|
||
}
|
||
return next, err
|
||
}
|
||
|
||
// SaveTelegramCursor посуває курсор.
|
||
//
|
||
// GREATEST, а не просто запис: два процеси, що на мить перетнулись на
|
||
// одному боті (старий ще не помітив втрати advisory-блокування),
|
||
// інакше могли б відкотити курсор назад — і вже оброблене натискання
|
||
// прийшло б удруге.
|
||
func (s *Store) SaveTelegramCursor(ctx context.Context, botHash []byte, next int64) error {
|
||
_, err := s.bg.Exec(ctx, `
|
||
INSERT INTO alr.telegram_cursors (bot_hash, next_update_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (bot_hash) DO UPDATE
|
||
SET next_update_id = GREATEST(alr.telegram_cursors.next_update_id, EXCLUDED.next_update_id),
|
||
updated_at = now()
|
||
`, botHash, next)
|
||
return err
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Прив'язка людини
|
||
// ---------------------------------------------------------------------
|
||
|
||
// TelegramAccountByTgID шукає, кому в цьому кабінеті належить
|
||
// telegram-акаунт.
|
||
//
|
||
// Пошук саме за парою (кабінет, tg_user_id), а не за одним лише
|
||
// telegram: кабінет уже відомий із чату, у якому натиснули кнопку, і
|
||
// брати його з прив'язки означало б дозволити натисканню самому
|
||
// вирішувати, над чиїми даними воно діє.
|
||
func (s *Store) TelegramAccountByTgID(ctx context.Context, tenantID string, tgUserID int64) (TelegramAccount, error) {
|
||
var a TelegramAccount
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT t.id::text, t.user_id::text, COALESCE(u.email::text,''),
|
||
t.tg_user_id, t.tg_username, t.tg_name, t.linked_at, t.last_action_at
|
||
FROM core.telegram_accounts t
|
||
JOIN core.users u ON u.id = t.user_id
|
||
WHERE t.tenant_id = $1 AND t.tg_user_id = $2
|
||
`, tenantID, tgUserID).Scan(&a.ID, &a.UserID, &a.Email, &a.TgUserID,
|
||
&a.TgUsername, &a.TgName, &a.LinkedAt, &a.LastActionAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrTelegramNotLinked
|
||
}
|
||
return err
|
||
})
|
||
return a, err
|
||
}
|
||
|
||
// TelegramLink — прив'язка конкретного користувача, для сторінки профілю.
|
||
func (s *Store) TelegramLink(ctx context.Context, tenantID, userID string) (*TelegramAccount, error) {
|
||
var a TelegramAccount
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT id::text, user_id::text, tg_user_id, tg_username, tg_name,
|
||
linked_at, last_action_at
|
||
FROM core.telegram_accounts
|
||
WHERE tenant_id = $1 AND user_id = $2
|
||
`, tenantID, userID).Scan(&a.ID, &a.UserID, &a.TgUserID,
|
||
&a.TgUsername, &a.TgName, &a.LinkedAt, &a.LastActionAt)
|
||
return err
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &a, nil
|
||
}
|
||
|
||
// TouchTelegramAccount позначає, що прив'язкою щойно скористались.
|
||
//
|
||
// Окремим викликом, а не всередині читання: читання, яке пише, робить
|
||
// невиконуваним найпростіше — подивитись, хто прив'язаний, нічого не
|
||
// змінивши.
|
||
func (s *Store) TouchTelegramAccount(ctx context.Context, tenantID, id string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE core.telegram_accounts SET last_action_at = now()
|
||
WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, id)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// linkAlphabet — з чого складається код прив'язки.
|
||
//
|
||
// Ні 0/O, ні 1/I/l: код читають з екрана й набирають у телефоні, і
|
||
// «нуль чи літера О» тут коштує ще однієї спроби. Рівно 32 символи —
|
||
// байт по модулю 32 розподілений рівномірно, тож нахилу до початку
|
||
// абетки, звичного для наївного %, немає.
|
||
const linkAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||
|
||
// CreateTelegramLinkCode видає одноразовий код.
|
||
//
|
||
// Попередній незужитий код тієї ж людини гаситься тут же: два чинні
|
||
// коди означали б, що надісланий колезі «спробуй цей» лишається робочим
|
||
// і після того, як людина взяла новий.
|
||
func (s *Store) CreateTelegramLinkCode(ctx context.Context, tenantID, userID string) (string, time.Time, error) {
|
||
raw := make([]byte, 8)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
code := make([]byte, len(raw))
|
||
for i, b := range raw {
|
||
code[i] = linkAlphabet[int(b)%len(linkAlphabet)]
|
||
}
|
||
sum := sha256.Sum256(code)
|
||
expires := time.Now().Add(TelegramLinkTTL)
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM core.telegram_link_codes
|
||
WHERE tenant_id = $1 AND user_id = $2 AND used_at IS NULL
|
||
`, tenantID, userID); err != nil {
|
||
return err
|
||
}
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO core.telegram_link_codes (code_hash, tenant_id, user_id, expires_at)
|
||
VALUES ($1, $2, $3, $4)
|
||
`, sum[:], tenantID, userID, expires)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
return string(code), expires, nil
|
||
}
|
||
|
||
// RedeemTelegramLinkCode міняє код на прив'язку.
|
||
//
|
||
// tenantIDs — кабінети, яким належить бот, що прийняв повідомлення.
|
||
// Це і є перевірка, без якої код був би універсальним ключем: чинний
|
||
// код кабінету А, надісланий боту кабінету Б, інакше прив'язав би
|
||
// telegram до чужого кабінету — і людина отримала б право підтверджувати
|
||
// алерти там, де її бот навіть не стоїть.
|
||
//
|
||
// Пошук іде поза тенантним контекстом (кабінет ще невідомий — його
|
||
// каже сам рядок), тому пул напряму, як у квитках завантаження.
|
||
func (s *Store) RedeemTelegramLinkCode(ctx context.Context, code string, tenantIDs []string,
|
||
tgUserID int64, username, name string) (TelegramAccount, error) {
|
||
|
||
var a TelegramAccount
|
||
sum := sha256.Sum256([]byte(code))
|
||
|
||
// Пулом воркера, а не основним: перший запит гасить код у
|
||
// core.telegram_link_codes, а вона під tenant_isolation — і на цьому
|
||
// кроці кабінет ще невідомий, його якраз і повертає RETURNING. Під
|
||
// netpulse_app без контексту UPDATE зачепив би нуль рядків, тобто
|
||
// будь-яке прив'язування акаунта відповідало б «код недійсний».
|
||
// Далі, коли кабінет уже названо, транзакція сама виставляє
|
||
// app.tenant_id — і решта запису йде в його межах.
|
||
tx, err := s.bg.Begin(ctx)
|
||
if err != nil {
|
||
return a, err
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
||
var tenantID, userID string
|
||
err = tx.QueryRow(ctx, `
|
||
UPDATE core.telegram_link_codes
|
||
SET used_at = now()
|
||
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||
AND tenant_id = ANY($2::uuid[])
|
||
RETURNING tenant_id::text, user_id::text
|
||
`, sum[:], tenantIDs).Scan(&tenantID, &userID)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return a, ErrTelegramLinkInvalid
|
||
}
|
||
if err != nil {
|
||
return a, err
|
||
}
|
||
|
||
// RLS вмикається вже після того, як кабінет з'ясовано з коду:
|
||
// далі працюємо в його межах, як і решта запису.
|
||
if _, err := tx.Exec(ctx, "SELECT set_config('app.tenant_id', $1, true)", tenantID); err != nil {
|
||
return a, err
|
||
}
|
||
|
||
// Перепідв'язка замість помилки. Людина міняє акаунт у Telegram, і
|
||
// «у вас уже щось прив'язано» на цьому місці означало б, що зняти
|
||
// стару прив'язку можна лише з того самого телефона, якого вже
|
||
// немає. Код одноразовий і виданий тому, хто вже увійшов паролем, —
|
||
// доказу володіння кабінетом для заміни досить.
|
||
err = tx.QueryRow(ctx, `
|
||
INSERT INTO core.telegram_accounts
|
||
(tenant_id, user_id, tg_user_id, tg_username, tg_name)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
ON CONFLICT (tenant_id, user_id) DO UPDATE
|
||
SET tg_user_id = EXCLUDED.tg_user_id,
|
||
tg_username = EXCLUDED.tg_username,
|
||
tg_name = EXCLUDED.tg_name,
|
||
linked_at = now(),
|
||
last_action_at = NULL
|
||
RETURNING id::text, user_id::text, tg_user_id, tg_username, tg_name, linked_at
|
||
`, tenantID, userID, tgUserID, username, name).
|
||
Scan(&a.ID, &a.UserID, &a.TgUserID, &a.TgUsername, &a.TgName, &a.LinkedAt)
|
||
if err != nil {
|
||
return a, err
|
||
}
|
||
a.TenantID = tenantID
|
||
return a, tx.Commit(ctx)
|
||
}
|
||
|
||
// UnlinkTelegram знімає прив'язку.
|
||
func (s *Store) UnlinkTelegram(ctx context.Context, tenantID, userID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
DELETE FROM core.telegram_link_codes
|
||
WHERE tenant_id = $1 AND user_id = $2 AND used_at IS NULL
|
||
`, tenantID, userID); err != nil {
|
||
return err
|
||
}
|
||
_, err := tx.Exec(ctx, `
|
||
DELETE FROM core.telegram_accounts WHERE tenant_id = $1 AND user_id = $2
|
||
`, tenantID, userID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// PruneTelegramLinkCodes прибирає протухлі й зужиті коди.
|
||
func (s *Store) PruneTelegramLinkCodes(ctx context.Context) (int64, error) {
|
||
tag, err := s.bg.Exec(ctx, `
|
||
DELETE FROM core.telegram_link_codes
|
||
WHERE expires_at < now() - interval '1 day'
|
||
`)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return tag.RowsAffected(), nil
|
||
}
|