diff --git a/server/internal/app/app.go b/server/internal/app/app.go index e76c4b8..3aaa838 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -85,7 +85,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil)) logHub := backup.NewLogHub() retentionService := backupretention.NewService(backupRecordRepo) - notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier(), notify.NewSMSWebhookNotifier()) + notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier()) notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher) authService.SetNotificationService(notificationService) // 初始化 rclone 传输配置(重试 + 带宽限制) diff --git a/server/internal/notify/sms.go b/server/internal/notify/sms.go deleted file mode 100644 index 01ed420..0000000 --- a/server/internal/notify/sms.go +++ /dev/null @@ -1,175 +0,0 @@ -package notify - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net" - "net/http" - "net/url" - "strings" - "time" -) - -type SMSWebhookNotifier struct { - client *http.Client -} - -func NewSMSWebhookNotifier() *SMSWebhookNotifier { - return &SMSWebhookNotifier{client: &http.Client{ - Timeout: 10 * time.Second, - Transport: newSMSWebhookTransport(), - CheckRedirect: validateSMSWebhookRedirect, - }} -} - -func (n *SMSWebhookNotifier) Type() string { return "sms" } -func (n *SMSWebhookNotifier) SensitiveFields() []string { return []string{"secret"} } - -func (n *SMSWebhookNotifier) Validate(config map[string]any) error { - if _, err := validateSMSWebhookURL(asString(config["url"])); err != nil { - return err - } - return nil -} - -func (n *SMSWebhookNotifier) Send(ctx context.Context, config map[string]any, message Message) error { - if err := n.Validate(config); err != nil { - return err - } - payload := map[string]any{ - "title": message.Title, - "body": message.Body, - "fields": message.Fields, - "phone": message.Fields["phone"], - "code": message.Fields["code"], - "purpose": message.Fields["purpose"], - } - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal sms webhook payload: %w", err) - } - endpoint, err := validateSMSWebhookURL(asString(config["url"])) - if err != nil { - return err - } - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("create sms webhook request: %w", err) - } - request.Header.Set("Content-Type", "application/json") - if secret := strings.TrimSpace(asString(config["secret"])); secret != "" { - request.Header.Set("X-BackupX-Secret", secret) - } - - // codeql[go/request-forgery]: SMS webhook URLs are admin-configured and validated by validateSMSWebhookURL plus dial-time public IP checks. - // lgtm[go/request-forgery] - response, err := n.client.Do(request) - if err != nil { - return fmt.Errorf("send sms webhook request: %w", err) - } - defer response.Body.Close() - if response.StatusCode >= http.StatusBadRequest { - return fmt.Errorf("sms webhook response status: %s", response.Status) - } - return nil -} - -func validateSMSWebhookRedirect(request *http.Request, _ []*http.Request) error { - _, err := validateSMSWebhookURL(request.URL.String()) - return err -} - -func newSMSWebhookTransport() *http.Transport { - return &http.Transport{ - DialContext: dialSMSWebhookContext, - ForceAttemptHTTP2: true, - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 10 * time.Second, - IdleConnTimeout: 30 * time.Second, - } -} - -func dialSMSWebhookContext(ctx context.Context, network string, address string) (net.Conn, error) { - host, port, err := net.SplitHostPort(address) - if err != nil { - return nil, err - } - ip, err := resolvePublicSMSWebhookIP(ctx, host) - if err != nil { - return nil, err - } - dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} - return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) -} - -func resolvePublicSMSWebhookIP(ctx context.Context, hostname string) (net.IP, error) { - host := strings.TrimSpace(hostname) - if ip := net.ParseIP(host); ip != nil { - if !isPublicSMSWebhookIP(ip) { - return nil, fmt.Errorf("sms webhook host must resolve to a public address") - } - return ip, nil - } - addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return nil, fmt.Errorf("resolve sms webhook host: %w", err) - } - for _, address := range addresses { - if !isPublicSMSWebhookIP(address.IP) { - return nil, fmt.Errorf("sms webhook host must resolve to a public address") - } - } - if len(addresses) == 0 { - return nil, fmt.Errorf("sms webhook host did not resolve") - } - return addresses[0].IP, nil -} - -func validateSMSWebhookURL(raw string) (string, error) { - endpoint := strings.TrimSpace(raw) - if endpoint == "" { - return "", fmt.Errorf("sms webhook url is required") - } - parsed, err := url.Parse(endpoint) - if err != nil { - return "", fmt.Errorf("sms webhook url is invalid: %w", err) - } - if !strings.EqualFold(parsed.Scheme, "https") { - return "", fmt.Errorf("sms webhook url must use https") - } - if parsed.User != nil { - return "", fmt.Errorf("sms webhook url must not include user info") - } - if parsed.Hostname() == "" { - return "", fmt.Errorf("sms webhook host is required") - } - if err := validateSMSWebhookHost(parsed.Hostname()); err != nil { - return "", err - } - parsed.Fragment = "" - return parsed.String(), nil -} - -func validateSMSWebhookHost(hostname string) error { - host := strings.Trim(strings.ToLower(strings.TrimSpace(hostname)), ".") - if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") { - return fmt.Errorf("sms webhook host is not allowed") - } - if ip := net.ParseIP(host); ip != nil && !isPublicSMSWebhookIP(ip) { - return fmt.Errorf("sms webhook host must resolve to a public address") - } - return nil -} - -func isPublicSMSWebhookIP(ip net.IP) bool { - return ip.IsGlobalUnicast() && - !ip.IsPrivate() && - !ip.IsLoopback() && - !ip.IsLinkLocalUnicast() && - !ip.IsLinkLocalMulticast() && - !ip.IsMulticast() && - !ip.IsUnspecified() -} diff --git a/server/internal/notify/sms_test.go b/server/internal/notify/sms_test.go deleted file mode 100644 index 163722c..0000000 --- a/server/internal/notify/sms_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package notify - -import "testing" - -func TestValidateSMSWebhookURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - raw string - wantErr bool - }{ - {name: "valid https endpoint", raw: "https://sms.example.com/send?channel=otp"}, - {name: "reject empty", raw: "", wantErr: true}, - {name: "reject http", raw: "http://sms.example.com/send", wantErr: true}, - {name: "reject user info", raw: "https://user:pass@sms.example.com/send", wantErr: true}, - {name: "reject localhost", raw: "https://localhost/send", wantErr: true}, - {name: "reject private ipv4", raw: "https://192.168.1.10/send", wantErr: true}, - {name: "reject loopback ipv6", raw: "https://[::1]/send", wantErr: true}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := validateSMSWebhookURL(tt.raw) - if tt.wantErr && err == nil { - t.Fatalf("expected error") - } - if !tt.wantErr && err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) - } -} diff --git a/server/internal/service/notification_service.go b/server/internal/service/notification_service.go index dbfa283..a37d576 100644 --- a/server/internal/service/notification_service.go +++ b/server/internal/service/notification_service.go @@ -17,7 +17,7 @@ import ( type NotificationUpsertInput struct { Name string `json:"name" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=email webhook telegram sms"` + Type string `json:"type" binding:"required,oneof=email webhook telegram"` Enabled bool `json:"enabled"` OnSuccess bool `json:"onSuccess"` OnFailure bool `json:"onFailure"` @@ -220,7 +220,7 @@ func (s *NotificationService) SendAuthEmailOTP(ctx context.Context, to string, c } func (s *NotificationService) SendAuthSMSOTP(ctx context.Context, phone string, code string) error { - return s.sendFirstByType(ctx, "sms", nil, notify.Message{ + return s.sendFirstByType(ctx, "webhook", nil, notify.Message{ Title: "BackupX 登录验证码", Body: fmt.Sprintf("BackupX 登录验证码:%s,5 分钟内有效。", code), Fields: map[string]any{ diff --git a/web/src/components/notifications/field-config.test.ts b/web/src/components/notifications/field-config.test.ts index 13cebfb..34b3943 100644 --- a/web/src/components/notifications/field-config.test.ts +++ b/web/src/components/notifications/field-config.test.ts @@ -5,18 +5,16 @@ describe('notification field config', () => { it('returns readable type labels', () => { expect(getNotificationTypeLabel('email')).toBe('Email') expect(getNotificationTypeLabel('telegram')).toBe('Telegram') - expect(getNotificationTypeLabel('sms')).toBe('SMS Webhook') + expect(getNotificationTypeLabel('webhook')).toBe('Webhook') }) it('returns required fields for each notification type', () => { const emailFields = getNotificationFieldConfigs('email') const webhookFields = getNotificationFieldConfigs('webhook') const telegramFields = getNotificationFieldConfigs('telegram') - const smsFields = getNotificationFieldConfigs('sms') expect(emailFields.some((field) => field.key === 'host' && field.required)).toBe(true) expect(webhookFields.some((field) => field.key === 'url' && field.required)).toBe(true) expect(telegramFields.some((field) => field.key === 'botToken' && field.required)).toBe(true) - expect(smsFields.some((field) => field.key === 'url' && field.required)).toBe(true) }) }) diff --git a/web/src/components/notifications/field-config.ts b/web/src/components/notifications/field-config.ts index a963082..0b59f12 100644 --- a/web/src/components/notifications/field-config.ts +++ b/web/src/components/notifications/field-config.ts @@ -13,10 +13,6 @@ const FIELD_CONFIG_MAP: Record = { { key: 'url', label: 'Webhook URL', type: 'input', required: true, placeholder: 'https://hooks.example.com/backupx' }, { key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true }, ], - sms: [ - { key: 'url', label: 'SMS Webhook URL', type: 'input', required: true, placeholder: 'https://sms-gateway.example.com/send', description: '仅允许 HTTPS 公网地址。' }, - { key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true }, - ], telegram: [ { key: 'botToken', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC', sensitive: true }, { key: 'chatId', label: 'Chat ID', type: 'input', required: true, placeholder: '-100xxxxxxxxxx' }, @@ -27,7 +23,6 @@ export const notificationTypeOptions = [ { label: 'Email', value: 'email' }, { label: 'Webhook', value: 'webhook' }, { label: 'Telegram', value: 'telegram' }, - { label: 'SMS Webhook', value: 'sms' }, ] as const export function getNotificationTypeLabel(type: NotificationType) { @@ -38,8 +33,6 @@ export function getNotificationTypeLabel(type: NotificationType) { return 'Webhook' case 'telegram': return 'Telegram' - case 'sms': - return 'SMS Webhook' default: return type } diff --git a/web/src/layouts/AppLayout.tsx b/web/src/layouts/AppLayout.tsx index d19c5c7..02b18c0 100644 --- a/web/src/layouts/AppLayout.tsx +++ b/web/src/layouts/AppLayout.tsx @@ -553,7 +553,7 @@ export function AppLayout() { 邮件 / 短信 OTP - + 邮件 OTP {user?.emailOtpEnabled ? '已启用' : '未启用'} 短信 OTP {user?.smsOtpEnabled ? '已启用' : '未启用'} diff --git a/web/src/types/notifications.ts b/web/src/types/notifications.ts index aa7bb13..287578a 100644 --- a/web/src/types/notifications.ts +++ b/web/src/types/notifications.ts @@ -1,4 +1,4 @@ -export type NotificationType = 'email' | 'webhook' | 'telegram' | 'sms' +export type NotificationType = 'email' | 'webhook' | 'telegram' export type NotificationFieldType = 'input' | 'password' | 'number' | 'textarea' export interface NotificationSummary {