7afd23d6d4
Уведомления в браузере и на телефоне: сервер сам решает, кому их слать, и подписывает запрос 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, очистка мёртвых подписок), ручки и лимиты, отправка при личном сообщении и упоминании, пропуск онлайн-получателей, миграция на чистой БД.
454 lines
17 KiB
Go
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
|
|
}
|