This commit is contained in:
MengMengCode
2026-08-11 00:22:54 +08:00
parent 21f210d219
commit 928ba7746e
17 changed files with 602 additions and 34 deletions
+33
View File
@@ -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))
}
+24
View File
@@ -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)
}
}
+40 -15
View File
@@ -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,
}})
}
@@ -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)
}
}
+34
View File
@@ -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)
+52
View File
@@ -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)
}
}
+1
View File
@@ -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, `
+10
View File
@@ -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
}
+88
View File
@@ -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
}
+58
View File
@@ -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)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 10
const schemaVersion = 11
var ErrNotFound = errors.New("store: not found")
+6 -1
View File
@@ -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 {
+50 -6
View File
@@ -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
+82 -10
View File
@@ -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.
@@ -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 (
<div className="ui-card group relative overflow-hidden p-8">
<CardDecor />
<div className="relative z-10 mb-6 flex items-center gap-3">
<CardIcon>
<SendRegular className="text-[24px]" />
</CardIcon>
<CardTitle
title={zh ? "短信发送速率限制" : "SMS send rate limit"}
subtitle={zh ? "所有设备与 SIM 卡共享的全局发送额度" : "One global quota shared by every device and SIM"}
/>
</div>
<div className="relative z-10 space-y-4">
<Input
type="number"
min={1}
max={value?.maxSmsHourlyLimit ?? 1000}
value={Number.isFinite(limit) ? limit : ""}
disabled={loading || saving}
onChange={(event) => onLimitChange(Number(event.target.value))}
suffix={zh ? "条 / 小时" : "messages / hour"}
/>
<p className="text-xs leading-5 text-gray-500 dark:text-gray-400">
{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.`}
</p>
<Button variant="primary" loading={saving} disabled={loading} onClick={onSave} className="w-full !border-0">
{zh ? "保存短信速率限制" : "Save SMS rate limit"}
</Button>
</div>
</div>
);
}
+32 -1
View File
@@ -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<DeveloperSettings | null>(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(<K extends keyof NotifyForms>(key: K, patch: Partial<NotifyForms[K]>) => {
setForms((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
@@ -131,6 +133,7 @@ export default function SettingsPage() {
const data = await api<DeveloperSettings>("/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<DeveloperSettings>("/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}
/>
<SMSRateLimitCard
value={developerSettings}
limit={smsHourlyLimit}
loading={loadingDeveloper}
saving={savingSMSLimit}
onLimitChange={setSMSHourlyLimit}
onSave={onSaveSMSHourlyLimit}
/>
<PluginsCard />
</>
) : null}
+3
View File
@@ -403,6 +403,9 @@ export interface DeveloperSettings {
deviceLimit: number;
defaultDeviceLimit: number;
maxDeviceLimit: number;
smsHourlyLimit: number;
defaultSmsHourlyLimit: number;
maxSmsHourlyLimit: number;
}
export type Notice = {