chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -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
}

View file

@ -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)
}
}