feat: sync Telegram Login OIDC provider

This commit is contained in:
A 2026-07-21 15:46:24 +08:00
parent 30774f8c39
commit ebead9e98c
63 changed files with 11374 additions and 37 deletions

View file

@ -4,6 +4,8 @@ package config
import (
"bufio"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"strconv"
@ -88,6 +90,22 @@ type Config struct {
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
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
TelegramLoginAllowLoopbackHTTP 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
@ -471,6 +489,19 @@ func Load() (Config, error) {
PublicWebBaseURL: publicWebBaseURL,
PublicAppName: publicAppName,
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_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),
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
@ -629,9 +660,56 @@ func Load() (Config, error) {
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
if err := validateTelegramLoginConfig(cfg); err != nil {
return Config{}, err
}
return cfg, 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":
host := issuer.Hostname()
ip := net.ParseIP(host)
if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development")
}
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
}
func validateStarGiftConfig(cfg Config) error {
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")

View file

@ -35,13 +35,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10")
t.Setenv("TELESRV_ADVERTISE_IP", "10.172.61.102")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AdvertiseIP != "192.0.2.10" {
if cfg.AdvertiseIP != "10.172.61.102" {
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
}
}
@ -431,6 +431,83 @@ func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
}
}
func TestLoadTelegramLoginConfig(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper")
t.Setenv("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", "7m")
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_TTL", "90s")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", "45m")
t.Setenv("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", "127.0.0.0/8,10.0.0.0/8")
t.Setenv("TELESRV_TELEGRAM_LOGIN_RETENTION", "48h")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", "30s")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", "73")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP {
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP)
}
if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" {
t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile)
}
if cfg.TelegramLoginRequestTTL != 7*time.Minute || cfg.TelegramLoginCodeTTL != 90*time.Second || cfg.TelegramLoginIDTokenTTL != 45*time.Minute ||
cfg.TelegramLoginRetention != 48*time.Hour || cfg.TelegramLoginSweepInterval != 30*time.Second || cfg.TelegramLoginSweepBatch != 73 {
t.Fatalf("telegram login durations/batch = %v / %v / %v / %v / %v / %d", cfg.TelegramLoginRequestTTL, cfg.TelegramLoginCodeTTL,
cfg.TelegramLoginIDTokenTTL, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch)
}
if len(cfg.TelegramLoginTrustedProxyCIDRs) != 2 || cfg.TelegramLoginTrustedProxyCIDRs[1] != "10.0.0.0/8" {
t.Fatalf("trusted proxy CIDRs = %#v", cfg.TelegramLoginTrustedProxyCIDRs)
}
}
func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.T) {
valid := Config{
TelegramLoginEnabled: true, PublicLinkWebAddr: "127.0.0.1:2401", TelegramLoginIssuer: "https://login.example.test",
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
}
if err := validateTelegramLoginConfig(valid); err != nil {
t.Fatalf("valid config: %v", err)
}
tests := []struct {
name string
mutate func(*Config)
}{
{name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }},
{name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }},
{name: "public http", mutate: func(c *Config) {
c.TelegramLoginIssuer = "http://login.example.test"
c.TelegramLoginAllowLoopbackHTTP = true
}},
{name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }},
{name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }},
{name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }},
{name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }},
{name: "id token ttl too long", mutate: func(c *Config) { c.TelegramLoginIDTokenTTL = 25 * time.Hour }},
{name: "retention too short", mutate: func(c *Config) { c.TelegramLoginRetention = 59 * time.Minute }},
{name: "sweep unbounded", mutate: func(c *Config) { c.TelegramLoginSweepBatch = 1001 }},
{name: "invalid proxy CIDR", mutate: func(c *Config) { c.TelegramLoginTrustedProxyCIDRs = []string{"10.0.0.0/33"} }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := valid
tc.mutate(&cfg)
if err := validateTelegramLoginConfig(cfg); err == nil {
t.Fatal("unsafe Telegram Login config was accepted")
}
})
}
}
func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one")