Compare commits

...
6 Commits
Author SHA1 Message Date
MengMengCode ae3a2a6eea FIX #29 2026-08-16 15:16:45 +08:00
MengMengCode 505ee1eac0 FIX #27 2026-08-16 13:49:42 +08:00
MengMengCode b19ae2240a FIX #25 2026-08-16 13:33:55 +08:00
MengMengCode adf7de6d29 FIX #28 2026-08-16 13:20:50 +08:00
MengMengCode ffa0fd23b8 feat: enhance IMS SMS handling with structured logging and payload extraction 2026-08-16 13:05:20 +08:00
MengMengCode f014628048 feat: add curl options for binary download with progress feedback 2026-08-16 12:02:13 +08:00
25 changed files with 1406 additions and 93 deletions
+12 -5
View File
@@ -192,7 +192,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
}
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders, Logger: logger})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -616,7 +616,7 @@ func configureVoWiFiRuntime(
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
adapter = nativeQMIAdapter
}
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
},
})
@@ -715,6 +715,7 @@ func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter vowifiDeviceAdapter,
logger *slog.Logger,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
@@ -725,6 +726,7 @@ func newVoWiFiOrchestrator(
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
}
imsProvider, err := ims.NewProvider(adapter, ims.Config{
Logger: logger,
// The userspace SWu data plane carries protected P-CSCF signalling over
// TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu.
Transport: "tcp",
@@ -732,9 +734,14 @@ func newVoWiFiOrchestrator(
"23410": "udp",
"234010": "udp",
},
// Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes
// this service-centre number for manual SMS setup.
SMSCenter: "+447785016005",
// Some UK SIM profiles leave EF_SMSP/AT+CSCA empty. Keep fallbacks scoped
// to their HPLMN so an O2/giffgaff SIM can never inherit Vodafone's SMSC.
SMSCenterByPLMN: map[string]string{
"23410": "+447802000332",
"234010": "+447802000332",
"23415": "+447785016005",
"234015": "+447785016005",
},
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
extra, _ := json.Marshal(map[string]any{
"transport": "ims",
+20
View File
@@ -3,6 +3,7 @@ package device
import (
_ "embed"
"encoding/json"
"sort"
"strings"
)
@@ -51,6 +52,25 @@ func CountryForMCC(mcc string) (string, bool) {
return country, len(country) == 2
}
// MCCsByCountry returns the complete MCC grouping from the embedded carrier
// database, keyed by ISO alpha-2 country/territory code. The returned map and
// slices are new values and may be safely modified by callers.
func MCCsByCountry() map[string][]string {
result := make(map[string][]string)
for mcc, rawCountry := range globalCarrierDatabase.Countries {
country := strings.ToUpper(strings.TrimSpace(rawCountry))
mcc = strings.TrimSpace(mcc)
if len(country) != 2 || len(mcc) != 3 {
continue
}
result[country] = append(result[country], mcc)
}
for country := range result {
sort.Strings(result[country])
}
return result
}
var globalCarrierDatabase = func() carrierDatabase {
var database carrierDatabase
if err := json.Unmarshal(carrierDatabaseJSON, &database); err != nil {
+58 -3
View File
@@ -829,7 +829,8 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// stays a sibling of A0, directly under BF31.
// EnableProfile is a non-idempotent commit. Once its APDU starts, a browser
// disconnect or reverse-proxy timeout must not cancel it halfway through and
// skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13).
// skip post-commit recovery; EC20 may otherwise remain in SIM failure
// (+CME 13).
commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout)
payload, err := channel.es10(commitContext, der)
cancelCommit()
@@ -900,6 +901,32 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
return err
}
manager.markCachedProfileEnabled(id, iccid)
// EnableProfile already requested an eUICC REFRESH. Some AT modems consume
// that proactive command and expose the new subscription immediately, so a
// full CFUN=1,1 reset would only add downtime. Give those devices a short
// chance to prove that their SIM cache is current; modems that keep reporting
// the old ICCID continue through the established reboot/recovery path below.
if manager.canVerifyProfileSwitchWithoutRestart(id) {
probeContext, cancelProbe := context.WithTimeout(
context.WithoutCancel(ctx),
profileSwitchRefreshProbeTimeout(manager),
)
probeErr := manager.verifySwitchedICCIDAttempts(probeContext, id, iccid, 3, time.Second)
cancelProbe()
if probeErr == nil {
// Repopulate the cached snapshot while the AT transport is still live.
// Verification above is authoritative, so snapshot refresh remains
// best-effort just as it is after the legacy reboot path.
refreshContext, cancelRefresh := context.WithTimeout(
context.WithoutCancel(ctx),
manager.longTimeout,
)
_, _ = manager.Refresh(refreshContext, id)
cancelRefresh()
manager.unlockESIM()
return nil
}
}
// The eUICC accepted the target profile. Reset and repopulate the modem in
// a detached recovery so it survives an HTTP disconnect, but keep this API
// call pending until the live modem ICCID proves that the switch took effect.
@@ -1182,13 +1209,41 @@ func profileSwitchVerificationTimeout(manager *Manager) time.Duration {
return timeout
}
func profileSwitchRefreshProbeTimeout(manager *Manager) time.Duration {
// Allow both standard ICCID commands to consume one ordinary command
// timeout, plus a small window for the eUICC REFRESH to settle. Keep the
// optimisation bounded so an older modem reaches its required reboot soon.
timeout := manager.commandTimeout*2 + time.Second
if timeout < 3*time.Second {
return 3 * time.Second
}
if timeout > 10*time.Second {
return 10 * time.Second
}
return timeout
}
func (manager *Manager) canVerifyProfileSwitchWithoutRestart(id string) bool {
_, native, err := manager.nativeQMIControl(id)
return err == nil && !native && !manager.isPCSCDevice(id)
}
// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c
// result of zero only means the eUICC accepted the operation; the state change
// is finalized by REFRESH/reset. The UI must not report success until the modem
// is actually exposing the requested ICCID.
func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error {
return manager.verifySwitchedICCIDAttempts(ctx, id, expected, 6, 2*time.Second)
}
func (manager *Manager) verifySwitchedICCIDAttempts(
ctx context.Context,
id string,
expected string,
attempts int,
interval time.Duration,
) error {
expected = strings.TrimSpace(expected)
const attempts = 6
var lastICCID string
var lastErr error
for attempt := 0; attempt < attempts; attempt++ {
@@ -1250,7 +1305,7 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
}
if attempt+1 < attempts {
select {
case <-time.After(2 * time.Second):
case <-time.After(interval):
case <-ctx.Done():
return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err())
}
+32
View File
@@ -207,6 +207,38 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
client.assertDone(t)
}
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
const target = "89492026266006792824"
client := &transcriptClient{steps: []clientStep{
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
}}
manager, id := newStartedTestManager(t, client)
if !manager.canVerifyProfileSwitchWithoutRestart(id) {
t.Fatal("AT modem should be eligible for refresh verification before restart")
}
if err := manager.verifySwitchedICCIDAttempts(context.Background(), id, target, 2, 0); err != nil {
t.Fatalf("verifySwitchedICCIDAttempts: %v", err)
}
client.assertDone(t)
}
func TestProfileSwitchRefreshProbeTimeoutIsBounded(t *testing.T) {
for _, test := range []struct {
command time.Duration
want time.Duration
}{
{command: 100 * time.Millisecond, want: 3 * time.Second},
{command: 3 * time.Second, want: 7 * time.Second},
{command: 30 * time.Second, want: 10 * time.Second},
} {
manager := &Manager{commandTimeout: test.command}
if got := profileSwitchRefreshProbeTimeout(manager); got != test.want {
t.Fatalf("command timeout %s: probe timeout = %s, want %s", test.command, got, test.want)
}
}
}
func TestEUMManufacturerForWatchData(t *testing.T) {
if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." {
t.Fatalf("manufacturer = %q", got)
+84
View File
@@ -0,0 +1,84 @@
package device
import (
"regexp"
"strings"
"unicode"
"vocat/internal/modem"
)
const maxHardwareErrorDetail = 1024
var longHexPayload = regexp.MustCompile(`(?i)\b[0-9a-f]{48,}\b`)
// HardwareErrorDetail returns a diagnostic error suitable for persistent and
// browser-visible logs. AT payloads can contain APDU authentication material,
// SMS data, or APN credentials, so CommandError values retain only the command
// name and modem final result. Very long hexadecimal payloads from wrapped
// protocol errors are removed as a second line of defence.
func HardwareErrorDetail(err error) string {
if err == nil {
return ""
}
detail := redactCommandErrors(err.Error(), err)
detail = longHexPayload.ReplaceAllString(detail, "[redacted hex payload]")
detail = strings.Map(func(character rune) rune {
if unicode.IsControl(character) && character != '\t' && character != '\n' {
return ' '
}
return character
}, strings.TrimSpace(detail))
runes := []rune(detail)
if len(runes) > maxHardwareErrorDetail {
detail = string(runes[:maxHardwareErrorDetail]) + "..."
}
return detail
}
func redactCommandErrors(detail string, err error) string {
if commandErr, ok := err.(*modem.CommandError); ok {
detail = strings.ReplaceAll(detail, commandErr.Error(), safeCommandError(commandErr))
}
switch wrapped := err.(type) {
case interface{ Unwrap() []error }:
for _, child := range wrapped.Unwrap() {
detail = redactCommandErrors(detail, child)
}
case interface{ Unwrap() error }:
if child := wrapped.Unwrap(); child != nil {
detail = redactCommandErrors(detail, child)
}
}
return detail
}
func safeCommandError(err *modem.CommandError) string {
command := safeATCommandName(err.Command)
final := strings.TrimSpace(err.Final)
if final == "" {
final = "unknown modem error"
}
return command + " failed: " + final
}
func safeATCommandName(command string) string {
command = strings.ToUpper(strings.TrimSpace(command))
if command == "" {
return "AT command"
}
if strings.HasPrefix(command, "ATD") {
return "ATD"
}
for index, character := range command {
if character == '=' || character == '?' || character == ',' ||
character == '"' || unicode.IsSpace(character) {
command = command[:index]
break
}
}
if !strings.HasPrefix(command, "AT") || len(command) > 32 {
return "AT command"
}
return command
}
+68
View File
@@ -0,0 +1,68 @@
package device
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
"vocat/internal/loghub"
"vocat/internal/modem"
)
func TestHardwareErrorDetailRedactsATPayload(t *testing.T) {
const payload = "00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"
commandErr := &modem.CommandError{
Command: `AT+CSIM=78,"` + payload + `"`,
Final: "+CME ERROR: 13",
Lines: []string{payload},
}
err := fmt.Errorf("select ISIM: %w", errors.Join(errors.New("reader reset failed"), commandErr))
detail := HardwareErrorDetail(err)
if strings.Contains(detail, payload) || strings.Contains(detail, "AT+CSIM=") {
t.Fatalf("hardware error exposed AT payload: %q", detail)
}
if !strings.Contains(detail, "select ISIM") || !strings.Contains(detail, "AT+CSIM failed: +CME ERROR: 13") {
t.Fatalf("hardware error lost useful diagnostics: %q", detail)
}
}
func TestManagerLogsNewHardwareFailuresWithoutPollingSpam(t *testing.T) {
commandError := func() error {
return &modem.CommandError{Command: "AT+CSQ", Final: "+CME ERROR: 13"}
}
client := &transcriptClient{steps: []clientStep{
{command: "AT+CSQ", err: commandError()},
{command: "AT+CSQ", err: commandError()},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{command: "AT+CSQ", err: commandError()},
}}
manager, id := newStartedTestManager(t, client)
hub := loghub.New(nil, 100)
manager.logger = slog.New(hub)
for attempt := 0; attempt < 2; attempt++ {
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
}
if entries := hub.History(10, slog.LevelDebug, ""); len(entries) != 1 {
t.Fatalf("continuous failure produced %d log entries, want 1", len(entries))
}
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
_, _ = manager.ExecuteAT(context.Background(), id, "AT+CSQ")
entries := hub.History(10, slog.LevelDebug, "")
if len(entries) != 2 {
t.Fatalf("failure after recovery produced %d total log entries, want 2", len(entries))
}
for _, entry := range entries {
if entry.Message != "hardware operation failed" || entry.Fields["device_id"] != id {
t.Fatalf("hardware log entry = %#v", entry)
}
if entry.Fields["error"] != "AT+CSQ failed: +CME ERROR: 13" {
t.Fatalf("hardware log detail = %#v", entry.Fields["error"])
}
}
client.assertDone(t)
}
+19 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
@@ -21,6 +22,7 @@ type Options struct {
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
Logger *slog.Logger
}
type Manager struct {
@@ -38,6 +40,7 @@ type Manager struct {
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
logger *slog.Logger
qmiRadioOpener qmiRadioSessionOpener
nativeQMIRegistrationMu sync.Mutex
@@ -120,6 +123,7 @@ func NewManager(options Options) (*Manager, error) {
smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
logger: options.Logger,
qmiRadioOpener: openQMIRadioSession,
nativeQMIRegistrationInFlight: make(map[string]struct{}),
@@ -373,10 +377,11 @@ func (manager *Manager) setResult(
err error,
) {
manager.mu.Lock()
defer manager.mu.Unlock()
if manager.devices[id] != state {
manager.mu.Unlock()
return
}
previousError := state.lastError
if snapshot != nil {
value := *snapshot
value.Warnings = append([]string(nil), snapshot.Warnings...)
@@ -388,6 +393,19 @@ func (manager *Manager) setResult(
} else {
state.lastError = ""
}
shouldLog := err != nil && manager.logger != nil && previousError != err.Error()
backend := state.backend
hardwareKind := state.candidate.HardwareKind
manager.mu.Unlock()
if shouldLog {
manager.logger.Warn(
"hardware operation failed",
"device_id", id,
"backend", backend,
"hardware_kind", hardwareKind,
"error", HardwareErrorDetail(err),
)
}
}
func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate {
+11
View File
@@ -74,6 +74,17 @@ func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
}
}
func TestMCCsByCountryReturnsCompleteIndependentGrouping(t *testing.T) {
grouped := MCCsByCountry()
if got := grouped["GB"]; len(got) != 2 || got[0] != "234" || got[1] != "235" {
t.Fatalf("GB MCCs = %#v", got)
}
grouped["GB"][0] = "999"
if country, ok := CountryForMCC("234"); !ok || country != "GB" {
t.Fatalf("mutating returned grouping changed embedded index: (%q, %v)", country, ok)
}
}
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
tests := []struct {
imsi string
+3 -3
View File
@@ -1421,9 +1421,9 @@ func (s *Server) writeDeviceError(w http.ResponseWriter, err error) {
case errors.Is(err, context.Canceled):
writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled")
default:
// Device errors may echo an AT command. Authentication commands can
// contain APN credentials, so keep raw errors out of logs and responses.
s.logger.Warn("device operation failed")
// Preserve the hardware failure reason in the operator-visible log while
// keeping AT payloads and long APDU material out of it.
s.logger.Warn("device operation failed", "error", device.HardwareErrorDetail(err))
writeError(w, http.StatusBadGateway, "modem_error", "the device operation failed")
}
}
+5 -4
View File
@@ -427,14 +427,15 @@ func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, config
return
}
// Profile operations run with RF disabled. The eUICC remains accessible in
// CFUN=4, and the recovery path reapplies CFUN=4 as soon as the AT port comes
// back after the mandatory modem reset.
// CFUN=4. Devices that consume the requested eUICC REFRESH stay online;
// older AT modems enter the reset recovery path and reapply CFUN=4 when the
// port returns.
if _, err := s.devices.SetFlight(r.Context(), physicalID, true); err != nil {
s.writeDeviceError(w, err)
return
}
// A confirmed profile switch includes the EC20 reset and a live ICCID read,
// which normally takes longer than the server's ordinary response deadline.
// A confirmed profile switch always includes a live ICCID read and may also
// include the EC20 reset fallback, so it can exceed the ordinary deadline.
controller := http.NewResponseController(w)
_ = controller.SetWriteDeadline(time.Time{})
aidHex := firstNonEmpty(request.AIDHex, request.AIDHexCamel)
+69 -2
View File
@@ -5,9 +5,11 @@ import (
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"time"
"vocat/internal/device"
"vocat/internal/i18n"
localproxy "vocat/internal/proxy"
"vocat/internal/store"
@@ -100,6 +102,48 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
payload.ID = id
s.saveAndProbeUpstream(w, r, payload)
case http.MethodPatch:
var request struct {
Enabled bool `json:"enabled"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
value, err := s.store.UpstreamProxy(r.Context(), id)
if err != nil {
s.writeStoreError(w, err)
return
}
value.Enabled = request.Enabled
value.UpdatedAt = time.Now().UTC()
if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil {
s.writeStoreError(w, err)
return
}
bindings, err := s.store.ListDeviceProxyBindings(r.Context())
if err != nil {
s.writeStoreError(w, err)
return
}
reconnectRequested := false
var reconnectErrors []string
for _, binding := range bindings {
if binding.UpstreamProxyID != id {
continue
}
requested, reconnectErr := s.requestProfileProxyRouteReconnect(binding.DeviceID, binding.ICCID)
reconnectRequested = reconnectRequested || requested
if reconnectErr != nil {
reconnectErrors = append(reconnectErrors, reconnectErr.Error())
}
}
response := upstreamProxyResponse(value.Redacted())
response["reconnect_requested"] = reconnectRequested
if len(reconnectErrors) > 0 {
response["reconnect_error"] = strings.Join(reconnectErrors, "; ")
}
writeJSON(w, http.StatusOK, map[string]any{"data": response})
case http.MethodDelete:
bindings, listErr := s.store.ListDeviceProxyBindings(r.Context())
if listErr != nil {
@@ -117,7 +161,7 @@ func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}})
default:
w.Header().Set("Allow", "PUT, DELETE")
w.Header().Set("Allow", "PUT, PATCH, DELETE")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
}
@@ -589,7 +633,7 @@ func countryNameForMCC(mcc string) string {
return ""
}
var proxyCountries = []proxyCountry{
var namedProxyCountries = []proxyCountry{
{Code: "CN", Name: "中国", MCCs: []string{"460", "461"}},
{Code: "HK", Name: "中国香港", MCCs: []string{"454"}},
{Code: "MO", Name: "中国澳门", MCCs: []string{"455"}},
@@ -644,3 +688,26 @@ var proxyCountries = []proxyCountry{
{Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}},
{Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}},
}
var proxyCountries = buildProxyCountries()
func buildProxyCountries() []proxyCountry {
byCode := make(map[string]proxyCountry)
for _, country := range namedProxyCountries {
byCode[country.Code] = country
}
for code, mccs := range device.MCCsByCountry() {
country, found := byCode[code]
if !found {
country = proxyCountry{Code: code, Name: code}
}
country.MCCs = append([]string(nil), mccs...)
byCode[code] = country
}
result := make([]proxyCountry, 0, len(byCode))
for _, country := range byCode {
result = append(result, country)
}
sort.Slice(result, func(i, j int) bool { return result[i].Code < result[j].Code })
return result
}
+46 -17
View File
@@ -357,23 +357,11 @@ func upstreamProxy(row rowScanner) (UpstreamProxy, error) {
}
func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ICCID = strings.TrimSpace(value.ICCID)
value.ProfileName = strings.TrimSpace(value.ProfileName)
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
value, err := normalizeDeviceProxyBinding(value)
if err != nil {
return err
}
now := time.Now().UTC()
createdAt := value.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
updatedAt := value.UpdatedAt
if updatedAt.IsZero() {
updatedAt = now
}
_, err := s.db.ExecContext(ctx, `
_, err = s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
@@ -382,13 +370,54 @@ func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyB
profile_name = excluded.profile_name,
upstream_proxy_id = excluded.upstream_proxy_id,
updated_at = excluded.updated_at
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix())
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
if err != nil {
return fmt.Errorf("upsert proxy binding for ICCID %q: %w", value.ICCID, err)
}
return nil
}
// InsertDeviceProxyBindingIfAbsent materializes a default route without ever
// replacing an explicit (or concurrently-created) ICCID binding.
func (s *Store) InsertDeviceProxyBindingIfAbsent(ctx context.Context, value DeviceProxyBinding) (bool, error) {
value, err := normalizeDeviceProxyBinding(value)
if err != nil {
return false, err
}
result, err := s.db.ExecContext(ctx, `
INSERT INTO device_proxy_bindings (
iccid, device_id, profile_name, upstream_proxy_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO NOTHING
`, value.ICCID, value.DeviceID, value.ProfileName, value.UpstreamProxyID, value.CreatedAt.Unix(), value.UpdatedAt.Unix())
if err != nil {
return false, fmt.Errorf("insert proxy binding for ICCID %q if absent: %w", value.ICCID, err)
}
affected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("read inserted proxy binding result for ICCID %q: %w", value.ICCID, err)
}
return affected > 0, nil
}
func normalizeDeviceProxyBinding(value DeviceProxyBinding) (DeviceProxyBinding, error) {
value.DeviceID = strings.TrimSpace(value.DeviceID)
value.ICCID = strings.TrimSpace(value.ICCID)
value.ProfileName = strings.TrimSpace(value.ProfileName)
value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID)
if value.DeviceID == "" || value.ICCID == "" || value.UpstreamProxyID == "" {
return DeviceProxyBinding{}, errors.New("profile proxy binding requires device ID, ICCID, and upstream proxy ID")
}
now := time.Now().UTC()
if value.CreatedAt.IsZero() {
value.CreatedAt = now
}
if value.UpdatedAt.IsZero() {
value.UpdatedAt = now
}
return value, nil
}
func (s *Store) DeviceProxyBinding(ctx context.Context, iccid string) (DeviceProxyBinding, error) {
return deviceProxyBinding(s.db.QueryRowContext(
ctx,
+30 -5
View File
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"strconv"
"strings"
@@ -52,12 +53,18 @@ type Config struct {
// SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP
// and AT+CSCA empty. It must be an international or national digit string.
SMSCenter string
// SMSCenterByPLMN provides narrow carrier fallbacks without applying one
// operator's service-centre address to every SIM.
SMSCenterByPLMN map[string]string
// OnSMS is invoked after a valid inbound RP-DATA/SMS-DELIVER has been
// decoded. Returning an error causes an RP-ERROR delivery report.
OnSMS func(context.Context, ReceivedSMS) error
// OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a
// submission that requested a delivery report.
OnSMSStatus func(context.Context, ReceivedSMSStatus) error
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
// not include message text or raw protocol payloads.
Logger *slog.Logger
}
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
@@ -85,6 +92,9 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
}
func normalizeConfig(config Config) (Config, error) {
if config.Logger == nil {
config.Logger = slog.Default()
}
if config.Port == 0 {
config.Port = defaultSIPPort
}
@@ -120,6 +130,19 @@ func normalizeConfig(config Config) (Config, error) {
transportByPLMN[plmn] = transport
}
config.TransportByPLMN = transportByPLMN
smsCenterByPLMN := make(map[string]string, len(config.SMSCenterByPLMN))
for plmn, smsCenter := range config.SMSCenterByPLMN {
plmn = strings.TrimSpace(plmn)
smsCenter = strings.TrimSpace(smsCenter)
if !digitsBetween(plmn, 5, 6) {
return Config{}, fmt.Errorf("ims: invalid SMS service-centre PLMN %q", plmn)
}
if !validSMSCenter(smsCenter) {
return Config{}, fmt.Errorf("ims: invalid SMS service-centre address for PLMN %s", plmn)
}
smsCenterByPLMN[plmn] = smsCenter
}
config.SMSCenterByPLMN = smsCenterByPLMN
if strings.TrimSpace(config.UserAgent) == "" {
config.UserAgent = "vocat/1"
}
@@ -156,15 +179,17 @@ func normalizeConfig(config Config) (Config, error) {
config.PublicIdentity = strings.TrimSpace(config.PublicIdentity)
config.UserAgent = strings.TrimSpace(config.UserAgent)
config.SMSCenter = strings.TrimSpace(config.SMSCenter)
if config.SMSCenter != "" {
digits := strings.TrimPrefix(config.SMSCenter, "+")
if !digitsBetween(digits, 3, 20) {
return Config{}, errors.New("ims: configured SMS service-centre address is invalid")
}
if config.SMSCenter != "" && !validSMSCenter(config.SMSCenter) {
return Config{}, errors.New("ims: configured SMS service-centre address is invalid")
}
return config, nil
}
func validSMSCenter(value string) bool {
digits := strings.TrimPrefix(strings.TrimSpace(value), "+")
return digitsBetween(digits, 3, 20)
}
func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) (vowifi.IMSSession, error) {
if ctx == nil {
ctx = context.Background()
+20
View File
@@ -73,6 +73,26 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
}
}
func TestNormalizeConfigValidatesSMSCentersByPLMN(t *testing.T) {
config, err := normalizeConfig(Config{SMSCenterByPLMN: map[string]string{
" 23410 ": " +447802000332 ",
}})
if err != nil {
t.Fatalf("normalizeConfig() error = %v", err)
}
if got := config.SMSCenterByPLMN["23410"]; got != "+447802000332" {
t.Fatalf("normalized O2 SMSC = %q", got)
}
for _, invalid := range []Config{
{SMSCenterByPLMN: map[string]string{"234": "+447802000332"}},
{SMSCenterByPLMN: map[string]string{"23410": "not-a-number"}},
} {
if _, err := normalizeConfig(invalid); err == nil {
t.Fatalf("normalizeConfig(%#v) succeeded", invalid.SMSCenterByPLMN)
}
}
}
func TestProviderRegisterAKAParseEvidenceAndClose(t *testing.T) {
for _, test := range []struct {
name string
+314 -24
View File
@@ -2,10 +2,17 @@ package ims
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"strconv"
"strings"
@@ -15,7 +22,11 @@ import (
"vocat/internal/vowifi"
)
const smsContentType = "application/vnd.3gpp.sms"
const (
smsContentType = "application/vnd.3gpp.sms"
sipMessageRetransmitT1 = 500 * time.Millisecond
sipMessageRetransmitMax = 4 * time.Second
)
var (
ErrSMSCUnavailable = errors.New("ims: SMS service-centre address is unavailable")
@@ -152,6 +163,11 @@ func (session *Session) readInboundTCP(connection net.Conn) {
for {
packet, err := readSIPPacket(reader)
if err != nil {
if !session.isClosed() && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
session.logInboundSMS(slog.LevelWarn, "IMS protected SIP packet read failed", nil,
"stage", "sip_parse", "transport", "tcp",
"remote", connection.RemoteAddr().String(), "error", err)
}
return
}
session.dispatchPacket(packet, func(response []byte) error {
@@ -174,6 +190,9 @@ func (session *Session) readProtectedUDP() {
}
packet, err := parseSIPPacket(buffer[:count])
if err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS protected SIP packet parse failed", nil,
"stage", "sip_parse", "transport", "udp", "remote", remote.String(),
"packet_bytes", count, "error", err)
continue
}
session.dispatchPacket(packet, func(response []byte) error {
@@ -198,6 +217,8 @@ func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) er
response := packet.Response
cseq, method, err := cseqNumber(response.value("CSeq"))
if err != nil {
session.logOutboundSMS(slog.LevelWarn, "IMS SIP response could not be matched",
"stage", "sip_response", "sip_status", response.StatusCode, "error", err)
return
}
key := sipTransactionKey{
@@ -213,6 +234,10 @@ func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) er
case channel <- response:
default:
}
} else if method == "MESSAGE" {
session.logOutboundSMS(slog.LevelWarn, "IMS SIP MESSAGE response was unmatched",
"stage", "sip_response", "call_id", key.callID,
"cseq", key.cseq, "sip_status", response.StatusCode)
}
return
}
@@ -240,22 +265,64 @@ func (session *Session) exchangeRuntime(
session.transactionsMu.Unlock()
}()
session.writeMu.Lock()
_, err := session.conn.Write(request)
session.writeMu.Unlock()
if err != nil {
writeRequest := func() error {
session.writeMu.Lock()
defer session.writeMu.Unlock()
_, err := session.conn.Write(request)
return err
}
if err := writeRequest(); err != nil {
return nil, fmt.Errorf("ims: send SIP %s: %w", key.method, err)
}
timer := time.NewTimer(session.provider.config.TransactionTimeout)
defer timer.Stop()
var retransmitTimer *time.Timer
var retransmit <-chan time.Time
retransmitInterval := sipMessageRetransmitT1
retransmitCount := 0
if session.transport == "udp" && key.method == "MESSAGE" {
retransmitTimer = time.NewTimer(retransmitInterval)
retransmit = retransmitTimer.C
defer retransmitTimer.Stop()
}
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
if retransmitTimer != nil {
return nil, fmt.Errorf(
"ims: SIP %s transaction timed out after %d retransmissions",
key.method,
retransmitCount,
)
}
return nil, fmt.Errorf("ims: SIP %s transaction timed out", key.method)
case <-retransmit:
if err := writeRequest(); err != nil {
return nil, fmt.Errorf("ims: retransmit SIP %s: %w", key.method, err)
}
retransmitCount++
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE retransmitted",
"stage", "sip_retransmit", "call_id", key.callID,
"cseq", key.cseq, "attempt", retransmitCount)
retransmitInterval *= 2
if retransmitInterval > sipMessageRetransmitMax {
retransmitInterval = sipMessageRetransmitMax
}
retransmitTimer.Reset(retransmitInterval)
case response := <-responses:
if response.StatusCode >= 100 && response.StatusCode < 200 {
if retransmitTimer != nil {
if !retransmitTimer.Stop() {
select {
case <-retransmitTimer.C:
default:
}
}
retransmitInterval = sipMessageRetransmitMax
retransmitTimer.Reset(retransmitInterval)
}
continue
}
return response, nil
@@ -271,23 +338,44 @@ func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byt
switch request.Method {
case "OPTIONS":
case "MESSAGE":
contentType := strings.ToLower(strings.TrimSpace(strings.SplitN(request.value("Content-Type"), ";", 2)[0]))
if contentType != smsContentType {
if !supportsSMSContentType(request.value("Content-Type")) {
status = 415
}
default:
status = 405
}
response, err := buildSIPResponse(request, status, session.fromTag)
if err == nil {
_ = respond(response)
if err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SIP request response failed", request,
"stage", "sip_response_build", "error", err)
} else if err = respond(response); err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SIP request response failed", request,
"stage", "sip_response_send", "sip_status", status, "error", err)
}
if status != 200 || request.Method != "MESSAGE" {
if request.Method == "MESSAGE" {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS MESSAGE rejected", request,
"stage", "content_type", "sip_status", status)
}
return
}
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS MESSAGE received", request,
"stage", "sip_accepted")
go session.processSMSMessage(request)
}
func supportsSMSContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil {
return false
}
if strings.EqualFold(mediaType, smsContentType) {
return true
}
return strings.EqualFold(mediaType, "multipart/mixed") &&
strings.TrimSpace(parameters["boundary"]) != ""
}
func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, error) {
reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status]
if reason == "" {
@@ -325,24 +413,46 @@ func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, erro
}
func (session *Session) processSMSMessage(request *sipRequest) {
rpdu, err := parseRPDU(request.Body)
payload, payloadSource, err := extractSMSPayload(request)
if err != nil {
session.sendDeliveryReport(request, buildRPError(0, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
"stage", "mime", "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(0, 95), "rp_error")
return
}
rpdu, err := parseRPDU(payload)
if err != nil {
reference := byte(0)
if len(payload) > 1 {
reference = payload[1]
}
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
"stage", "rpdu", "payload_source", payloadSource,
"rp_reference", int(reference), "payload_bytes", len(payload), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(reference, 95), "rp_error")
return
}
if rpdu.messageType != 1 { // RP-DATA, network to MS.
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS control message received", request,
"stage", "rpdu", "payload_source", payloadSource,
"rp_message_type", int(rpdu.messageType), "rp_reference", int(rpdu.reference))
return
}
message, err := device.DecodeSMSDeliverTPDU(rpdu.tpdu)
if err != nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS decode failed", request,
"stage", "tpdu", "payload_source", payloadSource,
"rp_reference", int(rpdu.reference), "tpdu_bytes", len(rpdu.tpdu), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
return
}
receivedAt := time.Now().UTC()
callID := strings.TrimSpace(request.value("Call-ID"))
if message.Direction == device.SMSDirectionStatusReport {
if message.MessageReference == nil || message.StatusCode == nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status report is incomplete", request,
"stage", "tpdu", "rp_reference", int(rpdu.reference))
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
return
}
status := ReceivedSMSStatus{
@@ -357,7 +467,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
Timestamp: receivedAt,
RPReference: int(rpdu.reference),
CallID: callID,
RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)),
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
}
if session.provider.config.OnSMSStatus != nil {
@@ -366,14 +476,21 @@ func (session *Session) processSMSMessage(request *sipRequest) {
cancel()
}
if err != nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS status persistence failed", request,
"stage", "status_callback", "rp_reference", int(rpdu.reference), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
return
}
session.sendDeliveryReport(request, []byte{0x02, rpdu.reference})
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS status report processed", request,
"stage", "status_callback", "rp_reference", int(rpdu.reference),
"status_code", *message.StatusCode)
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
return
}
if message.Direction != device.SMSDirectionReceived {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS has unexpected TPDU direction", request,
"stage", "tpdu", "rp_reference", int(rpdu.reference), "direction", message.Direction)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 95), "rp_error")
return
}
var serviceCenterTimestamp *time.Time
@@ -396,7 +513,7 @@ func (session *Session) processSMSMessage(request *sipRequest) {
Concat: message.Concat,
RPReference: int(rpdu.reference),
CallID: callID,
RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)),
RawRPDU: strings.ToUpper(hex.EncodeToString(payload)),
RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)),
}
if session.provider.config.OnSMS != nil {
@@ -405,26 +522,130 @@ func (session *Session) processSMSMessage(request *sipRequest) {
cancel()
}
if err != nil {
session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22))
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS persistence failed", request,
"stage", "sms_callback", "rp_reference", int(rpdu.reference), "error", err)
session.sendLoggedDeliveryReport(request, buildRPError(rpdu.reference, 22), "rp_error")
return
}
session.sendDeliveryReport(request, []byte{0x02, rpdu.reference})
session.logInboundSMS(slog.LevelInfo, "IMS inbound SMS processed", request,
"stage", "sms_callback", "payload_source", payloadSource,
"rp_reference", int(rpdu.reference), "encoding", message.Encoding,
"concatenated", message.Concat != nil)
session.sendLoggedDeliveryReport(request, []byte{0x02, rpdu.reference}, "rp_ack")
}
func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) {
func extractSMSPayload(request *sipRequest) ([]byte, string, error) {
if request == nil {
return nil, "", errors.New("ims: SMS MESSAGE is nil")
}
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(request.value("Content-Type")))
if err != nil {
return nil, "", fmt.Errorf("ims: parse SMS Content-Type: %w", err)
}
if strings.EqualFold(mediaType, smsContentType) {
payload, decodeErr := decodeSMSTransfer(request.Body, request.value("Content-Transfer-Encoding"))
return payload, smsContentType, decodeErr
}
if !strings.EqualFold(mediaType, "multipart/mixed") {
return nil, "", fmt.Errorf("ims: unsupported SMS Content-Type %q", mediaType)
}
boundary := strings.TrimSpace(parameters["boundary"])
if boundary == "" {
return nil, "", errors.New("ims: multipart SMS has no boundary")
}
reader := multipart.NewReader(bytes.NewReader(request.Body), boundary)
for {
part, nextErr := reader.NextRawPart()
if errors.Is(nextErr, io.EOF) {
break
}
if nextErr != nil {
return nil, "", fmt.Errorf("ims: read multipart SMS: %w", nextErr)
}
partType, _, parseErr := mime.ParseMediaType(strings.TrimSpace(part.Header.Get("Content-Type")))
if parseErr != nil || !strings.EqualFold(partType, smsContentType) {
_ = part.Close()
continue
}
body, readErr := io.ReadAll(part)
_ = part.Close()
if readErr != nil {
return nil, "", fmt.Errorf("ims: read multipart SMS payload: %w", readErr)
}
payload, decodeErr := decodeSMSTransfer(body, part.Header.Get("Content-Transfer-Encoding"))
return payload, "multipart/mixed", decodeErr
}
return nil, "", errors.New("ims: multipart MESSAGE omitted application/vnd.3gpp.sms payload")
}
func decodeSMSTransfer(body []byte, encoding string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "binary", "8bit":
return append([]byte(nil), body...), nil
case "base64":
decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, bytes.NewReader(body)))
if err != nil {
return nil, fmt.Errorf("ims: decode base64 SMS payload: %w", err)
}
return decoded, nil
case "quoted-printable":
decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(body)))
if err != nil {
return nil, fmt.Errorf("ims: decode quoted-printable SMS payload: %w", err)
}
return decoded, nil
default:
return nil, fmt.Errorf("ims: unsupported SMS Content-Transfer-Encoding %q", encoding)
}
}
func (session *Session) logInboundSMS(level slog.Level, message string, request *sipRequest, attributes ...any) {
logger := slog.Default()
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
base := []any{"device_id", session.request.DeviceID}
if request != nil {
base = append(base,
"call_id", strings.TrimSpace(request.value("Call-ID")),
"content_type", strings.TrimSpace(request.value("Content-Type")),
"body_bytes", len(request.Body),
)
}
logger.Log(context.Background(), level, message, append(base, attributes...)...)
}
func (session *Session) sendLoggedDeliveryReport(request *sipRequest, report []byte, reportType string) {
if err := session.sendDeliveryReport(request, report); err != nil {
session.logInboundSMS(slog.LevelWarn, "IMS inbound SMS delivery report failed", request,
"stage", "delivery_report", "report_type", reportType, "error", err)
return
}
session.logInboundSMS(slog.LevelDebug, "IMS inbound SMS delivery report sent", request,
"stage", "delivery_report", "report_type", reportType)
}
func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) error {
target := firstURI(request.value("P-Asserted-Identity"))
if target == "" {
target = firstURI(request.value("From"))
}
if target == "" {
return
return errors.New("ims: SMS MESSAGE omitted a delivery-report target")
}
_, _ = session.sendSIPMessage(
response, err := session.sendSIPMessage(
context.Background(),
target,
report,
strings.TrimSpace(request.value("Call-ID")),
)
if err != nil {
return err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("ims: SMS delivery report returned SIP %d", response.StatusCode)
}
return nil
}
func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error) {
@@ -441,14 +662,21 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
}
smsc := strings.TrimSpace(session.request.Identity.SMSC)
session.mu.Unlock()
smscSource := "sim"
if smsc == "" {
smscSource = "sim_reader"
reader, ok := session.provider.aka.(smsCenterReader)
var readErr error
if ok {
smsc, readErr = reader.ReadSMSCenter(ctx, session.request.DeviceID)
}
if strings.TrimSpace(smsc) == "" {
smsc = smsCenterForIdentity(session.provider.config, session.request.Identity)
smscSource = "plmn_fallback"
}
if strings.TrimSpace(smsc) == "" {
smsc = session.provider.config.SMSCenter
smscSource = "configured_fallback"
}
if strings.TrimSpace(smsc) == "" {
return vowifi.SMSSubmitResult{}, errors.Join(ErrSMSCUnavailable, readErr)
@@ -471,6 +699,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
SubmissionStatus: "pending",
PartResults: make([]vowifi.SMSSubmitPart, 0, len(parts)),
}
session.logOutboundSMS(slog.LevelInfo, "IMS outbound SMS submission started",
"stage", "prepare", "parts", len(parts), "smsc_source", smscSource,
"recipient_type", smsRecipientType(parts[0].To))
psi := "tel:" + normalizeE164(smsc)
for _, part := range parts {
reference := session.allocateRPReference()
@@ -501,19 +732,60 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
}
result.PartResults = append(result.PartResults, partResult)
if sendErr != nil {
session.logOutboundSMS(slog.LevelWarn, "IMS outbound SMS submission failed",
"stage", "sip_transaction", "part", part.Part,
"rp_reference", int(reference), "error", sendErr)
result.SubmissionStatus = "failed"
return result, sendErr
}
if !partResult.Accepted {
session.logOutboundSMS(slog.LevelWarn, "IMS outbound SMS was rejected",
"stage", "sip_response", "part", part.Part,
"rp_reference", int(reference), "sip_status", response.StatusCode)
result.SubmissionStatus = "rejected"
return result, fmt.Errorf("%w: SIP %d", ErrSMSRejected, response.StatusCode)
}
}
result.AllPartsAccepted = true
result.SubmissionStatus = "accepted_by_ims"
session.logOutboundSMS(slog.LevelInfo, "IMS outbound SMS submission accepted",
"stage", "sip_response", "parts", result.PartsAccepted)
return result, nil
}
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
return strings.TrimSpace(config.SMSCenterByPLMN[plmn])
}
func smsRecipientType(recipient string) string {
recipient = strings.TrimSpace(recipient)
digits := strings.TrimPrefix(recipient, "+")
switch {
case strings.HasPrefix(recipient, "+"):
return "international"
case len(digits) <= 6:
return "short_code"
default:
return "national"
}
}
func (session *Session) logOutboundSMS(level slog.Level, message string, attributes ...any) {
logger := slog.Default()
if session != nil && session.provider != nil && session.provider.config.Logger != nil {
logger = session.provider.config.Logger
}
plmn := strings.TrimSpace(session.request.Identity.HomeMCC) + strings.TrimSpace(session.request.Identity.HomeMNC)
base := []any{
"device_id", session.request.DeviceID,
"home_plmn", plmn,
"transport", session.transport,
"security", session.effectiveSecurityMode(),
}
logger.Log(context.Background(), level, message, append(base, attributes...)...)
}
func (session *Session) allocateRPReference() byte {
session.mu.Lock()
defer session.mu.Unlock()
@@ -567,6 +839,8 @@ func (session *Session) sendSIPMessage(
fmt.Sprintf("CSeq: %d MESSAGE", cseq),
"P-Preferred-Identity: <"+session.identity.public+">",
"Accept-Contact: *;+g.3gpp.smsip",
"Request-Disposition: no-fork",
"Allow: MESSAGE",
)
if inReplyTo != "" {
lines = append(lines, "In-Reply-To: "+inReplyTo)
@@ -578,7 +852,23 @@ func (session *Session) sendSIPMessage(
"", "",
)
request := append([]byte(strings.Join(lines, "\r\n")), body...)
return session.exchangeRuntime(ctx, request, sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"})
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE transaction started",
"stage", "sip_send", "call_id", callID, "cseq", cseq,
"body_bytes", len(body), "service_routes", len(serviceRoutes))
response, exchangeErr := session.exchangeRuntime(
ctx,
request,
sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"},
)
if exchangeErr != nil {
session.logOutboundSMS(slog.LevelWarn, "IMS SIP MESSAGE transaction failed",
"stage", "sip_transaction", "call_id", callID, "cseq", cseq, "error", exchangeErr)
return response, exchangeErr
}
session.logOutboundSMS(slog.LevelDebug, "IMS SIP MESSAGE response received",
"stage", "sip_response", "call_id", callID, "cseq", cseq,
"sip_status", response.StatusCode)
return response, nil
}
func runtimeSecurityHeaders(active bool, verifyValue string) []string {
+143 -4
View File
@@ -1,11 +1,14 @@
package ims
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"mime/multipart"
"net"
"net/textproto"
"strings"
"testing"
"time"
@@ -100,6 +103,111 @@ func TestRuntimeSecurityHeaders(t *testing.T) {
}
}
func TestExtractSMSPayload(t *testing.T) {
rpdu := []byte{0x01, 0x2a, 0x00, 0x00, 0x03, 0x04, 0x00, 0x00}
tests := []struct {
name string
request *sipRequest
wantSource string
wantPayload []byte
}{
{
name: "direct binary",
request: &sipRequest{Headers: map[string][]string{
"content-type": {smsContentType + "; charset=binary"},
"content-transfer-encoding": {"binary"},
}, Body: rpdu},
wantSource: smsContentType,
wantPayload: rpdu,
},
{
name: "multipart base64",
request: multipartSMSRequest(t, rpdu),
wantSource: "multipart/mixed",
wantPayload: rpdu,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
payload, source, err := extractSMSPayload(test.request)
if err != nil {
t.Fatalf("extractSMSPayload() error = %v", err)
}
if source != test.wantSource || !bytes.Equal(payload, test.wantPayload) {
t.Fatalf("extractSMSPayload() = (%x, %q), want (%x, %q)",
payload, source, test.wantPayload, test.wantSource)
}
})
}
}
func TestSupportsSMSContentType(t *testing.T) {
for _, test := range []struct {
value string
want bool
}{
{smsContentType, true},
{"Application/Vnd.3gpp.Sms; charset=binary", true},
{`multipart/mixed; boundary="vodafone-boundary"`, true},
{"multipart/mixed", false},
{"text/plain", false},
} {
if got := supportsSMSContentType(test.value); got != test.want {
t.Errorf("supportsSMSContentType(%q) = %v, want %v", test.value, got, test.want)
}
}
}
func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
config := Config{SMSCenterByPLMN: map[string]string{
"23410": "+447802000332",
"234010": "+447802000332",
"23415": "+447785016005",
}}
for _, test := range []struct {
mnc string
want string
}{
{mnc: "10", want: "+447802000332"},
{mnc: "010", want: "+447802000332"},
{mnc: "15", want: "+447785016005"},
{mnc: "30", want: ""},
} {
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
if got := smsCenterForIdentity(config, identity); got != test.want {
t.Errorf("smsCenterForIdentity(234/%s) = %q, want %q", test.mnc, got, test.want)
}
}
}
func multipartSMSRequest(t *testing.T, payload []byte) *sipRequest {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.SetBoundary("vodafone-boundary"); err != nil {
t.Fatal(err)
}
header := make(textproto.MIMEHeader)
header.Set("Content-Type", smsContentType)
header.Set("Content-Transfer-Encoding", "base64")
part, err := writer.CreatePart(header)
if err != nil {
t.Fatal(err)
}
if _, err = part.Write([]byte(base64.StdEncoding.EncodeToString(payload))); err != nil {
t.Fatal(err)
}
if err = writer.Close(); err != nil {
t.Fatal(err)
}
return &sipRequest{
Headers: map[string][]string{
"content-type": {`multipart/mixed; boundary="vodafone-boundary"`},
},
Body: body.Bytes(),
}
}
func TestSessionSendsSMSOverIMS(t *testing.T) {
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
if err != nil {
@@ -202,6 +310,24 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
}
rpdu := []byte{0x01, 0x2a, 0x00, 0x00, byte(len(tpdu))}
rpdu = append(rpdu, tpdu...)
var messageBody bytes.Buffer
mimeWriter := multipart.NewWriter(&messageBody)
if err = mimeWriter.SetBoundary("vodafone-delivery"); err != nil {
return err
}
mimeHeader := make(textproto.MIMEHeader)
mimeHeader.Set("Content-Type", smsContentType)
mimeHeader.Set("Content-Transfer-Encoding", "binary")
mimePart, createErr := mimeWriter.CreatePart(mimeHeader)
if createErr != nil {
return createErr
}
if _, err = mimePart.Write(rpdu); err != nil {
return err
}
if err = mimeWriter.Close(); err != nil {
return err
}
request := []byte(strings.Join([]string{
"MESSAGE sip:[email protected] SIP/2.0",
"Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKdeliver",
@@ -210,10 +336,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s
"P-Asserted-Identity: <sip:[email protected]>",
"Call-ID: network-deliver-1",
"CSeq: 1 MESSAGE",
"Content-Type: application/vnd.3gpp.sms",
fmt.Sprintf("Content-Length: %d", len(rpdu)), "", "",
`Content-Type: multipart/mixed; boundary="vodafone-delivery"`,
fmt.Sprintf("Content-Length: %d", messageBody.Len()), "", "",
}, "\r\n"))
request = append(request, rpdu...)
request = append(request, messageBody.Bytes()...)
if _, err = listener.WriteToUDP(request, remote); err != nil {
return err
}
@@ -292,12 +418,25 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<-
if err != nil {
return err
}
firstMessage := append([]byte(nil), packet[:count]...)
firstRemote := remote.String()
// Exercise the RFC SIP/UDP non-INVITE transaction retransmission path by
// deliberately dropping the first MESSAGE request.
count, remote, err = listener.ReadFromUDP(packet)
if err != nil {
return err
}
if remote.String() != firstRemote || !bytes.Equal(packet[:count], firstMessage) {
return errors.New("outbound MESSAGE retransmission changed transaction bytes or source")
}
message, err := parseSIPPacket(packet[:count])
if err != nil || message.Request == nil {
return fmt.Errorf("outbound MESSAGE parse: %v", err)
}
if message.Request.Method != "MESSAGE" || message.Request.URI != "tel:+447785016005" ||
strings.ToLower(message.Request.value("Content-Type")) != smsContentType {
strings.ToLower(message.Request.value("Content-Type")) != smsContentType ||
message.Request.value("Request-Disposition") != "no-fork" ||
message.Request.value("Allow") != "MESSAGE" {
return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request)
}
rpdu, err := parseRPDU(message.Request.Body)
+37 -1
View File
@@ -32,6 +32,7 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
var upstreamID string
matchedCountryRule := false
if iccid != "" {
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if err == nil {
@@ -43,7 +44,10 @@ func (resolver ProxyResolver) Resolve(
if upstreamID == "" {
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
if !found {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
country = strings.ToUpper(strings.TrimSpace(request.CountryCode))
if len(country) != 2 {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
}
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
@@ -53,6 +57,7 @@ func (resolver ProxyResolver) Resolve(
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
}
upstreamID = rule.UpstreamProxyID
matchedCountryRule = true
}
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
if err != nil {
@@ -64,12 +69,43 @@ func (resolver ProxyResolver) Resolve(
)
}
if !upstream.Enabled {
if matchedCountryRule {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
return vowifi.ProxyRoute{}, fmt.Errorf(
"upstream proxy %q for device %s is disabled",
upstream.ID,
deviceID,
)
}
if matchedCountryRule && iccid != "" {
created, bindErr := resolver.Store.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
DeviceID: deviceID,
ICCID: iccid,
ProfileName: iccid,
UpstreamProxyID: upstream.ID,
})
if bindErr != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("materialize MCC proxy route for ICCID %s: %w", iccid, bindErr)
}
if !created {
// Another request or an administrator may have created an explicit
// binding after our first lookup. The persisted ICCID route wins.
binding, bindingErr := resolver.Store.DeviceProxyBinding(ctx, iccid)
if bindingErr != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("reload proxy binding for ICCID %s: %w", iccid, bindingErr)
}
if binding.UpstreamProxyID != upstream.ID {
upstream, err = resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("load materialized upstream proxy %q for device %s: %w", binding.UpstreamProxyID, deviceID, err)
}
if !upstream.Enabled {
return vowifi.ProxyRoute{}, fmt.Errorf("upstream proxy %q for device %s is disabled", upstream.ID, deviceID)
}
}
}
}
return vowifi.ProxyRoute{
Mode: vowifi.ProxyModeSOCKS5,
ID: upstream.ID,
+135
View File
@@ -103,6 +103,141 @@ func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
}
}
func TestProxyResolverCountryRuleWithDisabledProxyFallsBackDirect(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertCountryRule(ctx, store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "disabled", Enabled: true,
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"})
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct for a disabled country default", route)
}
}
func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
ID: "disabled", Name: "Disabled", Addr: "127.0.0.1:1080", Enabled: false,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
}); err != nil {
t.Fatal(err)
}
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
})
if err == nil {
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
}
}
func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
for _, proxy := range []store.UpstreamProxy{
{ID: "first", Name: "First", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "later", Name: "Later", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(ctx, proxy); err != nil {
t.Fatal(err)
}
}
if err := database.UpsertCountryRule(ctx, store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "first", Enabled: true,
}); err != nil {
t.Fatal(err)
}
request := vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
}
resolver := ProxyResolver{Store: database}
route, err := resolver.Resolve(ctx, request)
if err != nil {
t.Fatal(err)
}
if route.ID != "first" {
t.Fatalf("first route = %#v, want MCC default", route)
}
binding, err := database.DeviceProxyBinding(ctx, request.ICCID)
if err != nil {
t.Fatal(err)
}
if binding.DeviceID != request.DeviceID || binding.UpstreamProxyID != "first" {
t.Fatalf("materialized binding = %#v", binding)
}
if err := database.UpsertCountryRule(ctx, store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "later", Enabled: true,
}); err != nil {
t.Fatal(err)
}
route, err = resolver.Resolve(ctx, request)
if err != nil {
t.Fatal(err)
}
if route.ID != "first" {
t.Fatalf("route after country rule edit = %#v, want durable ICCID binding", route)
}
}
func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testing.T) {
database := testStore(t)
ctx := context.Background()
if err := database.UpsertDevice(ctx, store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
for _, proxyID := range []string{"explicit", "default"} {
if err := database.UpsertUpstreamProxy(ctx, store.UpstreamProxy{
ID: proxyID, Name: proxyID, Addr: "127.0.0.1:1080", Enabled: true,
}); err != nil {
t.Fatal(err)
}
}
iccid := "89441000400128014257"
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
}); err != nil {
t.Fatal(err)
}
created, err := database.InsertDeviceProxyBindingIfAbsent(ctx, store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: iccid, ProfileName: "Automatic", UpstreamProxyID: "default",
})
if err != nil {
t.Fatal(err)
}
if created {
t.Fatal("default binding unexpectedly replaced an explicit binding")
}
binding, err := database.DeviceProxyBinding(ctx, iccid)
if err != nil {
t.Fatal(err)
}
if binding.UpstreamProxyID != "explicit" || binding.ProfileName != "Manual" {
t.Fatalf("binding = %#v, want explicit binding unchanged", binding)
}
}
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
database := testStore(t)
for _, proxy := range []store.UpstreamProxy{
+13 -1
View File
@@ -298,6 +298,18 @@ detect_arch() {
# --- Download + verify -------------------------------------------------------
VOCAT_TMP=""
# curl transfer options for the binary download. -f makes curl fail on HTTP
# errors and -L follows the release-asset redirect. On an interactive terminal
# we show a single-line progress bar so a multi-megabyte download gives visible
# feedback; otherwise (piped, cron, systemd) we stay quiet but still surface
# errors via -S.
if [ -t 2 ]; then
CURL_DL_OPTS=(-fSL --progress-bar)
else
CURL_DL_OPTS=(-fsSL)
fi
download_and_verify() {
VOCAT_TMP=$(mktemp -d)
trap 'rm -rf "$VOCAT_TMP"' EXIT
@@ -307,7 +319,7 @@ download_and_verify() {
asset="vocat-linux-${ARCH_FALLBACK}"
fi
msg "下载 $asset ..." "Downloading $asset ..."
curl -fsSL -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl "${CURL_DL_OPTS[@]}" -o "${VOCAT_TMP}/vocat" "${base}/${asset}" || die "下载二进制失败。" "Failed to download the binary."
curl -fsSL -o "${VOCAT_TMP}/SHA256SUMS" "${base}/SHA256SUMS" || die "下载 SHA256SUMS 失败。" "Failed to download SHA256SUMS."
local expected actual
+16
View File
@@ -0,0 +1,16 @@
package web
import (
"io/fs"
"testing"
)
func TestEmbeddedDistributionContainsIndex(t *testing.T) {
index, err := fs.ReadFile(Dist, "index.html")
if err != nil {
t.Fatalf("read embedded index.html: %v", err)
}
if len(index) == 0 {
t.Fatal("embedded index.html is empty")
}
}
@@ -0,0 +1,126 @@
import { SearchRegular } from "@fluentui/react-icons";
import { useEffect, useMemo, useState } from "react";
import type { Country, CountryRule, UpstreamProxy } from "../../types";
import { Button, EmptyState, Input, Modal, Select } from "../ui";
import { useI18n } from "../../lib/i18n";
export interface CountryRulesDialogProps {
open: boolean;
proxies: UpstreamProxy[];
countries: Country[];
rules: CountryRule[];
busy: boolean;
onSave: (assignments: Record<string, string>) => void;
onClose: () => void;
}
export function CountryRulesDialog(props: CountryRulesDialogProps) {
const { t, lang } = useI18n();
const { open, proxies, countries, rules, busy, onSave, onClose } = props;
const [query, setQuery] = useState("");
const [assignments, setAssignments] = useState<Record<string, string>>({});
const regionNames = useMemo(() => {
try {
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
} catch {
return null;
}
}, [lang]);
const countryLabel = (country: Country) => regionNames?.of(country.countryCode) || country.countryName || country.countryCode;
const proxyOptions = useMemo(() => [
{ value: "", label: t("直连") },
...proxies.map((proxy) => ({
value: proxy.id,
label: proxy.enabled ? (proxy.name || proxy.id) : `${proxy.name || proxy.id}${t("已禁用")}`,
disabled: !proxy.enabled,
})),
], [proxies, t, lang]);
useEffect(() => {
if (!open) {
setQuery("");
setAssignments({});
return;
}
setAssignments(Object.fromEntries(rules.filter((rule) => rule.enabled).map((rule) => [rule.countryCode, rule.upstreamProxyId])));
// Sample rules only when opening. Polling must not discard in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return [...countries]
.sort((a, b) => countryLabel(a).localeCompare(countryLabel(b), lang === "zh" ? "zh-CN" : "en"))
.filter((country) => {
if (!needle) return true;
return [country.countryCode, country.countryName, countryLabel(country), ...country.mccs]
.some((value) => String(value || "").toLocaleLowerCase().includes(needle));
});
}, [countries, query, lang, regionNames]);
const configuredCount = Object.values(assignments).filter(Boolean).length;
return (
<Modal
open={open}
onClose={onClose}
title={t("MCC 国家规则")}
width="max-w-5xl"
footer={(
<>
<Button onClick={onClose} disabled={busy}>{t("取消")}</Button>
<Button variant="primary" loading={busy} onClick={() => onSave(assignments)}>{t("保存规则")}</Button>
</>
)}
>
<div className="space-y-4 pb-1">
<div className="rounded-lg border border-sky-200/70 bg-sky-50 px-3 py-2 text-xs leading-5 text-sky-800 dark:border-sky-800/50 dark:bg-sky-900/20 dark:text-sky-200">
{t("为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。")}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-gray-500">{configuredCount} {t("个国家规则")}</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("搜索国家、地区代码或 MCC")}
prefix={<SearchRegular />}
className="w-full sm:w-72"
/>
</div>
<div className="overflow-hidden rounded-xl border border-gray-100 dark:border-white/10">
<div className="max-h-[55vh] overflow-auto">
<table className="w-full min-w-[680px] text-left text-sm">
<thead className="sticky top-0 z-10 bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-[#202027]">
<tr>
<th className="px-4 py-3">{t("国家 / 地区")}</th>
<th className="px-4 py-3">MCC</th>
<th className="w-72 px-4 py-3">{t("规则")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-white/10">
{filtered.map((country) => (
<tr key={country.countryCode} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3">
<span className="font-medium">{countryLabel(country)}</span>
<span className="ml-2 font-mono text-xs text-gray-400">{country.countryCode}</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{country.mccs.join(", ")}</td>
<td className="px-4 py-2">
<Select
value={assignments[country.countryCode] || ""}
options={proxyOptions}
disabled={busy}
onChange={(value) => setAssignments((current) => ({ ...current, [country.countryCode]: value }))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length === 0 ? <EmptyState title={t("没有匹配的国家或 MCC")} /> : null}
</div>
</div>
</Modal>
);
}
+23 -12
View File
@@ -1,4 +1,4 @@
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular } from "@fluentui/react-icons";
import { DeleteRegular, DesktopRegular, EditRegular, GlobeRegular, PauseRegular, PlayRegular } from "@fluentui/react-icons";
import type { UpstreamProxy } from "../../types";
import { Button, Tag } from "../ui";
import type { LoadError, UpstreamRow } from "./shared";
@@ -12,9 +12,11 @@ export interface UpstreamSectionProps {
onEdit: (proxy: UpstreamProxy) => void;
onDelete: (proxy: UpstreamProxy) => void;
onOpenBindings: (proxy: UpstreamProxy) => void;
onToggle: (proxy: UpstreamProxy) => void;
toggleBusyId?: string;
}
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings }: UpstreamSectionProps) {
export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelete, onOpenBindings, onToggle, toggleBusyId }: UpstreamSectionProps) {
const { t } = useI18n();
return (
<div className="ui-card overflow-hidden">
@@ -30,15 +32,14 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="border-b border-gray-100 bg-gray-50/70 text-xs uppercase tracking-wide text-gray-500 dark:border-white/10 dark:bg-white/[0.025]">
<tr>
<th className="px-4 py-3">{t("名称")}</th>
<th className="px-4 py-3">{t("协议")}</th>
<th className="px-4 py-3">{t("地址")}</th>
<th className="px-4 py-3">{t("鉴权")}</th>
<th className="px-4 py-3">{t("状态")}</th>
<th className="px-4 py-3">{t("SIM / Profile 绑定")}</th>
<th className="px-4 py-3">{t("国家规则")}</th>
<th className="px-4 py-3 text-right">{t("操作")}</th>
</tr>
</thead>
@@ -46,18 +47,28 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
{rows.map((row) => (
<tr key={row.id} className="hover:bg-sky-50/40 dark:hover:bg-sky-500/[0.04]">
<td className="px-4 py-3 font-semibold">{row.name || row.id}</td>
<td className="px-4 py-3"><Tag type="primary">SOCKS5</Tag></td>
<td className="px-4 py-3 font-mono text-xs">{row.addr}</td>
<td className="px-4 py-3">{row.username || t("无")}</td>
<td className="px-4 py-3"><Tag type={row.enabled ? "success" : "info"}>{row.enabled ? t("已启用") : t("已禁用")}</Tag></td>
<td className="px-4 py-3">
<div className="inline-flex items-center gap-1 rounded border border-indigo-200/60 bg-indigo-50 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:border-indigo-800/40 dark:bg-indigo-900/20 dark:text-indigo-400">
<DesktopRegular className="text-[14px]" />
<span>{row.bindingCount} {t("个 SIM / Profile")}</span>
</div>
{row.bindingCount}
</td>
<td className="px-4 py-3">
{row.countryNames.length ? (
<div className="flex max-w-sm flex-wrap gap-1">
{row.countryNames.map((countryName) => <Tag key={countryName} type="primary">{countryName}</Tag>)}
</div>
) : <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
size="small"
variant={row.enabled ? "warning" : "success"}
plain
icon={row.enabled ? <PauseRegular /> : <PlayRegular />}
loading={toggleBusyId === row.id}
onClick={() => onToggle(row)}
>{row.enabled ? t("禁用") : t("启用")}</Button>
<Button size="small" icon={<DesktopRegular />} onClick={() => onOpenBindings(row)}>{t("SIM / Profile 绑定")}</Button>
<Button size="small" icon={<EditRegular />} onClick={() => onEdit(row)}>{t("编辑")}</Button>
<Button size="small" variant="danger" plain icon={<DeleteRegular />} onClick={() => onDelete(row)}>{t("删除")}</Button>
@@ -72,7 +83,7 @@ export function UpstreamSection({ rows, loading, error, onRetry, onEdit, onDelet
<div className="flex flex-col items-center justify-center px-6 py-16 text-center text-gray-400">
<GlobeRegular className="mb-3 text-4xl" />
<div className="text-sm">{t("暂无上游代理")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再 ICCID 绑定实体 SIM 或 eSIM Profile;未绑定的卡默认直连。")}</div>
<div className="mt-1 text-xs">{t("点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。")}</div>
</div>
) : null}
{loading ? <div className="px-6 py-16 text-center text-sm text-gray-400">{t("加载中...")}</div> : null}
+1
View File
@@ -29,6 +29,7 @@ export interface UpstreamProbeResult {
export interface UpstreamRow extends UpstreamProxy {
bindingCount: number;
countryNames: string[];
}
export function ipv6Hint(): string {
+23
View File
@@ -1005,6 +1005,13 @@ export const EN_DICT: Record<string, string> = {
"绑定:": "Bound:",
"鉴权:": "Auth:",
: "Country Rules",
"MCC 国家规则": "MCC Country Rules",
: "Rule",
: "Proxy enabled",
: "Proxy disabled",
: "Failed to change proxy status",
"代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连":
"Proxy disabled. Explicit ICCID bindings stop using this route without falling back to direct; MCC defaults not yet materialized fall back to direct.",
: "Add Proxy",
: "Add Instance",
: "Delete Rule",
@@ -1013,6 +1020,22 @@ export const EN_DICT: Record<string, string> = {
"UDP 中继地址:": "UDP Relay Address: ",
"规则按 SIM 归属 MCC 解析国家。例如 US 会覆盖 MCC 310/311/312/313/314/315/316 等表内分组;没有配置规则的国家默认直连。需要重启 VoWiFi 生效。":
"Country is resolved from the SIM home MCC. For example, US covers the listed MCC 310/311/312/313/314/315/316 groups; countries without a rule use direct connection. Restart VoWiFi to take effect.",
"未绑定 ICCID 的卡会按 SIM 归属 MCC 匹配国家规则;首次命中后会生成独立的 ICCID 绑定。ICCID 绑定优先,未命中任何规则时直连。":
"A SIM without an ICCID binding uses the country rule matching its home MCC. The first match creates an independent ICCID binding. ICCID bindings take priority; otherwise unmatched SIMs connect directly.",
"为每个国家的 MCC 选择代理。未配置时直连;已有 ICCID 绑定始终优先,首次命中国家规则后会生成独立的 ICCID 绑定。":
"Choose a proxy for each country's MCC. Unconfigured MCCs connect directly. Existing ICCID bindings always take priority, and the first country-rule match creates an independent ICCID binding.",
"同一国家只能属于一个代理;选择已分配的国家会将它迁移到当前代理。":
"Each country can belong to only one proxy. Selecting a country assigned elsewhere moves it to this proxy.",
"搜索国家、地区代码或 MCC": "Search country, region code, or MCC",
"国家 / 地区": "Country / Region",
: "Current Rule",
: "This Proxy",
: "Direct",
"没有匹配的国家或 MCC": "No matching country or MCC",
"管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定":
"Manage VoWiFi upstream proxies, MCC country rules, and physical SIM / eSIM profile bindings",
"点击“新增代理”创建 SOCKS5 上游代理,再配置国家规则或 ICCID 绑定;未匹配的卡默认直连。":
"Create a SOCKS5 upstream proxy, then configure country rules or ICCID bindings. Unmatched SIMs connect directly by default.",
"VoWiFi 通过此 Socks5 代理连接运营商,实现跨区域本地 VoWiFi。":
"VoWiFi connects to the carrier through this Socks5 proxy, enabling cross-region local VoWiFi. ",
+98 -11
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AddRegular } from "@fluentui/react-icons";
import { AddRegular, GlobeRegular } from "@fluentui/react-icons";
import { api, ApiError, apiMessage } from "../api";
import type { DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
import type { Country, CountryRule, DeviceListItem, DeviceProxyBinding, DevicesResponse, ProfileProxyCandidate, UpstreamProxy } from "../types";
import { usePolling } from "../lib/usePolling";
import { Button, PageHeader, confirmDialog, message } from "../components/ui";
import {
@@ -14,6 +14,7 @@ import {
} from "../components/proxy/shared";
import { UpstreamDialog } from "../components/proxy/UpstreamDialog";
import { DeviceBindingsDialog } from "../components/proxy/DeviceBindingsDialog";
import { CountryRulesDialog } from "../components/proxy/CountryRulesDialog";
import { UpstreamSection } from "../components/proxy/UpstreamSection";
import { tf, useI18n } from "../lib/i18n";
import { listPlugins, pluginAssetURL, type InstalledPlugin } from "../extensions";
@@ -24,11 +25,13 @@ interface BindingMutationResult {
}
export default function ProxyPage() {
const { t } = useI18n();
const { t, lang } = useI18n();
const [proxies, setProxies] = useState<UpstreamProxy[]>([]);
const [devices, setDevices] = useState<DeviceListItem[]>([]);
const [bindings, setBindings] = useState<DeviceProxyBinding[]>([]);
const [countries, setCountries] = useState<Country[]>([]);
const [countryRules, setCountryRules] = useState<CountryRule[]>([]);
const [upstreamLoading, setUpstreamLoading] = useState(true);
const [upstreamError, setUpstreamError] = useState<LoadError | null>(null);
const [upstreamDialogOpen, setUpstreamDialogOpen] = useState(false);
@@ -39,28 +42,46 @@ export default function ProxyPage() {
const [bindingsDialogOpen, setBindingsDialogOpen] = useState(false);
const [bindingsProxy, setBindingsProxy] = useState<UpstreamProxy | null>(null);
const [bindingBusy, setBindingBusy] = useState(false);
const [countryDialogOpen, setCountryDialogOpen] = useState(false);
const [countryBusy, setCountryBusy] = useState(false);
const [toggleBusyId, setToggleBusyId] = useState("");
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const proxyRows = useMemo<UpstreamRow[]>(
() => proxies.map((proxy) => ({
const regionNames = useMemo(() => {
try {
return new Intl.DisplayNames([lang === "zh" ? "zh-CN" : "en"], { type: "region" });
} catch {
return null;
}
}, [lang]);
const proxyRows = useMemo<UpstreamRow[]>(() => proxies.map((proxy) => {
const countryNames = countryRules
.filter((rule) => rule.enabled && rule.upstreamProxyId === proxy.id)
.map((rule) => regionNames?.of(rule.countryCode) || rule.countryName || rule.countryCode);
return {
...proxy,
bindingCount: bindings.filter((binding) => binding.upstreamProxyId === proxy.id).length,
})),
[proxies, bindings],
);
countryNames,
};
}), [proxies, bindings, countryRules, regionNames]);
const loadUpstream = useCallback(async (initial = false) => {
if (initial) setUpstreamLoading(true);
setUpstreamError(null);
try {
const [proxyList, bindingList, deviceList] = await Promise.all([
const [proxyList, bindingList, deviceList, countryList, ruleList] = await Promise.all([
api<UpstreamProxy[]>("/upstream-proxies"),
api<DeviceProxyBinding[]>("/upstream-proxy-profile-bindings"),
api<DevicesResponse>("/devices"),
api<Country[]>("/upstream-proxy-countries"),
api<CountryRule[]>("/upstream-proxy-country-rules"),
]);
setProxies(proxyList || []);
setBindings(bindingList || []);
setDevices(deviceList?.devices || []);
setCountries(countryList || []);
setCountryRules(ruleList || []);
} catch (error) {
setUpstreamError({ message: apiMessage(error), status: error instanceof ApiError ? error.status : undefined });
} finally {
@@ -165,6 +186,8 @@ export default function ProxyPage() {
{tf("确定删除上游代理“{name}”?", { name: proxy.name || proxy.id })}
<br />
{t("绑定到该代理的 Profile 将自动解绑并恢复直连。")}
<br />
{t("绑定到该代理的国家规则将自动删除,相关国家会恢复直连。")}
</>,
t("确认删除"),
{ confirmText: t("删除"), cancelText: t("取消"), type: "warning" },
@@ -174,6 +197,7 @@ export default function ProxyPage() {
await api(`/upstream-proxies/${proxy.id}`, { method: "DELETE" });
message.success(t("上游代理已删除"));
if (bindingsProxy?.id === proxy.id) setBindingsDialogOpen(false);
setCountryDialogOpen(false);
await loadUpstream(false);
} catch (error) {
message.error(apiMessage(error) || t("删除失败"));
@@ -185,6 +209,53 @@ export default function ProxyPage() {
setBindingsDialogOpen(true);
}, []);
const toggleUpstream = useCallback(async (proxy: UpstreamProxy) => {
const enabled = !proxy.enabled;
setToggleBusyId(proxy.id);
try {
const result = await api<BindingMutationResult>(`/upstream-proxies/${encodeURIComponent(proxy.id)}`, {
method: "PATCH",
body: { enabled },
});
if (result.reconnectError) {
message.warning(`${enabled ? t("代理已启用") : t("代理已禁用")}${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
} else if (enabled) {
message.success(t("代理已启用"));
} else {
message.success(t("代理已禁用;显式 ICCID 绑定将停止使用该线路且不会转为直连,尚未固化的 MCC 默认规则会回退直连"));
}
await loadUpstream(false);
} catch (error) {
message.error(apiMessage(error) || t("切换代理状态失败"));
} finally {
setToggleBusyId("");
}
}, [loadUpstream, t]);
const saveCountryRules = useCallback(async (assignments: Record<string, string>) => {
setCountryBusy(true);
const current = new Map(countryRules.map((rule) => [rule.countryCode, rule.upstreamProxyId]));
const changed = Object.entries(assignments).filter(([code, proxyID]) => proxyID && current.get(code) !== proxyID);
const removed = countryRules.filter((rule) => !assignments[rule.countryCode]);
try {
await Promise.all(changed.map(([code, proxyID]) => api(`/upstream-proxy-country-rules/${encodeURIComponent(code)}`, {
method: "PUT",
body: { upstreamProxyId: proxyID, enabled: true },
})));
await Promise.all(removed.map((rule) => api(`/upstream-proxy-country-rules/${encodeURIComponent(rule.countryCode)}`, {
method: "DELETE",
})));
message.success(t("国家规则已保存"));
await loadUpstream(false);
setCountryDialogOpen(false);
} catch (error) {
await loadUpstream(false);
message.error(apiMessage(error) || t("保存规则失败"));
} finally {
setCountryBusy(false);
}
}, [countryRules, loadUpstream, t]);
const showRouteChangeResult = useCallback((result: BindingMutationResult, successText: string) => {
if (result.reconnectError) {
message.warning(`${successText}${t("线路已保存,将在下次启动 VoWiFi 时应用")}`);
@@ -241,8 +312,13 @@ export default function ProxyPage() {
<div className="mx-auto max-w-7xl">
<PageHeader
title={t("代理管理")}
subtitle={t("管理 VoWiFi 上游代理以及实体 SIM / eSIM Profile 绑定")}
actions={<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>}
subtitle={t("管理 VoWiFi 上游代理、MCC 国家规则以及实体 SIM / eSIM Profile 绑定")}
actions={(
<div className="flex gap-2">
<Button icon={<GlobeRegular />} onClick={() => setCountryDialogOpen(true)}>{t("MCC 国家规则")}</Button>
<Button variant="primary" icon={<AddRegular />} onClick={() => openUpstreamDialog()}>{t("新增代理")}</Button>
</div>
)}
/>
<UpstreamSection
rows={proxyRows}
@@ -252,6 +328,8 @@ export default function ProxyPage() {
onEdit={openUpstreamDialog}
onDelete={removeUpstream}
onOpenBindings={openBindingsDialog}
onToggle={(proxy) => void toggleUpstream(proxy)}
toggleBusyId={toggleBusyId}
/>
{plugins.filter((plugin) => plugin.enabled).flatMap((plugin) =>
plugin.contributions.filter((contribution) => contribution.location === "proxy").map((contribution) => (
@@ -293,6 +371,15 @@ export default function ProxyPage() {
onDelete={(iccids) => void deleteProfileBindings(iccids)}
onClose={() => setBindingsDialogOpen(false)}
/>
<CountryRulesDialog
open={countryDialogOpen}
proxies={proxies}
countries={countries}
rules={countryRules}
busy={countryBusy}
onSave={(assignments) => void saveCountryRules(assignments)}
onClose={() => setCountryDialogOpen(false)}
/>
</div>
);
}