Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -11,10 +11,16 @@ import (
"strings"
"time"
"golang.org/x/text/language"
"telesrv/internal/domain"
"telesrv/internal/links"
)
const defaultConfigFile = ".env"
const (
defaultConfigFile = ".env"
defaultCountryCode = "CN"
)
// Config 是 telesrv 的运行配置。
type Config struct {
@ -31,6 +37,9 @@ type Config struct {
RSAKeyPath string
// DC 是本 server 的 DC ID。
DC int
// DefaultCountryCode 是 help.getNearestDc 返回的 ISO 3166-1 alpha-2 国家码。
// 客户端登录页据此预选国家和国际电话区号;例如 CN 对应 +86。
DefaultCountryCode string
// StrictDCCheck turns on exact DC-ID validation for the permanent-key
// exchange (default off = lenient). See mtprotoedge.Options.StrictDC doc
// for the full rationale: telesrv is always a single physical backend,
@ -53,15 +62,13 @@ type Config struct {
MTProtoRPCGlobalWorkers int
MTProtoRPCGlobalMaxTasks int
MTProtoRPCGlobalMaxBytes int64
// Pending ownership and completed rpc_result replay state share a three-level
// global/raw-auth/session budget over the full MTProto duplicate horizon.
MTProtoRPCResultCacheMaxEntries int
MTProtoRPCResultCacheMaxBytes int64
MTProtoRPCResultCacheAuthMaxEntries int
MTProtoRPCResultCacheAuthMaxBytes int64
MTProtoRPCResultCacheSessionMaxEntries int
MTProtoRPCResultCacheSessionMaxBytes int64
MTProtoRPCResultPendingPerAuth int
// Pending ownership and compact completed receipts share three-level
// global/raw-auth/session entry accounting. Result bodies are never cached
// here; the logical-session outbox owns unacknowledged wire bytes.
MTProtoRPCExecutionMaxEntries int
MTProtoRPCExecutionAuthMaxEntries int
MTProtoRPCExecutionSessionMaxEntries int
MTProtoRPCExecutionPendingPerAuth int
// MTProtoInboundFrameGlobalMaxBytes 是 transport wire + 最大解密 plaintext 的
// 进程级在途预算;frame 长度读出后、payload 分配前预留。
MTProtoInboundFrameGlobalMaxBytes int64
@ -106,7 +113,8 @@ type Config struct {
ScamWarning string
FakeWarning string
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/、
// /addlist/ 与 hash-only /appeal/ 路由反代到该地址。
PublicLinkWebAddr string
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
@ -131,6 +139,19 @@ type Config struct {
AdminUIPassword string
AdminUIToken string
AdminSessionKey string
// AdminUIPermissions is the permission set granted to a panel session that
// authenticated with TELESRV_ADMIN_UI_PASSWORD / _TOKEN. The single entry "*"
// means "every permission" and is the shipped default, so enabling RBAC never
// silently locks an operator out of a panel that worked before.
AdminUIPermissions []string
// AdminScopedTokens are additional adminapi bearer tokens with a bounded
// permission set each. They exist so an integration can be given exactly the
// rights it needs instead of the unrestricted TELESRV_ADMIN_API_TOKEN. Parsed
// from
// "name:token:perm1,perm2" entries separated by ';'; a malformed entry, a
// duplicate name or a duplicate token fails startup rather than silently
// granting or dropping rights.
AdminScopedTokens []AdminScopedToken
// PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。
// 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。
@ -236,6 +257,9 @@ type Config struct {
StickerSeedDir string
// StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。
StickerSeedMaxSets int
// PremiumPromoSeedDir 是 help.getPremiumPromo 视频与缩略图导出目录。
// 目录缺失时保留无视频兼容响应;目录存在但内容非法时启动失败。
PremiumPromoSeedDir string
// BusinessAIProvider 控制服务端 Business automation 回复生成器。
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
// "template" 使用 quick reply 模板。
@ -344,6 +368,8 @@ type Config struct {
CallTombstoneTTL time.Duration
// CallMaxActivePerUser 是单用户并发非终态通话上限。
CallMaxActivePerUser int
// CallRegistryMaxEntries 是进程内通话 registry 的全局硬上限。
CallRegistryMaxEntries int
// CallSignalingMaxBytes 是 phone.sendSignalingData 单条载荷上限。
CallSignalingMaxBytes int
// CallSignalingRate 是单通话每秒信令转发上限(超限静默丢弃)。
@ -391,6 +417,106 @@ type Config struct {
StarGiftCraftDelay time.Duration
StarGiftCraftChancePermille int
// RatingEnabled controls the local admin-only composite account rating.
// Disabled keeps every local projection empty and refuses rating writes; no
// client-facing Telegram field changes in either mode.
RatingEnabled bool
// RatingPendingDelay is how long a rating increase stays parked as a pending
// local score before it becomes the visible admin level. A decrease is
// always applied immediately: a penalty must not sit behind a delay.
// 0 applies every change immediately.
RatingPendingDelay time.Duration
// RatingRecomputeInterval / RatingRecomputeBatch drive the background
// recompute worker. The rating derives from signals owned by other
// subsystems, so freshness is a worker property, not a write-path one.
RatingRecomputeInterval time.Duration
RatingRecomputeBatch int
// RatingStaleAfter is the projection age after which the worker recomputes a
// user.
RatingStaleAfter time.Duration
// Rating weights are the integer composite formula. Defaults mirror
// domain.DefaultAccountRatingWeights() exactly, so the shipped behaviour is
// identical whether or not these keys are set. Every weight is a magnitude:
// the penalties are subtracted by the domain formula, so all values are
// non-negative and a negative value fails startup.
RatingWeightStarsReceivedPermille int64
RatingWeightStarsSpentPermille int64
RatingWeightMessageSent int64
RatingWeightAccountAgeDay int64
RatingWeightGiftReceived int64
RatingWeightModerationCase int64
RatingWeightScamPenalty int64
RatingWeightFakePenalty int64
// RatingActivityCap bounds the activity component so activity alone cannot
// outweigh Stars and moderation; 0 leaves it uncapped.
RatingActivityCap int64
// VerificationEnabled controls official platform verification: the @verifybot
// application flow and the panel's review queue. Disabled refuses every
// verification use case explicitly; already-verified peers keep their badge,
// because the flag lives on the peer record and is not derived from this
// feature being on.
VerificationEnabled bool
// VerificationAllowUserTargets opts plain user accounts in as verification
// subjects. Off by default: the official process verifies a public presence
// (bot, public channel, public supergroup), and a private account has nothing
// to check.
VerificationAllowUserTargets bool
// VerificationRejectCooldown is how long an applicant must wait before filing
// the same target again after a rejection. Measured from the decision, so a
// slow review never shortens it; 0 disables the cooldown.
VerificationRejectCooldown time.Duration
// VerificationApplyRateLimit / VerificationApplyRateWindow bound how many
// applications one applicant may create per window. 0 for either disables the
// budget.
VerificationApplyRateLimit int
VerificationApplyRateWindow time.Duration
// VerificationBotRateLimit / VerificationBotRateWindow bound the @verifybot
// dialog itself (per-applicant command rate), independently of how many
// applications are actually created.
VerificationBotRateLimit int
VerificationBotRateWindow time.Duration
// VerificationNotifyInterval / VerificationNotifyBatch drive the applicant
// notification worker. A decision commits with its outbox row, never with a
// message send, so delivery cadence is a worker property.
VerificationNotifyInterval time.Duration
VerificationNotifyBatch int
// VerificationMaxActivePerUser bounds how many applications one applicant may
// keep open at once; 0 disables the cap.
VerificationMaxActivePerUser int
// BotVerificationEnabled controls THIRD-PARTY bot verification
// (core.telegram.org/api/bots/verification): a verifier bot marking peers with
// its own icon and description, projected onto
// user/channel.bot_verification_icon and botInfo.verifier_settings. It is a
// different mechanism from VerificationEnabled above -- that one is the
// operator-granted platform checkmark, and the two never read each other's
// state.
//
// Disabled refuses every third-party mutation (grants, revocations,
// applications, catalogue edits) while the marks already granted keep
// projecting: blanking one verifier's badges is what its per-verifier kill
// switch is for.
BotVerificationEnabled bool
// BotVerificationMaxPerVerifier bounds how many peers one verifier bot may
// mark. Verifier status is granted per deployment rather than earned per peer,
// so an unbounded verifier would be an unbounded badge printer. 0 disables the
// service-level bound and leaves only the storage bound
// (domain.MaxCustomVerificationsPerVerifier), which is also the maximum this
// key accepts.
BotVerificationMaxPerVerifier int
// BotVerificationRequestRateLimit / BotVerificationRequestRateWindow bound how
// many verification applications one applicant may file per window, across all
// verifier bots. 0 for either disables the budget.
BotVerificationRequestRateLimit int
BotVerificationRequestRateWindow time.Duration
// CollectibleUsernameURLTemplate is the landing URL recorded on a minted
// collectible username when the mint request carries no explicit URL.
// Empty derives <TELESRV_PUBLIC_BASE_URL>/nft/username/<username>; a template
// may carry the {username} placeholder, and without it the name is appended
// as the last path segment. No external marketplace is contacted.
CollectibleUsernameURLTemplate string
// GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态
// 4s 一跳;M1 起 SFU liveness reporter 同样刷新该水位)。
GroupCallCheckTTL time.Duration
@ -440,6 +566,15 @@ type Config struct {
SFUAdvertiseIP string
}
// AdminScopedToken is one adminapi bearer token restricted to a permission set.
// Name is the audit identity written next to actions performed with the token;
// Permissions is the closed list of rights it carries ("*" means all).
type AdminScopedToken struct {
Name string
Token string
Permissions []string
}
type AIProviderConfig struct {
Name string
Kind string
@ -465,6 +600,9 @@ func Load() (Config, error) {
envInt64Or := fileEnv.envInt64Or
envDurationOr := fileEnv.envDurationOr
envAllowEmptyOr := fileEnv.envAllowEmptyOr
if err := validateStrictMTProtoCapacityEnv(fileEnv); err != nil {
return Config{}, err
}
publicBaseURL, err := links.ValidateBaseURL(envOr("TELESRV_PUBLIC_BASE_URL", links.DefaultPublicBaseURL))
if err != nil {
@ -495,6 +633,21 @@ func Load() (Config, error) {
if err != nil {
return Config{}, fmt.Errorf("TELESRV_PUBLIC_DOWNLOAD_URL: %w", err)
}
countryCode, err := normalizeDefaultCountryCode(envOr("TELESRV_DEFAULT_COUNTRY_CODE", defaultCountryCode))
if err != nil {
return Config{}, fmt.Errorf("TELESRV_DEFAULT_COUNTRY_CODE: %w", err)
}
advertiseIP, err := normalizeAdvertiseIP(envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"))
if err != nil {
return Config{}, fmt.Errorf("TELESRV_ADVERTISE_IP: %w", err)
}
// The composite rating weight defaults are the domain formula's own defaults;
// see RatingWeight* below.
defaultRatingWeights := domain.DefaultAccountRatingWeights()
adminScopedTokens, err := parseAdminScopedTokens(envAllowEmptyOr("TELESRV_ADMIN_SCOPED_TOKENS", ""))
if err != nil {
return Config{}, err
}
cfg := Config{
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
@ -503,33 +656,28 @@ func Load() (Config, error) {
"http://localhost:1234",
"http://127.0.0.1:1234",
}),
// AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions,
// 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。
// 字段与默认值保留,供未来需要显式下发 DC 地址时使用。
AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"),
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
DC: envIntOr("TELESRV_DC", 2),
StrictDCCheck: envBoolOr("TELESRV_STRICT_DC_CHECK", false),
MTProtoMaxConnections: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS", 200000),
MTProtoMaxConnectionsPerIP: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP", 4096),
MTProtoMaxConcurrentHandshakes: envIntOr("TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES", 256),
MTProtoRPCMaxInflight: envIntOr("TELESRV_MTPROTO_RPC_MAX_INFLIGHT", 32),
MTProtoRPCQueueSize: envIntOr("TELESRV_MTPROTO_RPC_QUEUE_SIZE", 64),
MTProtoRPCTimeout: envDurationOr("TELESRV_MTPROTO_RPC_TIMEOUT", 30*time.Second),
MTProtoRPCGlobalWorkers: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", 256),
MTProtoRPCGlobalMaxTasks: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", 8192),
MTProtoRPCGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", 512<<20),
MTProtoRPCResultCacheMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", 1<<18),
MTProtoRPCResultCacheMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES", 64<<20),
MTProtoRPCResultCacheAuthMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES", 1<<15),
MTProtoRPCResultCacheAuthMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", 32<<20),
MTProtoRPCResultCacheSessionMaxEntries: envIntOr(
"TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES", 1<<14,
// help.getConfig 必须下发至少一个可重连的主 DC 地址;远端部署不能
// 沿用 loopback 默认值,需显式设置客户端实际可达的 IP。
AdvertiseIP: advertiseIP,
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
DC: envIntOr("TELESRV_DC", 2),
DefaultCountryCode: countryCode,
StrictDCCheck: envBoolOr("TELESRV_STRICT_DC_CHECK", false),
MTProtoMaxConnections: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS", 200000),
MTProtoMaxConnectionsPerIP: envIntOr("TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP", 4096),
MTProtoMaxConcurrentHandshakes: envIntOr("TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES", 256),
MTProtoRPCMaxInflight: envIntOr("TELESRV_MTPROTO_RPC_MAX_INFLIGHT", 32),
MTProtoRPCQueueSize: envIntOr("TELESRV_MTPROTO_RPC_QUEUE_SIZE", 64),
MTProtoRPCTimeout: envDurationOr("TELESRV_MTPROTO_RPC_TIMEOUT", 30*time.Second),
MTProtoRPCGlobalWorkers: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", 256),
MTProtoRPCGlobalMaxTasks: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", 8192),
MTProtoRPCGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", 512<<20),
MTProtoRPCExecutionMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_EXECUTION_MAX_ENTRIES", 1<<18),
MTProtoRPCExecutionAuthMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_EXECUTION_AUTH_MAX_ENTRIES", 1<<15),
MTProtoRPCExecutionSessionMaxEntries: envIntOr(
"TELESRV_MTPROTO_RPC_EXECUTION_SESSION_MAX_ENTRIES", 1<<14,
),
MTProtoRPCResultCacheSessionMaxBytes: envInt64Or(
"TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", 16<<20,
),
MTProtoRPCResultPendingPerAuth: envIntOr("TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", 1<<11),
MTProtoRPCExecutionPendingPerAuth: envIntOr("TELESRV_MTPROTO_RPC_EXECUTION_PENDING_PER_AUTH", 1<<11),
MTProtoInboundFrameGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", 512<<20),
MTProtoOutboundQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", 128),
MTProtoOutboundControlQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", 32),
@ -561,6 +709,8 @@ func Load() (Config, error) {
TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour),
TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute),
TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500),
AdminUIPermissions: envListOr("TELESRV_ADMIN_UI_PERMISSIONS", []string{adminPermissionAll}),
AdminScopedTokens: adminScopedTokens,
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
@ -608,6 +758,7 @@ func Load() (Config, error) {
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
PremiumPromoSeedDir: envOr("TELESRV_PREMIUM_PROMO_SEED_DIR", "data/premium-promo"),
MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""),
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true),
@ -662,12 +813,13 @@ func Load() (Config, error) {
UploadInFlightMaxParts: envIntOr("TELESRV_UPLOAD_INFLIGHT_MAX_PARTS", 8000),
UploadInFlightMaxFiles: envIntOr("TELESRV_UPLOAD_INFLIGHT_MAX_FILES", 64),
CallRingTimeout: envDurationOr("TELESRV_CALL_RING_TIMEOUT", 90*time.Second),
CallTombstoneTTL: envDurationOr("TELESRV_CALL_TOMBSTONE_TTL", 60*time.Second),
CallMaxActivePerUser: envIntOr("TELESRV_CALL_MAX_ACTIVE_PER_USER", 4),
CallSignalingMaxBytes: envIntOr("TELESRV_CALL_SIGNALING_MAX_BYTES", 65536),
CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50),
CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second),
CallRingTimeout: envDurationOr("TELESRV_CALL_RING_TIMEOUT", 90*time.Second),
CallTombstoneTTL: envDurationOr("TELESRV_CALL_TOMBSTONE_TTL", 60*time.Second),
CallMaxActivePerUser: envIntOr("TELESRV_CALL_MAX_ACTIVE_PER_USER", 4),
CallRegistryMaxEntries: envIntOr("TELESRV_CALL_REGISTRY_MAX_ENTRIES", 10_000),
CallSignalingMaxBytes: envIntOr("TELESRV_CALL_SIGNALING_MAX_BYTES", 65536),
CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50),
CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second),
PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3),
DefaultStickerSetID: envInt64Or("TELESRV_DEFAULT_STICKER_SET_ID", 0),
@ -689,6 +841,47 @@ func Load() (Config, error) {
StarGiftCraftDelay: envDurationOr("TELESRV_STARGIFT_CRAFT_DELAY", 0),
StarGiftCraftChancePermille: envIntOr("TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE", 250),
RatingEnabled: envBoolOr("TELESRV_RATING_ENABLED", true),
RatingPendingDelay: envDurationOr("TELESRV_RATING_PENDING_DELAY", 24*time.Hour),
RatingRecomputeInterval: envDurationOr("TELESRV_RATING_RECOMPUTE_INTERVAL", 15*time.Minute),
RatingRecomputeBatch: envIntOr("TELESRV_RATING_RECOMPUTE_BATCH", 500),
RatingStaleAfter: envDurationOr("TELESRV_RATING_STALE_AFTER", 6*time.Hour),
// Weight defaults are read from the domain formula itself so the shipped
// behaviour cannot drift from domain.DefaultAccountRatingWeights().
RatingWeightStarsReceivedPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE", defaultRatingWeights.StarsReceivedPermille),
RatingWeightStarsSpentPermille: envInt64Or("TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE", defaultRatingWeights.StarsSpentPermille),
RatingWeightMessageSent: envInt64Or("TELESRV_RATING_WEIGHT_MESSAGE_SENT", defaultRatingWeights.PerMessageSent),
RatingWeightAccountAgeDay: envInt64Or("TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY", defaultRatingWeights.PerAccountAgeDay),
RatingWeightGiftReceived: envInt64Or("TELESRV_RATING_WEIGHT_GIFT_RECEIVED", defaultRatingWeights.PerGiftReceived),
RatingWeightModerationCase: envInt64Or("TELESRV_RATING_WEIGHT_MODERATION_CASE", defaultRatingWeights.PerModerationCase),
RatingWeightScamPenalty: envInt64Or("TELESRV_RATING_WEIGHT_SCAM_PENALTY", defaultRatingWeights.ScamPenalty),
RatingWeightFakePenalty: envInt64Or("TELESRV_RATING_WEIGHT_FAKE_PENALTY", defaultRatingWeights.FakePenalty),
RatingActivityCap: envInt64Or("TELESRV_RATING_ACTIVITY_CAP", defaultRatingWeights.ActivityCap),
CollectibleUsernameURLTemplate: strings.TrimSpace(envAllowEmptyOr("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE", "")),
// Official verification defaults ship the feature on with the official bar
// in place: user accounts are not accepted, a rejection costs a month, and
// an applicant can neither flood the queue nor keep an unbounded number of
// applications open.
VerificationEnabled: envBoolOr("TELESRV_VERIFICATION_ENABLED", true),
VerificationAllowUserTargets: envBoolOr("TELESRV_VERIFICATION_ALLOW_USER_TARGETS", false),
VerificationRejectCooldown: envDurationOr("TELESRV_VERIFICATION_REJECT_COOLDOWN", 720*time.Hour),
VerificationApplyRateLimit: envIntOr("TELESRV_VERIFICATION_APPLY_RATE_LIMIT", 3),
VerificationApplyRateWindow: envDurationOr("TELESRV_VERIFICATION_APPLY_RATE_WINDOW", 24*time.Hour),
VerificationBotRateLimit: envIntOr("TELESRV_VERIFICATION_BOT_RATE_LIMIT", 30),
VerificationBotRateWindow: envDurationOr("TELESRV_VERIFICATION_BOT_RATE_WINDOW", time.Minute),
VerificationNotifyInterval: envDurationOr("TELESRV_VERIFICATION_NOTIFY_INTERVAL", 15*time.Second),
VerificationNotifyBatch: envIntOr("TELESRV_VERIFICATION_NOTIFY_BATCH", 50),
VerificationMaxActivePerUser: envIntOr("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER", 3),
BotVerificationEnabled: envBoolOr("TELESRV_BOT_VERIFICATION_ENABLED", true),
BotVerificationMaxPerVerifier: envIntOr("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER", domain.MaxCustomVerificationsPerVerifier),
// The applicant budget is deliberately looser than the official one
// (TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3): a deployment can run many
// verifier bots, and filing with a second company is not a retry of the first.
BotVerificationRequestRateLimit: envIntOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT", 5),
BotVerificationRequestRateWindow: envDurationOr("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW", 24*time.Hour),
GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second),
GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second),
GroupCallMaxParticipants: envIntOr("TELESRV_GROUPCALL_MAX_PARTICIPANTS", 32),
@ -716,18 +909,54 @@ func Load() (Config, error) {
if err := validateLoginEmailConfig(cfg); err != nil {
return Config{}, err
}
if err := validateRPCResultCacheConfig(cfg); err != nil {
if err := validateRPCExecutionConfig(cfg); err != nil {
return Config{}, err
}
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
if err := validateAccountRatingConfig(cfg); err != nil {
return Config{}, err
}
if err := validateCollectibleUsernameConfig(cfg); err != nil {
return Config{}, err
}
if err := validateVerificationConfig(cfg); err != nil {
return Config{}, err
}
if err := validateAdminRBACConfig(cfg); err != nil {
return Config{}, err
}
if err := validateTelegramLoginConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func normalizeDefaultCountryCode(raw string) (string, error) {
code := strings.ToUpper(strings.TrimSpace(raw))
if len(code) != 2 || code[0] < 'A' || code[0] > 'Z' || code[1] < 'A' || code[1] > 'Z' {
return "", fmt.Errorf("must be a two-letter ISO 3166-1 alpha-2 code")
}
region, err := language.ParseRegion(code)
if err != nil || !region.IsCountry() {
return "", fmt.Errorf("must identify a country or autonomous area")
}
return region.String(), nil
}
func normalizeAdvertiseIP(raw string) (string, error) {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("must be an IPv4 or IPv6 address: %w", err)
}
addr = addr.Unmap()
if addr.IsUnspecified() || addr.IsMulticast() || addr.Zone() != "" {
return "", fmt.Errorf("must be a unicast address usable by clients")
}
return addr.String(), nil
}
func validateTelegramLoginConfig(cfg Config) error {
if !cfg.TelegramLoginEnabled {
return nil
@ -798,35 +1027,262 @@ func validateStarGiftConfig(cfg Config) error {
return nil
}
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
// AccountRatingWeights renders the configured composite rating formula. It is
// the single conversion point between env keys and the domain formula, so the
// app service and the admin explanation always use the same numbers.
func (c Config) AccountRatingWeights() domain.AccountRatingWeights {
return domain.AccountRatingWeights{
StarsReceivedPermille: c.RatingWeightStarsReceivedPermille,
StarsSpentPermille: c.RatingWeightStarsSpentPermille,
PerMessageSent: c.RatingWeightMessageSent,
PerAccountAgeDay: c.RatingWeightAccountAgeDay,
PerGiftReceived: c.RatingWeightGiftReceived,
PerModerationCase: c.RatingWeightModerationCase,
ScamPenalty: c.RatingWeightScamPenalty,
FakePenalty: c.RatingWeightFakePenalty,
ActivityCap: c.RatingActivityCap,
}
}
func validateRPCResultCacheConfig(cfg Config) error {
if cfg.MTProtoRPCResultCacheMaxEntries <= 0 || cfg.MTProtoRPCResultCacheAuthMaxEntries <= 0 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries <= 0 {
return fmt.Errorf("MTProto rpc_result entry limits must be positive")
// validateAccountRatingConfig rejects a formula or worker cadence that cannot
// produce a reproducible rating. Weights are validated even when the feature is
// disabled: enabling it later must not be the moment a typo is discovered.
func validateAccountRatingConfig(cfg Config) error {
if err := cfg.AccountRatingWeights().Validate(); err != nil {
return fmt.Errorf("TELESRV_RATING_WEIGHT_* and TELESRV_RATING_ACTIVITY_CAP must be non-negative: %w", err)
}
if cfg.MTProtoRPCResultCacheMaxEntries < cfg.MTProtoRPCResultCacheAuthMaxEntries ||
cfg.MTProtoRPCResultCacheAuthMaxEntries < cfg.MTProtoRPCResultCacheSessionMaxEntries {
return fmt.Errorf("MTProto rpc_result entry hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxEntries)
if cfg.RatingPendingDelay < 0 {
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must be non-negative")
}
if cfg.MTProtoRPCResultCacheMaxBytes < mtProtoRPCResultMinBytes ||
cfg.MTProtoRPCResultCacheAuthMaxBytes < mtProtoRPCResultMinBytes ||
cfg.MTProtoRPCResultCacheSessionMaxBytes < mtProtoRPCResultMinBytes {
return fmt.Errorf("MTProto rpc_result byte limits must each be at least %d: %d/%d/%d",
mtProtoRPCResultMinBytes, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxBytes, cfg.MTProtoRPCResultCacheSessionMaxBytes)
const maxRatingPendingDelay = 30 * 24 * time.Hour
if cfg.RatingPendingDelay > maxRatingPendingDelay {
return fmt.Errorf("TELESRV_RATING_PENDING_DELAY must not exceed 720h")
}
if cfg.MTProtoRPCResultCacheMaxBytes < cfg.MTProtoRPCResultCacheAuthMaxBytes ||
cfg.MTProtoRPCResultCacheAuthMaxBytes < cfg.MTProtoRPCResultCacheSessionMaxBytes {
return fmt.Errorf("MTProto rpc_result byte hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCResultCacheMaxBytes, cfg.MTProtoRPCResultCacheAuthMaxBytes, cfg.MTProtoRPCResultCacheSessionMaxBytes)
if cfg.RatingRecomputeInterval <= 0 {
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_INTERVAL must be positive")
}
if cfg.MTProtoRPCGlobalMaxTasks <= 0 || cfg.MTProtoRPCResultPendingPerAuth <= 0 ||
cfg.MTProtoRPCResultPendingPerAuth > cfg.MTProtoRPCGlobalMaxTasks ||
cfg.MTProtoRPCResultPendingPerAuth > cfg.MTProtoRPCResultCacheAuthMaxEntries {
return fmt.Errorf("MTProto rpc_result pending-per-auth %d must be positive and <= global pending %d and auth entries %d",
cfg.MTProtoRPCResultPendingPerAuth, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCResultCacheAuthMaxEntries)
if cfg.RatingStaleAfter <= 0 {
return fmt.Errorf("TELESRV_RATING_STALE_AFTER must be positive")
}
if cfg.RatingRecomputeBatch <= 0 || cfg.RatingRecomputeBatch > 10000 {
return fmt.Errorf("TELESRV_RATING_RECOMPUTE_BATCH must be 1..10000")
}
return nil
}
// adminPermissionAll is the wildcard permission: a session or token carrying it
// may perform every admin action.
const adminPermissionAll = "*"
// validateVerificationConfig rejects a verification policy that cannot be
// enforced, for both mechanisms: the operator-granted platform badge and the
// third-party bot verification marks. It runs even when either feature is
// disabled, so enabling it later is not the moment a typo is discovered.
func validateVerificationConfig(cfg Config) error {
if cfg.VerificationRejectCooldown < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_REJECT_COOLDOWN must be non-negative")
}
const maxVerificationRejectCooldown = 365 * 24 * time.Hour
if cfg.VerificationRejectCooldown > maxVerificationRejectCooldown {
return fmt.Errorf("TELESRV_VERIFICATION_REJECT_COOLDOWN must not exceed 8760h")
}
if cfg.VerificationApplyRateLimit < 0 || cfg.VerificationBotRateLimit < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_LIMIT and TELESRV_VERIFICATION_BOT_RATE_LIMIT must be non-negative")
}
if cfg.VerificationApplyRateWindow < 0 || cfg.VerificationBotRateWindow < 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_WINDOW and TELESRV_VERIFICATION_BOT_RATE_WINDOW must be non-negative")
}
// A positive limit with a zero window is not "unlimited", it is a limiter that
// can never refill: reject it instead of shipping a permanent lockout.
if cfg.VerificationApplyRateLimit > 0 && cfg.VerificationApplyRateWindow <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_APPLY_RATE_WINDOW must be positive when TELESRV_VERIFICATION_APPLY_RATE_LIMIT is set")
}
if cfg.VerificationBotRateLimit > 0 && cfg.VerificationBotRateWindow <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_BOT_RATE_WINDOW must be positive when TELESRV_VERIFICATION_BOT_RATE_LIMIT is set")
}
if cfg.VerificationNotifyInterval <= 0 {
return fmt.Errorf("TELESRV_VERIFICATION_NOTIFY_INTERVAL must be positive")
}
if cfg.VerificationNotifyBatch <= 0 || cfg.VerificationNotifyBatch > 500 {
return fmt.Errorf("TELESRV_VERIFICATION_NOTIFY_BATCH must be 1..500")
}
if cfg.VerificationMaxActivePerUser < 0 || cfg.VerificationMaxActivePerUser > 50 {
return fmt.Errorf("TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER must be 0..50")
}
// Third-party bot verification. The ceiling is the storage bound: a value above
// it would be silently unreachable, and a configuration key that cannot do what
// it says is worse than no key.
if cfg.BotVerificationMaxPerVerifier < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER must be non-negative")
}
if cfg.BotVerificationMaxPerVerifier > domain.MaxCustomVerificationsPerVerifier {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER must not exceed %d", domain.MaxCustomVerificationsPerVerifier)
}
if cfg.BotVerificationRequestRateLimit < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT must be non-negative")
}
if cfg.BotVerificationRequestRateWindow < 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW must be non-negative")
}
// Same trap as the official budget above: a positive limit with a zero window is
// not "unlimited", it is a limiter that can never refill.
if cfg.BotVerificationRequestRateLimit > 0 && cfg.BotVerificationRequestRateWindow <= 0 {
return fmt.Errorf("TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW must be positive when TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT is set")
}
return nil
}
// validateAdminRBACConfig checks the panel/adminapi permission configuration.
// An unparsable permission name is refused rather than ignored: a silently
// dropped permission is either a lockout or an unintended grant.
func validateAdminRBACConfig(cfg Config) error {
if len(cfg.AdminUIPermissions) == 0 {
return fmt.Errorf("TELESRV_ADMIN_UI_PERMISSIONS must not be empty; use * to grant every permission")
}
for _, permission := range cfg.AdminUIPermissions {
if !validAdminPermission(permission) {
return fmt.Errorf("TELESRV_ADMIN_UI_PERMISSIONS contains invalid permission %q", permission)
}
}
names := make(map[string]struct{}, len(cfg.AdminScopedTokens))
tokens := make(map[string]struct{}, len(cfg.AdminScopedTokens))
for _, scoped := range cfg.AdminScopedTokens {
if _, dup := names[strings.ToLower(scoped.Name)]; dup {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS has duplicate name %q", scoped.Name)
}
names[strings.ToLower(scoped.Name)] = struct{}{}
if _, dup := tokens[scoped.Token]; dup {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS reuses one token for several names")
}
tokens[scoped.Token] = struct{}{}
if scoped.Token == cfg.AdminAPIToken && strings.TrimSpace(cfg.AdminAPIToken) != "" {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q reuses TELESRV_ADMIN_API_TOKEN, which would silently widen it to every permission", scoped.Name)
}
if len(scoped.Permissions) == 0 {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has no permissions", scoped.Name)
}
for _, permission := range scoped.Permissions {
if !validAdminPermission(permission) {
return fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has invalid permission %q", scoped.Name, permission)
}
}
}
return nil
}
// parseAdminScopedTokens reads "name:token:perm1,perm2" entries separated by ';'.
//
// The shape is strict on purpose: the value carries credentials, and a
// half-understood entry must fail startup rather than produce a token whose
// rights nobody can predict. The permission list is the last field, so a token
// itself may not contain ':' -- which is also why it is validated here rather
// than being re-split later by a consumer.
func parseAdminScopedTokens(raw string) ([]AdminScopedToken, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
out := make([]AdminScopedToken, 0, 4)
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.Split(entry, ":")
if len(parts) != 3 {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must be name:token:perm1,perm2", entry)
}
name := strings.TrimSpace(parts[0])
token := strings.TrimSpace(parts[1])
if name == "" || token == "" {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must carry a non-empty name and token", entry)
}
if strings.ContainsAny(token, " \t\r\n") {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q has whitespace inside its token", name)
}
permissions := make([]string, 0, 4)
for _, permission := range strings.Split(parts[2], ",") {
permission = strings.TrimSpace(permission)
if permission == "" {
continue
}
permissions = append(permissions, permission)
}
if len(permissions) == 0 {
return nil, fmt.Errorf("TELESRV_ADMIN_SCOPED_TOKENS entry %q must list at least one permission", name)
}
out = append(out, AdminScopedToken{Name: name, Token: token, Permissions: permissions})
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// validAdminPermission accepts the wildcard and dotted/namespaced permission
// names such as "users.read" or "verification:decide".
func validAdminPermission(permission string) bool {
if permission == adminPermissionAll {
return true
}
if permission == "" || len(permission) > 64 {
return false
}
for i := 0; i < len(permission); i++ {
c := permission[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case c == '_' || c == '-':
case (c == '.' || c == ':') && i != 0 && i != len(permission)-1:
case c == '*' && i == len(permission)-1 && i > 0 && (permission[i-1] == '.' || permission[i-1] == ':'):
// A trailing "namespace.*" grants a whole namespace.
default:
return false
}
}
return true
}
// validateCollectibleUsernameConfig checks the optional mint URL template. An
// empty template is the documented default (the public-link route is derived
// from TELESRV_PUBLIC_BASE_URL); a configured one must be a client-openable
// absolute http(s) URL that still fits the registry's url column.
func validateCollectibleUsernameConfig(cfg Config) error {
template := strings.TrimSpace(cfg.CollectibleUsernameURLTemplate)
if template == "" {
return nil
}
if len(template)+domain.MaxCollectibleUsernameLength > domain.MaxCollectibleUsernameURLLength {
return fmt.Errorf("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE is too long to hold a rendered username")
}
// Render the placeholder before parsing so a templated path segment is
// validated in its final shape.
rendered := strings.ReplaceAll(template, "{username}", "username")
parsed, err := url.Parse(rendered)
if err != nil || parsed.Host == "" || parsed.User != nil ||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE must be an absolute http(s) URL without userinfo")
}
return nil
}
func validateRPCExecutionConfig(cfg Config) error {
if cfg.MTProtoRPCExecutionMaxEntries <= 0 || cfg.MTProtoRPCExecutionAuthMaxEntries <= 0 ||
cfg.MTProtoRPCExecutionSessionMaxEntries <= 0 {
return fmt.Errorf("MTProto rpc execution entry limits must be positive")
}
if cfg.MTProtoRPCExecutionMaxEntries < cfg.MTProtoRPCExecutionAuthMaxEntries ||
cfg.MTProtoRPCExecutionAuthMaxEntries < cfg.MTProtoRPCExecutionSessionMaxEntries {
return fmt.Errorf("MTProto rpc execution entry hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCExecutionMaxEntries, cfg.MTProtoRPCExecutionAuthMaxEntries, cfg.MTProtoRPCExecutionSessionMaxEntries)
}
if cfg.MTProtoRPCGlobalMaxTasks <= 0 || cfg.MTProtoRPCExecutionPendingPerAuth <= 0 ||
cfg.MTProtoRPCExecutionPendingPerAuth > cfg.MTProtoRPCGlobalMaxTasks ||
cfg.MTProtoRPCExecutionPendingPerAuth > cfg.MTProtoRPCExecutionAuthMaxEntries {
return fmt.Errorf("MTProto rpc execution pending-per-auth %d must be positive and <= global pending %d and auth entries %d",
cfg.MTProtoRPCExecutionPendingPerAuth, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCExecutionAuthMaxEntries)
}
return nil
}
@ -1139,6 +1595,43 @@ func (e envSource) envIntOr(key string, def int) int {
return def
}
func validateStrictMTProtoCapacityEnv(e envSource) error {
for _, key := range []string{
"TELESRV_MTPROTO_MAX_CONNECTIONS",
"TELESRV_MTPROTO_MAX_CONNECTIONS_PER_IP",
"TELESRV_MTPROTO_MAX_CONCURRENT_HANDSHAKES",
"TELESRV_MTPROTO_RPC_MAX_INFLIGHT",
"TELESRV_MTPROTO_RPC_QUEUE_SIZE",
"TELESRV_MTPROTO_RPC_GLOBAL_WORKERS",
"TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS",
"TELESRV_MTPROTO_RPC_EXECUTION_MAX_ENTRIES",
"TELESRV_MTPROTO_RPC_EXECUTION_AUTH_MAX_ENTRIES",
"TELESRV_MTPROTO_RPC_EXECUTION_SESSION_MAX_ENTRIES",
"TELESRV_MTPROTO_RPC_EXECUTION_PENDING_PER_AUTH",
"TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE",
"TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE",
} {
if raw := e.envOr(key, ""); raw != "" {
if _, err := strconv.Atoi(raw); err != nil {
return fmt.Errorf("%s must be a base-10 integer: %w", key, err)
}
}
}
for _, key := range []string{
"TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES",
"TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES",
"TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES",
"TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES",
} {
if raw := e.envOr(key, ""); raw != "" {
if _, err := strconv.ParseInt(raw, 10, 64); err != nil {
return fmt.Errorf("%s must be a base-10 int64: %w", key, err)
}
}
}
return nil
}
func (e envSource) envInt64Or(key string, def int64) int64 {
if v := e.envOr(key, ""); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {