修复Telegram回调与沙箱推送未使用本机代理

This commit is contained in:
smail 2026-10-03 20:42:13 +08:00
parent 2199587d0e
commit 1d63e9fd1d
8 changed files with 233 additions and 17 deletions

View File

@ -4,6 +4,7 @@ package hooks
import (
"context"
"encoding/json"
"errors"
"io"
"log"
@ -45,8 +46,24 @@ func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error {
return errors.New("plugin egress socket identity failed")
}
ctx, cancel := context.WithCancel(context.Background())
var telegramProxy func() (string, error)
if logicalID == "telegram_push" {
telegramProxy = func() (string, error) {
instance, _, ok := pluginInstanceForStartup(logicalID)
if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil {
return "", errors.New("Telegram plugin configuration is unavailable")
}
var settings struct {
Proxy string `json:"proxy"`
}
if err := json.Unmarshal(instance.Settings, &settings); err != nil {
return "", errors.New("Telegram plugin configuration is invalid")
}
return credential.NormalizeTelegramProxyURL(settings.Proxy)
}
}
server := &http.Server{
Handler: credential.NewPluginEgressHandler(token, func() []credential.NetworkTarget {
Handler: credential.NewPluginEgressHandlerWithTelegramProxy(token, func() []credential.NetworkTarget {
instance, _, ok := pluginInstanceForStartup(logicalID)
if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil {
return nil
@ -65,7 +82,7 @@ func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error {
}
}
return targets
}),
}, telegramProxy),
BaseContext: func(net.Listener) context.Context { return ctx },
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,

View File

@ -50,6 +50,8 @@ type telegramBinding struct {
type telegramBindingConfig struct {
Token string
Proxy string
TimeoutMS int
BotID string
Callback string
Secret string
@ -68,6 +70,8 @@ func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) {
}
var settings struct {
WebDomain string `json:"webDomain"`
Proxy string `json:"proxy"`
Timeout int `json:"timeout"`
}
domain := strings.TrimSpace(current.WebDomain)
parsed, err := url.Parse("https://" + domain)
@ -76,6 +80,16 @@ func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) {
!strings.EqualFold(settings.WebDomain, domain) {
return result, TelegramNotice("机器人网页域名与后台域名不一致,请联系超级管理员")
}
proxyURL, err := credential.NormalizeTelegramProxyURL(settings.Proxy)
if err != nil {
return result, TelegramNotice(err.Error())
}
if settings.Timeout == 0 {
settings.Timeout = 30
}
if settings.Timeout < 1 || settings.Timeout > 120 {
return result, TelegramNotice("Telegram 请求超时时间无效")
}
token, err := s.openSecretForPlugin(telegramPluginID, "botToken")
parts := telegramTokenPattern.FindStringSubmatch(token)
if err != nil || len(parts) != 2 {
@ -90,7 +104,7 @@ func (s *ConfigService) telegramConfiguration() (telegramBindingConfig, error) {
return result, ErrPluginStoreUnavailable
}
// 回调地址只携带配置标识,不包含机器人令牌或请求认证密钥。
return telegramBindingConfig{Token: token, BotID: parts[1], Secret: secret, Fingerprint: fingerprint,
return telegramBindingConfig{Token: token, Proxy: proxyURL, TimeoutMS: settings.Timeout * 1000, BotID: parts[1], Secret: secret, Fingerprint: fingerprint,
Callback: "https://" + domain + TelegramCallbackPath + "?v=" + fingerprint[:16]}, nil
}
@ -107,7 +121,11 @@ func requestTelegramBindingJSON(ctx context.Context, configuration telegramBindi
return ErrTelegramBinding
}
request.Header.Set("Content-Type", "application/json")
client := credential.NewRestrictedHTTPClientForTargets(10_000, []credential.NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}})
client, err := credential.NewTelegramHTTPClient(configuration.TimeoutMS, configuration.Proxy)
if err != nil {
return TelegramNotice(err.Error())
}
defer client.CloseIdleConnections()
response, err := client.Do(request)
if err != nil {
return TelegramNotice("无法连接 Telegram,请稍后重试")

View File

@ -105,6 +105,53 @@ func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *
return newRestrictedHTTPClientForTargets(timeoutMS, targets, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1")
}
// NormalizeTelegramProxyURL 仅接受超管指定的本机 HTTP 代理,不解析代理域名。
func NormalizeTelegramProxyURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
endpoint, err := url.Parse(raw)
if err != nil || endpoint.Scheme != "http" || endpoint.User != nil || endpoint.Path != "" ||
endpoint.RawQuery != "" || endpoint.ForceQuery || strings.ContainsRune(raw, '#') || len(raw) > 256 {
return "", errors.New("Telegram 代理仅支持带端口的本机 HTTP 地址,如 http://127.0.0.1:9000")
}
address, addressErr := netip.ParseAddr(endpoint.Hostname())
port, portErr := strconv.Atoi(endpoint.Port())
if addressErr != nil || !address.IsLoopback() || address.Zone() != "" || portErr != nil || port < 1 || port > 65535 {
return "", errors.New("Telegram 代理仅支持带端口的本机 HTTP 地址,如 http://127.0.0.1:9000")
}
return "http://" + net.JoinHostPort(address.String(), strconv.Itoa(port)), nil
}
// NewTelegramHTTPClient 将代理权限限定为 Telegram;沙箱始终由主进程代为出站。
func NewTelegramHTTPClient(timeoutMS int, proxyURL string) (*http.Client, error) {
return newTelegramHTTPClient(timeoutMS, proxyURL, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1")
}
func newTelegramHTTPClient(timeoutMS int, proxyURL string, sandbox bool) (*http.Client, error) {
proxyURL, err := NormalizeTelegramProxyURL(proxyURL)
if err != nil {
return nil, err
}
targets := []NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}}
client := newRestrictedHTTPClientForTargets(timeoutMS, targets, sandbox)
if proxyURL == "" || sandbox {
return client, nil
}
endpoint, _ := url.Parse(proxyURL)
transport := client.Transport.(restrictedRoundTripper).transport.(*http.Transport)
transport.Proxy = http.ProxyURL(endpoint)
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
if address != endpoint.Host {
return nil, errors.New("Telegram 代理连接目标不匹配")
}
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, endpoint.Host)
}
// HTTPS CONNECT 后仍验证 Telegram 的系统可信证书,不接受其他目标或明文请求。
return client, nil
}
func newRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget, sandbox bool) *http.Client {
timeout := time.Duration(NormalizeTimeoutMS(timeoutMS)) * time.Millisecond
allowedTargets := normalizeNetworkTargets(targets)

View File

@ -1,6 +1,8 @@
package credential
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@ -9,6 +11,107 @@ import (
"testing"
)
func TestTelegramProxyValidation(t *testing.T) {
for _, raw := range []string{"", "http://127.0.0.1:9000", "http://[::1]:8080"} {
if _, err := NormalizeTelegramProxyURL(raw); err != nil {
t.Fatalf("valid proxy rejected: %q", raw)
}
}
for _, raw := range []string{
"http://localhost:9000", "http://10.0.0.1:9000", "http://8.8.8.8:9000",
"https://127.0.0.1:9000", "socks5://127.0.0.1:9000", "http://127.0.0.1",
"http://127.0.0.1:0", "http://127.0.0.1:65536", "http://user:pass@127.0.0.1:9000",
"http://127.0.0.1:9000/", "http://127.0.0.1:9000?", "http://127.0.0.1:9000#",
"http://[::1%25lo]:9000",
} {
if _, err := NormalizeTelegramProxyURL(raw); err == nil {
t.Fatalf("invalid proxy accepted: %q", raw)
}
}
}
func TestTelegramProxyRetainsTargetAndTLSRestrictions(t *testing.T) {
client, err := newTelegramHTTPClient(30000, "http://127.0.0.1:9000", false)
if err != nil {
t.Fatal(err)
}
defer client.CloseIdleConnections()
transport := client.Transport.(restrictedRoundTripper).transport.(*http.Transport)
request := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot-test/getMe", nil)
proxy, err := transport.Proxy(request)
if err != nil || proxy.String() != "http://127.0.0.1:9000" {
t.Fatal("configured proxy was not applied")
}
if transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("TLS verification was disabled")
}
if connection, err := transport.DialContext(context.Background(), "tcp", "127.0.0.1:9001"); err == nil {
connection.Close()
t.Fatal("dial escaped configured proxy")
}
for _, target := range []string{"https://example.com/", "https://127.0.0.1/", "http://api.telegram.org/", "https://api.telegram.org:444/"} {
request := httptest.NewRequest(http.MethodGet, target, nil)
if _, err := client.Transport.RoundTrip(request); err == nil {
t.Fatalf("unauthorized target accepted: %s", target)
}
if err := client.CheckRedirect(request, nil); err == nil {
t.Fatalf("unauthorized redirect accepted: %s", target)
}
}
}
func TestTelegramSandboxNeverDialsProxyDirectly(t *testing.T) {
client, err := newTelegramHTTPClient(30000, "http://127.0.0.1:9000", true)
if err != nil {
t.Fatal(err)
}
defer client.CloseIdleConnections()
if _, ok := client.Transport.(restrictedRoundTripper).transport.(*pluginEgressTransport); !ok {
t.Fatal("sandbox escaped host-managed egress")
}
}
func TestTelegramProxyUsesConnectWithoutSendingTokenInPlaintext(t *testing.T) {
requests := make(chan string, 1)
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requests <- request.Method + " " + request.RequestURI
writer.WriteHeader(http.StatusForbidden)
}))
defer proxy.Close()
client, err := newTelegramHTTPClient(1000, proxy.URL, false)
if err != nil {
t.Fatal(err)
}
defer client.CloseIdleConnections()
if _, err := client.Get("https://api.telegram.org/bot-secret/getMe"); err == nil {
t.Fatal("failed CONNECT was accepted")
}
select {
case request := <-requests:
if request != "CONNECT api.telegram.org:443" {
t.Fatalf("unexpected proxy request: %s", request)
}
default:
t.Fatal("proxy was not contacted")
}
}
func TestTelegramEgressRejectsInvalidProxyWithoutFallback(t *testing.T) {
token := strings.Repeat("a", 64)
handler := NewPluginEgressHandlerWithTelegramProxy(token, func() []NetworkTarget {
return []NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}}
}, func() (string, error) { return "", errors.New("private configuration detail") })
request := httptest.NewRequest(http.MethodPost, "/relay", strings.NewReader("{}"))
request.Host = "plugin-egress"
request.Header.Set(egressTokenHeader, token)
request.Header.Set(egressTargetHeader, "https://api.telegram.org/bot-secret/getMe")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || strings.Contains(response.Body.String(), "private") {
t.Fatal("invalid proxy did not fail closed with a sanitized error")
}
}
func TestValidateSecret(t *testing.T) {
value, err := ValidateSecret(" token-value ", 64)
if err != nil || value != "token-value" {

View File

@ -78,6 +78,11 @@ func (t *pluginEgressTransport) CloseIdleConnections() {
// NewPluginEgressHandler 只供沙箱专属本地通道使用,不注册到公网路由。
// 每次重新取权限;空权限拒绝访问,禁止继承通用客户端的空名单语义。
func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http.Handler {
return NewPluginEgressHandlerWithTelegramProxy(token, authorize, nil)
}
// NewPluginEgressHandlerWithTelegramProxy 的代理只能由主进程读取,插件请求不能指定代理。
func NewPluginEgressHandlerWithTelegramProxy(token string, authorize func() []NetworkTarget, telegramProxy func() (string, error)) http.Handler {
slots := make(chan struct{}, 2)
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if len(token) != 64 || len(request.Header.Values(egressTokenHeader)) != 1 ||
@ -142,6 +147,18 @@ func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http
outbound.Header = request.Header.Clone()
stripEgressHeaders(outbound.Header)
client := newRestrictedHTTPClientForTargets(30_000, targets, false)
if telegramProxy != nil {
proxyURL, proxyErr := telegramProxy()
if proxyErr != nil {
http.Error(writer, "Telegram 代理配置无效", http.StatusForbidden)
return
}
client, err = newTelegramHTTPClient(30_000, proxyURL, false)
if err != nil {
http.Error(writer, "Telegram 代理配置无效", http.StatusForbidden)
return
}
}
// 每次请求使用独立连接,避免配置撤销后继续复用已有上游连接。
if restricted, ok := client.Transport.(restrictedRoundTripper); ok {
if transport, ok := restricted.transport.(*http.Transport); ok {

View File

@ -19,16 +19,10 @@ func NewBot(config pluginSettings, token string) (*bot.Bot, error) {
}),
)
}
if config.Proxy != "" {
return nil, errUnsupportedProxy
httpClient, err := credential.NewTelegramHTTPClient(config.Timeout*1000, config.Proxy)
if err != nil {
return nil, err
}
httpClient := credential.NewRestrictedHTTPClientForTargets(config.Timeout*1000, []credential.NetworkTarget{{Scheme: "https", Host: "api.telegram.org", Port: 443}})
opts = append(opts, bot.WithHTTPClient(timeout, httpClient))
return bot.New(token, opts...)
}
var errUnsupportedProxy = &unsupportedProxyError{}
type unsupportedProxyError struct{}
func (*unsupportedProxyError) Error() string { return "不支持的代理协议" }

View File

@ -7,6 +7,8 @@ import (
"regexp"
"strings"
"sync"
"git.pcux.cn/xuans/Smail/utils/credential"
)
const (
@ -162,9 +164,11 @@ func parseRuntimeConfig(data json.RawMessage) (pluginRuntimeConfig, pluginSettin
if settings.Timeout < 1 || settings.Timeout > maxTimeout {
return pluginRuntimeConfig{}, pluginSettings{}, "", errors.New("Telegram 请求超时时间无效")
}
if settings.Proxy != "" {
return pluginRuntimeConfig{}, pluginSettings{}, "", errors.New("Telegram 插件不允许使用未授权代理")
proxyURL, err := credential.NormalizeTelegramProxyURL(settings.Proxy)
if err != nil {
return pluginRuntimeConfig{}, pluginSettings{}, "", err
}
settings.Proxy = proxyURL
secret := strings.TrimSpace(runtime.Secrets["botToken"])
if secret == "" {
return pluginRuntimeConfig{}, pluginSettings{}, "", errors.New("Telegram Bot Token 未配置")

View File

@ -48,6 +48,22 @@ func TestParseRuntimeConfigRejectsZeroGeneration(t *testing.T) {
}
}
func TestParseRuntimeConfigAcceptsHostManagedLocalProxy(t *testing.T) {
var payload pluginRuntimeConfig
if err := json.Unmarshal(validRuntimePayload(), &payload); err != nil {
t.Fatal(err)
}
payload.Settings = json.RawMessage(`{"proxy":"http://127.0.0.1:9000","timeout":30}`)
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
_, settings, _, err := parseRuntimeConfig(data)
if err != nil || settings.Proxy != "http://127.0.0.1:9000" {
t.Fatal("host-managed local proxy was rejected")
}
}
func TestParseRuntimeConfigRejectsSecurityBoundaryViolations(t *testing.T) {
tests := []struct {
name string
@ -70,8 +86,8 @@ func TestParseRuntimeConfigRejectsSecurityBoundaryViolations(t *testing.T) {
payload.Network = append(payload.Network, pluginNetworkTarget{Scheme: "https", Host: "example.test", Port: 443})
}, wantError: "Telegram \u7f51\u7edc\u6743\u9650\u672a\u914d\u7f6e"},
{name: "proxy bypass", mutate: func(payload *pluginRuntimeConfig) {
payload.Settings = json.RawMessage(`{"proxy":"http://127.0.0.1:8080"}`)
}, wantError: "Telegram \u63d2\u4ef6\u4e0d\u5141\u8bb8\u4f7f\u7528\u672a\u6388\u6743\u4ee3\u7406"},
payload.Settings = json.RawMessage(`{"proxy":"http://192.168.1.1:8080"}`)
}, wantError: "Telegram 代理仅支持带端口的本机 HTTP 地址"},
{name: "timeout too large", mutate: func(payload *pluginRuntimeConfig) { payload.Settings = json.RawMessage(`{"timeout":121}`) }, wantError: "Telegram \u8bf7\u6c42\u8d85\u65f6\u65f6\u95f4\u65e0\u6548"},
{name: "unknown setting", mutate: func(payload *pluginRuntimeConfig) { payload.Settings = json.RawMessage(`{"unknown":true}`) }, wantError: "Telegram \u63d2\u4ef6\u914d\u7f6e\u65e0\u6548"},
}