chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
|
|
@ -2,18 +2,25 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultConfigFile = ".env"
|
||||
|
||||
// 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 路径;不存在时自动生成。
|
||||
|
|
@ -21,6 +28,24 @@ type Config struct {
|
|||
// DC 是本 server 的 DC ID。
|
||||
DC int
|
||||
|
||||
// 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
|
||||
// Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。
|
||||
AdminUIAddr string
|
||||
AdminUIPassword string
|
||||
AdminUIToken string
|
||||
AdminSessionKey string
|
||||
|
||||
// PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。
|
||||
// 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。
|
||||
PostgresDSN string
|
||||
|
|
@ -38,6 +63,26 @@ type Config struct {
|
|||
|
||||
// DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。
|
||||
DevAuthCode string
|
||||
// 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 根目录(媒体文件字节内容)。
|
||||
|
|
@ -46,6 +91,28 @@ type Config struct {
|
|||
StickerSeedDir string
|
||||
// StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。
|
||||
StickerSeedMaxSets int
|
||||
// BusinessAIProvider 控制服务端 Business automation 回复生成器。
|
||||
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
|
||||
// "template" 使用 quick reply 模板。
|
||||
BusinessAIProvider string
|
||||
// TempKeyResolveCacheMaxEntries 是 Router temp→perm 解析缓存容量。
|
||||
TempKeyResolveCacheMaxEntries int
|
||||
|
||||
// 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 是并发 claim 的 outbox worker 数。默认 1,保证同一用户 pts update
|
||||
// 在线投递顺序与持久化顺序一致;后续需要吞吐时应改成按 target_user_id 分片的串行 worker。
|
||||
|
|
@ -60,163 +127,361 @@ type Config struct {
|
|||
OutboxLeaseTimeout 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
|
||||
// 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
|
||||
// CallSignalingMaxBytes 是 phone.sendSignalingData 单条载荷上限。
|
||||
CallSignalingMaxBytes int
|
||||
// CallSignalingRate 是单通话每秒信令转发上限(超限静默丢弃)。
|
||||
CallSignalingRate int
|
||||
// CallExpiryInterval 是通话超时兜底 dispatcher 的轮询间隔。
|
||||
CallExpiryInterval time.Duration
|
||||
|
||||
// PremiumGrantMonths 是新注册账号默认赠送的会员月数;0 关闭赠送。
|
||||
// 存量账号的一次性赠送由迁移 0094 backfill,不受该配置影响。
|
||||
PremiumGrantMonths int
|
||||
|
||||
// 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
|
||||
// StarsStartingGrant 是 Stars 本地账本的起始余额(首读时惰性授予、granted 布尔幂等,
|
||||
// 新老账号都覆盖、免回填迁移);0 关闭自动授予。
|
||||
StarsStartingGrant int64
|
||||
// PremiumSweepInterval 是会员到期 sweeper 的轮询间隔。premium 下发正确性
|
||||
// 由读取路径即时派生,sweeper 只负责清理过期行并推 updateUser 通知。
|
||||
PremiumSweepInterval time.Duration
|
||||
// PremiumSweepBatch 是单次到期清理的最大行数。
|
||||
PremiumSweepBatch int
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Load 从环境变量读取配置并填充默认值。第一阶段不做严格校验。
|
||||
// Load 从环境变量与可选配置文件读取配置并填充默认值。环境变量优先于配置文件。
|
||||
func Load() (Config, error) {
|
||||
advertiseIP := os.Getenv("TELESRV_ADVERTISE_IP")
|
||||
if advertiseIP == "" {
|
||||
advertiseIP = defaultAdvertiseIP()
|
||||
fileEnv, err := loadConfigEnv()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg := Config{
|
||||
ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"),
|
||||
AdvertiseIP: advertiseIP,
|
||||
RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"),
|
||||
DC: envIntOr("TELESRV_DC", 2),
|
||||
envBoolOr := fileEnv.envBoolOr
|
||||
envOr := fileEnv.envOr
|
||||
envListOr := fileEnv.envListOr
|
||||
envIntOr := fileEnv.envIntOr
|
||||
envInt64Or := fileEnv.envInt64Or
|
||||
envDurationOr := fileEnv.envDurationOr
|
||||
|
||||
PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"),
|
||||
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",
|
||||
}),
|
||||
// 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),
|
||||
DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"),
|
||||
BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""),
|
||||
AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""),
|
||||
AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""),
|
||||
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"),
|
||||
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", "localhost:6399"),
|
||||
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"),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
||||
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 40),
|
||||
DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"),
|
||||
StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"),
|
||||
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 200),
|
||||
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"),
|
||||
TempKeyResolveCacheMaxEntries: envIntOr("TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES", 4096),
|
||||
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", 1),
|
||||
OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100),
|
||||
OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond),
|
||||
OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second),
|
||||
OutboundPushTimeout: envDurationOr("TELESRV_OUTBOUND_PUSH_TIMEOUT", 200*time.Millisecond),
|
||||
UpdateEventRetention: envDurationOr("TELESRV_UPDATE_EVENT_RETENTION", 168*time.Hour),
|
||||
RetentionInterval: envDurationOr("TELESRV_RETENTION_INTERVAL", time.Hour),
|
||||
RetentionBatch: envIntOr("TELESRV_RETENTION_BATCH", 10000),
|
||||
OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 1),
|
||||
OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100),
|
||||
OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond),
|
||||
OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*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),
|
||||
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),
|
||||
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),
|
||||
PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"),
|
||||
PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil),
|
||||
StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)),
|
||||
PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute),
|
||||
PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500),
|
||||
|
||||
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", ""),
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func defaultAdvertiseIP() string {
|
||||
if ip := detectAdvertiseIP(); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
type envSource map[string]string
|
||||
|
||||
func detectAdvertiseIP() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
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 {
|
||||
return ""
|
||||
if !explicit && os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("config file %q: %w", path, err)
|
||||
}
|
||||
var physical []net.IP
|
||||
var virtual []net.IP
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
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
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip := ipFromAddr(addr)
|
||||
if ip == nil || !isPrivateIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
if likelyVirtualInterface(iface.Name) {
|
||||
virtual = append(virtual, ip)
|
||||
continue
|
||||
}
|
||||
physical = append(physical, ip)
|
||||
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 ip := preferredAdvertiseIP(physical); ip != "" {
|
||||
return ip
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preferredAdvertiseIP(virtual)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func ipFromAddr(addr net.Addr) net.IP {
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
return v.IP.To4()
|
||||
case *net.IPAddr:
|
||||
return v.IP.To4()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func preferredAdvertiseIP(ips []net.IP) string {
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 10 {
|
||||
return v4.String()
|
||||
}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 192 && v4[1] == 168 {
|
||||
return v4.String()
|
||||
}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
|
||||
return v4.String()
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ips[0].String()
|
||||
}
|
||||
|
||||
func isPrivateIPv4(ip net.IP) bool {
|
||||
v4 := ip.To4()
|
||||
if v4 == nil {
|
||||
func validEnvKey(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
return v4[0] == 10 ||
|
||||
(v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) ||
|
||||
(v4[0] == 192 && v4[1] == 168)
|
||||
for i, r := range key {
|
||||
if r == '_' || ('A' <= r && r <= 'Z') || (i > 0 && '0' <= r && r <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func likelyVirtualInterface(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
virtualMarkers := []string{
|
||||
"docker",
|
||||
"hyper-v",
|
||||
"tailscale",
|
||||
"virtual",
|
||||
"virtualbox",
|
||||
"vethernet",
|
||||
"vmware",
|
||||
"wsl",
|
||||
func unquoteEnvValue(value string) (string, bool) {
|
||||
if len(value) < 2 {
|
||||
return "", false
|
||||
}
|
||||
for _, marker := range virtualMarkers {
|
||||
if strings.Contains(name, marker) {
|
||||
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 false
|
||||
return def
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
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
|
||||
}
|
||||
|
||||
func envIntOr(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
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
|
||||
}
|
||||
|
|
@ -224,9 +489,18 @@ func envIntOr(key string, def int) int {
|
|||
return def
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// envDurationOr 读取 time.ParseDuration 格式(如 "200ms"、"30s")的时长配置;解析失败回退默认值。
|
||||
func envDurationOr(key string, def time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,124 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "10.172.61.102")
|
||||
func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AdvertiseIP != "10.172.61.102" {
|
||||
if cfg.AdvertiseIP != "127.0.0.1" {
|
||||
t.Fatalf("AdvertiseIP = %q, want loopback default", cfg.AdvertiseIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
|
||||
t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AdvertiseIP != "203.0.113.10" {
|
||||
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredAdvertiseIPPrefersPhysicalLANRanges(t *testing.T) {
|
||||
got := preferredAdvertiseIP([]net.IP{
|
||||
net.IPv4(172, 17, 0, 1),
|
||||
net.IPv4(192, 168, 1, 20),
|
||||
net.IPv4(10, 172, 61, 102),
|
||||
})
|
||||
if got != "10.172.61.102" {
|
||||
t.Fatalf("preferredAdvertiseIP = %q, want 10.172.61.102", got)
|
||||
}
|
||||
func TestLoadBusinessAIProvider(t *testing.T) {
|
||||
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "echo")
|
||||
|
||||
got = preferredAdvertiseIP([]net.IP{
|
||||
net.IPv4(172, 17, 0, 1),
|
||||
net.IPv4(192, 168, 1, 20),
|
||||
})
|
||||
if got != "192.168.1.20" {
|
||||
t.Fatalf("preferredAdvertiseIP = %q, want 192.168.1.20", got)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.BusinessAIProvider != "echo" {
|
||||
t.Fatalf("BusinessAIProvider = %q, want echo", cfg.BusinessAIProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateIPv4AndVirtualInterfaceDetection(t *testing.T) {
|
||||
if !isPrivateIPv4(net.IPv4(10, 172, 61, 102)) {
|
||||
t.Fatal("10.172.61.102 should be private")
|
||||
func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) {
|
||||
t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if isPrivateIPv4(net.IPv4(8, 8, 8, 8)) {
|
||||
t.Fatal("8.8.8.8 should not be private")
|
||||
}
|
||||
if !likelyVirtualInterface("vEthernet (WSL)") {
|
||||
t.Fatal("vEthernet (WSL) should be treated as virtual")
|
||||
if cfg.BusinessAIProvider != "echo" {
|
||||
t.Fatalf("BusinessAIProvider = %q, want echo", cfg.BusinessAIProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsEnvStyleConfigFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "telesrv.env")
|
||||
writeConfigFile(t, path, `
|
||||
TELESRV_MAPBOX_TOKEN="file-token"
|
||||
TELESRV_POSTGRES_MAX_CONNS=77
|
||||
TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example
|
||||
TELESRV_CALL_RING_TIMEOUT=2m
|
||||
`)
|
||||
t.Setenv("TELESRV_CONFIG", path)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.MapboxToken != "file-token" {
|
||||
t.Fatalf("MapboxToken = %q, want file-token", cfg.MapboxToken)
|
||||
}
|
||||
if cfg.PostgresMaxConns != 77 {
|
||||
t.Fatalf("PostgresMaxConns = %d, want 77", cfg.PostgresMaxConns)
|
||||
}
|
||||
if got, want := cfg.WebSocketAllowedOrigins, []string{"https://one.example", "https://two.example"}; len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("WebSocketAllowedOrigins = %#v, want %#v", got, want)
|
||||
}
|
||||
if cfg.CallRingTimeout != 2*time.Minute {
|
||||
t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvironmentOverridesConfigFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "telesrv.env")
|
||||
writeConfigFile(t, path, `TELESRV_MAPBOX_TOKEN=file-token`)
|
||||
t.Setenv("TELESRV_CONFIG", path)
|
||||
t.Setenv("TELESRV_MAPBOX_TOKEN", "env-token")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.MapboxToken != "env-token" {
|
||||
t.Fatalf("MapboxToken = %q, want env-token", cfg.MapboxToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExplicitMissingConfigFileErrors(t *testing.T) {
|
||||
t.Setenv("TELESRV_CONFIG", filepath.Join(t.TempDir(), "missing.env"))
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load succeeded with explicit missing config file, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "telesrv.env")
|
||||
writeConfigFile(t, path, `MAPBOX_TOKEN=file-token`)
|
||||
t.Setenv("TELESRV_CONFIG", path)
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load succeeded with unsupported config key, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfigFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write config file: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue