mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-17 05:13:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eee89a92a | ||
|
|
ae3a2a6eea | ||
|
|
505ee1eac0 | ||
|
|
b19ae2240a | ||
|
|
adf7de6d29 | ||
|
|
ffa0fd23b8 | ||
|
|
f014628048 |
+12
-13
@@ -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,26 +715,25 @@ func newVoWiFiOrchestrator(
|
||||
deviceConfig store.Device,
|
||||
database *store.Store,
|
||||
adapter vowifiDeviceAdapter,
|
||||
logger *slog.Logger,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
apn = "ims"
|
||||
}
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn})
|
||||
tunnelProvider, err := ike.NewProvider(ike.Config{
|
||||
APN: apn, Logger: logger, AutoProposalFallback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err)
|
||||
}
|
||||
imsProvider, err := ims.NewProvider(adapter, ims.Config{
|
||||
// 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",
|
||||
TransportByPLMN: map[string]string{
|
||||
"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",
|
||||
Logger: logger,
|
||||
// Carrier-specific transport and SMSC defaults live in the shared data
|
||||
// profile. Prefer network-provided P-CSCF hints, then safely try the
|
||||
// alternate transport only if no SIP response was observed.
|
||||
Transport: "tcp",
|
||||
AutoTransportFallback: true,
|
||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/vowifi"
|
||||
"vocat/internal/vowifi/integration"
|
||||
)
|
||||
|
||||
@@ -30,6 +31,10 @@ func (mapper nativeQMIControllerMapper) ReadNativeQMIIdentity(ctx context.Contex
|
||||
return mapper.Devices.ReadNativeQMIIdentity(ctx, physical)
|
||||
}
|
||||
|
||||
func (mapper nativeQMIControllerMapper) ReadSIMMetadata(ctx context.Context, id string) (vowifi.SIMMetadata, error) {
|
||||
return mapper.Mapper.ReadSIMMetadata(ctx, id)
|
||||
}
|
||||
|
||||
func (mapper nativeQMIControllerMapper) ProbeNativeQMIApplication(ctx context.Context, id, preference string) ([]byte, string, error) {
|
||||
physical, err := mapper.physical(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1686,56 +1686,60 @@ func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any {
|
||||
enabled, _ := extra["enabled"].(bool)
|
||||
active, _ := extra["active"].(bool)
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": runtime.Phase,
|
||||
"enabled": enabled,
|
||||
"active": active,
|
||||
"carrier_profile": extra["carrier_profile"],
|
||||
"carrier_profile_from": extra["carrier_profile_from"],
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": runtime.RegStatus,
|
||||
"reg_status_text": runtime.RegStatusText,
|
||||
"network_mode": runtime.NetworkMode,
|
||||
"local_phone": runtime.LocalPhone,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": rawJSONObject(runtime.Tunnel),
|
||||
"imscore": rawJSONObject(runtime.IMSCore),
|
||||
"smsip": rawJSONObject(runtime.SMSIP),
|
||||
}
|
||||
}
|
||||
|
||||
func liveVoWiFiRuntime(runtime vowifi.State) map[string]any {
|
||||
return map[string]any{
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"device_id": runtime.DeviceID,
|
||||
"phase": string(runtime.Phase),
|
||||
"enabled": runtime.Enabled,
|
||||
"active": runtime.Active,
|
||||
"carrier_profile": runtime.CarrierProfile,
|
||||
"carrier_profile_from": runtime.CarrierProfileFrom,
|
||||
"dataplane_mode": runtime.DataplaneMode,
|
||||
"iccid": runtime.ICCID,
|
||||
"imsi": runtime.IMSI,
|
||||
"sim_ready": runtime.SIMReady,
|
||||
"access_ready": runtime.AccessReady,
|
||||
"tunnel_ready": runtime.TunnelReady,
|
||||
"ims_ready": runtime.IMSReady,
|
||||
"sms_ready": runtime.SMSReady,
|
||||
"reg_status": map[bool]int{true: 1, false: 0}[runtime.IMSReady],
|
||||
"reg_status_text": map[bool]string{true: "registered", false: "not registered"}[runtime.IMSReady],
|
||||
"network_mode": "Wi-Fi",
|
||||
"local_phone": runtime.PhoneNumber,
|
||||
"phone_number_source": runtime.PhoneNumberSource,
|
||||
"last_error_class": runtime.LastErrorClass,
|
||||
"last_error": runtime.LastError,
|
||||
"last_reason": runtime.LastReason,
|
||||
"updated_at": runtime.UpdatedAt,
|
||||
"tunnel": map[string]any{
|
||||
"established": runtime.TunnelReady,
|
||||
"name": runtime.TunnelName,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -1,52 +1,338 @@
|
||||
package vowifi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const att310280EPDG = "epdg.epc.att.net"
|
||||
const (
|
||||
CarrierProfileStandard = "standard-3gpp"
|
||||
IKEProposalModern = "modern"
|
||||
IKEProposalLegacy = "legacy-sha1-modp1024"
|
||||
IMSProfileStandard = "standard"
|
||||
IMSProfileO2Germany = "o2-germany"
|
||||
IMSProfileATT = "att"
|
||||
)
|
||||
|
||||
// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without
|
||||
// changing the subscription PLMN used for AKA identities. Some multi-profile
|
||||
// and MVNO SIMs authenticate against their own HPLMN but use a host network's
|
||||
// VoWiFi access gateway.
|
||||
// CarrierProfile contains only interoperability choices that cannot be
|
||||
// reliably discovered from the SIM or negotiated with the network. All
|
||||
// protocol layers consume this common result so their carrier handling cannot
|
||||
// drift into separate MCC/MNC switch statements.
|
||||
type CarrierProfile struct {
|
||||
ID string
|
||||
MatchSource string
|
||||
RouteMCC string
|
||||
RouteMNC string
|
||||
EPDG string
|
||||
IKEProposal string
|
||||
AdvertiseEAPOnly bool
|
||||
IMSTransport string
|
||||
IMSIdentityProfile string
|
||||
IMSRegisterProfile string
|
||||
IMSIPSecEncryption string
|
||||
SMSCenter string
|
||||
}
|
||||
|
||||
type carrierProfileDocument struct {
|
||||
Version int `json:"version"`
|
||||
Profiles []carrierProfileRule `json:"profiles"`
|
||||
}
|
||||
|
||||
type carrierProfileRule struct {
|
||||
ID string `json:"id"`
|
||||
Match carrierProfileMatch `json:"match"`
|
||||
Route carrierProfileRoute `json:"route"`
|
||||
EPDG carrierProfileEPDG `json:"epdg"`
|
||||
IKE carrierProfileIKE `json:"ike"`
|
||||
IMS carrierProfileIMS `json:"ims"`
|
||||
}
|
||||
|
||||
type carrierProfileMatch struct {
|
||||
HomePLMNs []string `json:"home_plmns"`
|
||||
IMSIPrefixes []string `json:"imsi_prefixes"`
|
||||
ICCIDPrefixes []string `json:"iccid_prefixes"`
|
||||
SPNs []string `json:"spns"`
|
||||
GID1Prefixes []string `json:"gid1_prefixes"`
|
||||
GID2Prefixes []string `json:"gid2_prefixes"`
|
||||
}
|
||||
|
||||
type carrierProfileRoute struct {
|
||||
MCC string `json:"mcc"`
|
||||
MNC string `json:"mnc"`
|
||||
}
|
||||
|
||||
type carrierProfileEPDG struct {
|
||||
Hostname string `json:"hostname"`
|
||||
DNSHosts []string `json:"dns_hosts"`
|
||||
DNSClientSubnet string `json:"dns_client_subnet"`
|
||||
}
|
||||
|
||||
type carrierProfileIKE struct {
|
||||
Proposal string `json:"proposal"`
|
||||
AdvertiseEAPOnly *bool `json:"advertise_eap_only"`
|
||||
}
|
||||
|
||||
type carrierProfileIMS struct {
|
||||
Transport string `json:"transport"`
|
||||
IdentityProfile string `json:"identity_profile"`
|
||||
RegisterProfile string `json:"register_profile"`
|
||||
IPSecEncryption string `json:"ipsec_encryption"`
|
||||
SMSCenter string `json:"sms_center"`
|
||||
}
|
||||
|
||||
//go:embed carrier_profiles.json
|
||||
var carrierProfilesJSON []byte
|
||||
|
||||
var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON)
|
||||
|
||||
func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule {
|
||||
var document carrierProfileDocument
|
||||
if err := json.Unmarshal(encoded, &document); err != nil {
|
||||
panic("vowifi: invalid embedded carrier profiles: " + err.Error())
|
||||
}
|
||||
if document.Version != 1 {
|
||||
panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(document.Profiles))
|
||||
for index := range document.Profiles {
|
||||
rule := &document.Profiles[index]
|
||||
rule.ID = strings.TrimSpace(rule.ID)
|
||||
if rule.ID == "" {
|
||||
panic("vowifi: carrier profile ID is empty")
|
||||
}
|
||||
if _, duplicate := seen[rule.ID]; duplicate {
|
||||
panic("vowifi: duplicate carrier profile " + rule.ID)
|
||||
}
|
||||
seen[rule.ID] = struct{}{}
|
||||
if !validCarrierProfileRule(*rule) {
|
||||
panic("vowifi: invalid carrier profile " + rule.ID)
|
||||
}
|
||||
}
|
||||
return document.Profiles
|
||||
}
|
||||
|
||||
func validCarrierProfileRule(rule carrierProfileRule) bool {
|
||||
match := rule.Match
|
||||
if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+
|
||||
len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, plmn := range match.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (rule.Route.MCC == "") != (rule.Route.MNC == "") ||
|
||||
(rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") {
|
||||
return false
|
||||
}
|
||||
if proposal := strings.TrimSpace(rule.IKE.Proposal); proposal != "" &&
|
||||
proposal != IKEProposalModern && proposal != IKEProposalLegacy {
|
||||
return false
|
||||
}
|
||||
if transport := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); transport != "" &&
|
||||
transport != "tcp" && transport != "udp" {
|
||||
return false
|
||||
}
|
||||
if encryption := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); encryption != "" &&
|
||||
encryption != "aes-cbc" && encryption != "null" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||
// PLMN without weakening the default match for unrelated subscriptions.
|
||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
resolved := CarrierProfile{
|
||||
ID: CarrierProfileStandard,
|
||||
MatchSource: "standard",
|
||||
IKEProposal: IKEProposalModern,
|
||||
AdvertiseEAPOnly: true,
|
||||
IMSIdentityProfile: IMSProfileStandard,
|
||||
IMSRegisterProfile: IMSProfileStandard,
|
||||
IMSIPSecEncryption: "aes-cbc",
|
||||
}
|
||||
bestScore := -1
|
||||
for _, rule := range builtinCarrierProfiles {
|
||||
score, source, matched := matchCarrierProfile(rule.Match, identity)
|
||||
if !matched || score <= bestScore {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||
score := 0
|
||||
sources := make([]string, 0, 6)
|
||||
if len(match.HomePLMNs) > 0 {
|
||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
|
||||
return canonicalPLMNValue(value) == wanted
|
||||
}) {
|
||||
return 0, "", false
|
||||
}
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
}
|
||||
for _, selector := range []struct {
|
||||
name string
|
||||
weight int
|
||||
values []string
|
||||
actual string
|
||||
foldCase bool
|
||||
}{
|
||||
{name: "imsi", weight: 80, values: match.IMSIPrefixes, actual: identity.IMSI},
|
||||
{name: "iccid", weight: 70, values: match.ICCIDPrefixes, actual: identity.ICCID},
|
||||
{name: "gid1", weight: 50, values: match.GID1Prefixes, actual: identity.GID1, foldCase: true},
|
||||
{name: "gid2", weight: 40, values: match.GID2Prefixes, actual: identity.GID2, foldCase: true},
|
||||
} {
|
||||
if len(selector.values) == 0 {
|
||||
continue
|
||||
}
|
||||
actual := strings.TrimSpace(selector.actual)
|
||||
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if selector.foldCase {
|
||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||
}
|
||||
return strings.HasPrefix(actual, prefix)
|
||||
}) {
|
||||
return 0, "", false
|
||||
}
|
||||
score += selector.weight
|
||||
sources = append(sources, selector.name)
|
||||
}
|
||||
if len(match.SPNs) > 0 {
|
||||
spn := strings.TrimSpace(identity.SPN)
|
||||
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||
}) {
|
||||
return 0, "", false
|
||||
}
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
}
|
||||
return score, strings.Join(sources, "+"), score > 0
|
||||
}
|
||||
|
||||
func matchesAny(values []string, match func(string) bool) bool {
|
||||
for _, value := range values {
|
||||
if match(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
||||
base.ID = rule.ID
|
||||
base.MatchSource = source
|
||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
||||
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||
base.IKEProposal = value
|
||||
}
|
||||
if rule.IKE.AdvertiseEAPOnly != nil {
|
||||
base.AdvertiseEAPOnly = *rule.IKE.AdvertiseEAPOnly
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.Transport)); value != "" {
|
||||
base.IMSTransport = value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.IMS.IdentityProfile); value != "" {
|
||||
base.IMSIdentityProfile = value
|
||||
}
|
||||
if value := strings.TrimSpace(rule.IMS.RegisterProfile); value != "" {
|
||||
base.IMSRegisterProfile = value
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(rule.IMS.IPSecEncryption)); value != "" {
|
||||
base.IMSIPSecEncryption = value
|
||||
}
|
||||
base.SMSCenter = strings.TrimSpace(rule.IMS.SMSCenter)
|
||||
return base
|
||||
}
|
||||
|
||||
func canonicalPLMN(mcc, mnc string) string {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
if !isNDigits(mcc, 3, 3) || !isNDigits(mnc, 2, 3) {
|
||||
return ""
|
||||
}
|
||||
for len(mnc) < 3 {
|
||||
mnc = "0" + mnc
|
||||
}
|
||||
return mcc + mnc
|
||||
}
|
||||
|
||||
func canonicalPLMNValue(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "/", ""))
|
||||
if len(value) != 5 && len(value) != 6 {
|
||||
return ""
|
||||
}
|
||||
return canonicalPLMN(value[:3], value[3:])
|
||||
}
|
||||
|
||||
// AssignedRoutePLMN remains available to callers that only have the legacy
|
||||
// identifier pair. New code resolves the complete SIMIdentity so SPN/GID
|
||||
// selectors can participate.
|
||||
func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
imsi = strings.TrimSpace(imsi)
|
||||
switch {
|
||||
case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"):
|
||||
// XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG.
|
||||
return "234", "15", true
|
||||
case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"):
|
||||
// CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by
|
||||
// the initial VoWiFi provisioning path.
|
||||
return "234", "30", true
|
||||
default:
|
||||
return "", "", false
|
||||
identity := SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi)}
|
||||
if len(identity.IMSI) >= 5 {
|
||||
identity.HomeMCC = identity.IMSI[:3]
|
||||
for _, length := range []int{3, 2} {
|
||||
if len(identity.IMSI) < 3+length {
|
||||
continue
|
||||
}
|
||||
identity.HomeMNC = identity.IMSI[3 : 3+length]
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.RouteMCC != "" {
|
||||
return profile.RouteMCC, profile.RouteMNC, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// IsATT310280 reports whether the live subscription is on AT&T's three-digit
|
||||
// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot
|
||||
// drift between protocol layers.
|
||||
func IsATT310280(identity SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280")
|
||||
return ResolveCarrierProfile(identity).IMSRegisterProfile == IMSProfileATT
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
return identity
|
||||
}
|
||||
if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok {
|
||||
identity.EPDG = standardEPDGHostname(routeMCC, routeMNC)
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
switch {
|
||||
case profile.EPDG != "":
|
||||
identity.EPDG = profile.EPDG
|
||||
case profile.RouteMCC != "":
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||
func EPDGDNSClientSubnet(host string) string {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
for _, rule := range builtinCarrierProfiles {
|
||||
for _, candidate := range rule.EPDG.DNSHosts {
|
||||
if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) {
|
||||
return strings.TrimSpace(rule.EPDG.DNSClientSubnet)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func standardEPDGHostname(mcc, mnc string) string {
|
||||
mnc = strings.TrimSpace(mnc)
|
||||
for len(mnc) < 3 {
|
||||
|
||||
@@ -54,3 +54,47 @@ func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
||||
})
|
||||
if profile.ID != CarrierProfileStandard || profile.MatchSource != "standard" {
|
||||
t.Fatalf("default profile = %#v", profile)
|
||||
}
|
||||
if profile.IKEProposal != IKEProposalModern || !profile.AdvertiseEAPOnly ||
|
||||
profile.IMSIdentityProfile != IMSProfileStandard || profile.IMSRegisterProfile != IMSProfileStandard {
|
||||
t.Fatalf("default profile lost standard capabilities: %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
||||
})
|
||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
||||
t.Fatalf("MVNO profile = %#v", profile)
|
||||
}
|
||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc})
|
||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
||||
}
|
||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": [
|
||||
{
|
||||
"id": "xesim-lebara-vodafone-uk",
|
||||
"match": {
|
||||
"home_plmns": ["20404"],
|
||||
"imsi_prefixes": ["204047"],
|
||||
"iccid_prefixes": ["894416"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "15" },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||
},
|
||||
{
|
||||
"id": "ctexcel-ee-uk",
|
||||
"match": {
|
||||
"home_plmns": ["23433"],
|
||||
"imsi_prefixes": ["23433"],
|
||||
"iccid_prefixes": ["894430"]
|
||||
},
|
||||
"route": { "mcc": "234", "mnc": "30" }
|
||||
},
|
||||
{
|
||||
"id": "att-us",
|
||||
"match": {
|
||||
"home_plmns": ["310280"],
|
||||
"imsi_prefixes": ["310280"]
|
||||
},
|
||||
"epdg": { "hostname": "epdg.epc.att.net" },
|
||||
"ims": {
|
||||
"identity_profile": "att",
|
||||
"register_profile": "att",
|
||||
"ipsec_encryption": "aes-cbc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "o2-germany",
|
||||
"match": { "home_plmns": ["26203"] },
|
||||
"ike": { "advertise_eap_only": false },
|
||||
"ims": {
|
||||
"register_profile": "o2-germany",
|
||||
"ipsec_encryption": "null"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vodafone-uk",
|
||||
"match": { "home_plmns": ["23415"] },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" },
|
||||
"ims": { "sms_center": "+447785016005" }
|
||||
},
|
||||
{
|
||||
"id": "vodafone-netherlands",
|
||||
"match": { "home_plmns": ["20404"] },
|
||||
"ike": { "proposal": "legacy-sha1-modp1024" }
|
||||
},
|
||||
{
|
||||
"id": "o2-uk",
|
||||
"match": { "home_plmns": ["23410"] },
|
||||
"ims": {
|
||||
"transport": "udp",
|
||||
"sms_center": "+447802000332"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vodafone-germany",
|
||||
"match": { "home_plmns": ["26202"] },
|
||||
"epdg": {
|
||||
"dns_hosts": ["epdg.epc.mnc002.mcc262.pub.3gppnetwork.org"],
|
||||
"dns_client_subnet": "109.192.0.0/24"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -173,6 +173,13 @@ func (adapter *EC20Adapter) ReadIdentity(
|
||||
HomeMCC: homeMCC,
|
||||
HomeMNC: homeMNC,
|
||||
}
|
||||
if reader, ok := adapter.executor.(SIMMetadataReader); ok {
|
||||
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||
}
|
||||
}
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
adapter.mu.Lock()
|
||||
adapter.bindings[iccid] = ec20SIMBinding{
|
||||
|
||||
@@ -10,19 +10,12 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const googleDNSOverHTTPS = "https://dns.google/resolve"
|
||||
|
||||
// A small number of operators publish the standard ePDG CNAME globally but
|
||||
// return its A records only when the recursive DNS query appears to originate
|
||||
// in the home country. Keep this list deliberately narrow: ordinary ePDGs must
|
||||
// continue to use the host resolver, and a fallback is attempted only after
|
||||
// that resolver has failed.
|
||||
var geoRestrictedEPDGSubnets = map[string]string{
|
||||
"epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany
|
||||
}
|
||||
|
||||
type dnsOverHTTPSResponse struct {
|
||||
Status int `json:"Status"`
|
||||
Answer []struct {
|
||||
@@ -41,7 +34,7 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
subnet := geoRestrictedEPDGSubnets[normalized]
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
if subnet == "" {
|
||||
if systemErr != nil {
|
||||
return nil, systemErr
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,17 +21,19 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Random io.Reader
|
||||
Resolver *net.Resolver
|
||||
Dialer *net.Dialer
|
||||
RootCAs *x509.CertPool
|
||||
ResponderPublicKey crypto.PublicKey
|
||||
ServerName string
|
||||
Timeout time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
Installer ChildSAInstaller
|
||||
IdentityType uint8
|
||||
APN string
|
||||
Random io.Reader
|
||||
Resolver *net.Resolver
|
||||
Dialer *net.Dialer
|
||||
RootCAs *x509.CertPool
|
||||
ResponderPublicKey crypto.PublicKey
|
||||
ServerName string
|
||||
Timeout time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
Installer ChildSAInstaller
|
||||
IdentityType uint8
|
||||
APN string
|
||||
AutoProposalFallback bool
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
@@ -42,6 +45,9 @@ func NewProvider(config Config) (*Provider, error) {
|
||||
if config.Random == nil {
|
||||
config.Random = rand.Reader
|
||||
}
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
if config.Resolver == nil {
|
||||
config.Resolver = net.DefaultResolver
|
||||
}
|
||||
@@ -77,6 +83,30 @@ func NewProvider(config Config) (*Provider, error) {
|
||||
}
|
||||
|
||||
func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("ike: nil provider")
|
||||
}
|
||||
session, err := provider.start(ctx, request, false)
|
||||
if err == nil || !provider.config.AutoProposalFallback {
|
||||
return session, err
|
||||
}
|
||||
profile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||
if profile.ID != vowifi.CarrierProfileStandard || !retryableLegacyProposal(err) {
|
||||
return nil, err
|
||||
}
|
||||
provider.config.Logger.Warn("IKE ePDG rejected modern proposal; trying bounded legacy fallback",
|
||||
"carrier_profile", profile.ID, "from_proposal", vowifi.IKEProposalModern,
|
||||
"to_proposal", vowifi.IKEProposalLegacy, "error", err)
|
||||
session, fallbackErr := provider.start(ctx, request, true)
|
||||
if fallbackErr != nil {
|
||||
return nil, errors.Join(err, fmt.Errorf("ike: legacy proposal fallback failed: %w", fallbackErr))
|
||||
}
|
||||
provider.config.Logger.Info("IKE automatic legacy proposal fallback succeeded",
|
||||
"carrier_profile", profile.ID, "proposal", vowifi.IKEProposalLegacy)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) start(ctx context.Context, request vowifi.TunnelRequest, forceLegacy bool) (vowifi.TunnelSession, error) {
|
||||
if provider == nil {
|
||||
return nil, errors.New("ike: nil provider")
|
||||
}
|
||||
@@ -110,8 +140,12 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
}()
|
||||
|
||||
group := uint16(dhMODP2048)
|
||||
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
carrierProfile := vowifi.ResolveCarrierProfile(request.Identity)
|
||||
legacyFirst := carrierProfile.IKEProposal == vowifi.IKEProposalLegacy
|
||||
if forceLegacy {
|
||||
legacyFirst = true
|
||||
}
|
||||
advertiseEAPOnly := carrierProfile.AdvertiseEAPOnly
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -582,30 +616,6 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func legacyIKEProfile(mcc, mnc string) bool {
|
||||
// Vodafone's UK and Netherlands ePDGs use the legacy group-2/SHA-1-first
|
||||
// proposal ordering. Some Lebara UK subscriptions carry a 204-04 IMSI from
|
||||
// that Vodafone NL core; treating them as a generic modern network causes
|
||||
// IKE_SA_INIT to fail before EAP-AKA even begins.
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func advertiseEAPOnlyAuthentication(mcc, mnc string) bool {
|
||||
// Android exposes the ePDG authentication method as carrier policy rather
|
||||
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
|
||||
// Germany's 262-03 ePDG rejects an initial IKE_AUTH that explicitly carries
|
||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
||||
}
|
||||
|
||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
||||
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
|
||||
return plmn == "2623"
|
||||
}
|
||||
|
||||
func buildInitialEAPAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -719,6 +729,27 @@ func decryptAndValidate(
|
||||
return header, payloads, nil
|
||||
}
|
||||
|
||||
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
|
||||
type invalidKEPayloadError struct {
|
||||
group uint16
|
||||
}
|
||||
|
||||
func (err *invalidKEPayloadError) Error() string {
|
||||
if err.group != 0 {
|
||||
return fmt.Sprintf("ike: responder requires DH group %d", err.group)
|
||||
}
|
||||
return "ike: responder reported INVALID_KE_PAYLOAD"
|
||||
}
|
||||
|
||||
func retryableLegacyProposal(err error) bool {
|
||||
if errors.Is(err, errNoProposalChosen) {
|
||||
return true
|
||||
}
|
||||
var invalidKE *invalidKEPayloadError
|
||||
return errors.As(err, &invalidKE) && (invalidKE.group == 0 || invalidKE.group == dhMODP1024)
|
||||
}
|
||||
|
||||
func rejectFatalNotifications(payloads []payload) error {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, data, err := parseNotify(item)
|
||||
@@ -727,12 +758,12 @@ func rejectFatalNotifications(payloads []payload) error {
|
||||
}
|
||||
switch kind {
|
||||
case notifyNoProposal:
|
||||
return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||
return errNoProposalChosen
|
||||
case notifyInvalidKE:
|
||||
if len(data) == 2 {
|
||||
return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data))
|
||||
return &invalidKEPayloadError{group: binary.BigEndian.Uint16(data)}
|
||||
}
|
||||
return errors.New("ike: responder reported INVALID_KE_PAYLOAD")
|
||||
return &invalidKEPayloadError{}
|
||||
}
|
||||
if kind < 16384 {
|
||||
return fmt.Errorf("ike: responder reported fatal notification %d", kind)
|
||||
|
||||
@@ -25,15 +25,37 @@ func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
||||
{mcc: "204", mnc: "04"},
|
||||
{mcc: "204", mnc: "004"},
|
||||
} {
|
||||
if !legacyIKEProfile(item.mcc, item.mnc) {
|
||||
t.Errorf("legacyIKEProfile(%q, %q) = false", item.mcc, item.mnc)
|
||||
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||
}
|
||||
}
|
||||
if legacyIKEProfile("234", "87") {
|
||||
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errNoProposalChosen,
|
||||
&invalidKEPayloadError{},
|
||||
&invalidKEPayloadError{group: dhMODP1024},
|
||||
} {
|
||||
if !retryableLegacyProposal(err) {
|
||||
t.Errorf("negotiation failure %v was not retryable", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{
|
||||
&invalidKEPayloadError{group: dhMODP2048},
|
||||
errors.New("ike: authentication failed"),
|
||||
vowifi.ErrEAPAuthenticationRejected,
|
||||
} {
|
||||
if retryableLegacyProposal(err) {
|
||||
t.Errorf("unsafe failure %v enabled legacy retry", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (reader constantReader) Read(destination []byte) (int, error) {
|
||||
for index := range destination {
|
||||
destination[index] = reader.value
|
||||
|
||||
@@ -171,11 +171,12 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
||||
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
||||
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
+201
-58
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -35,38 +36,50 @@ var (
|
||||
// LocalAddress is empty, Provider uses the corresponding value proven by the
|
||||
// TunnelSession. The default transport is TCP and the default port is 5060.
|
||||
type Config struct {
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
PrivateIdentity string
|
||||
PublicIdentity string
|
||||
UserAgent string
|
||||
SecurityMode SecurityMode
|
||||
IPSecInstaller IPSecSAInstaller
|
||||
ProtectedClientPort int
|
||||
ProtectedServerPort int
|
||||
PCSCF string
|
||||
LocalAddress string
|
||||
Transport string
|
||||
TransportByPLMN map[string]string
|
||||
// AutoTransportFallback tries the alternate TCP/UDP transport only when
|
||||
// the initial P-CSCF attempt produced no SIP response at all. A challenge
|
||||
// or rejection is authoritative and is never retried as another transport.
|
||||
AutoTransportFallback bool
|
||||
Port int
|
||||
RegistrationExpiry time.Duration
|
||||
TransactionTimeout time.Duration
|
||||
PrivateIdentity string
|
||||
PublicIdentity string
|
||||
UserAgent string
|
||||
SecurityMode SecurityMode
|
||||
IPSecInstaller IPSecSAInstaller
|
||||
ProtectedClientPort int
|
||||
ProtectedServerPort int
|
||||
// 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
|
||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||
// runtime dependency outside the Go standard library.
|
||||
type Provider struct {
|
||||
aka vowifi.AKAProvider
|
||||
config Config
|
||||
installer IPSecSAInstaller
|
||||
aka vowifi.AKAProvider
|
||||
config Config
|
||||
installer IPSecSAInstaller
|
||||
transportMu sync.RWMutex
|
||||
transportCache map[string]string
|
||||
}
|
||||
|
||||
func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||
@@ -81,10 +94,16 @@ func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) {
|
||||
if installer == nil {
|
||||
installer = defaultIPSecInstaller()
|
||||
}
|
||||
return &Provider{aka: aka, config: normalized, installer: installer}, nil
|
||||
return &Provider{
|
||||
aka: aka, config: normalized, installer: installer,
|
||||
transportCache: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeConfig(config Config) (Config, error) {
|
||||
if config.Logger == nil {
|
||||
config.Logger = slog.Default()
|
||||
}
|
||||
if config.Port == 0 {
|
||||
config.Port = defaultSIPPort
|
||||
}
|
||||
@@ -120,6 +139,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 +188,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()
|
||||
@@ -199,10 +233,17 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) {
|
||||
return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel")
|
||||
}
|
||||
transport := transportForIdentity(provider.config, request.Identity)
|
||||
if transport == "" {
|
||||
transport, carrierSelected := carrierTransportForIdentity(provider.config, request.Identity)
|
||||
if cached := provider.cachedTransport(request.Identity); cached != "" {
|
||||
transport = cached
|
||||
carrierSelected = true
|
||||
}
|
||||
if transport == "" && !carrierSelected {
|
||||
transport = transportHint
|
||||
}
|
||||
if transport == "" {
|
||||
transport = provider.config.Transport
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "tcp"
|
||||
}
|
||||
@@ -225,31 +266,96 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||
}
|
||||
|
||||
connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err)
|
||||
transports := []string{transport}
|
||||
if provider.config.AutoTransportFallback {
|
||||
alternate := "udp"
|
||||
if transport == "udp" {
|
||||
alternate = "tcp"
|
||||
}
|
||||
transports = append(transports, alternate)
|
||||
}
|
||||
session, err := newSession(provider, request, identities, endpoint, transport, connection)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := session.establish(ctx); err != nil {
|
||||
var lastErr error
|
||||
for attempt, candidate := range transports {
|
||||
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||
if dialErr != nil {
|
||||
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||
if sessionErr != nil {
|
||||
_ = connection.Close()
|
||||
return nil, sessionErr
|
||||
}
|
||||
establishErr := session.establish(ctx)
|
||||
if establishErr == nil {
|
||||
provider.rememberTransport(request.Identity, candidate)
|
||||
if attempt > 0 {
|
||||
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
||||
"transport", candidate)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
sipResponseObserved := session.evidence.LastSIPCode != 0
|
||||
session.abort()
|
||||
return nil, err
|
||||
lastErr = establishErr
|
||||
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||
}
|
||||
return session, nil
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func transportForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
if transport, selected := carrierTransportForIdentity(config, identity); selected {
|
||||
return transport
|
||||
}
|
||||
return config.Transport
|
||||
}
|
||||
|
||||
func carrierTransportForIdentity(config Config, identity vowifi.SIMIdentity) (string, bool) {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
||||
if transport := config.TransportByPLMN[mcc+mnc]; transport != "" {
|
||||
return transport, true
|
||||
}
|
||||
if transport := vowifi.ResolveCarrierProfile(identity).IMSTransport; transport != "" {
|
||||
return transport, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func transportCacheKey(identity vowifi.SIMIdentity) string {
|
||||
if iccid := strings.TrimSpace(identity.ICCID); iccid != "" {
|
||||
return "iccid:" + iccid
|
||||
}
|
||||
return "plmn:" + strings.TrimSpace(identity.HomeMCC) + "/" + strings.TrimSpace(identity.HomeMNC)
|
||||
}
|
||||
|
||||
func (provider *Provider) cachedTransport(identity vowifi.SIMIdentity) string {
|
||||
provider.transportMu.RLock()
|
||||
transport := provider.transportCache[transportCacheKey(identity)]
|
||||
provider.transportMu.RUnlock()
|
||||
return transport
|
||||
}
|
||||
|
||||
func (provider *Provider) rememberTransport(identity vowifi.SIMIdentity, transport string) {
|
||||
provider.transportMu.Lock()
|
||||
provider.transportCache[transportCacheKey(identity)] = transport
|
||||
provider.transportMu.Unlock()
|
||||
}
|
||||
|
||||
func (provider *Provider) logTransportFallback(identity vowifi.SIMIdentity, from, to string, err error) {
|
||||
provider.config.Logger.Warn("IMS P-CSCF did not respond; trying alternate SIP transport",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(identity).ID,
|
||||
"from_transport", from, "to_transport", to, "error", err)
|
||||
}
|
||||
|
||||
type identitySet struct {
|
||||
domain string
|
||||
private string
|
||||
@@ -273,7 +379,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||
privateDomain := domain
|
||||
publicDomain := domain
|
||||
if vowifi.IsATT310280(identity) {
|
||||
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||
// the generic 3GPP PLMN IMS domain.
|
||||
domain = "one.att.net"
|
||||
@@ -595,18 +701,11 @@ func newSession(
|
||||
}
|
||||
|
||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
if usesO2GermanyIMSProfile(identity) {
|
||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSIPSecEncryption
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
return mcc+mnc == "2623"
|
||||
return vowifi.ResolveCarrierProfile(identity).IMSRegisterProfile == vowifi.IMSProfileO2Germany
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
@@ -926,19 +1025,33 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
method: "REGISTER",
|
||||
})
|
||||
}
|
||||
deadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
||||
deadline = contextDeadline
|
||||
transactionDeadline := time.Now().Add(session.provider.config.TransactionTimeout)
|
||||
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(transactionDeadline) {
|
||||
transactionDeadline = contextDeadline
|
||||
}
|
||||
readUDP := session.protectedUDP
|
||||
protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil
|
||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||
}
|
||||
if protectedUDP {
|
||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||
return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||
setReadDeadline := func(deadline time.Time) error {
|
||||
if err := session.conn.SetDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("ims: set SIP transaction deadline: %w", err)
|
||||
}
|
||||
if protectedUDP {
|
||||
if err := readUDP.SetReadDeadline(deadline); err != nil {
|
||||
return fmt.Errorf("ims: set protected SIP receive deadline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
retransmitInterval := time.Duration(0)
|
||||
readDeadline := transactionDeadline
|
||||
if session.transport == "udp" {
|
||||
retransmitInterval = sipMessageRetransmitT1
|
||||
if candidate := time.Now().Add(retransmitInterval); candidate.Before(readDeadline) {
|
||||
readDeadline = candidate
|
||||
}
|
||||
}
|
||||
if err := setReadDeadline(readDeadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stopCancellation := context.AfterFunc(ctx, func() {
|
||||
_ = session.conn.SetDeadline(time.Now())
|
||||
@@ -951,6 +1064,7 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err)
|
||||
}
|
||||
|
||||
retransmissions := 0
|
||||
for {
|
||||
var response *sipResponse
|
||||
var err error
|
||||
@@ -979,6 +1093,31 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
return nil, contextErr
|
||||
}
|
||||
var networkErr net.Error
|
||||
if retransmitInterval > 0 && errors.As(err, &networkErr) && networkErr.Timeout() &&
|
||||
time.Now().Before(transactionDeadline) {
|
||||
retransmitInterval *= 2
|
||||
if retransmitInterval > sipMessageRetransmitMax {
|
||||
retransmitInterval = sipMessageRetransmitMax
|
||||
}
|
||||
nextDeadline := time.Now().Add(retransmitInterval)
|
||||
if nextDeadline.After(transactionDeadline) {
|
||||
nextDeadline = transactionDeadline
|
||||
}
|
||||
// The previous read deadline has already expired and net.Conn applies
|
||||
// it to writes too. Extend it before retransmitting.
|
||||
if deadlineErr := setReadDeadline(nextDeadline); deadlineErr != nil {
|
||||
return nil, deadlineErr
|
||||
}
|
||||
if _, writeErr := session.conn.Write(request); writeErr != nil {
|
||||
return nil, fmt.Errorf("ims: retransmit SIP REGISTER: %w", writeErr)
|
||||
}
|
||||
retransmissions++
|
||||
session.provider.config.Logger.Debug("IMS SIP REGISTER retransmitted",
|
||||
"carrier_profile", vowifi.ResolveCarrierProfile(session.request.Identity).ID,
|
||||
"transport", session.transport, "attempt", retransmissions)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) {
|
||||
@@ -989,6 +1128,10 @@ func (session *Session) exchange(ctx context.Context, request []byte, cseq uint3
|
||||
continue
|
||||
}
|
||||
if response.StatusCode >= 100 && response.StatusCode < 200 {
|
||||
retransmitInterval = 0
|
||||
if err := setReadDeadline(transactionDeadline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response, nil
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +20,40 @@ type evidenceTunnel struct {
|
||||
evidence vowifi.TunnelEvidence
|
||||
}
|
||||
|
||||
type immediateTimeoutError struct{}
|
||||
|
||||
func (immediateTimeoutError) Error() string { return "test timeout" }
|
||||
func (immediateTimeoutError) Timeout() bool { return true }
|
||||
func (immediateTimeoutError) Temporary() bool { return true }
|
||||
|
||||
type registerRetransmitConn struct {
|
||||
writes int
|
||||
response []byte
|
||||
}
|
||||
|
||||
func (connection *registerRetransmitConn) Read(destination []byte) (int, error) {
|
||||
if connection.writes < 2 {
|
||||
return 0, immediateTimeoutError{}
|
||||
}
|
||||
return copy(destination, connection.response), nil
|
||||
}
|
||||
|
||||
func (connection *registerRetransmitConn) Write(source []byte) (int, error) {
|
||||
connection.writes++
|
||||
return len(source), nil
|
||||
}
|
||||
|
||||
func (*registerRetransmitConn) Close() error { return nil }
|
||||
func (*registerRetransmitConn) LocalAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 5060}
|
||||
}
|
||||
func (*registerRetransmitConn) RemoteAddr() net.Addr {
|
||||
return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 5060}
|
||||
}
|
||||
func (*registerRetransmitConn) SetDeadline(time.Time) error { return nil }
|
||||
func (*registerRetransmitConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (*registerRetransmitConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence {
|
||||
return tunnel.evidence
|
||||
}
|
||||
@@ -73,6 +109,82 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
||||
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
||||
}
|
||||
if got := transportForIdentity(Config{
|
||||
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
||||
}, identity); got != "tcp" {
|
||||
t.Fatalf("explicit configuration did not override profile: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCachesSuccessfulTransportPerSIM(t *testing.T) {
|
||||
t.Parallel()
|
||||
provider := &Provider{transportCache: make(map[string]string)}
|
||||
first := vowifi.SIMIdentity{ICCID: "8901000000000000001", HomeMCC: "001", HomeMNC: "01"}
|
||||
second := vowifi.SIMIdentity{ICCID: "8901000000000000002", HomeMCC: "001", HomeMNC: "01"}
|
||||
provider.rememberTransport(first, "udp")
|
||||
if got := provider.cachedTransport(first); got != "udp" {
|
||||
t.Fatalf("cached first transport = %q", got)
|
||||
}
|
||||
if got := provider.cachedTransport(second); got != "" {
|
||||
t.Fatalf("second SIM inherited cached transport %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPRegisterRetransmitsBeforeTransactionTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
connection := ®isterRetransmitConn{response: []byte(strings.Join([]string{
|
||||
"SIP/2.0 200 OK",
|
||||
"Call-ID: register-retransmit-test",
|
||||
"CSeq: 7 REGISTER",
|
||||
"Content-Length: 0",
|
||||
"",
|
||||
"",
|
||||
}, "\r\n"))}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
TransactionTimeout: 3 * time.Second,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"}},
|
||||
transport: "udp",
|
||||
conn: connection,
|
||||
callID: "register-retransmit-test",
|
||||
}
|
||||
response, err := session.exchange(context.Background(), []byte("REGISTER test"), 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != 200 || connection.writes != 2 {
|
||||
t.Fatalf("response=%#v writes=%d, want SIP 200 after one retransmission", response, connection.writes)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,63 @@ 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)
|
||||
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||
return configured
|
||||
}
|
||||
return strings.TrimSpace(vowifi.ResolveCarrierProfile(identity).SMSCenter)
|
||||
}
|
||||
|
||||
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 +842,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 +855,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 {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package ims
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -100,6 +103,127 @@ 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 TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
mnc string
|
||||
want string
|
||||
}{
|
||||
{mnc: "10", 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("profile SMSC for 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 +326,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 +352,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 +434,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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"vocat/internal/device"
|
||||
"vocat/internal/modem"
|
||||
"vocat/internal/store"
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
type ATDeviceController interface {
|
||||
@@ -73,6 +74,28 @@ func (mapper ATMapper) ExecuteSensitiveAT(
|
||||
return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command)
|
||||
}
|
||||
|
||||
// ReadSIMMetadata reuses the device manager's per-ICCID EF cache. VoWiFi
|
||||
// identity discovery therefore gains Android-style SPN/GID MVNO selectors
|
||||
// without issuing duplicate APDUs on every reconnect.
|
||||
func (mapper ATMapper) ReadSIMMetadata(ctx context.Context, configuredID string) (vowifi.SIMMetadata, error) {
|
||||
physicalID, err := mapper.resolve(ctx, configuredID)
|
||||
if err != nil {
|
||||
return vowifi.SIMMetadata{}, err
|
||||
}
|
||||
entry, err := mapper.Devices.Get(physicalID)
|
||||
if err != nil {
|
||||
return vowifi.SIMMetadata{}, err
|
||||
}
|
||||
if entry.Snapshot == nil {
|
||||
return vowifi.SIMMetadata{}, nil
|
||||
}
|
||||
return vowifi.SIMMetadata{
|
||||
SPN: strings.TrimSpace(entry.Snapshot.SPN),
|
||||
GID1: strings.TrimSpace(entry.Snapshot.GID1),
|
||||
GID2: strings.TrimSpace(entry.Snapshot.GID2),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (mapper ATMapper) resolve(
|
||||
ctx context.Context,
|
||||
configuredID string,
|
||||
|
||||
@@ -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,
|
||||
@@ -198,6 +234,8 @@ func (projector StateProjector) Save(
|
||||
"pure_airplane_policy": state.PureAirplanePolicy,
|
||||
"home_mcc": state.HomeMCC,
|
||||
"home_mnc": state.HomeMNC,
|
||||
"carrier_profile": state.CarrierProfile,
|
||||
"carrier_profile_from": state.CarrierProfileFrom,
|
||||
"warnings": state.Warnings,
|
||||
"cleanup_errors": state.CleanupErrors,
|
||||
"attempt": state.Attempt,
|
||||
|
||||
@@ -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{
|
||||
@@ -216,13 +351,15 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
||||
}
|
||||
projector := StateProjector{Store: database}
|
||||
if err := projector.Save(context.Background(), vowifi.State{
|
||||
DeviceID: "ec25",
|
||||
Phase: vowifi.PhaseIMSReady,
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
TunnelName: "vocat-swu-ec25",
|
||||
DataplaneMode: "userspace",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
DeviceID: "ec25",
|
||||
Phase: vowifi.PhaseIMSReady,
|
||||
TunnelReady: true,
|
||||
IMSReady: true,
|
||||
TunnelName: "vocat-swu-ec25",
|
||||
DataplaneMode: "userspace",
|
||||
CarrierProfile: "vodafone-uk",
|
||||
CarrierProfileFrom: "hplmn",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,6 +377,13 @@ func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) {
|
||||
if tunnel["dataplane_mode"] != "userspace" {
|
||||
t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"])
|
||||
}
|
||||
var extra map[string]any
|
||||
if err := json.Unmarshal(runtime.Extra, &extra); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extra["carrier_profile"] != "vodafone-uk" || extra["carrier_profile_from"] != "hplmn" {
|
||||
t.Fatalf("carrier profile projection = %#v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
||||
|
||||
@@ -56,6 +56,14 @@ func (adapter *NativeQMIAdapter) ReadIdentity(ctx context.Context, deviceID stri
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{ICCID: strings.TrimSpace(iccid), IMSI: strings.TrimSpace(imsi), IMEI: strings.TrimSpace(imei), HomeMCC: strings.TrimSpace(mcc), HomeMNC: strings.TrimSpace(mnc)})
|
||||
if reader, ok := adapter.controller.(SIMMetadataReader); ok {
|
||||
if metadata, metadataErr := reader.ReadSIMMetadata(ctx, deviceID); metadataErr == nil {
|
||||
identity.SPN = strings.TrimSpace(metadata.SPN)
|
||||
identity.GID1 = strings.TrimSpace(metadata.GID1)
|
||||
identity.GID2 = strings.TrimSpace(metadata.GID2)
|
||||
identity = applyAssignedCarrierRoute(identity)
|
||||
}
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return SIMIdentity{}, err
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
|
||||
}
|
||||
}
|
||||
carrierProfile := ResolveCarrierProfile(identity)
|
||||
orchestrator.mutate(func(state *State) {
|
||||
state.Phase = PhaseSIMReady
|
||||
state.ICCID = strings.TrimSpace(identity.ICCID)
|
||||
@@ -248,6 +249,8 @@ func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
|
||||
state.SIMReady = true
|
||||
state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
|
||||
state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
|
||||
state.CarrierProfile = carrierProfile.ID
|
||||
state.CarrierProfileFrom = carrierProfile.MatchSource
|
||||
state.LastReason = "sim_and_aka_ready"
|
||||
})
|
||||
|
||||
@@ -645,8 +648,12 @@ func DeriveEPDG(identity SIMIdentity) (string, error) {
|
||||
}
|
||||
return strings.ToLower(configured), nil
|
||||
}
|
||||
if IsATT310280(identity) {
|
||||
return att310280EPDG, nil
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.EPDG != "" {
|
||||
return profile.EPDG, nil
|
||||
}
|
||||
if profile.RouteMCC != "" {
|
||||
return standardEPDGHostname(profile.RouteMCC, profile.RouteMNC), nil
|
||||
}
|
||||
if err := identity.validate(); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -53,7 +53,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
mncLength := identity.MNCLength
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil
|
||||
return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC, SPN: identity.SPN}), nil
|
||||
}
|
||||
return SIMIdentity{}, ErrEC20MNCUnavailable
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (
|
||||
return applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: identity.ICCID, IMSI: identity.IMSI,
|
||||
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
|
||||
SMSC: identity.SMSC,
|
||||
SMSC: identity.SMSC, SPN: identity.SPN,
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ type State struct {
|
||||
PureAirplanePolicy bool `json:"pure_airplane_policy"`
|
||||
HomeMCC string `json:"home_mcc,omitempty"`
|
||||
HomeMNC string `json:"home_mnc,omitempty"`
|
||||
CarrierProfile string `json:"carrier_profile,omitempty"`
|
||||
CarrierProfileFrom string `json:"carrier_profile_from,omitempty"`
|
||||
EPDG string `json:"epdg,omitempty"`
|
||||
ProxyMode ProxyMode `json:"proxy_mode,omitempty"`
|
||||
ProxyID string `json:"proxy_id,omitempty"`
|
||||
@@ -137,6 +139,9 @@ type SIMIdentity struct {
|
||||
HomeMCC string
|
||||
HomeMNC string
|
||||
HomeCountryCode string
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
EPDG string
|
||||
// SMSC is the TS-Service-Centre address used to build SMS-over-IMS
|
||||
// RP-DATA. It is optional during identity discovery, but IMS submission
|
||||
@@ -304,6 +309,22 @@ type SIMIdentityReader interface {
|
||||
ReadIdentity(context.Context, string) (SIMIdentity, error)
|
||||
}
|
||||
|
||||
// SIMMetadata contains optional, non-secret carrier selectors stored by the
|
||||
// UICC. They improve MVNO matching but are never required for AKA or exposed in
|
||||
// the public runtime state.
|
||||
type SIMMetadata struct {
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
}
|
||||
|
||||
// SIMMetadataReader is an optional companion implemented by device mappers
|
||||
// that already cache EF_SPN and EF_GID1/2. Identity readers degrade to PLMN,
|
||||
// IMSI and ICCID matching when it is unavailable.
|
||||
type SIMMetadataReader interface {
|
||||
ReadSIMMetadata(context.Context, string) (SIMMetadata, error)
|
||||
}
|
||||
|
||||
// SMSCenterReader optionally supplies the SIM-configured service-centre
|
||||
// address needed for mobile-originated SMS over IMS.
|
||||
type SMSCenterReader interface {
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,8 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
||||
</div>
|
||||
) : null}
|
||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
||||
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||
{rt?.lastError ? <FieldRow label={t("错误详情")} value={rt.lastError} monospace copyable /> : null}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface UpstreamProbeResult {
|
||||
|
||||
export interface UpstreamRow extends UpstreamProxy {
|
||||
bindingCount: number;
|
||||
countryNames: string[];
|
||||
}
|
||||
|
||||
export function ipv6Hint(): string {
|
||||
|
||||
@@ -780,6 +780,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"改动将在此卡激活后生效": "Changes take effect once this card is activated",
|
||||
"数据未开启": "Data is off",
|
||||
"数据平面": "Data Plane",
|
||||
"运营商配置": "Carrier Profile",
|
||||
"匹配依据": "Profile Match",
|
||||
"方向": "Direction",
|
||||
"无法读取 IMEI(控制口可能挂死),暂不可添加。": "Cannot read the IMEI (the control port may be stuck); cannot add for now.",
|
||||
"未找到可用的 AT 端口(串口可能仍在枚举),系统会自动重试;也可点击重新扫描。":
|
||||
@@ -1005,6 +1007,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 +1022,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface VoWiFiRuntime {
|
||||
phase: string;
|
||||
enabled?: boolean;
|
||||
active?: boolean;
|
||||
carrierProfile?: string;
|
||||
carrierProfileFrom?: string;
|
||||
dataplaneMode: string;
|
||||
iccid: string;
|
||||
imsi: string;
|
||||
|
||||
Reference in New Issue
Block a user