From 928ba7746e6a2d978e66fd302fedeae580796c3f Mon Sep 17 00:00:00 2001 From: MengMengCode Date: Tue, 11 Aug 2026 00:22:54 +0800 Subject: [PATCH] update --- internal/developer/settings.go | 33 +++++++ internal/developer/settings_test.go | 24 +++++ internal/server/developer_settings.go | 55 ++++++++--- internal/server/developer_settings_test.go | 30 ++++++ internal/server/sms_api.go | 34 +++++++ internal/server/sms_api_test.go | 52 +++++++++++ internal/store/domain_test.go | 1 + internal/store/migrations.go | 10 ++ internal/store/sms_rate.go | 88 ++++++++++++++++++ internal/store/sms_rate_test.go | 58 ++++++++++++ internal/store/store.go | 2 +- internal/vowifi/ike/relay.go | 7 +- internal/vowifi/ike/relay_test.go | 56 +++++++++-- internal/vowifi/ike/userspace_linux.go | 92 +++++++++++++++++-- .../components/settings/SMSRateLimitCard.tsx | 58 ++++++++++++ web/src/pages/SettingsPage.tsx | 33 ++++++- web/src/types.ts | 3 + 17 files changed, 602 insertions(+), 34 deletions(-) create mode 100644 internal/store/sms_rate.go create mode 100644 internal/store/sms_rate_test.go create mode 100644 web/src/components/settings/SMSRateLimitCard.tsx diff --git a/internal/developer/settings.go b/internal/developer/settings.go index a8e5273..a98225c 100644 --- a/internal/developer/settings.go +++ b/internal/developer/settings.go @@ -25,8 +25,11 @@ func Enabled(ctx context.Context, database *store.Store) bool { const ( EnabledSettingKey = "developer.enabled" DeviceLimitSettingKey = "developer.device_limit" + SMSHourlyLimitKey = "developer.sms_hourly_limit" DefaultDeviceLimit = 5 MaxDeviceLimit = 128 + DefaultSMSHourlyLimit = 10 + MaxSMSHourlyLimit = 1000 ) func DeviceLimit(ctx context.Context, database *store.Store, enabled bool) int { @@ -57,6 +60,33 @@ func SetDeviceLimit(ctx context.Context, database *store.Store, limit int) error return database.UpsertAppSetting(ctx, store.AppSetting{Key: DeviceLimitSettingKey, Value: value}) } +// SMSHourlyLimit is enforced regardless of developer mode. Developer mode +// only controls whether administrators can see and modify this value. +func SMSHourlyLimit(ctx context.Context, database *store.Store) int { + setting, err := database.AppSetting(ctx, SMSHourlyLimitKey) + if err != nil { + return DefaultSMSHourlyLimit + } + var document struct { + Limit int `json:"limit"` + } + if json.Unmarshal(setting.Value, &document) != nil || document.Limit < 1 || document.Limit > MaxSMSHourlyLimit { + return DefaultSMSHourlyLimit + } + return document.Limit +} + +func SetSMSHourlyLimit(ctx context.Context, database *store.Store, limit int) error { + if limit < 1 || limit > MaxSMSHourlyLimit { + return fmt.Errorf("SMS hourly limit must be between 1 and %d", MaxSMSHourlyLimit) + } + value, err := json.Marshal(map[string]int{"limit": limit}) + if err != nil { + return err + } + return database.UpsertAppSetting(ctx, store.AppSetting{Key: SMSHourlyLimitKey, Value: value}) +} + // ResetExperimental restores every mutable developer-only setting. It is // called both by `vocat develop off` and at startup whenever developer mode is // disabled, so stale database values cannot silently remain active. @@ -72,6 +102,9 @@ func ResetExperimental(ctx context.Context, database *store.Store) error { if err := SetDeviceLimit(ctx, database, DefaultDeviceLimit); err != nil { resetErrors = append(resetErrors, fmt.Errorf("reset device limit: %w", err)) } + if err := SetSMSHourlyLimit(ctx, database, DefaultSMSHourlyLimit); err != nil { + resetErrors = append(resetErrors, fmt.Errorf("reset SMS hourly limit: %w", err)) + } if err := database.DeleteAppSetting(ctx, exportproxy.SettingKey); err != nil && !errors.Is(err, store.ErrNotFound) { resetErrors = append(resetErrors, fmt.Errorf("delete export proxy configurations: %w", err)) } diff --git a/internal/developer/settings_test.go b/internal/developer/settings_test.go index 89fdd96..ac3576f 100644 --- a/internal/developer/settings_test.go +++ b/internal/developer/settings_test.go @@ -22,6 +22,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) { if err := SetDeviceLimit(ctx, database, 24); err != nil { t.Fatal(err) } + if err := SetSMSHourlyLimit(ctx, database, 42); err != nil { + t.Fatal(err) + } enabled, _ := json.Marshal(map[string]bool{"enabled": true}) if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: httpsmode.SettingKey, Value: enabled}); err != nil { t.Fatal(err) @@ -41,6 +44,9 @@ func TestResetExperimentalRestoresDefaults(t *testing.T) { if limit := DeviceLimit(ctx, database, true); limit != DefaultDeviceLimit { t.Fatalf("device limit = %d, want %d", limit, DefaultDeviceLimit) } + if limit := SMSHourlyLimit(ctx, database); limit != DefaultSMSHourlyLimit { + t.Fatalf("SMS hourly limit = %d, want %d", limit, DefaultSMSHourlyLimit) + } setting, err := database.AppSetting(ctx, httpsmode.SettingKey) if err != nil { t.Fatal(err) @@ -75,3 +81,21 @@ func TestSetDeviceLimitValidatesRange(t *testing.T) { t.Fatal("out-of-range device limit was accepted") } } + +func TestSetSMSHourlyLimitValidatesRange(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "vocat.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if SetSMSHourlyLimit(ctx, database, 0) == nil || SetSMSHourlyLimit(ctx, database, MaxSMSHourlyLimit+1) == nil { + t.Fatal("out-of-range SMS hourly limit was accepted") + } + if err := SetSMSHourlyLimit(ctx, database, 25); err != nil { + t.Fatal(err) + } + if got := SMSHourlyLimit(ctx, database); got != 25 { + t.Fatalf("SMS hourly limit = %d, want 25", got) + } +} diff --git a/internal/server/developer_settings.go b/internal/server/developer_settings.go index f347277..59d7ac8 100644 --- a/internal/server/developer_settings.go +++ b/internal/server/developer_settings.go @@ -7,37 +7,62 @@ import ( ) func (s *Server) handleDeveloperSettings(w http.ResponseWriter, r *http.Request) { - if !s.developerEnabled { + if !s.developerActive(r.Context()) { writeError(w, http.StatusNotFound, "not_found", "resource not found") return } switch r.Method { case http.MethodGet: - writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ - "device_limit": developer.DeviceLimit(r.Context(), s.store, true), - "default_device_limit": developer.DefaultDeviceLimit, - "max_device_limit": developer.MaxDeviceLimit, - }}) + s.writeDeveloperSettings(w, r) case http.MethodPut: var request struct { - DeviceLimit int `json:"device_limit"` + DeviceLimit *int `json:"device_limit"` + SMSHourlyLimit *int `json:"sms_hourly_limit"` } if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } - if err := developer.SetDeviceLimit(r.Context(), s.store, request.DeviceLimit); err != nil { - writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error()) + if request.DeviceLimit == nil && request.SMSHourlyLimit == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "at least one developer setting is required") return } - s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated") - writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ - "device_limit": request.DeviceLimit, - "default_device_limit": developer.DefaultDeviceLimit, - "max_device_limit": developer.MaxDeviceLimit, - }}) + if request.DeviceLimit != nil && (*request.DeviceLimit < 1 || *request.DeviceLimit > developer.MaxDeviceLimit) { + writeError(w, http.StatusBadRequest, "invalid_device_limit", "device limit is outside the supported range") + return + } + if request.SMSHourlyLimit != nil && (*request.SMSHourlyLimit < 1 || *request.SMSHourlyLimit > developer.MaxSMSHourlyLimit) { + writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", "SMS hourly limit is outside the supported range") + return + } + if request.DeviceLimit != nil { + if err := developer.SetDeviceLimit(r.Context(), s.store, *request.DeviceLimit); err != nil { + writeError(w, http.StatusBadRequest, "invalid_device_limit", err.Error()) + return + } + s.recordAudit(r.Context(), "admin", "settings.developer.device_limit", "settings", "developer", "success", "device limit updated") + } + if request.SMSHourlyLimit != nil { + if err := developer.SetSMSHourlyLimit(r.Context(), s.store, *request.SMSHourlyLimit); err != nil { + writeError(w, http.StatusBadRequest, "invalid_sms_hourly_limit", err.Error()) + return + } + s.recordAudit(r.Context(), "admin", "settings.developer.sms_hourly_limit", "settings", "developer", "success", "global SMS hourly limit updated") + } + s.writeDeveloperSettings(w, r) default: w.Header().Set("Allow", "GET, PUT") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } } + +func (s *Server) writeDeveloperSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "device_limit": developer.DeviceLimit(r.Context(), s.store, true), + "default_device_limit": developer.DefaultDeviceLimit, + "max_device_limit": developer.MaxDeviceLimit, + "sms_hourly_limit": developer.SMSHourlyLimit(r.Context(), s.store), + "default_sms_hourly_limit": developer.DefaultSMSHourlyLimit, + "max_sms_hourly_limit": developer.MaxSMSHourlyLimit, + }}) +} diff --git a/internal/server/developer_settings_test.go b/internal/server/developer_settings_test.go index 4e40fa0..c5b198e 100644 --- a/internal/server/developer_settings_test.go +++ b/internal/server/developer_settings_test.go @@ -1,9 +1,15 @@ package server import ( + "context" + "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + + "vocat/internal/developer" + "vocat/internal/store" ) func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) { @@ -20,3 +26,27 @@ func TestDeveloperOnlySettingsAreHiddenWhenModeIsOff(t *testing.T) { } } } + +func TestDeveloperSettingsUpdatesGlobalSMSLimit(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + enabled, _ := json.Marshal(map[string]bool{"enabled": true}) + if err := database.UpsertAppSetting(ctx, store.AppSetting{Key: developer.EnabledSettingKey, Value: enabled}); err != nil { + t.Fatal(err) + } + server := &Server{store: database, developerEnabled: true, logger: regionTestLogger(), maxRequestBodyBytes: 4096} + request := httptest.NewRequest(http.MethodPut, "/api/settings/developer", strings.NewReader(`{"sms_hourly_limit":25}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.handleDeveloperSettings(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", response.Code, response.Body.String()) + } + if got := developer.SMSHourlyLimit(ctx, database); got != 25 { + t.Fatalf("SMS hourly limit = %d, want 25", got) + } +} diff --git a/internal/server/sms_api.go b/internal/server/sms_api.go index 3ca8b04..139c8aa 100644 --- a/internal/server/sms_api.go +++ b/internal/server/sms_api.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "vocat/internal/developer" "vocat/internal/device" "vocat/internal/store" "vocat/internal/vowifi" @@ -224,6 +225,12 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "blocked_destination", reason) return } + // Validate the logical message before consuming a global send slot. Both + // cellular AT and VoWiFi IMS use this same encoder/validator. + if _, err := device.PrepareSMSSubmitTPDUs(request.Phone, request.Message); err != nil { + s.writeDeviceError(w, err) + return + } config, err := s.store.Device(r.Context(), request.DeviceID) if err != nil { s.writeStoreError(w, err) @@ -233,6 +240,33 @@ func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) { if !s.requirePhysicalDevice(w, present) { return } + limit := developer.SMSHourlyLimit(r.Context(), s.store) + reservation, err := s.store.ReserveSMSSend(r.Context(), request.DeviceID, limit, time.Now().UTC()) + if err != nil { + s.writeStoreError(w, err) + return + } + if !reservation.Allowed { + retryAfter := time.Until(reservation.ResetAt) + if retryAfter < time.Second { + retryAfter = time.Second + } + w.Header().Set("Retry-After", strconv.FormatInt(int64((retryAfter+time.Second-1)/time.Second), 10)) + writeJSON(w, http.StatusTooManyRequests, map[string]any{ + "error": apiError{ + Code: "sms_rate_limited", + Message: fmt.Sprintf("Global SMS limit reached: at most %d messages may be submitted in a rolling one-hour window.", reservation.Limit), + }, + "data": map[string]any{ + "limit": reservation.Limit, + "used": reservation.Used, + "remaining": reservation.Remaining, + "reset_at": reservation.ResetAt, + "retry_after": int64((retryAfter + time.Second - 1) / time.Second), + }, + }) + return + } if config.VoWiFiEnabled && s.vowifi != nil { state, stateErr := s.vowifi.State(request.DeviceID) sender, canSendIMS := s.vowifi.(imsSMSController) diff --git a/internal/server/sms_api_test.go b/internal/server/sms_api_test.go index aa0fde9..b74da2e 100644 --- a/internal/server/sms_api_test.go +++ b/internal/server/sms_api_test.go @@ -5,9 +5,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" + "vocat/internal/developer" + "vocat/internal/device" "vocat/internal/store" ) @@ -155,3 +158,52 @@ func TestBlockedSMSDestination(t *testing.T) { }) } } + +func TestHandleSMSSendEnforcesGlobalHourlyLimit(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := developer.SetSMSHourlyLimit(ctx, database, 1); err != nil { + t.Fatal(err) + } + if err := database.UpsertDevice(ctx, store.Device{ID: "ec20_1", Name: "EC20"}); err != nil { + t.Fatal(err) + } + if reservation, err := database.ReserveSMSSend(ctx, "another-device", 1, time.Now().UTC()); err != nil || !reservation.Allowed { + t.Fatalf("seed global SMS reservation = %+v, %v", reservation, err) + } + server := &Server{ + store: database, + logger: regionTestLogger(), + maxRequestBodyBytes: 4096, + devices: fakeDeviceController{entry: device.Device{ + ID: "ec20_1", + Discovered: true, + Snapshot: &device.Snapshot{DeviceID: "ec20_1"}, + }}, + } + request := httptest.NewRequest( + http.MethodPost, + "/api/sms/send", + strings.NewReader(`{"device_id":"ec20_1","phone":"+447700900123","message":"hello"}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.handleSMSSend(response, request) + if response.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429; body=%s", response.Code, response.Body.String()) + } + if response.Header().Get("Retry-After") == "" { + t.Fatal("Retry-After header is missing") + } + var envelope errorEnvelope + if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Error.Code != "sms_rate_limited" { + t.Fatalf("error code = %q, want sms_rate_limited", envelope.Error.Code) + } +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index a2cb420..a80c3cc 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -59,6 +59,7 @@ func TestMigrationFromAuthenticationSchema(t *testing.T) { "device_proxy_bindings", "notification_settings", "app_settings", "audit_events", "log_events", "card_policies", "traffic_buckets", + "sms_send_attempts", } { var found string err := database.db.QueryRowContext(ctx, ` diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 03d86f1..c38a0b2 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -186,6 +186,16 @@ func migrationStatements(version int) []string { `CREATE INDEX IF NOT EXISTS automatic_task_runs_task_idx ON automatic_task_runs(task_id, id DESC)`, `CREATE INDEX IF NOT EXISTS automatic_task_runs_status_idx ON automatic_task_runs(status, id)`, } + case 11: + return []string{ + `CREATE TABLE IF NOT EXISTS sms_send_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS sms_send_attempts_created_idx + ON sms_send_attempts(created_at, id)`, + } default: return nil } diff --git a/internal/store/sms_rate.go b/internal/store/sms_rate.go new file mode 100644 index 0000000..1d010a1 --- /dev/null +++ b/internal/store/sms_rate.go @@ -0,0 +1,88 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +const SMSRateWindow = time.Hour + +// SMSRateReservation is the durable result of claiming one global outbound +// SMS slot. The quota is shared by every device, SIM, transport, and caller. +type SMSRateReservation struct { + Allowed bool + Limit int + Used int + Remaining int + ResetAt time.Time +} + +// ReserveSMSSend atomically claims one slot in the rolling one-hour window. +// It intentionally records submission attempts separately from SMS history so +// deleting a conversation cannot reset the global safety limit. +func (s *Store) ReserveSMSSend( + ctx context.Context, + deviceID string, + limit int, + now time.Time, +) (SMSRateReservation, error) { + if limit < 1 { + return SMSRateReservation{}, errors.New("SMS hourly limit must be positive") + } + if now.IsZero() { + now = time.Now().UTC() + } else { + now = now.UTC() + } + cutoff := now.Add(-SMSRateWindow).Unix() + result, err := s.db.ExecContext(ctx, ` + INSERT INTO sms_send_attempts (device_id, created_at) + SELECT ?, ? + WHERE ( + SELECT COUNT(*) FROM sms_send_attempts WHERE created_at > ? + ) < ? + `, strings.TrimSpace(deviceID), now.Unix(), cutoff, limit) + if err != nil { + return SMSRateReservation{}, fmt.Errorf("reserve global SMS send slot: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return SMSRateReservation{}, fmt.Errorf("read global SMS reservation result: %w", err) + } + + status, err := s.smsRateStatus(ctx, limit, cutoff) + if err != nil { + return SMSRateReservation{}, err + } + status.Allowed = affected == 1 + if status.Allowed { + // Old rows are irrelevant to enforcement. Pruning after the atomic claim + // keeps the hot index compact without creating a delete-before-insert race. + _, _ = s.db.ExecContext(ctx, `DELETE FROM sms_send_attempts WHERE created_at <= ?`, now.Add(-7*24*time.Hour).Unix()) + } + return status, nil +} + +func (s *Store) smsRateStatus(ctx context.Context, limit int, cutoff int64) (SMSRateReservation, error) { + var used int + var earliest *int64 + if err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*), MIN(created_at) + FROM sms_send_attempts + WHERE created_at > ? + `, cutoff).Scan(&used, &earliest); err != nil { + return SMSRateReservation{}, fmt.Errorf("read global SMS rate status: %w", err) + } + remaining := limit - used + if remaining < 0 { + remaining = 0 + } + status := SMSRateReservation{Limit: limit, Used: used, Remaining: remaining} + if earliest != nil { + status.ResetAt = time.Unix(*earliest, 0).UTC().Add(SMSRateWindow) + } + return status, nil +} diff --git a/internal/store/sms_rate_test.go b/internal/store/sms_rate_test.go new file mode 100644 index 0000000..d47443a --- /dev/null +++ b/internal/store/sms_rate_test.go @@ -0,0 +1,58 @@ +package store + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestReserveSMSSendIsGlobalAndRolling(t *testing.T) { + database := openTestStore(t, ":memory:") + now := time.Unix(1_800_000_000, 0).UTC() + + first, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now) + if err != nil || !first.Allowed || first.Used != 1 || first.Remaining != 1 { + t.Fatalf("first reservation = %+v, %v", first, err) + } + second, err := database.ReserveSMSSend(context.Background(), "ec20_2", 2, now.Add(time.Second)) + if err != nil || !second.Allowed || second.Used != 2 || second.Remaining != 0 { + t.Fatalf("second reservation = %+v, %v", second, err) + } + blocked, err := database.ReserveSMSSend(context.Background(), "another-device", 2, now.Add(2*time.Second)) + if err != nil || blocked.Allowed || blocked.Used != 2 || !blocked.ResetAt.Equal(now.Add(SMSRateWindow)) { + t.Fatalf("blocked reservation = %+v, %v", blocked, err) + } + afterWindow, err := database.ReserveSMSSend(context.Background(), "ec20_1", 2, now.Add(SMSRateWindow+time.Second)) + if err != nil || !afterWindow.Allowed || afterWindow.Used != 1 { + t.Fatalf("reservation after rolling window = %+v, %v", afterWindow, err) + } +} + +func TestReserveSMSSendCannotExceedLimitConcurrently(t *testing.T) { + database := openTestStore(t, ":memory:") + now := time.Unix(1_800_000_000, 0).UTC() + const limit = 10 + const callers = 40 + var allowed atomic.Int32 + var wait sync.WaitGroup + for index := 0; index < callers; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + result, err := database.ReserveSMSSend(context.Background(), "device", limit, now) + if err != nil { + t.Errorf("reservation %d: %v", index, err) + return + } + if result.Allowed { + allowed.Add(1) + } + }(index) + } + wait.Wait() + if got := allowed.Load(); got != limit { + t.Fatalf("allowed reservations = %d, want %d", got, limit) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 2dfb792..e0d9d4f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -13,7 +13,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 10 +const schemaVersion = 11 var ErrNotFound = errors.New("store: not found") diff --git a/internal/vowifi/ike/relay.go b/internal/vowifi/ike/relay.go index 9206f54..5aacf98 100644 --- a/internal/vowifi/ike/relay.go +++ b/internal/vowifi/ike/relay.go @@ -205,8 +205,13 @@ func (relay *sessionRelay) terminalError() error { func (relay *sessionRelay) Close() error { relay.cancel() + // ReceiveSessionPacket implementations normally observe the canceled + // context through a short read deadline. Close the transport as an explicit + // wake-up as well: a socket implementation that is stuck in Read must not + // hold teardown (and the associated TUN interface) indefinitely. + transportErr := relay.transport.Close() <-relay.done - return relay.terminalErrorIfFailure() + return errors.Join(relay.terminalErrorIfFailure(), transportErr) } func (relay *sessionRelay) terminalErrorIfFailure() error { diff --git a/internal/vowifi/ike/relay_test.go b/internal/vowifi/ike/relay_test.go index 0a61a69..8d64203 100644 --- a/internal/vowifi/ike/relay_test.go +++ b/internal/vowifi/ike/relay_test.go @@ -22,12 +22,13 @@ type fakeSentPacket struct { } type fakeSessionTransport struct { - incoming chan fakeSessionPacket - sent chan fakeSentPacket - closed chan struct{} - once sync.Once - readers atomic.Int32 - maxReads atomic.Int32 + incoming chan fakeSessionPacket + sent chan fakeSentPacket + closed chan struct{} + ignoreContext bool + once sync.Once + readers atomic.Int32 + maxReads atomic.Int32 } func newFakeSessionTransport() *fakeSessionTransport { @@ -81,6 +82,18 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket( } } defer transport.readers.Add(-1) + if transport.ignoreContext { + select { + case packet := <-transport.incoming: + if packet.err != nil { + return 0, false, packet.err + } + copy(buffer, packet.data) + return len(packet.data), packet.ike, nil + case <-transport.closed: + return 0, false, net.ErrClosed + } + } select { case packet := <-transport.incoming: if packet.err != nil { @@ -96,6 +109,37 @@ func (transport *fakeSessionTransport) ReceiveSessionPacket( return 0, false, net.ErrClosed } } + +func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) { + transport := newFakeSessionTransport() + transport.ignoreContext = true + relay := newSessionRelay( + transport, + legacyTestSuite(), + ikeKeys{}, + [8]byte{1}, + [8]byte{2}, + true, + time.Hour, + ) + deadline := time.Now().Add(time.Second) + for transport.readers.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if transport.readers.Load() == 0 { + t.Fatal("relay did not enter the transport read") + } + done := make(chan error, 1) + go func() { done <- relay.Close() }() + select { + case err := <-done: + if err != nil { + t.Fatalf("close relay: %v", err) + } + case <-time.After(time.Second): + t.Fatal("relay Close did not interrupt the transport read") + } +} func (transport *fakeSessionTransport) Close() error { transport.once.Do(func() { close(transport.closed) }) return nil diff --git a/internal/vowifi/ike/userspace_linux.go b/internal/vowifi/ike/userspace_linux.go index c97a935..83c6aa9 100644 --- a/internal/vowifi/ike/userspace_linux.go +++ b/internal/vowifi/ike/userspace_linux.go @@ -7,7 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net" "os" "os/exec" @@ -21,6 +20,8 @@ import ( const userspaceTunnelMTU = 1380 +const userspaceTunnelPollInterval = 100 * time.Millisecond + type linuxUserspaceInstaller struct { ipCommand string } @@ -30,6 +31,7 @@ type linuxUserspaceHandle struct { config ChildSAConfig tunnel *espTunnel tun *os.File + tunFD int relay NATTPacketRelay runContext context.Context @@ -93,6 +95,7 @@ func (installer linuxUserspaceInstaller) Install( config: cloneChildSAConfig(config), tunnel: tunnel, tun: tun, + tunFD: int(tun.Fd()), relay: config.Relay, runContext: runContext, cancel: cancel, @@ -128,6 +131,15 @@ func openLinuxTUN(name string) (*os.File, string, error) { _ = unix.Close(descriptor) return nil, "", fmt.Errorf("ike: create TUN interface: %w", err) } + // A blocking TUN read is not guaranteed to wake when another goroutine + // closes the descriptor on Linux. Keep the descriptor non-blocking and use + // poll below so cancellation can always drain the data-plane workers before + // the interface is released. Without this, a failed session can retain the + // TUN forever and every automatic reconnect fails with EBUSY. + if err := unix.SetNonblock(descriptor, true); err != nil { + _ = unix.Close(descriptor) + return nil, "", fmt.Errorf("ike: make TUN interface cancellable: %w", err) + } file := os.NewFile(uintptr(descriptor), "/dev/net/tun:"+request.Name()) if file == nil { _ = unix.Close(descriptor) @@ -475,7 +487,7 @@ func (handle *linuxUserspaceHandle) copyTUNToRelay() { defer handle.wait.Done() buffer := make([]byte, 65535) for { - count, err := handle.tun.Read(buffer) + count, err := readTUNPacket(handle.runContext, handle.tunFD, buffer) if err != nil { if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) { handle.fail(fmt.Errorf("ike: read TUN packet: %w", err)) @@ -520,7 +532,7 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() { // without allowing a forged datagram to tear down the CHILD_SA. continue } - if err := writeFull(handle.tun, cleartext); err != nil { + if err := writeTUNPacket(handle.runContext, handle.tunFD, cleartext); err != nil { if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) { handle.fail(fmt.Errorf("ike: write TUN packet: %w", err)) } @@ -529,17 +541,74 @@ func (handle *linuxUserspaceHandle) copyRelayToTUN() { } } -func writeFull(destination io.Writer, packet []byte) error { - count, err := destination.Write(packet) - if err != nil { - return err +func readTUNPacket(ctx context.Context, descriptor int, buffer []byte) (int, error) { + for { + if err := ctx.Err(); err != nil { + return 0, err + } + ready, err := pollTUN(ctx, descriptor, unix.POLLIN) + if err != nil { + return 0, err + } + if !ready { + continue + } + count, err := unix.Read(descriptor, buffer) + if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) { + continue + } + return count, err } - if count != len(packet) { - return io.ErrShortWrite +} + +func writeTUNPacket(ctx context.Context, descriptor int, packet []byte) error { + for written := 0; written < len(packet); { + if err := ctx.Err(); err != nil { + return err + } + ready, err := pollTUN(ctx, descriptor, unix.POLLOUT) + if err != nil { + return err + } + if !ready { + continue + } + count, err := unix.Write(descriptor, packet[written:]) + if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) { + continue + } + if err != nil { + return err + } + if count == 0 { + return errors.New("ike: zero-length TUN write") + } + written += count } return nil } +func pollTUN(ctx context.Context, descriptor int, events int16) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + poll := []unix.PollFd{{Fd: int32(descriptor), Events: events}} + count, err := unix.Poll(poll, int(userspaceTunnelPollInterval/time.Millisecond)) + if errors.Is(err, unix.EINTR) { + return false, nil + } + if err != nil { + return false, err + } + if count == 0 { + return false, nil + } + if poll[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 { + return false, os.ErrClosed + } + return poll[0].Revents&events != 0, nil +} + func (handle *linuxUserspaceHandle) fail(err error) { handle.mu.Lock() notify := false @@ -583,9 +652,12 @@ func (handle *linuxUserspaceHandle) Close(ctx context.Context) error { handle.mu.Unlock() handle.cancelRun() + // Workers use a non-blocking, polled TUN descriptor and therefore leave on + // cancellation without requiring a cross-goroutine close. Wait first so no + // blocked syscall can retain the interface after Close returns. + handle.wait.Wait() cleanupErr := handle.cleanupNetwork(ctx) handle.closeTUN() - handle.wait.Wait() // A terminal data-plane error is delivered exactly once through Failures. // Close reports only teardown errors so the orchestrator does not record // the same runtime cause again as a cleanup failure. diff --git a/web/src/components/settings/SMSRateLimitCard.tsx b/web/src/components/settings/SMSRateLimitCard.tsx new file mode 100644 index 0000000..0d22724 --- /dev/null +++ b/web/src/components/settings/SMSRateLimitCard.tsx @@ -0,0 +1,58 @@ +import { SendRegular } from "@fluentui/react-icons"; +import type { DeveloperSettings } from "../../types"; +import { useI18n } from "../../lib/i18n"; +import { Button } from "../ui/Button"; +import { Input } from "../ui/Input"; +import { CardDecor, CardIcon, CardTitle } from "./Cards"; + +export function SMSRateLimitCard({ + value, + limit, + loading, + saving, + onLimitChange, + onSave, +}: { + value: DeveloperSettings | null; + limit: number; + loading: boolean; + saving: boolean; + onLimitChange: (limit: number) => void; + onSave: () => void; +}) { + const { lang } = useI18n(); + const zh = lang === "zh"; + return ( +
+ +
+ + + + +
+
+ onLimitChange(Number(event.target.value))} + suffix={zh ? "条 / 小时" : "messages / hour"} + /> +

+ {zh + ? `采用滚动一小时窗口,网页、TG Bot、自动任务、API、VoWiFi 与基站发送全部计入;接收短信不受限制。关闭开发者模式后恢复为 ${value?.defaultSmsHourlyLimit ?? 10} 条/小时。` + : `Uses a rolling one-hour window across the web UI, Telegram bot, automatic tasks, API, VoWiFi, and cellular sending. Receiving is unlimited. Disabling developer mode restores ${value?.defaultSmsHourlyLimit ?? 10} messages/hour.`} +

+ +
+
+ ); +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index b9270cb..1b64b63 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -24,6 +24,7 @@ import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs"; import { PluginsCard } from "../components/settings/PluginsCard"; import { HTTPSCard } from "../components/settings/HTTPSCard"; import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard"; +import { SMSRateLimitCard } from "../components/settings/SMSRateLimitCard"; const EMPTY_PASSWORD: PasswordForm = { oldPassword: "", newPassword: "", confirmPassword: "" }; @@ -38,7 +39,6 @@ const NOTIFY_TABS = [ const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" }; const EMPTY_SECURITY: NetworkAccessForm = { mode: "internal", allowedCidrs: [], trustProxyHeaders: false }; - export default function SettingsPage() { const { refresh } = useAuth(); const { t, lang } = useI18n(); @@ -65,8 +65,10 @@ export default function SettingsPage() { const [savingHTTPS, setSavingHTTPS] = useState(false); const [developerSettings, setDeveloperSettings] = useState(null); const [deviceLimit, setDeviceLimit] = useState(5); + const [smsHourlyLimit, setSMSHourlyLimit] = useState(10); const [loadingDeveloper, setLoadingDeveloper] = useState(false); const [savingDeveloper, setSavingDeveloper] = useState(false); + const [savingSMSLimit, setSavingSMSLimit] = useState(false); const updateChannel = useCallback((key: K, patch: Partial) => { setForms((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); @@ -131,6 +133,7 @@ export default function SettingsPage() { const data = await api("/settings/developer"); setDeveloperSettings(data); setDeviceLimit(data.deviceLimit); + setSMSHourlyLimit(data.smsHourlyLimit); } catch (error) { message.error(apiMessage(error) || (lang === "zh" ? "设备配额配置加载失败" : "Failed to load device quota settings")); } finally { @@ -152,6 +155,7 @@ export default function SettingsPage() { setHTTPSSettings(null); setDeveloperSettings(null); setDeviceLimit(5); + setSMSHourlyLimit(10); } }, [systemInfo.developer, fetchHTTPS, fetchDeveloperSettings]); @@ -188,6 +192,25 @@ export default function SettingsPage() { } }, [developerSettings, deviceLimit, lang]); + const onSaveSMSHourlyLimit = useCallback(async () => { + const maximum = developerSettings?.maxSmsHourlyLimit ?? 1000; + if (!Number.isInteger(smsHourlyLimit) || smsHourlyLimit < 1 || smsHourlyLimit > maximum) { + message.error(lang === "zh" ? `短信发送限制必须是 1 到 ${maximum} 的整数` : `SMS limit must be an integer between 1 and ${maximum}`); + return; + } + setSavingSMSLimit(true); + try { + const data = await api("/settings/developer", { method: "PUT", body: { smsHourlyLimit } }); + setDeveloperSettings(data); + setSMSHourlyLimit(data.smsHourlyLimit); + message.success(lang === "zh" ? "短信发送速率限制已保存" : "SMS rate limit saved"); + } catch (error) { + message.error(apiMessage(error) || (lang === "zh" ? "短信发送速率限制保存失败" : "Failed to save SMS rate limit")); + } finally { + setSavingSMSLimit(false); + } + }, [developerSettings, smsHourlyLimit, lang]); + const onSaveSecurity = useCallback(async () => { setSavingSecurity(true); try { @@ -406,6 +429,14 @@ export default function SettingsPage() { onLimitChange={setDeviceLimit} onSave={onSaveDeviceLimit} /> + ) : null} diff --git a/web/src/types.ts b/web/src/types.ts index 249ea14..e9710e4 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -403,6 +403,9 @@ export interface DeveloperSettings { deviceLimit: number; defaultDeviceLimit: number; maxDeviceLimit: number; + smsHourlyLimit: number; + defaultSmsHourlyLimit: number; + maxSmsHourlyLimit: number; } export type Notice = {