Files
glchat/cmd/glchat/cli.go
T
grendervill 7afd23d6d4 feat(push): Web Push — VAPID, подписки устройств и отправка (AGENT.md 7.16)
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и
подписывает запрос VAPID-ключом, поэтому уведомление приходит, даже когда
клиент закрыт.

Сервер:
- миграция 00021: `push_subscriptions` (эндпоинт уникален, ключи, счётчик
  неудач, время последней доставки);
- `internal/push` — VAPID-ключи (приватный PKCS#8 из конфига, публичный
  выводится из него), правила уведомлений повторяют
  `web/src/lib/desktopNotifications.ts` (упоминания и личные беседы, без своих
  и системных сообщений), очередь доставки, TTL 12 часов, Topic по комнате;
- 404/410 от push-сервиса удаляют подписку сразу, 5 неудач подряд — тоже,
  иначе копились бы мёртвые эндпоинты; retention чистит «молчащие» подписки;
- `internal/httpx/safeurl.go` — общий запрет внутренних адресов с проверкой
  адреса в момент подключения (DNS rebinding): эндпоинт подписки приходит от
  клиента, и без проверки сервер сам себе организует SSRF;
- `internal/gateway/presence.go` — `IsUserOnline`: если получатель в клиенте,
  уведомление покажет клиент, дублировать на телефон не нужно;
- ручки `GET /push/config`, `GET|POST|DELETE /push/subscriptions`, лимиты
  10 подписок и 20 уведомлений в минуту; ключ p256dh проверяется как настоящая
  точка P-256;
- установщик генерирует `VAPID_PRIVATE_KEY` (openssl, PKCS#8 DER в base64) и
  `VAPID_SUBJECT`, ключ переиспользуется при переустановке; для ручной установки
  есть `glchat vapid-keys`; `features.web_push_enabled` виден в `/meta`.

Тесты: правила и VAPID, доставка с поддельным push-эндпоинтом (шифрование,
заголовки VAPID/TTL/Topic, очистка мёртвых подписок), ручки и лимиты, отправка
при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на
чистой БД.
2026-09-26 16:14:20 +03:00

454 lines
17 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"strconv"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/pquerna/otp/totp"
"glchat/internal/auth"
"glchat/internal/bootstrap"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/retention"
"glchat/internal/store"
)
// runCLI обрабатывает подкоманды обслуживания (AGENT.md 10.7). Команды,
// требующие БД, работают внутри контейнера: путь берётся из DATA_DIR.
func runCLI(args []string) int {
switch args[0] {
case "bootstrap-admin":
return cliBootstrapAdmin(args[1:])
case "reset-password":
return cliResetPassword(args[1:])
case "make-admin", "remove-admin":
return cliSetInstanceAdmin(args[0], args[1:])
case "totp-setup":
return cliTOTPSetup(args[1:])
case "totp-reset":
return cliTOTPReset(args[1:])
case "create-user":
return cliCreateUser(args[1:])
case "cleanup":
return cliCleanup(args[1:])
case "reindex":
return cliReindex(args[1:])
case "vapid-keys":
return cliVAPIDKeys(args[1:])
default:
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
return 2
}
}
func withServices(fn func(ctx context.Context, st *store.Store, authService *auth.Service, cfg config.Config) error) int {
ctx := context.Background()
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
db, err := database.Open(ctx, database.Options{
Path: cfg.DatabasePath,
ReadPool: cfg.ReadPoolSize,
CacheSize: cfg.SQLiteCache,
MmapSize: cfg.SQLiteMmap,
Migrate: true,
})
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
defer func() { _ = db.Close() }()
st := store.New(db)
authService, err := auth.New(ctx, cfg, st, newLogger(cfg))
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
if err := fn(ctx, st, authService, cfg); err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
return 0
}
func cliBootstrapAdmin(args []string) int {
flags := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError)
email := flags.String("email", "", "email администратора инстанса")
password := flags.String("password", "", "пароль администратора")
username := flags.String("username", "", "username (по умолчанию — часть email)")
instanceName := flags.String("instance-name", "", "название инстанса")
mainGuild := flags.String("main-guild", "", "название главного сервера")
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat bootstrap-admin: укажите --email")
return 2
}
if *password == "" {
*password = os.Getenv("ADMIN_PASSWORD")
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, cfg config.Config) error {
runner := bootstrap.New(st, authService, newLogger(cfg))
result, err := runner.Run(ctx, bootstrap.Options{
Email: *email,
Password: *password,
Username: *username,
InstanceName: *instanceName,
MainGuild: *mainGuild,
})
if err != nil {
return err
}
if result.CreatedUser {
fmt.Println("инстанс-администратор создан")
} else {
fmt.Println("инстанс-администратор уже существует (права подтверждены)")
}
fmt.Printf("главный сервер: %d (создан: %t)\n", result.GuildID, result.CreatedGuild)
fmt.Printf("роли: администратор=%d пользователь=%d\n", result.AdminRoleID, result.MemberRoleID)
return nil
})
}
func cliResetPassword(args []string) int {
flags := flag.NewFlagSet("reset-password", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
password := flags.String("password", "", "новый пароль")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat reset-password: укажите email")
return 2
}
if *password == "" {
fmt.Fprintln(os.Stderr, "glchat reset-password: укажите --password")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := authService.SetPassword(ctx, user.ID, *password); err != nil {
return err
}
fmt.Printf("пароль пользователя %s обновлён, все сессии отозваны\n", user.Username)
return nil
})
}
// cliTOTPSetup включает 2FA для пользователя (обычно инстанс-администратора):
// 2FA обязательна для админов (AGENT.md 7.19), а до её включения вход закрыт,
// поэтому первичная настройка выполняется командой обслуживания.
func cliTOTPSetup(args []string) int {
flags := flag.NewFlagSet("totp-setup", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя (обычно администратора инстанса)")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat totp-setup: укажите --email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
setup, err := authService.SetupTOTP(ctx, user.ID)
if err != nil {
if errors.Is(err, auth.ErrTOTPAlreadyEnabled) {
return fmt.Errorf("2FA уже включена: сбросьте секрет через glchat totp-reset")
}
return err
}
// Код подтверждения считаем из секрета: команда выполняется на сервере,
// где секрет и так доступен (расшифровка идёт внутри сервиса).
code, err := totp.GenerateCode(setup.Secret, time.Now())
if err != nil {
return fmt.Errorf("сгенерировать код подтверждения: %w", err)
}
codes, err := authService.EnableTOTP(ctx, user.ID, code)
if err != nil {
return err
}
fmt.Printf("2FA включена для %s\n", user.Username)
fmt.Printf("секрет: %s\n", setup.Secret)
fmt.Printf("otpauth: %s\n", setup.URL)
fmt.Println("резервные коды (каждый одноразовый, сохраните вне сервера):")
for _, recovery := range codes {
fmt.Printf(" %s\n", recovery)
}
return nil
})
}
// cliTOTPReset удаляет секрет 2FA (например, при потере устройства и кодов).
func cliTOTPReset(args []string) int {
flags := flag.NewFlagSet("totp-reset", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat totp-reset: укажите --email")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := st.DeleteTOTPSecret(ctx, user.ID); err != nil {
return err
}
fmt.Printf("секрет 2FA для %s удалён; включите заново командой totp-setup\n", user.Username)
return nil
})
}
func cliSetInstanceAdmin(command string, args []string) int {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintf(os.Stderr, "glchat %s: укажите email\n", command)
return 2
}
grant := command == "make-admin"
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := st.SetInstanceAdmin(ctx, user.ID, grant); err != nil {
return err
}
badges := []string{}
if grant {
// Бейдж администратора выдаётся только системой (AGENT.md 7.2).
badges = append(badges, "instance_admin")
}
if err := st.SetUserBadges(ctx, user.ID, badges); err != nil {
return err
}
action := "выданы"
if !grant {
action = "сняты"
}
fmt.Printf("права администратора сервера %s пользователю %s\n", action, user.Username)
return nil
})
}
// cliCleanup запускает чистку вручную: сессии, аудит и файлы без ссылок
// (AGENT.md 6.4, 10.7).
// cliCreateUser создаёт аккаунты по команде обслуживания: регистрация на
// инстансе может быть выключена, а для нагрузочного теста (AGENT.md 11.4)
// нужны десятки пользователей. Пароль задаёт оператор, аккаунты создаются без
// сессий; список логинов и паролей можно выгрузить в файл для генератора
// нагрузки (--out).
func cliCreateUser(args []string) int {
flags := flag.NewFlagSet("create-user", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
username := flags.String("username", "", "username (по умолчанию — часть email)")
password := flags.String("password", "", "пароль")
count := flags.Int("count", 1, "сколько аккаунтов создать (для нагрузки)")
prefix := flags.String("prefix", "", "префикс логина в пакетном режиме, например loadtest")
domain := flags.String("domain", "loadtest.local", "домен email в пакетном режиме")
out := flags.String("out", "", "файл с созданными учётными данными (JSON)")
sessions := flags.Bool("sessions", false, "выдать каждому аккаунту сессию (для нагрузочного теста)")
if err := flags.Parse(args); err != nil {
return 2
}
if *password == "" {
fmt.Fprintln(os.Stderr, "glchat create-user: укажите --password")
return 2
}
if *count > 1 && *prefix == "" {
fmt.Fprintln(os.Stderr, "glchat create-user: для --count > 1 нужен --prefix")
return 2
}
if *count <= 1 && *email == "" {
fmt.Fprintln(os.Stderr, "glchat create-user: укажите --email")
return 2
}
type credentials struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Token string `json:"token,omitempty"`
}
created := make([]credentials, 0, *count)
code := withServices(func(ctx context.Context, _ *store.Store, authService *auth.Service, _ config.Config) error {
for index := 1; index <= *count; index += 1 {
name := *username
address := *email
if *count > 1 {
name = fmt.Sprintf("%s%d", *prefix, index)
address = fmt.Sprintf("%s%d@%s", *prefix, index, *domain)
}
user, err := authService.CreateUserByOperator(ctx, auth.RegisterInput{
Username: name,
Email: address,
Password: *password,
})
if err != nil {
if errors.Is(err, auth.ErrUsernameTaken) || errors.Is(err, auth.ErrEmailTaken) {
fmt.Printf("пропущен (уже существует): %s\n", address)
continue
}
return fmt.Errorf("создать %s: %w", address, err)
}
entry := credentials{
ID: strconv.FormatUint(user.ID, 10),
Username: user.Username,
Email: address,
Password: *password,
}
if *sessions {
token, err := authService.IssueSessionForOperator(ctx, user.ID, "glchat-cli loadtest")
if err != nil {
return fmt.Errorf("сессия для %s: %w", address, err)
}
entry.Token = token
}
created = append(created, entry)
if *count == 1 || index%25 == 0 || index == *count {
fmt.Printf("создано %d из %d\n", index, *count)
}
}
return nil
})
if code != 0 {
return code
}
if *out != "" {
// #nosec G117 -- файл с тестовыми паролями создаётся намеренно, права 600.
encoded, err := json.MarshalIndent(created, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "glchat create-user: %v\n", err)
return 1
}
// Файл содержит пароли тестовых аккаунтов: права 600.
if err := os.WriteFile(*out, encoded, 0o600); err != nil {
fmt.Fprintf(os.Stderr, "glchat create-user: %v\n", err)
return 1
}
fmt.Printf("учётные данные: %s (%d)\n", *out, len(created))
}
fmt.Printf("создано аккаунтов: %d\n", len(created))
return 0
}
func cliCleanup(args []string) int {
flags := flag.NewFlagSet("cleanup", flag.ContinueOnError)
auditDays := flags.Int("audit-days", -1, "сколько дней хранить журнал аудита (по умолчанию из конфигурации)")
orphanHours := flags.Int("orphan-hours", -1, "через сколько часов удалять файлы без ссылок")
if err := flags.Parse(args); err != nil {
return 2
}
return withServices(func(ctx context.Context, st *store.Store, _ *auth.Service, cfg config.Config) error {
options := retention.Options{
DataDir: cfg.DataDir,
AuditDays: cfg.RetainAuditDays,
OrphanHours: cfg.RetainOrphanHours,
Logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})),
}
if *auditDays >= 0 {
options.AuditDays = *auditDays
}
if *orphanHours >= 0 {
options.OrphanHours = *orphanHours
}
stats, err := retention.New(st, options).RunOnce(ctx)
fmt.Printf("удалено: сессий %d, записей аудита %d, файлов %d, файлов без записи %d, "+
"подписок Web Push %d (%d байт)\n",
stats.Sessions, stats.Audit, stats.Files, stats.Unknown,
stats.PushSubscriptions, stats.Bytes)
return err
})
}
// cliReindex пересобирает индекс полнотекстового поиска (AGENT.md 10.7).
func cliReindex(_ []string) int {
return withServices(func(ctx context.Context, st *store.Store, _ *auth.Service, _ config.Config) error {
if err := st.RebuildSearchIndex(ctx); err != nil {
return err
}
fmt.Println("индекс поиска пересобран")
return nil
})
}
// cliVAPIDKeys печатает пару VAPID-ключей для Web Push (AGENT.md 7.16).
// Установщик генерирует ключ сам; команда нужна тем, кто ставил инстанс иначе
// или меняет ключ вручную. Публичный ключ приложение выводит из приватного,
// поэтому в .env достаточно одной строки.
func cliVAPIDKeys(args []string) int {
flags := flag.NewFlagSet("vapid-keys", flag.ContinueOnError)
subject := flags.String("subject", "", "контакт администратора для push-сервисов (mailto:… или https://…)")
if err := flags.Parse(args); err != nil {
return 2
}
domain := "localhost"
if cfg, err := config.Load(); err == nil && cfg.Domain != "" {
domain = cfg.Domain
}
contact := *subject
if contact == "" {
contact = "mailto:admin@" + domain
}
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: не удалось сгенерировать ключи: %v\n", err)
return 1
}
fmt.Println("# Web Push (Фаза 7): строки ниже добавьте в /opt/glchat/.env и перезапустите glchat-app.")
fmt.Printf("VAPID_PRIVATE_KEY=%s\n", privateKey)
fmt.Printf("VAPID_SUBJECT=%s\n", contact)
// Публичный ключ выводится приложением из приватного: печатаем справочно.
fmt.Printf("# публичный ключ: %s\n", publicKey)
return 0
}