feat(telegram): split connector into home validator + remote bot
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
Move all Telegram egress off the main host. The single connector held the bot token, long-polled Telegram and answered the gateway/backend over the trusted internal network, so the whole component (including login validation) shared fate with its VPN sidecar. Split it into two binaries that share the token: - cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only, never calls the Bot API. The gateway dials it for Telegram auth, so game login is now independent of Telegram reachability. - cmd/bot (remote): Bot API long-poll + sendMessage, the only component reaching Telegram. It holds no inbound port — it dials the gateway over a new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send commands the gateway pushes. The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget (at-most-once, dropped if no bot is connected); the backend admin broadcasts reach a gateway-served relay that forwards them and awaits the bot's ack (SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one inter-service link that leaves the trusted segment; validator<->gateway and the relay stay plaintext internal. The bot is Telegram-rate-limited. One bot now; the gateway bot registry, an owns_updates flag and per-command ids leave seams for N later. Webhook rejected (one URL per token, adds inbound + a static address). The unified test contour runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; bot-link certs from deploy/gen-certs.sh, generated in CI). The prod wiring — the bot on a separate host (no VPN), the gateway bot-link port published, PROD_ certs with scheduled rotation, an SSH deploy of both hosts together — is the deferred final stage (PRERELEASE.md TX, Stage 18). Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway + backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// Command bot is the remote-side Telegram bot. It runs the Bot API long-poll
|
||||
// (Mini App launch + /start deep-links) and dials the gateway over the reverse mTLS
|
||||
// bot-link to receive and execute send commands (out-of-app push and admin sends).
|
||||
// It holds the bot token and is the only component that reaches the Telegram Bot
|
||||
// API, so it runs on a host with native Telegram access (no VPN) and needs no
|
||||
// inbound port. See platform/telegram/README.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
"scrabble/pkg/mtls"
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/botlink"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
)
|
||||
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
const telemetryShutdownTimeout = 5 * time.Second
|
||||
|
||||
func main() {
|
||||
cfg, err := config.LoadBot()
|
||||
if err != nil {
|
||||
log.Fatalf("bot: load config: %v", err)
|
||||
}
|
||||
logger, err := newLogger(cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Fatalf("bot: build logger: %v", err)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx, cfg, logger); err != nil {
|
||||
logger.Fatal("bot: terminated", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// run wires the bot long-poll and the bot-link client and runs both until the
|
||||
// context is cancelled.
|
||||
func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
tel, err := pkgtel.New(ctx, cfg.Telemetry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := tel.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Warn("telemetry shutdown", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
if err := tel.StartRuntimeMetrics(); err != nil {
|
||||
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
|
||||
}
|
||||
|
||||
b, err := bot.New(bot.Config{
|
||||
Token: cfg.Token,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
TestEnv: cfg.TestEnv,
|
||||
MiniAppURL: cfg.MiniAppURL,
|
||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exec := botlink.NewExecutor(b, cfg.GameChannelID, logger)
|
||||
client := botlink.NewClient(botlink.ClientConfig{
|
||||
GatewayAddr: cfg.BotLink.GatewayAddr,
|
||||
InstanceID: cfg.BotLink.InstanceID,
|
||||
OwnsUpdates: cfg.OwnsUpdates,
|
||||
Creds: credentials.NewTLS(tlsCfg),
|
||||
ReconnectDelay: cfg.BotLink.ReconnectDelay,
|
||||
}, exec, logger)
|
||||
|
||||
logger.Info("telegram bot starting",
|
||||
zap.String("gateway", cfg.BotLink.GatewayAddr),
|
||||
zap.String("miniapp_url", cfg.MiniAppURL),
|
||||
zap.Bool("owns_updates", cfg.OwnsUpdates),
|
||||
zap.Bool("test_env", cfg.TestEnv))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
// The long-poll holds the exclusive getUpdates lease (one bot per token); a bot
|
||||
// that does not own it only delivers sends over the bot-link.
|
||||
if cfg.OwnsUpdates {
|
||||
wg.Go(func() { b.Run(ctx) })
|
||||
}
|
||||
wg.Go(func() {
|
||||
if err := client.Run(ctx); err != nil && ctx.Err() == nil {
|
||||
logger.Error("bot-link client stopped", zap.Error(err))
|
||||
}
|
||||
})
|
||||
|
||||
<-ctx.Done()
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// newLogger builds a production JSON logger at the given level.
|
||||
func newLogger(level string) (*zap.Logger, error) {
|
||||
var lvl zap.AtomicLevel
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := zap.NewProductionConfig()
|
||||
cfg.Level = lvl
|
||||
return cfg.Build()
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// Command telegram is the Telegram platform side-service (the "connector"). It is
|
||||
// the only component holding the bot token: it runs the Bot API long-poll loop
|
||||
// (Mini App launch + /start deep-links) and serves the connector gRPC API
|
||||
// (pkg/proto/telegram/v1) that the gateway and backend call over the trusted
|
||||
// internal network. See platform/telegram/README.md.
|
||||
// Command validator is the home-side Telegram validator. It serves the validation
|
||||
// gRPC API (pkg/proto/telegram/v1 ValidateInitData/ValidateLoginWidget) the gateway
|
||||
// calls during Telegram auth. It holds the bot token only as the HMAC secret and
|
||||
// never reaches the Telegram Bot API, so it runs on the main host with no VPN and
|
||||
// no Telegram egress, and game login stays independent of the remote bot. See
|
||||
// platform/telegram/README.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -20,24 +21,23 @@ import (
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/connector"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
"scrabble/platform/telegram/internal/loginwidget"
|
||||
"scrabble/platform/telegram/internal/validator"
|
||||
)
|
||||
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
const telemetryShutdownTimeout = 5 * time.Second
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
cfg, err := config.LoadValidator()
|
||||
if err != nil {
|
||||
log.Fatalf("telegram: load config: %v", err)
|
||||
log.Fatalf("validator: load config: %v", err)
|
||||
}
|
||||
logger, err := newLogger(cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Fatalf("telegram: build logger: %v", err)
|
||||
log.Fatalf("validator: build logger: %v", err)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
@@ -45,13 +45,12 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx, cfg, logger); err != nil {
|
||||
logger.Fatal("telegram: terminated", zap.Error(err))
|
||||
logger.Fatal("validator: terminated", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// run wires the bot and the gRPC server and serves both until the context is
|
||||
// cancelled.
|
||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// run wires the validator gRPC server and serves it until the context is cancelled.
|
||||
func run(ctx context.Context, cfg config.ValidatorConfig, logger *zap.Logger) error {
|
||||
tel, err := pkgtel.New(ctx, cfg.Telemetry)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -67,23 +66,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
|
||||
}
|
||||
|
||||
// The single bot validates launch data and delivers push/admin messages.
|
||||
b, err := bot.New(bot.Config{
|
||||
Token: cfg.Token,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
TestEnv: cfg.TestEnv,
|
||||
MiniAppURL: cfg.MiniAppURL,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := connector.NewServer(connector.BotRuntime{
|
||||
Sender: b,
|
||||
ChannelID: cfg.GameChannelID,
|
||||
InitValidator: initdata.NewHMACValidator(cfg.Token),
|
||||
WidgetValidator: loginwidget.NewHMACValidator(cfg.Token),
|
||||
}, logger)
|
||||
|
||||
srv := validator.NewServer(initdata.NewHMACValidator(cfg.Token), loginwidget.NewHMACValidator(cfg.Token))
|
||||
grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
|
||||
telegramv1.RegisterTelegramServer(grpcServer, srv)
|
||||
|
||||
@@ -92,18 +75,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// The long-poll loop and the gRPC server run together; cancelling the context
|
||||
// stops the bot loop and gracefully drains the gRPC server.
|
||||
go b.Run(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
grpcServer.GracefulStop()
|
||||
}()
|
||||
|
||||
logger.Info("telegram connector starting",
|
||||
zap.String("grpc_addr", cfg.GRPCAddr),
|
||||
zap.String("miniapp_url", cfg.MiniAppURL),
|
||||
zap.Bool("test_env", cfg.TestEnv))
|
||||
logger.Info("telegram validator starting", zap.String("grpc_addr", cfg.GRPCAddr))
|
||||
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user