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

348 lines
15 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"
"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
}