Files
glchat/cmd/glchat/main.go
T
grendervill bf0d130ee8 feat(cli): включение 2FA инстанс-админа командой totp-setup
2FA обязательна для инстанс-администраторов, но до её включения вход закрыт —
получался замкнутый круг. Добавлены команды обслуживания:

- `glchat totp-setup --email <admin>`: создаёт секрет, подтверждает его кодом,
  печатает секрет, otpauth-ссылку и 8 резервных кодов (каждый одноразовый);
- `glchat totp-reset --email <admin>`: удаляет секрет при потере устройства;
- код `auth.2fa_enrollment_required` с подсказкой, какую команду выполнить;
- установщик: флаг `--admin-2fa` для автоматического включения (по умолчанию
  печатает подсказку, чтобы секреты не оседали в логах установки).

Проверено сквозным прогоном локально: bootstrap → 403 на входе без 2FA →
totp-setup → вход с TOTP-кодом → профиль, серверы, комнаты, роли, участники,
аудит, создание сервера админом, 429 на шестой попытке входа, секретов в логах
нет.
2026-09-19 21:53:35 +03:00

198 lines
5.0 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/permissions"
"glchat/internal/server"
"glchat/internal/source"
"glchat/internal/store"
)
// Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...".
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
func main() {
// healthcheck используется HEALTHCHECK контейнера: собственный бинарник
// избавляет образ от curl и лишних пакетов.
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin, totp-setup, totp-reset. Остальные команды
// реализует shell-обёртка glchat.
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset":
os.Exit(runCLI(os.Args[1:]))
}
}
if err := run(); err != nil {
slog.Error("glchat exited with error", slog.Any("error", err))
os.Exit(1)
}
}
func healthcheck() int {
addr := config.DefaultListenAddr
if cfg, err := config.Load(); err == nil {
addr = cfg.ListenAddr
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
host, port = "127.0.0.1", "8080"
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
url := "http://" + net.JoinHostPort(host, port) + "/healthz"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 1
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 1
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}
func run() error {
if v := os.Getenv("APP_VERSION"); v != "" {
version = v
}
if c := os.Getenv("APP_COMMIT"); c != "" {
commit = c
}
if b := os.Getenv("APP_BUILD_DATE"); b != "" {
buildDate = b
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
cfg.Version = version
cfg.Commit = commit
cfg.BuildDate = buildDate
logger := newLogger(cfg)
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := database.Open(ctx, database.Options{
Path: cfg.DatabasePath,
ReadPool: cfg.ReadPoolSize,
CacheSize: cfg.SQLiteCache,
MmapSize: cfg.SQLiteMmap,
Migrate: true,
})
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("close database", slog.Any("error", err))
}
}()
schemaVersion, err := db.SchemaVersion(ctx)
if err != nil {
return err
}
journalMode, err := db.JournalMode(ctx)
if err != nil {
return err
}
logger.Info("database ready",
slog.String("path", db.Path()),
slog.Int64("schema_version", schemaVersion),
slog.String("journal_mode", journalMode),
)
stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance)
defer stopMaintenance()
st := store.New(db)
authService, err := auth.New(ctx, cfg, st, logger)
if err != nil {
return fmt.Errorf("initialize authentication: %w", err)
}
// Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш.
calculator := permissions.NewCalculator(source.New(st))
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
srv := server.New(cfg, db, logger, server.Deps{
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
})
errCh := make(chan error, 1)
go func() {
logger.Info("http server listening",
slog.String("addr", cfg.ListenAddr),
slog.String("version", cfg.Version),
slog.String("domain", cfg.Domain),
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Info("shutdown requested")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTTL)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return nil
}
func newLogger(cfg config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.Level(cfg.LogLevelNumeric())}
var handler slog.Handler
if cfg.LogFormat == "text" {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return slog.New(handler).With(
slog.String("service", "glchat"),
slog.String("version", cfg.Version),
)
}