// Package config 负责 telesrv 运行配置的加载与校验。 package config import ( "bufio" "fmt" "net/netip" "net/url" "os" "strconv" "strings" "time" "golang.org/x/text/language" "telesrv/internal/domain" "telesrv/internal/links" ) const ( defaultConfigFile = ".env" defaultCountryCode = "CN" ) // Config 是 telesrv 的运行配置。 type Config struct { // ListenAddr 是 MTProto TCP 监听地址。 // 需与 TDesktop patch 指向的自建 DC 地址/端口一致(记录于 docs/tdesktop-patch-notes.md)。 ListenAddr string // WebSocketEnable 在同一端口启用 MTProto-over-WebSocket 分流(WebA/telegram-tt)。 WebSocketEnable bool // WebSocketAllowedOrigins 是允许浏览器发起 WS upgrade 的页面 origin;"*" 仅用于临时调试。 WebSocketAllowedOrigins []string // AdvertiseIP 是写入 help.getConfig DCOptions 的对外可达 IP(客户端据此连接本 DC)。 AdvertiseIP string // RSAKeyPath 是 server RSA 私钥的 PEM 路径;不存在时自动生成。 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, // but the OwpenGram client forks intentionally alias dc_id 1..5 to it, // so a mismatched client-chosen dc_id is expected, not an attack — strict // mode exists only for a hypothetical future real multi-DC deployment. StrictDCCheck bool // MTProtoMaxConnections / PerIP 覆盖 raw Accept、codec sniff、握手到认证 session // 的完整物理连接生命周期;负数关闭对应 admission 上限。 MTProtoMaxConnections int MTProtoMaxConnectionsPerIP int // MTProtoMaxConcurrentHandshakes 限制昂贵 RSA/DH exchange 并发;负数关闭。 MTProtoMaxConcurrentHandshakes int // MTProto RPC 使用 Server 共享公平调度器;per-connection 与 global 预算共同限制 // goroutine、排队任务和 request memory charge。legacy charge 等于 copied body, // exact charge 是 typed decode 前的保守 materialization 上界,不等同 wire bytes。 MTProtoRPCMaxInflight int MTProtoRPCQueueSize int MTProtoRPCTimeout time.Duration MTProtoRPCGlobalWorkers int MTProtoRPCGlobalMaxTasks int MTProtoRPCGlobalMaxBytes int64 // 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 // MTProto outbound mailbox 按连接有界;resend pending body 另受 Server 全局预算约束。 MTProtoOutboundQueueSize int MTProtoOutboundControlQueueSize int MTProtoOutboundTrackedGlobalMaxBytes int64 MTProtoOutboundWriteGlobalMaxBytes int64 // DebugAddr 是 net/http/pprof 调试端点监听地址(CPU/heap/goroutine/mutex/block 剖析)。 // telesrv 是宿主进程、不在 docker 内,docker stats 看不到它,性能定位主要靠此端点。 // 默认仅绑 127.0.0.1,避免 profile 数据对外暴露;置空关闭。生产需远程抓取时走 SSH 隧道, // 不要改成 0.0.0.0。 DebugAddr string // BotAPIAddr 是最小 HTTP Bot API 网关监听地址;为空关闭。该网关复用 MTProto // app/store 事实源,不维护独立 bot 状态。 BotAPIAddr string // AdminAPIAddr 是 telesrv 进程内管理写 API 监听地址;为空关闭。 AdminAPIAddr string // AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。 AdminAPIToken string // PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。 // 生产默认 https://telesrv.net;本地可设为 http://127.0.0.1:2401。 PublicBaseURL string // PublicAppScheme 是公开落地页自动唤起自建客户端时使用的 URL scheme。 // 必须与 TDesktop/Android 客户端构建时注册的 scheme 一致,且不能占用 tg/http/https。 PublicAppScheme string // PublicAppLinkBase 是可选的 host-based 自建客户端链接根,例如 // owpg://example.com。为空时继续生成 PublicAppScheme://;非空时 // 生成 /,同时保留旧 scheme 作为服务端输入兼容。 PublicAppLinkBase string // PublicWebBaseURL 是公开 username 页面“Open in Web”按钮指向的 Web 客户端根 URL。 PublicWebBaseURL string // PublicAppName 是公开落地页展示的产品名,不参与协议路由。 PublicAppName string // PublicDownloadURL 是公开落地页头部“Download”按钮指向的产品官网/下载页 URL。 PublicDownloadURL string // ScamWarning / FakeWarning override the profile warning text injected into // getFullUser/getFullChannel About for scam/fake peers. Empty keeps the // built-in per-peer-type English defaults. Clients cannot localize // server-provided text, so operators set these to their audience language. ScamWarning string FakeWarning string // ReservedUsernames blocks self-service username registration/changes // (account.updateUsername, channels.updateUsername) from claiming any // of these, case-insensitively -- brand-adjacent or staff-sounding // handles (owpengram, admin, support), or the operator's own real name, // so a user can't squat an identity that would read as official. // Admin-panel-driven username assignment bypasses this deliberately: an // operator setting one of these on an account on purpose is not the // squatting this exists to stop. ReservedUsernames []string // PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。 // 生产应只监听 loopback,并由 nginx 将 /、/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 // process listings or accidentally copied into tracked .env templates. TelegramLoginEnabled bool TelegramLoginIssuer string // TelegramLoginAllowHTTP permits HTTP issuers and registered Login URLs on // any valid host/IP and port. HTTPS remains mandatory when false. TelegramLoginAllowHTTP bool TelegramLoginSigningKeysFile string TelegramLoginCodeKeysFile string TelegramLoginSecretPepperFile string TelegramLoginRequestTTL time.Duration TelegramLoginCodeTTL time.Duration TelegramLoginIDTokenTTL time.Duration TelegramLoginTrustedProxyCIDRs []string TelegramLoginRetention time.Duration TelegramLoginSweepInterval time.Duration TelegramLoginSweepBatch int // Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。 AdminUIAddr string 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。 PostgresDSN string // PostgresMaxConns 是 pgxpool 最大连接数。<=0 用 pgx 默认(max(4, NumCPU),生产偏小)。 // 需覆盖发送事务 + outbox worker 并发 + RPC 读,过小会在高并发下排队(表现为尾延迟突刺)。 PostgresMaxConns int // PostgresMinConns 是启动时预热的 pgxpool 连接数,降低 TDesktop 冷启动并发 RPC 的建连等待。 PostgresMinConns int // RedisAddr 是高频易失态(验证码、限流计数、update 队列)的 Redis 地址。 RedisAddr string // RedisPassword 是 Redis 密码;开发默认空。 RedisPassword string // RedisDB 是 Redis 逻辑库编号。 RedisDB int // DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。 DevAuthCode string // AuthCodeTTL 是登录/注册/邮箱验证 code 的有效期。 AuthCodeTTL time.Duration // PhoneCodeLength 是使用外部 provider 时生成的短信验证码长度。development // provider 继续使用 DevAuthCode 原样,不受此字段影响。 PhoneCodeLength int // AuthCodeMaxAttempts 是同一 phone_code_hash / email verification code 的最大错误次数。 // 达到上限后验证码立即失效,用户必须重发。 AuthCodeMaxAttempts int // AuthCodePhoneRateLimit / AuthCodeAuthKeyRateLimit 对未授权验证码签发按规范化手机号摘要 // 与连接实际 raw auth_key 分别限流。两个维度共用 AuthCodeRateWindow;<=0 关闭对应维度。 // 手机号只以 SHA-256 摘要进入限流 key,禁止把原文写入 Redis key 或日志。 AuthCodePhoneRateLimit int AuthCodeAuthKeyRateLimit int AuthCodeRateWindow time.Duration // LoginEmailEnable 启用手机号登录流程中的邮箱验证码投递。 LoginEmailEnable bool // LoginEmailRequireSetup 为 true 时,没有登录邮箱的账号/新手机号会要求先设置邮箱。 LoginEmailRequireSetup bool // LoginEmailCodeLength 是邮箱验证码长度。 LoginEmailCodeLength int // EmailSignupEnable 启用「邮箱作为账号身份」模式:客户端用邮箱注册/登录,服务端把邮箱 // 编码进一个 888 前缀的合成号码复用现有 phone 全流程(sendCode/signUp/signIn/changePhone // 不变),验证码通过登录邮箱同一投递通道(PhoneCodeDeliveryProvider/smtp 或 webhook) // 发到解码出的邮箱而非发短信。要求该通道配置可用(与 LoginEmailEnable 共用同一组 // TELESRV_SMTP_* / TELESRV_OTP_WEBHOOK_* 变量)。 EmailSignupEnable bool // EmailSignupPhonePrefixes 是账号实际可见的 users.phone 短号码 // (domain.NewEmailSignupDisplayPhone)随机选用的号段前缀列表,逗号分隔, // 默认仅 "888"。注意这与合成 wire 号码(domain.EncodeEmailPhone,sendCode // 阶段用于携带邮箱本身)无关——那个前缀恒为 "888" 且从不下发给客户端; // 这里只影响注册后账号真正落库/展示的号码好不好看,可通过 // help.getAppConfig 的 email_signup_phone_prefixes 下发给客户端,管理员 // 改动此列表不需要客户端升级。 EmailSignupPhonePrefixes []string // PhoneCodeDeliveryProvider 选择普通登录/注册与改号验证码的投递方式: // development 保留固定码与 777000 app-code;webhook 使用随机 SMS code。 PhoneCodeDeliveryProvider string // EmailCodeDeliveryProvider 选择登录邮箱与邮箱 setup/change 的投递方式 // (login email 与 email-signup 共用同一个开关,见 EmailSignupEnable)。 EmailCodeDeliveryProvider string // OTPWebhook* 定义固定 v1 webhook 协议的端点、HMAC secret 与请求超时。 OTPWebhookURL string OTPWebhookSecret string OTPWebhookTimeout time.Duration // SMTP* 是登录邮箱验证码的出站邮件配置。LoginEmailEnable=true 或 // EmailSignupEnable=true 且 provider=smtp 时使用。 SMTPHost string SMTPPort int SMTPUsername string SMTPPassword string SMTPFrom string SMTPFromName string SMTPTLSMode string SMTPTimeout time.Duration // MapboxToken 是服务端代理地图缩略图(upload.getWebFile)请求 Mapbox Static Images API // 的 access token;为空则关闭代理、回退确定性占位图。客户端选点器 token 经 appConfig // `tdesktop_config_map` 下发(同源运行时配置)。 MapboxToken string // MapTileCacheDir 是已抓取地图缩略图的磁盘缓存目录(保证分片续传字节一致 + 控制配额消耗)。 MapTileCacheDir string // ExternalMediaEnable 控制是否启用外链媒体抓取(inputMediaPhoto/DocumentExternal)。 // 默认开启:服务端 SSRF 安全抓取用户 URL 并铸造 Photo/Document(含 SSRF 防护/大小/限速)。 ExternalMediaEnable bool // ExternalMediaMaxBytes 是单次外链抓取响应体上限;<=0 用默认 10MB。 ExternalMediaMaxBytes int64 // ExternalMediaRatePerMin 是全局每分钟外链抓取上限(防放大攻击);<=0 用默认。 ExternalMediaRatePerMin int // WebPagePreviewEnable 控制是否启用链接预览抓取(messages.getWebPagePreview / 发送时挂卡片)。 // 默认开启:服务端 SSRF 安全抓取消息内 URL,解析 OG/Twitter/标题元数据并铸造预览卡片。 WebPagePreviewEnable bool // WebPagePreviewMaxBytes 是单次链接预览抓取响应体上限(HTML 与预览图共用);<=0 用默认 5MB。 WebPagePreviewMaxBytes int64 // WebPagePreviewRatePerMin 是全局每分钟链接预览抓取上限;<=0 用默认。一次解析最多 2 次上游。 WebPagePreviewRatePerMin int // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 LangPackSeedDir string // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 BlobDir string // BlobBackendKind selects the blob storage backend: "localfs" (default) // or "s3". Transient upload parts always stay on local disk (BlobDir) // regardless of this setting -- only the permanent blob store moves. BlobBackendKind string // S3Endpoint/S3Region/S3Bucket/S3AccessKeyID/S3SecretAccessKey/S3UseSSL/ // S3PathStyle configure the s3 blob backend; only used when // BlobBackendKind == "s3". Works against self-hosted MinIO or AWS S3. S3Endpoint string S3Region string S3Bucket string S3AccessKeyID string S3SecretAccessKey string S3UseSSL bool S3PathStyle bool // StorageLowSpaceGuardEnable turns on the pre-upload free-space check. StorageLowSpaceGuardEnable bool // StorageMinFreeBytes: for the localfs backend, reject new uploads once // real free disk bytes fall below this. <=0 disables this check. StorageMinFreeBytes int64 // StorageMaxTotalBytes: reject new uploads once total tracked blob // bytes would exceed this -- the only meaningful "low space" signal for // the s3 backend (no OS-level free space concept), and usable as an // optional soft budget cap on localfs too. <=0 disables this check. StorageMaxTotalBytes int64 // StorageUsageRefreshInterval is how often the cached free-space/budget // usage gauge refreshes; <=0 uses a 1 minute default. StorageUsageRefreshInterval time.Duration // StorageRetentionEnable turns on the orphaned-media age sweep. Off by // default: orphaned media (no live message/profile-photo/sticker-set // reference) is tracked and visible in the admin panel, but nothing is // auto-deleted until the operator explicitly opts in. StorageRetentionEnable bool // StorageRetentionMaxAge is how long a document/photo must have been // orphaned (not how old the media itself is) before the sweep deletes // it. Never deletes media that still has a live reference, regardless // of age. <=0 uses a 30 day default. The sweep itself runs on the // shared RetentionInterval/RetentionBatch cadence alongside every other // retention check (see maintenance.RetentionWorker). StorageRetentionMaxAge time.Duration // StickerSeedDir 是 reaction / sticker 资源种子目录(导入到 documents/sticker_sets + blob)。 StickerSeedDir string // StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。 StickerSeedMaxSets int // PremiumPromoSeedDir 是 help.getPremiumPromo 视频与缩略图导出目录。 // 目录缺失时保留无视频兼容响应;目录存在但内容非法时启动失败。 PremiumPromoSeedDir string // GifSeedDir is a directory of plain .gif/.mp4 files to import into the // admin-curated GIF catalog on startup (files.Service.SeedGifs) -- unlike // StickerSeedDir, this expects no export manifest, just raw files dropped // in. Missing directory is skipped, not an error. GifSeedDir string // IdentityDir holds the admin-editable server identity (name/description // in identity.json, icon as icon.) served over /owpengram/server-info // and /owpengram/server-icon -- see internal/identity. Read fresh on every // request, so admin edits apply immediately with no server restart. IdentityDir string // BusinessAIProvider 控制服务端 Business automation 回复生成器。 // 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路; // "template" 使用 quick reply 模板。 BusinessAIProvider string // AIEnabled 控制客户端输入框 AI 改写/润色能力;关闭时 getTones 返回空集合以隐藏入口。 AIEnabled bool // AIProviders 是 compose AI provider 链路,按顺序尝试;默认 local,不出网。 AIProviders []AIProviderConfig // AITimeout 是单次 provider 调用总超时。 AITimeout time.Duration // AIRateLimit/AIRateWindow 是账号级 compose AI 限流。 AIRateLimit int AIRateWindow time.Duration // AIPrivacyLogContent 为 false 时日志只写长度/provider/状态,不写用户输入和生成文本。 AIPrivacyLogContent bool // Translation* controls messages.translateText. Remote provider credentials // are reused from AIProviders; TranslationProviders optionally selects names // from that list. The deterministic local provider is never used. TranslationEnabled bool TranslationProviders []string TranslationTimeout time.Duration TranslationRateLimit int TranslationRateWindow time.Duration // TempKeyResolveCacheMaxEntries 是 Router temp→perm 解析缓存容量。 TempKeyResolveCacheMaxEntries int // TempKeyResolveCacheTTL 是 temp→perm 绑定的进程内复核周期。绑定/revoke 有精确 // 失效,TTL 作为跨进程或异常路径兜底;默认 30m 避免大连接数下每 5s 全量打 PG。 TempKeyResolveCacheTTL time.Duration // ChannelRowCacheMaxEntries 是「共享频道行」进程内缓存容量(channelID→domain.Channel)。 // 由 channels 表 LISTEN/NOTIFY 触发器实时失效(强一致、零 TTL)。<=0 禁用缓存与监听。 ChannelRowCacheMaxEntries int // ChannelMemberCacheMaxEntries 是频道成员/访问态 read-model 缓存容量((channelID,userID)→member)。 // 由 read_model_versions 统一通知实时失效。<=0 禁用缓存。 ChannelMemberCacheMaxEntries int // ChannelDialogCacheMaxEntries 是频道 dialog 读投影缓存容量((viewerUserID,channelID)→dialog)。 // 由 channel_base/channel_member/dialog_light 统一通知实时失效。<=0 禁用缓存。 ChannelDialogCacheMaxEntries int // ChannelBoostCacheMaxEntries 是频道 boost read-model 缓存容量,覆盖当前用户 // SelfBoostsApplied 与频道总 active boost 数两类投影。写入 channel_boost_slots // 时精确失效,TTL 兜底自然过期。<=0 禁用缓存。 ChannelBoostCacheMaxEntries int // ChannelBoostCacheTTL 是 boost 读投影在未收到写侧通知时的最大陈旧窗口。 ChannelBoostCacheTTL time.Duration // OutboxWorkers 是并发 outbox worker 数。用户先稳定哈希到固定 logical shard, // 每个 shard 只归一个 worker,故提高 worker 数不会破坏同一用户 pts 顺序。 OutboxWorkers int // OutboxBatch 是 transactional outbox worker 每次 claim 的最大条数。 // 调大提升吞吐、增大单批 PG/推送压力;调小降低延迟抖动。配套压测见 docs/message-module.md。 OutboxBatch int // OutboxInterval 是 outbox worker 两次 claim 之间的轮询间隔。 OutboxInterval time.Duration // OutboxLeaseTimeout 是 'dispatching' 行被判定为租约过期、允许其它 worker 重新 claim 的时长。 // 取值需大于单批投递耗时,否则会重复推送;过大则 worker 崩溃后积压恢复变慢。 OutboxLeaseTimeout time.Duration // OutboxPoisonRetention 是 terminal failed outbox head 的隔离窗口。隔离期内保留 // last_error 供排障,期满只删除在线投递任务;durable user_update_events 仍保留, // 客户端可经 updates.getDifference 恢复。 OutboxPoisonRetention time.Duration // OutboxPoisonCleanupInterval 独立于大表 retention 周期清理 terminal failed head, // 避免一条确定性坏事件长期冻结同账号更高 pts 的在线投递 lane。 OutboxPoisonCleanupInterval time.Duration // OutboundPushTimeout 是 best-effort updates 推送等待 outbound 队列接受的最长时间。 OutboundPushTimeout time.Duration // SendRateLimit 是账号级发送窗口内允许的消息条数;<=0 表示关闭发送限流。 SendRateLimit int // SendRateWindow 是发送限流窗口。 SendRateWindow time.Duration // CatchupRateLimit 是 difference 类 catch-up RPC(getChannelDifference / getPeerDialogs) // 每用户每窗口允许的次数;<=0 关闭(设计 fan-out Phase 2 / §10.3,放开大群 nudge 全速前置)。 CatchupRateLimit int // CatchupRateWindow 是 catch-up 限流窗口。 CatchupRateWindow time.Duration // ChannelNudgeMaxTargets 是一次 fan-out >cap nudge 的目标上限;<=0 用内置默认。 ChannelNudgeMaxTargets int // UpdateEventRetention 是 durable update log 保留期;只清理已被水位/state 覆盖的事件。 UpdateEventRetention time.Duration // BotAPIUpdateRetention 是 bot_api_updates 投递队列的最大保留期(官方 Bot API 语义 24h); // 已确认的行另按固定短宽限提前回收(性能审计 H1)。 BotAPIUpdateRetention time.Duration // OrphanAuthKeyRetention 是握手已创建、但没有 authorization/temp binding/活跃连接的 // auth key 最短保留期。过期后由有界 GC 回收;客户端收到 -404 会重建 key。 OrphanAuthKeyRetention time.Duration // RetentionInterval 是 retention worker 的运行间隔。 RetentionInterval time.Duration // RetentionBatch 是单次 retention 最多删除的行数。 RetentionBatch int // UploadPartTTL 是未组装上传分片的保留期。 UploadPartTTL time.Duration // UploadPartGCInterval 是 upload_parts GC worker 的运行间隔。 UploadPartGCInterval time.Duration // UploadPartGCBatch 是单次 upload_parts GC 最多删除的行数。 UploadPartGCBatch int // UploadInFlightMaxBytes 是单用户未组装上传分片的字节上限;<=0 表示不限。 UploadInFlightMaxBytes int64 // UploadInFlightMaxParts 是单用户未组装上传分片行数上限;<=0 表示不限。 UploadInFlightMaxParts int // UploadInFlightMaxFiles 是单用户未组装 file_id 数上限;<=0 表示不限。 UploadInFlightMaxFiles int // CallRingTimeout 是私聊通话服务端兜底超时(振铃/Accepted 悬挂),与下发给 // 客户端的 callRingTimeoutMs(compat/tdesktop/config.go,90000ms)同源。 CallRingTimeout time.Duration // CallTombstoneTTL 是终态通话 tombstone 保留期(幂等/晚到 RPC 吸收窗口)。 CallTombstoneTTL time.Duration // CallMaxActivePerUser 是单用户并发非终态通话上限。 CallMaxActivePerUser int // CallRegistryMaxEntries 是进程内通话 registry 的全局硬上限。 CallRegistryMaxEntries int // CallSignalingMaxBytes 是 phone.sendSignalingData 单条载荷上限。 CallSignalingMaxBytes int // CallSignalingRate 是单通话每秒信令转发上限(超限静默丢弃)。 CallSignalingRate int // CallExpiryInterval 是通话超时兜底 dispatcher 的轮询间隔。 CallExpiryInterval time.Duration // PremiumGrantMonths 是新注册账号默认赠送的会员月数;0 关闭赠送。 // 存量账号的一次性赠送由迁移 0094 backfill,不受该配置影响。 PremiumGrantMonths int // DefaultStickerSetID 是新注册账号自动安装的默认贴纸集 id;<=0 关闭(默认关闭)。 // 曾经默认指向 UtyaDuck(Telegram 版权素材),迁移 20260721202007 backfill 过存量账号; // 迁移 20260723150000 已撤销该 backfill。 DefaultStickerSetID int64 // PasskeyRPID 是 passkey(WebAuthn) relying-party id(域名)。服务端据此校验 // authData.rpIdHash;真机经 Android CredentialManager 时须与托管 assetlinks.json // 的公网域名一致(详见 docs)。本地/软件 authenticator 验证用任意稳定值即可。 PasskeyRPID string // PasskeyAllowedOrigins 是允许的 WebAuthn origin 白名单;为空=不强校验 origin //(服务端通常不预知 Android apk-key-hash origin)。 PasskeyAllowedOrigins []string // PremiumSweepInterval 是会员到期 sweeper 的轮询间隔。premium 下发正确性 // 由读取路径即时派生,sweeper 只负责清理过期行并推 updateUser 通知。 PremiumSweepInterval time.Duration // PremiumSweepBatch 是单次到期清理的最大行数。 PremiumSweepBatch int // 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 // BroadcastWorkerInterval / BroadcastWorkerBatch drive the system-broadcast // delivery worker (internal/app/broadcast): an admin-created broadcast is // snapshotted into a durable per-recipient outbox immediately, and this // worker drains it in batches, so sending to thousands of users never blocks // the admin action itself. BroadcastWorkerInterval time.Duration BroadcastWorkerBatch int // HideThirdPartyVerification hides third-party bot verification instead of // removing it: the admin panel drops its "Third-party marks" nav entry and // refuses every botverification.* route with 404 (regardless of session // permissions), and the built-in @marksbot service bot stops responding to // messages entirely. Defaults to true because this feature is NOT FULLY // FINISHED and may cause unstable server behavior -- it stays wired up // (nothing is deleted) so it can be re-enabled later, but it should not be // exposed on a deployment that isn't specifically testing it. HideThirdPartyVerification bool // CollectibleUsernameURLTemplate is the landing URL recorded on a minted // collectible username when the mint request carries no explicit URL. // Empty derives /nft/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 // GroupCallSweepInterval 是幽灵参与者 sweeper 的轮询间隔。 GroupCallSweepInterval time.Duration // GroupCallMaxParticipants 是单房间参与者上限(演示规模)。 GroupCallMaxParticipants int // TURNEnable 为 false 时私聊通话不下发中继(退回 P1 的 LAN 直连模式)。 TURNEnable bool // TURNUDPPort 是内嵌 TURN/STUN 的监听端口(独立于 SFU 端口,两者都要独占 // 消费各自 socket 的 STUN 流量)。Windows 防火墙需放行。 TURNUDPPort int // TURNAdvertiseIP 是写进 phoneConnectionWebrtc 与 relay 分配的客户端可达 // 地址,默认回落 SFUAdvertiseIP → AdvertiseIP。 TURNAdvertiseIP string // TURNSecret 是 TURN REST 凭据 HMAC 密钥;为空则进程级随机(单实例自洽, // 多实例/外部 coturn 必须显式配置同一值)。 TURNSecret string // TURNRelayMinPort/TURNRelayMaxPort 限定 relay 分配端口段(防火墙放行范围)。 TURNRelayMinPort int TURNRelayMaxPort int // CallTURNCredentialTTL 是按通话签发的 TURN 凭据有效期。 CallTURNCredentialTTL time.Duration // CallForceRelay 强制 p2p_allowed=false(调试 TURN 中继路径用)。 CallForceRelay bool // LiveStreamEnable 为 true 时启用频道 RTMP 直播媒体面(内嵌 RTMP ingest + ffmpeg 切段)。 LiveStreamEnable bool // LiveStreamRtmpAddr 是 RTMP ingest 的 TCP 监听地址(默认 ":2400")。 LiveStreamRtmpAddr string // LiveStreamRtmpURL 是返回给推流端(OBS)的服务器地址;为空回落 rtmp://:2400/live。 LiveStreamRtmpURL string // LiveStreamFFmpegPath 是 ffmpeg 可执行路径(默认走 PATH 的 "ffmpeg")。 LiveStreamFFmpegPath string // LiveStreamWorkDir 是切段临时目录(默认系统临时目录)。 LiveStreamWorkDir string // LiveStreamSegmentKeep 是每路流内存保留的 segment 秒数(默认 32)。 LiveStreamSegmentKeep int // SFUEnable 为 false 时群通话只走信令(M0 模式,无媒体)。 SFUEnable bool // SFUUDPPort 是内嵌 SFU 的单 UDP 端口(pion ICE UDPMux)。Windows 防火墙需放行。 SFUUDPPort int // SFUAdvertiseIP 是写进下行 candidate 的客户端可达地址,默认回落 AdvertiseIP。 // ⚠ 127.0.0.1 会让真机 ICE 永远连不上且无任何 RPC 错误(纯媒体面静默失败)。 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 BaseURL string APIKey string Model string MaxOutputTokens int Temperature float64 OmitTemperature bool Thinking string } // Load 从环境变量与可选配置文件读取配置并填充默认值。环境变量优先于配置文件。 func Load() (Config, error) { fileEnv, err := loadConfigEnv() if err != nil { return Config{}, err } envBoolOr := fileEnv.envBoolOr envOr := fileEnv.envOr envListOr := fileEnv.envListOr envIntOr := fileEnv.envIntOr 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 { return Config{}, fmt.Errorf("TELESRV_PUBLIC_BASE_URL: %w", err) } publicAppScheme, err := links.ValidateAppScheme(envOr("TELESRV_PUBLIC_APP_SCHEME", links.DefaultAppScheme)) if err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_SCHEME: %w", err) } publicAppLinkBase, err := links.ValidateAppLinkBase(envAllowEmptyOr("TELESRV_PUBLIC_APP_LINK_BASE", "")) if err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_LINK_BASE: %w", err) } // TELESRV_PUBLIC_WEB_BASE_URL is nullable: an explicitly empty value disables // the "Open in Web" button on public landing pages instead of falling back // to the default telesrv Web client URL. publicWebBaseURL := envAllowEmptyOr("TELESRV_PUBLIC_WEB_BASE_URL", links.DefaultWebBaseURL) if publicWebBaseURL != "" { if publicWebBaseURL, err = links.ValidateBaseURL(publicWebBaseURL); err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_WEB_BASE_URL: %w", err) } } publicAppName, err := links.ValidateAppName(envOr("TELESRV_PUBLIC_APP_NAME", links.DefaultAppName)) if err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_NAME: %w", err) } publicDownloadURL, err := links.ValidateBaseURL(envOr("TELESRV_PUBLIC_DOWNLOAD_URL", links.DefaultDownloadURL)) 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) } 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"), WebSocketEnable: envBoolOr("TELESRV_WEBSOCKET_ENABLE", true), WebSocketAllowedOrigins: envListOr("TELESRV_WEBSOCKET_ALLOWED_ORIGINS", []string{ "http://localhost:1234", "http://127.0.0.1:1234", }), // 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, ), 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), MTProtoOutboundTrackedGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_OUTBOUND_TRACKED_GLOBAL_MAX_BYTES", 512<<20), MTProtoOutboundWriteGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_OUTBOUND_WRITE_GLOBAL_MAX_BYTES", 512<<20), DebugAddr: envAllowEmptyOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"), BotAPIAddr: envAllowEmptyOr("TELESRV_BOT_API_ADDR", ""), AdminAPIAddr: envAllowEmptyOr("TELESRV_ADMIN_API_ADDR", ""), AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), PublicBaseURL: publicBaseURL, PublicAppScheme: publicAppScheme, PublicAppLinkBase: publicAppLinkBase, PublicWebBaseURL: publicWebBaseURL, PublicAppName: publicAppName, PublicDownloadURL: publicDownloadURL, ScamWarning: envAllowEmptyOr("TELESRV_SCAM_WARNING", ""), FakeWarning: envAllowEmptyOr("TELESRV_FAKE_WARNING", ""), ReservedUsernames: envListOr("TELESRV_RESERVED_USERNAMES", []string{ "owpengram", "admin", "administrator", "support", "staff", "moderator", "official", "root", "owner", }), PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false), TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"), TelegramLoginAllowHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP", false), TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"), TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"), TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"), TelegramLoginRequestTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", 5*time.Minute), TelegramLoginCodeTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_CODE_TTL", 2*time.Minute), TelegramLoginIDTokenTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", time.Hour), TelegramLoginTrustedProxyCIDRs: envListOr("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", nil), 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", ""), AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""), // 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker // Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost // 建连 1.0s vs 127.0.0.1 6ms)。冷连接洪峰下池扩容的新连接各等 1s → pre-handler 惊群卡顿。 // 生产由 TELESRV_POSTGRES_DSN 覆盖;该默认值仅作用于本地开发。 PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable"), PostgresMaxConns: envIntOr("TELESRV_POSTGRES_MAX_CONNS", 50), PostgresMinConns: envIntOr("TELESRV_POSTGRES_MIN_CONNS", 16), RedisAddr: envOr("TELESRV_REDIS_ADDR", "127.0.0.1:6399"), // 同理避开 localhost→IPv6 回退延迟 RedisPassword: envOr("TELESRV_REDIS_PASSWORD", ""), RedisDB: envIntOr("TELESRV_REDIS_DB", 0), DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"), AuthCodeTTL: envDurationOr("TELESRV_AUTH_CODE_TTL", 5*time.Minute), PhoneCodeLength: envIntOr("TELESRV_PHONE_CODE_LENGTH", 5), AuthCodeMaxAttempts: envIntOr("TELESRV_AUTH_CODE_MAX_ATTEMPTS", 5), AuthCodePhoneRateLimit: envIntOr("TELESRV_AUTH_CODE_PHONE_RATE_LIMIT", 5), AuthCodeAuthKeyRateLimit: envIntOr("TELESRV_AUTH_CODE_AUTH_KEY_RATE_LIMIT", 20), AuthCodeRateWindow: envDurationOr("TELESRV_AUTH_CODE_RATE_WINDOW", 10*time.Minute), LoginEmailEnable: envBoolOr("TELESRV_LOGIN_EMAIL_ENABLE", false), LoginEmailRequireSetup: envBoolOr("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP", false), EmailSignupEnable: envBoolOr("TELESRV_EMAIL_SIGNUP_ENABLE", false), EmailSignupPhonePrefixes: envListOr("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", []string{"888"}), LoginEmailCodeLength: envIntOr("TELESRV_LOGIN_EMAIL_CODE_LENGTH", 6), PhoneCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_PHONE_CODE_DELIVERY_PROVIDER", "development"))), EmailCodeDeliveryProvider: strings.ToLower(strings.TrimSpace(envOr("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER", "smtp"))), OTPWebhookURL: envOr("TELESRV_OTP_WEBHOOK_URL", ""), OTPWebhookSecret: envOr("TELESRV_OTP_WEBHOOK_SECRET", ""), OTPWebhookTimeout: envDurationOr("TELESRV_OTP_WEBHOOK_TIMEOUT", 5*time.Second), SMTPHost: envOr("TELESRV_SMTP_HOST", ""), SMTPPort: envIntOr("TELESRV_SMTP_PORT", 587), SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""), SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""), SMTPFrom: envOr("TELESRV_SMTP_FROM", ""), SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", "OwpenGram"), SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))), SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second), LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), // s3 (MinIO by default, see deploy/docker-compose.yml's minio service) is // the default blob backend; localfs remains fully supported as an // explicit opt-in (TELESRV_BLOB_BACKEND=localfs). BlobBackendKind: strings.ToLower(strings.TrimSpace(envOr("TELESRV_BLOB_BACKEND", "s3"))), S3Endpoint: envOr("TELESRV_S3_ENDPOINT", "127.0.0.1:9000"), // 同理避开 localhost→IPv6 回退延迟 S3Region: envOr("TELESRV_S3_REGION", "us-east-1"), S3Bucket: envOr("TELESRV_S3_BUCKET", "owpengram-media"), S3AccessKeyID: envOr("TELESRV_S3_ACCESS_KEY_ID", "owpengram"), S3SecretAccessKey: envOr("TELESRV_S3_SECRET_ACCESS_KEY", "owpengram123"), S3UseSSL: envBoolOr("TELESRV_S3_USE_SSL", false), S3PathStyle: envBoolOr("TELESRV_S3_PATH_STYLE", true), StorageLowSpaceGuardEnable: envBoolOr("TELESRV_STORAGE_LOW_SPACE_GUARD_ENABLE", true), StorageMinFreeBytes: envInt64Or("TELESRV_STORAGE_MIN_FREE_BYTES", 1<<30), StorageMaxTotalBytes: envInt64Or("TELESRV_STORAGE_MAX_TOTAL_BYTES", 0), StorageUsageRefreshInterval: envDurationOr("TELESRV_STORAGE_USAGE_REFRESH_INTERVAL", time.Minute), StorageRetentionEnable: envBoolOr("TELESRV_STORAGE_RETENTION_ENABLE", false), StorageRetentionMaxAge: envDurationOr("TELESRV_STORAGE_RETENTION_MAX_AGE", 30*24*time.Hour), 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"), GifSeedDir: envOr("TELESRV_GIF_SEED_DIR", "data/gifs"), IdentityDir: envOr("TELESRV_IDENTITY_DIR", "data/identity"), MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""), MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"), ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true), ExternalMediaMaxBytes: int64(envIntOr("TELESRV_EXTERNAL_MEDIA_MAX_BYTES", 10<<20)), ExternalMediaRatePerMin: envIntOr("TELESRV_EXTERNAL_MEDIA_RATE_PER_MIN", 60), WebPagePreviewEnable: envBoolOr("TELESRV_WEBPAGE_PREVIEW_ENABLE", true), WebPagePreviewMaxBytes: int64(envIntOr("TELESRV_WEBPAGE_PREVIEW_MAX_BYTES", 5<<20)), WebPagePreviewRatePerMin: envIntOr("TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN", 300), BusinessAIProvider: envOr("TELESRV_BUSINESS_AI_PROVIDER", "echo"), AIEnabled: envBoolOr("TELESRV_AI_ENABLED", true), AIProviders: loadAIProviders(fileEnv), AITimeout: envDurationOr("TELESRV_AI_TIMEOUT", 15*time.Second), AIRateLimit: envIntOr("TELESRV_AI_RATE_LIMIT", 20), AIRateWindow: envDurationOr("TELESRV_AI_RATE_WINDOW", time.Minute), AIPrivacyLogContent: envBoolOr("TELESRV_AI_LOG_CONTENT", false), TranslationEnabled: envBoolOr("TELESRV_TRANSLATION_ENABLED", true), TranslationProviders: envListOr("TELESRV_TRANSLATION_PROVIDERS", []string{}), TranslationTimeout: envDurationOr("TELESRV_TRANSLATION_TIMEOUT", 15*time.Second), TranslationRateLimit: envIntOr("TELESRV_TRANSLATION_RATE_LIMIT", 60), TranslationRateWindow: envDurationOr("TELESRV_TRANSLATION_RATE_WINDOW", time.Minute), TempKeyResolveCacheMaxEntries: envIntOr("TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES", 262144), TempKeyResolveCacheTTL: envDurationOr("TELESRV_TEMP_KEY_CACHE_TTL", 30*time.Minute), ChannelRowCacheMaxEntries: envIntOr("TELESRV_CHANNEL_ROW_CACHE_MAX", 50000), ChannelMemberCacheMaxEntries: envIntOr("TELESRV_CHANNEL_MEMBER_CACHE_MAX", 100000), ChannelDialogCacheMaxEntries: envIntOr("TELESRV_CHANNEL_DIALOG_CACHE_MAX", 100000), ChannelBoostCacheMaxEntries: envIntOr("TELESRV_CHANNEL_BOOST_CACHE_MAX", 100000), ChannelBoostCacheTTL: envDurationOr("TELESRV_CHANNEL_BOOST_CACHE_TTL", 10*time.Second), OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 4), OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100), OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond), OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second), OutboxPoisonRetention: envDurationOr("TELESRV_OUTBOX_POISON_RETENTION", time.Minute), OutboxPoisonCleanupInterval: envDurationOr( "TELESRV_OUTBOX_POISON_CLEANUP_INTERVAL", 15*time.Second, ), OutboundPushTimeout: envDurationOr("TELESRV_OUTBOUND_PUSH_TIMEOUT", 200*time.Millisecond), SendRateLimit: envIntOr("TELESRV_SEND_RATE_LIMIT", 30), SendRateWindow: envDurationOr("TELESRV_SEND_RATE_WINDOW", time.Minute), CatchupRateLimit: envIntOr("TELESRV_CATCHUP_RATE_LIMIT", 0), CatchupRateWindow: envDurationOr("TELESRV_CATCHUP_RATE_WINDOW", time.Minute), ChannelNudgeMaxTargets: envIntOr("TELESRV_CHANNEL_NUDGE_MAX_TARGETS", 0), UpdateEventRetention: envDurationOr("TELESRV_UPDATE_EVENT_RETENTION", 168*time.Hour), BotAPIUpdateRetention: envDurationOr("TELESRV_BOT_API_UPDATE_RETENTION", 24*time.Hour), OrphanAuthKeyRetention: envDurationOr("TELESRV_ORPHAN_AUTH_KEY_RETENTION", 24*time.Hour), RetentionInterval: envDurationOr("TELESRV_RETENTION_INTERVAL", time.Hour), RetentionBatch: envIntOr("TELESRV_RETENTION_BATCH", 10000), UploadPartTTL: envDurationOr("TELESRV_UPLOAD_PART_TTL", 24*time.Hour), UploadPartGCInterval: envDurationOr("TELESRV_UPLOAD_PART_GC_INTERVAL", 30*time.Minute), UploadPartGCBatch: envIntOr("TELESRV_UPLOAD_PART_GC_BATCH", 10000), UploadInFlightMaxBytes: envInt64Or("TELESRV_UPLOAD_INFLIGHT_MAX_BYTES", 4194304000), 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), 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), PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), 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), BroadcastWorkerInterval: envDurationOr("TELESRV_BROADCAST_WORKER_INTERVAL", 3*time.Second), BroadcastWorkerBatch: envIntOr("TELESRV_BROADCAST_WORKER_BATCH", 50), HideThirdPartyVerification: envBoolOr("TELESRV_HIDE_THIRD_PARTY_VERIFICATION", true), 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), TURNEnable: envBoolOr("TELESRV_TURN_ENABLE", true), TURNUDPPort: envIntOr("TELESRV_TURN_UDP_PORT", 12400), TURNAdvertiseIP: envOr("TELESRV_TURN_ADVERTISE_IP", ""), TURNSecret: envOr("TELESRV_TURN_SECRET", ""), TURNRelayMinPort: envIntOr("TELESRV_TURN_RELAY_MIN_PORT", 12500), TURNRelayMaxPort: envIntOr("TELESRV_TURN_RELAY_MAX_PORT", 12999), CallTURNCredentialTTL: envDurationOr("TELESRV_CALL_TURN_CREDENTIAL_TTL", 6*time.Hour), CallForceRelay: envBoolOr("TELESRV_CALL_FORCE_RELAY", false), SFUEnable: envBoolOr("TELESRV_SFU_ENABLE", true), SFUUDPPort: envIntOr("TELESRV_SFU_UDP_PORT", 12399), SFUAdvertiseIP: envOr("TELESRV_SFU_ADVERTISE_IP", ""), LiveStreamEnable: envBoolOr("TELESRV_LIVESTREAM_ENABLE", true), LiveStreamRtmpAddr: envOr("TELESRV_LIVESTREAM_RTMP_ADDR", ":2400"), LiveStreamRtmpURL: envOr("TELESRV_LIVESTREAM_RTMP_URL", ""), LiveStreamFFmpegPath: envOr("TELESRV_LIVESTREAM_FFMPEG_PATH", "ffmpeg"), LiveStreamWorkDir: envOr("TELESRV_LIVESTREAM_WORK_DIR", ""), LiveStreamSegmentKeep: envIntOr("TELESRV_LIVESTREAM_SEGMENT_KEEP", 32), } if err := validateLoginEmailConfig(cfg); err != nil { return Config{}, err } if err := validateRPCExecutionConfig(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 } if err := validateStorageConfig(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 } if strings.TrimSpace(cfg.PublicLinkWebAddr) == "" { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ENABLE requires TELESRV_PUBLIC_LINK_WEB_ADDR") } issuer, err := url.Parse(strings.TrimSpace(cfg.TelegramLoginIssuer)) if err != nil || issuer.User != nil || issuer.Host == "" || issuer.RawQuery != "" || issuer.Fragment != "" || (issuer.Path != "" && issuer.Path != "/") { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must be an absolute origin URL") } switch issuer.Scheme { case "https": case "http": if !cfg.TelegramLoginAllowHTTP { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http requires TELESRV_TELEGRAM_LOGIN_ALLOW_HTTP=true") } default: return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https") } if strings.TrimSpace(cfg.TelegramLoginSigningKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginCodeKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginSecretPepperFile) == "" { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_* key and pepper files are required") } if cfg.TelegramLoginRequestTTL < time.Minute || cfg.TelegramLoginRequestTTL > 15*time.Minute || cfg.TelegramLoginCodeTTL < 30*time.Second || cfg.TelegramLoginCodeTTL > 10*time.Minute || cfg.TelegramLoginIDTokenTTL < time.Minute || cfg.TelegramLoginIDTokenTTL > 24*time.Hour { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN TTL values are outside their bounded ranges") } if cfg.TelegramLoginRetention < time.Hour || cfg.TelegramLoginRetention > 90*24*time.Hour || cfg.TelegramLoginSweepInterval < 10*time.Second || cfg.TelegramLoginSweepInterval > time.Hour || cfg.TelegramLoginSweepBatch <= 0 || cfg.TelegramLoginSweepBatch > 1000 { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN retention must be 1h..90d, sweep interval 10s..1h, and sweep batch 1..1000") } for _, raw := range cfg.TelegramLoginTrustedProxyCIDRs { if _, err := netip.ParsePrefix(strings.TrimSpace(raw)); err != nil { return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS contains invalid CIDR %q: %w", raw, err) } } 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 } // validateStorageConfig checks the blob backend selection and storage // management (low-space guard, retention sweep) settings. func validateStorageConfig(cfg Config) error { switch cfg.BlobBackendKind { case "localfs": // no extra requirements case "s3": if strings.TrimSpace(cfg.S3Endpoint) == "" { return fmt.Errorf("TELESRV_S3_ENDPOINT is required when TELESRV_BLOB_BACKEND=s3") } if strings.TrimSpace(cfg.S3Bucket) == "" { return fmt.Errorf("TELESRV_S3_BUCKET is required when TELESRV_BLOB_BACKEND=s3") } default: return fmt.Errorf("TELESRV_BLOB_BACKEND must be \"localfs\" or \"s3\", got %q", cfg.BlobBackendKind) } if cfg.StorageMinFreeBytes < 0 { return fmt.Errorf("TELESRV_STORAGE_MIN_FREE_BYTES must be non-negative") } if cfg.StorageMaxTotalBytes < 0 { return fmt.Errorf("TELESRV_STORAGE_MAX_TOTAL_BYTES must be non-negative") } if cfg.StorageUsageRefreshInterval < 0 { return fmt.Errorf("TELESRV_STORAGE_USAGE_REFRESH_INTERVAL must be non-negative") } if cfg.StorageRetentionMaxAge < 0 { return fmt.Errorf("TELESRV_STORAGE_RETENTION_MAX_AGE must be non-negative") } if cfg.StorageRetentionEnable && cfg.StorageRetentionMaxAge <= 0 { return fmt.Errorf("TELESRV_STORAGE_RETENTION_MAX_AGE must be positive when TELESRV_STORAGE_RETENTION_ENABLE is true") } 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 } func validateLoginEmailConfig(cfg Config) error { if cfg.LoginEmailRequireSetup && !cfg.LoginEmailEnable { return fmt.Errorf("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP requires TELESRV_LOGIN_EMAIL_ENABLE=true") } if cfg.AuthCodeTTL <= 0 { return fmt.Errorf("TELESRV_AUTH_CODE_TTL must be positive") } if cfg.AuthCodeMaxAttempts <= 0 { return fmt.Errorf("TELESRV_AUTH_CODE_MAX_ATTEMPTS must be positive") } if cfg.PhoneCodeLength < 4 || cfg.PhoneCodeLength > 10 { return fmt.Errorf("TELESRV_PHONE_CODE_LENGTH must be between 4 and 10") } if cfg.LoginEmailCodeLength < 4 || cfg.LoginEmailCodeLength > 10 { return fmt.Errorf("TELESRV_LOGIN_EMAIL_CODE_LENGTH must be between 4 and 10") } switch cfg.SMTPTLSMode { case "", "starttls", "tls", "none": default: return fmt.Errorf("TELESRV_SMTP_TLS must be starttls, tls, or none") } if cfg.EmailSignupEnable { if len(cfg.EmailSignupPhonePrefixes) == 0 { return fmt.Errorf("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES must not be empty when TELESRV_EMAIL_SIGNUP_ENABLE=true") } for _, prefix := range cfg.EmailSignupPhonePrefixes { if !isDigitsOnly(prefix) || len(prefix) < 1 || len(prefix) > 4 { return fmt.Errorf("TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES entry %q must be 1-4 digits", prefix) } } } switch cfg.PhoneCodeDeliveryProvider { case "development", "webhook": default: return fmt.Errorf("TELESRV_PHONE_CODE_DELIVERY_PROVIDER must be development or webhook") } switch cfg.EmailCodeDeliveryProvider { case "smtp", "webhook": default: return fmt.Errorf("TELESRV_EMAIL_CODE_DELIVERY_PROVIDER must be smtp or webhook") } // EmailSignupEnable shares the same delivery channel as LoginEmailEnable // (see Config.EmailSignupEnable doc), so it must gate the webhook/SMTP // requirement checks identically, or an email-signup-only deployment // (LoginEmailEnable=false) would skip webhook URL validation and SMTP // requiredness below even though it needs one of them configured. webhookEnabled := cfg.PhoneCodeDeliveryProvider == "webhook" || ((cfg.LoginEmailEnable || cfg.EmailSignupEnable) && cfg.EmailCodeDeliveryProvider == "webhook") if webhookEnabled { if cfg.OTPWebhookTimeout <= 0 { return fmt.Errorf("TELESRV_OTP_WEBHOOK_TIMEOUT must be positive") } u, err := url.Parse(strings.TrimSpace(cfg.OTPWebhookURL)) if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "http" && u.Scheme != "https") { return fmt.Errorf("TELESRV_OTP_WEBHOOK_URL must be an absolute http(s) URL without userinfo") } } if (!cfg.LoginEmailEnable && !cfg.EmailSignupEnable) || cfg.EmailCodeDeliveryProvider == "webhook" { return nil } if strings.TrimSpace(cfg.SMTPHost) == "" { return fmt.Errorf("TELESRV_SMTP_HOST is required when TELESRV_LOGIN_EMAIL_ENABLE=true or TELESRV_EMAIL_SIGNUP_ENABLE=true") } if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 { return fmt.Errorf("TELESRV_SMTP_PORT must be between 1 and 65535") } if strings.TrimSpace(cfg.SMTPFrom) == "" && strings.TrimSpace(cfg.SMTPUsername) == "" { return fmt.Errorf("TELESRV_SMTP_FROM or TELESRV_SMTP_USERNAME is required when TELESRV_LOGIN_EMAIL_ENABLE=true or TELESRV_EMAIL_SIGNUP_ENABLE=true") } if cfg.SMTPTimeout <= 0 { return fmt.Errorf("TELESRV_SMTP_TIMEOUT must be positive") } return nil } func loadAIProviders(env envSource) []AIProviderConfig { names := env.envListOr("TELESRV_AI_PROVIDERS", []string{"local"}) out := make([]AIProviderConfig, 0, len(names)) for _, name := range names { name = strings.ToLower(strings.TrimSpace(name)) if name == "" { continue } suffix := providerEnvSuffix(name) kind := env.envOr("TELESRV_AI_"+suffix+"_KIND", defaultAIProviderKind(name)) out = append(out, AIProviderConfig{ Name: name, Kind: strings.ToLower(strings.TrimSpace(kind)), BaseURL: env.envOr("TELESRV_AI_"+suffix+"_BASE_URL", ""), APIKey: env.envOr("TELESRV_AI_"+suffix+"_API_KEY", defaultAIProviderAPIKey(env, name)), Model: env.envOr("TELESRV_AI_"+suffix+"_MODEL", ""), MaxOutputTokens: env.envIntOr("TELESRV_AI_"+suffix+"_MAX_OUTPUT_TOKENS", 1024), Temperature: env.envFloatOr("TELESRV_AI_"+suffix+"_TEMPERATURE", 0.2), OmitTemperature: env.envBoolOr("TELESRV_AI_"+suffix+"_OMIT_TEMPERATURE", false), Thinking: strings.ToLower(strings.TrimSpace(env.envOr("TELESRV_AI_"+suffix+"_THINKING", ""))), }) } if len(out) == 0 { out = append(out, AIProviderConfig{Name: "local", Kind: "local", MaxOutputTokens: 1024, Temperature: 0.2}) } return out } func providerEnvSuffix(name string) string { var b strings.Builder for _, r := range strings.ToUpper(name) { if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { b.WriteRune(r) } else { b.WriteByte('_') } } return strings.Trim(b.String(), "_") } func defaultAIProviderKind(name string) string { switch name { case "openai": return "openai_responses" case "openai_chat", "openai-compatible", "openai_compat": return "openai_chat" case "gemini": return "gemini" case "anthropic": return "anthropic" default: return name } } func defaultAIProviderAPIKey(env envSource, name string) string { switch name { case "openai", "openai_chat", "openai-compatible", "openai_compat": return env.envOr("OPENAI_API_KEY", "") case "gemini": return env.envOr("GEMINI_API_KEY", "") case "anthropic": return env.envOr("ANTHROPIC_API_KEY", "") default: return "" } } type envSource map[string]string func loadConfigEnv() (envSource, error) { path, explicit := os.LookupEnv("TELESRV_CONFIG") if !explicit { path = defaultConfigFile } path = strings.TrimSpace(path) if path == "" { return nil, nil } env, err := readEnvFile(path) if err != nil { if !explicit && os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("config file %q: %w", path, err) } return env, nil } func readEnvFile(path string) (envSource, error) { f, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = f.Close() }() values := make(envSource) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for lineNo := 1; scanner.Scan(); lineNo++ { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) key, value, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) } key = strings.TrimSpace(key) if !strings.HasPrefix(key, "TELESRV_") || !validEnvKey(key) { return nil, fmt.Errorf("line %d: unsupported key %q; use TELESRV_* keys", lineNo, key) } value = strings.TrimSpace(value) if unquoted, ok := unquoteEnvValue(value); ok { value = unquoted } values[key] = value } if err := scanner.Err(); err != nil { return nil, err } return values, nil } func validEnvKey(key string) bool { if key == "" { return false } for i, r := range key { if r == '_' || ('A' <= r && r <= 'Z') || (i > 0 && '0' <= r && r <= '9') { continue } return false } return true } func unquoteEnvValue(value string) (string, bool) { if len(value) < 2 { return "", false } quote := value[0] if quote != '"' && quote != '\'' { return "", false } if value[len(value)-1] != quote { return "", false } if quote == '\'' { return value[1 : len(value)-1], true } unquoted, err := strconv.Unquote(value) if err != nil { return value[1 : len(value)-1], true } return unquoted, true } func (e envSource) envBoolOr(key string, def bool) bool { if v := e.envOr(key, ""); v != "" { switch v { case "1", "true", "TRUE", "True", "yes", "on": return true case "0", "false", "FALSE", "False", "no", "off": return false } } return def } func (e envSource) envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } if v := e[key]; v != "" { return v } return def } // envAllowEmptyOr is for nullable settings where an explicitly empty process // environment value must override a non-empty config-file value or default. func (e envSource) envAllowEmptyOr(key, def string) string { if v, ok := os.LookupEnv(key); ok { return v } if v, ok := e[key]; ok { return v } return def } func isDigitsOnly(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true } func (e envSource) envListOr(key string, def []string) []string { v := e.envOr(key, "") if v == "" { return append([]string(nil), def...) } parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } if len(out) == 0 { return append([]string(nil), def...) } return out } func (e envSource) envIntOr(key string, def int) int { if v := e.envOr(key, ""); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } 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 { return n } } return def } func (e envSource) envFloatOr(key string, def float64) float64 { if v := e.envOr(key, ""); v != "" { if n, err := strconv.ParseFloat(v, 64); err == nil { return n } } return def } // envDurationOr 读取 time.ParseDuration 格式(如 "200ms"、"30s")的时长配置;解析失败回退默认值。 func (e envSource) envDurationOr(key string, def time.Duration) time.Duration { if v := e.envOr(key, ""); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } } return def }