mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d06231494 | ||
|
|
ee22576124 | ||
|
|
4f3f37ba7c | ||
|
|
53345d2915 | ||
|
|
0cba13634a | ||
|
|
b8df7f43f8 |
@@ -330,6 +330,18 @@ func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*e
|
|||||||
// operation self-healing without disturbing an active AKA exchange.
|
// operation self-healing without disturbing an active AKA exchange.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if attempt == 1 && isTransientEuiccCME(err) {
|
||||||
|
// When SIM hot-swap occurs or the modem baseband APDU channel is stuck (+CME ERROR: 0),
|
||||||
|
// perform a soft SIM subsystem reset (AT+CFUN=0 -> AT+CFUN=1/4) to re-initialize
|
||||||
|
// card interface voltage and ATR without restarting the whole hardware module.
|
||||||
|
_ = manager.softResetForProfileSwitch(ctx, id)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(600 * time.Millisecond):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !isTransientEuiccCME(err) {
|
if !isTransientEuiccCME(err) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ func testNotificationMetadata(t *testing.T, sequence byte, event []byte, address
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParsePendingNotifications(t *testing.T) {
|
func TestParsePendingNotifications(t *testing.T) {
|
||||||
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944476500017228672")
|
installMetadata := testNotificationMetadata(t, 7, []byte{7, 0x80}, "install.example.com", "8944470000000000001")
|
||||||
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
install := derConstruct(0xBF37, derConstruct(0xBF27, installMetadata))
|
||||||
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "89441000400128014257")
|
deleteMetadata := testNotificationMetadata(t, 9, []byte{4, 0x10}, "delete.example.com", "8944100000000000001")
|
||||||
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
deleted := derConstruct(0x30, deleteMetadata, derEncode(0x5F37, []byte{1, 2, 3}))
|
||||||
|
|
||||||
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
notifications, err := parsePendingNotifications(derConstruct(0xBF2B, derConstruct(0xA0, install, deleted)))
|
||||||
@@ -44,11 +44,11 @@ func TestParsePendingNotifications(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Results are grouped by receiver, then sorted by sequence number.
|
// Results are grouped by receiver, then sorted by sequence number.
|
||||||
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
if got := notifications[0]; got.SequenceNumber != 9 || got.Event != "delete" ||
|
||||||
got.Address != "delete.example.com" || got.ICCID != "89441000400128014257" || !bytes.Equal(got.raw, deleted) {
|
got.Address != "delete.example.com" || got.ICCID != "8944100000000000001" || !bytes.Equal(got.raw, deleted) {
|
||||||
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
t.Fatalf("delete notification = %#v, raw=%X", got, got.raw)
|
||||||
}
|
}
|
||||||
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
if got := notifications[1]; got.SequenceNumber != 7 || got.Event != "install" ||
|
||||||
got.Address != "install.example.com" || got.ICCID != "8944476500017228672" || !bytes.Equal(got.raw, install) {
|
got.Address != "install.example.com" || got.ICCID != "8944470000000000001" || !bytes.Equal(got.raw, install) {
|
||||||
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
t.Fatalf("install notification = %#v, raw=%X", got, got.raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byt
|
|||||||
func TestParseProfilesInfoRealShape(t *testing.T) {
|
func TestParseProfilesInfoRealShape(t *testing.T) {
|
||||||
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
// BF2D root (this card echoes the request tag) -> A0 list -> E3 records.
|
||||||
body := tlv([]byte{0xA0},
|
body := tlv([]byte{0xA0},
|
||||||
esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
esimTestProfile(t, "8944100000000000001", "Vodafone UK", "Vodafone UK eSIM", 0x00),
|
||||||
esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
esimTestProfile(t, "8944100000000000002", "Vodafone UK", "Vodafone UK eSIM", 0x01),
|
||||||
esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00),
|
esimTestProfile(t, "8985200000000000001", "Webbing", "WEBBING", 0x00),
|
||||||
)
|
)
|
||||||
payload := tlv([]byte{0xBF, 0x2D}, body)
|
payload := tlv([]byte{0xBF, 0x2D}, body)
|
||||||
|
|
||||||
@@ -65,10 +65,10 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
|||||||
if len(profiles) != 3 {
|
if len(profiles) != 3 {
|
||||||
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles)
|
||||||
}
|
}
|
||||||
if profiles[0].ICCID != "89441000400128014257" || profiles[0].State != 0 {
|
if profiles[0].ICCID != "8944100000000000001" || profiles[0].State != 0 {
|
||||||
t.Fatalf("profile[0] = %#v", profiles[0])
|
t.Fatalf("profile[0] = %#v", profiles[0])
|
||||||
}
|
}
|
||||||
if profiles[1].ICCID != "89441000430011604140" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
if profiles[1].ICCID != "8944100000000000002" || profiles[1].State != 1 || profiles[1].StateText != "已启用" {
|
||||||
t.Fatalf("profile[1] = %#v", profiles[1])
|
t.Fatalf("profile[1] = %#v", profiles[1])
|
||||||
}
|
}
|
||||||
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 {
|
||||||
@@ -82,8 +82,8 @@ func TestParseProfilesInfoRealShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
||||||
real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
real := esimTestProfile(t, "8944100000000000003", "Vodafone UK", "Vodafone UK eSIM", 0x01)
|
||||||
duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00)
|
duplicate := esimTestProfile(t, "8944100000000000003", "Duplicate", "Duplicate", 0x00)
|
||||||
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01}))
|
||||||
empty := tlv([]byte{0xE3})
|
empty := tlv([]byte{0xE3})
|
||||||
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate))
|
||||||
@@ -92,13 +92,13 @@ func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) {
|
|||||||
if len(profiles) != 1 {
|
if len(profiles) != 1 {
|
||||||
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
t.Fatalf("profiles = %#v, want one addressable profile", profiles)
|
||||||
}
|
}
|
||||||
if profiles[0].ICCID != "89441000400316048687" || profiles[0].Name != "Vodafone UK eSIM" {
|
if profiles[0].ICCID != "8944100000000000003" || profiles[0].Name != "Vodafone UK eSIM" {
|
||||||
t.Fatalf("profile = %#v", profiles[0])
|
t.Fatalf("profile = %#v", profiles[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestICCIDRoundTrip(t *testing.T) {
|
func TestICCIDRoundTrip(t *testing.T) {
|
||||||
for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} {
|
for _, digits := range []string{"8944100000000000001", "8985200000000000001", "1"} {
|
||||||
bcd, err := encodeICCID(digits)
|
bcd, err := encodeICCID(digits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
t.Fatalf("encodeICCID(%q): %v", digits, err)
|
||||||
@@ -110,7 +110,7 @@ func TestICCIDRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("round trip %q -> %q", digits, got)
|
t.Fatalf("round trip %q -> %q", digits, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := encodeICCID("894410004001280142571"); err == nil {
|
if _, err := encodeICCID("894410000000000000001"); err == nil {
|
||||||
t.Fatal("21-digit ICCID was accepted")
|
t.Fatal("21-digit ICCID was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,11 +126,11 @@ func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
func TestDeleteProfileRequestAndResult(t *testing.T) {
|
||||||
request, err := buildDeleteProfileRequest("89441000400128014257")
|
request, err := buildDeleteProfileRequest("89441000000000000001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100041082102475" {
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100000000000010" {
|
||||||
t.Fatalf("DeleteProfile request = %s", got)
|
t.Fatalf("DeleteProfile request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00})
|
||||||
@@ -144,28 +144,28 @@ func TestDeleteProfileRequestAndResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSetNicknameRequestAndResult(t *testing.T) {
|
func TestSetNicknameRequestAndResult(t *testing.T) {
|
||||||
request, err := buildSetNicknameRequest("89441000400128014257", "Test")
|
request, err := buildSetNicknameRequest("89441000000000000001", "Test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100041082102475900454657374" {
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100000000000010900454657374" {
|
||||||
t.Fatalf("SetNickname request = %s", got)
|
t.Fatalf("SetNickname request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00})
|
||||||
if !ok || result != 0 {
|
if !ok || result != 0 {
|
||||||
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
t.Fatalf("SetNickname result = (%d, %v)", result, ok)
|
||||||
}
|
}
|
||||||
if _, err := buildSetNicknameRequest("89441000400128014257", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
if _, err := buildSetNicknameRequest("89441000000000000001", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) {
|
||||||
t.Fatalf("long nickname error = %v", err)
|
t.Fatalf("long nickname error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDisableProfileRequestAndResult(t *testing.T) {
|
func TestDisableProfileRequestAndResult(t *testing.T) {
|
||||||
request, err := buildDisableProfileRequest("89441000400128014257")
|
request, err := buildDisableProfileRequest("89441000000000000001")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000410821024758101FF" {
|
if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000000000000108101FF" {
|
||||||
t.Fatalf("DisableProfile request = %s", got)
|
t.Fatalf("DisableProfile request = %s", got)
|
||||||
}
|
}
|
||||||
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00})
|
||||||
@@ -210,7 +210,7 @@ func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) {
|
|||||||
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
func TestVerifySwitchedICCIDAttemptsAllowsProactiveRefreshToSettle(t *testing.T) {
|
||||||
const target = "89492026266006792824"
|
const target = "89492026266006792824"
|
||||||
client := &transcriptClient{steps: []clientStep{
|
client := &transcriptClient{steps: []clientStep{
|
||||||
{command: "AT+CCID", response: okResponse("+CCID: 89441000400128014257F")},
|
{command: "AT+CCID", response: okResponse("+CCID: 8944100000000000001F")},
|
||||||
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
{command: "AT+CCID", response: okResponse("+CCID: " + target + "F")},
|
||||||
}}
|
}}
|
||||||
manager, id := newStartedTestManager(t, client)
|
manager, id := newStartedTestManager(t, client)
|
||||||
|
|||||||
@@ -91,11 +91,11 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
|||||||
wantPLMN string
|
wantPLMN string
|
||||||
wantCountry string
|
wantCountry string
|
||||||
}{
|
}{
|
||||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
{imsi: "234330000000001", wantPLMN: "23433", wantCountry: "GB"},
|
||||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
{imsi: "234150000000001", wantPLMN: "23415", wantCountry: "GB"},
|
||||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
{imsi: "234870000000001", wantPLMN: "23487", wantCountry: "GB"},
|
||||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
{imsi: "454000000000001", wantPLMN: "45400", wantCountry: "HK"},
|
||||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
{imsi: "310260000000001", wantPLMN: "310260", wantCountry: "US"},
|
||||||
}
|
}
|
||||||
for _, item := range tests {
|
for _, item := range tests {
|
||||||
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
plmn, name, country, ok := CarrierForIMSI(item.imsi)
|
||||||
|
|||||||
@@ -65,6 +65,32 @@ func (manager *Manager) readSnapshot(
|
|||||||
if response, ok := optional("AT+CPIN?"); ok {
|
if response, ok := optional("AT+CPIN?"); ok {
|
||||||
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||||
}
|
}
|
||||||
|
previousICCID = strings.TrimSpace(previousICCID)
|
||||||
|
if !snapshot.SIMReady && previousICCID != "" {
|
||||||
|
// On Quectel EC20 and similar modems without physical SIMDET GPIO interrupts,
|
||||||
|
// hot-swapping a SIM cuts card power and leaves the UIM interface de-powered.
|
||||||
|
// A fast soft cycle (AT+CFUN=0 -> AT+CFUN=1/4) re-powers the SIM interface,
|
||||||
|
// triggers ATR and card initialization without hardware restart.
|
||||||
|
_, _ = manager.command(ctx, client, "AT+CFUN=0")
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return snapshot, ctx.Err()
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
targetCFUN := "AT+CFUN=1"
|
||||||
|
if snapshot.FlightMode {
|
||||||
|
targetCFUN = "AT+CFUN=4"
|
||||||
|
}
|
||||||
|
_, _ = manager.command(ctx, client, targetCFUN)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return snapshot, ctx.Err()
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if response, ok := optional("AT+CPIN?"); ok {
|
||||||
|
snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
ccid, ccidErr := manager.command(ctx, client, "AT+CCID")
|
||||||
if ccidErr != nil {
|
if ccidErr != nil {
|
||||||
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
ccid, ccidErr = manager.command(ctx, client, "AT+QCCID")
|
||||||
@@ -92,14 +118,11 @@ func (manager *Manager) readSnapshot(
|
|||||||
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
previousICCID = strings.TrimSpace(previousICCID)
|
|
||||||
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) {
|
||||||
// A different physical SIM must never inherit the previous card's
|
// A different physical SIM must never inherit the previous card's
|
||||||
// permission to use cellular RF. Disable RF before reading serving-cell
|
// permission to use cellular RF. Disable RF before reading serving-cell
|
||||||
// or operator state; policy reconciliation will then start VoWiFi.
|
// or operator state; policy reconciliation will then start VoWiFi.
|
||||||
if _, err := manager.command(ctx, client, "AT+CFUN=4"); err != nil {
|
_, _ = manager.command(ctx, client, "AT+CFUN=4")
|
||||||
return snapshot, fmt.Errorf("protect changed SIM with RF off: %w", err)
|
|
||||||
}
|
|
||||||
snapshot.SIMChanged = true
|
snapshot.SIMChanged = true
|
||||||
}
|
}
|
||||||
if response, ok := optional("AT+CIMI"); ok {
|
if response, ok := optional("AT+CIMI"); ok {
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ func TestHandleESIMNotificationsListAndRetry(t *testing.T) {
|
|||||||
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
controller := &fakeEsimNotificationController{items: []device.EsimNotification{{
|
||||||
SequenceNumber: 12,
|
SequenceNumber: 12,
|
||||||
Event: "delete",
|
Event: "delete",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
Address: "rsp.example.com",
|
Address: "rsp.example.com",
|
||||||
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
AIDHex: "A0000005591010FFFFFFFF8900000100",
|
||||||
CanRetry: true,
|
CanRetry: true,
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
|||||||
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
if err := database.UpsertVoWiFiRuntime(context.Background(), store.VoWiFiRuntime{
|
||||||
DeviceID: "ec20_1",
|
DeviceID: "ec20_1",
|
||||||
Phase: "stopping",
|
Phase: "stopping",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
IMSI: "234159608751160",
|
IMSI: "234150000000001",
|
||||||
TunnelReady: true,
|
TunnelReady: true,
|
||||||
IMSReady: true,
|
IMSReady: true,
|
||||||
SMSReady: true,
|
SMSReady: true,
|
||||||
LocalPhone: "+447386083638",
|
LocalPhone: "+447700900123",
|
||||||
PhoneNumberSource: "ims_p_associated_uri",
|
PhoneNumberSource: "ims_p_associated_uri",
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -60,7 +60,7 @@ func TestConfiguredDeviceSummaryIgnoresVoWiFiRuntimeFromPreviousSIM(t *testing.T
|
|||||||
if got["vowifi_active"] != false {
|
if got["vowifi_active"] != false {
|
||||||
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
t.Fatalf("vowifi_active = %#v", got["vowifi_active"])
|
||||||
}
|
}
|
||||||
if got["local_phone"] == "+447386083638" {
|
if got["local_phone"] == "+447700900123" {
|
||||||
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
t.Fatalf("old phone leaked into current SIM summary: %#v", got)
|
||||||
}
|
}
|
||||||
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
runtime, ok := got["vowifi_runtime"].(map[string]any)
|
||||||
@@ -169,7 +169,7 @@ func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, snapshot := range []*device.Snapshot{
|
for _, snapshot := range []*device.Snapshot{
|
||||||
{SIMStatus: "pin_required"},
|
{SIMStatus: "pin_required"},
|
||||||
{ICCID: "89441000400128014257"},
|
{ICCID: "8944100000000000001"},
|
||||||
{SIMReady: true},
|
{SIMReady: true},
|
||||||
} {
|
} {
|
||||||
if !snapshotHasSIM(snapshot) {
|
if !snapshotHasSIM(snapshot) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"vocat/internal/vowifi"
|
"vocat/internal/vowifi"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testProfileICCID = "89441000400128014257"
|
const testProfileICCID = "8944100000000000001"
|
||||||
|
|
||||||
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
func newProfileBindingTestServer(t *testing.T) (*Server, *store.Store, *fakeVoWiFiController) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -51,7 +51,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
response := profileBindingRequest(t, server, http.MethodPost, `{
|
response := profileBindingRequest(t, server, http.MethodPost, `{
|
||||||
"upstream_proxy_id":"route-1",
|
"upstream_proxy_id":"route-1",
|
||||||
"bindings":[
|
"bindings":[
|
||||||
{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Vodafone UK","state_text":"Enabled"},
|
{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Vodafone UK","state_text":"Enabled"},
|
||||||
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
{"device_id":"ec20","iccid":"89104100000028106378","profile_name":"TIM"}
|
||||||
]
|
]
|
||||||
}`)
|
}`)
|
||||||
@@ -66,7 +66,7 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
t.Fatalf("reconnects = %d, want only the current ICCID to reconnect", controller.reconnects)
|
||||||
}
|
}
|
||||||
|
|
||||||
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["89441000400128014257","89104100000028106378"]}`)
|
response = profileBindingRequest(t, server, http.MethodDelete, `{"upstream_proxy_id":"route-1","iccids":["8944100000000000001","89104100000028106378"]}`)
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,11 @@ func TestProfileProxyBindingPersistsAndReconnectsOnlyCurrentICCID(t *testing.T)
|
|||||||
|
|
||||||
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
|
||||||
server, database, _ := newProfileBindingTestServer(t)
|
server, database, _ := newProfileBindingTestServer(t)
|
||||||
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
first := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-1","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
|
||||||
if first.Code != http.StatusOK {
|
if first.Code != http.StatusOK {
|
||||||
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
t.Fatalf("initial bind status = %d, body = %s", first.Code, first.Body.String())
|
||||||
}
|
}
|
||||||
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"89441000400128014257","profile_name":"Profile"}]}`)
|
second := profileBindingRequest(t, server, http.MethodPost, `{"upstream_proxy_id":"route-2","bindings":[{"device_id":"ec20","iccid":"8944100000000000001","profile_name":"Profile"}]}`)
|
||||||
if second.Code != http.StatusConflict {
|
if second.Code != http.StatusConflict {
|
||||||
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
t.Fatalf("rebind status = %d, want 409, body = %s", second.Code, second.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.writeStoreError(w, err)
|
s.writeStoreError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, message := range messages {
|
unreadIDs := make([]int64, 0, len(messages))
|
||||||
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
|
for i := range messages {
|
||||||
message.Read = true
|
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
|
||||||
_, _ = s.store.SaveSMSMessage(r.Context(), message)
|
messages[i].Read = true
|
||||||
|
unreadIDs = append(unreadIDs, messages[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unreadIDs) > 0 {
|
||||||
|
if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil {
|
||||||
|
s.logger.Warn("mark SMS messages read failed", "error", markErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reverseSMS(messages)
|
reverseSMS(messages)
|
||||||
|
|||||||
@@ -236,10 +236,10 @@ func (bot *telegramBot) getUpdates(
|
|||||||
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
func (bot *telegramBot) handleUpdate(ctx context.Context, config telegramRuntimeConfig, update telegramUpdate) {
|
||||||
if callback := update.CallbackQuery; callback != nil {
|
if callback := update.CallbackQuery; callback != nil {
|
||||||
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
if callback.Message == nil || !bot.authorized(config, callback.Message.Chat.ID, callback.From.ID) {
|
||||||
_ = bot.answerCallback(ctx, config, callback.ID, "无权限")
|
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "无权限") }()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = bot.answerCallback(ctx, config, callback.ID, "")
|
go func() { _ = bot.answerCallback(context.Background(), config, callback.ID, "") }()
|
||||||
bot.handleCallback(ctx, config, callback)
|
bot.handleCallback(ctx, config, callback)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
|||||||
}
|
}
|
||||||
state := &vowifi.State{
|
state := &vowifi.State{
|
||||||
ICCID: "previous-card",
|
ICCID: "previous-card",
|
||||||
PhoneNumber: "+447386083638",
|
PhoneNumber: "+447700900123",
|
||||||
}
|
}
|
||||||
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
if got := resolveTelegramPhoneNumber("", state, snapshot); got != "--" {
|
||||||
t.Fatalf("stale or placeholder number leaked as %q", got)
|
t.Fatalf("stale or placeholder number leaked as %q", got)
|
||||||
@@ -135,10 +135,10 @@ func TestResolveTelegramPhoneNumberRejectsPlaceholderAndStaleRuntime(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T) {
|
||||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
if got := telegramHomeCarrier("234330000000001"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||||
t.Fatalf("home carrier = %q", got)
|
t.Fatalf("home carrier = %q", got)
|
||||||
}
|
}
|
||||||
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
if got := telegramHomeCarrier("454000000000001", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
||||||
t.Fatalf("profile brand overrode home carrier = %q", got)
|
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||||
}
|
}
|
||||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
|||||||
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
INSERT INTO device_proxy_bindings (device_id, upstream_proxy_id, created_at, updated_at) VALUES
|
||||||
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
('known', 'route', 100, 100), ('unknown', 'route', 100, 100);
|
||||||
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
INSERT INTO vowifi_runtime (device_id, iccid, updated_at)
|
||||||
VALUES ('known', '89441000400128014257', 100);
|
VALUES ('known', '8944100000000000001', 100);
|
||||||
PRAGMA user_version = 11;
|
PRAGMA user_version = 11;
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -138,7 +138,7 @@ func TestMigration12ConvertsOnlyKnownActiveDeviceBindingToICCID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
database := openTestStore(t, path)
|
database := openTestStore(t, path)
|
||||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
|
||||||
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
if err != nil || binding.DeviceID != "known" || binding.UpstreamProxyID != "route" {
|
||||||
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
t.Fatalf("migrated binding = %+v, %v", binding, err)
|
||||||
}
|
}
|
||||||
@@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
|
|||||||
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||||
t.Fatalf("thread should be read: %+v", contacts)
|
t.Fatalf("thread should be read: %+v", contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0.
|
||||||
|
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
|
||||||
|
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
|
||||||
|
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
|
||||||
|
Timestamp: base, Status: "received", Read: false,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
|
||||||
|
if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 {
|
||||||
|
t.Fatalf("thread read state must survive modem rescan: %+v", contacts)
|
||||||
|
}
|
||||||
|
|
||||||
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
|
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
|
||||||
if err != nil || deleted != 2 {
|
if err != nil || deleted != 2 {
|
||||||
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
|
||||||
@@ -789,11 +803,11 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
|||||||
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
t.Fatalf("CountryRule() = %+v, %v", rule, err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{
|
||||||
DeviceID: "ec20-1", ICCID: "89441000400128014257", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
DeviceID: "ec20-1", ICCID: "8944100000000000001", ProfileName: "Vodafone", UpstreamProxyID: "up-1",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
binding, err := database.DeviceProxyBinding(ctx, "89441000400128014257")
|
binding, err := database.DeviceProxyBinding(ctx, "8944100000000000001")
|
||||||
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
if err != nil || binding.UpstreamProxyID != "up-1" || binding.DeviceID != "ec20-1" || binding.ProfileName != "Vodafone" {
|
||||||
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err)
|
||||||
}
|
}
|
||||||
@@ -803,7 +817,7 @@ func TestProxyCredentialsAndCountryRules(t *testing.T) {
|
|||||||
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
t.Fatalf("country rule should cascade with upstream deletion, got %v", err)
|
||||||
}
|
}
|
||||||
if _, err := database.DeviceProxyBinding(ctx, "89441000400128014257"); !errors.Is(err, ErrNotFound) {
|
if _, err := database.DeviceProxyBinding(ctx, "8944100000000000001"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
t.Fatalf("device binding should cascade with upstream deletion, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-9
@@ -92,15 +92,16 @@ func saveSMSMessage(
|
|||||||
if mergeErr != nil {
|
if mergeErr != nil {
|
||||||
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
|
||||||
}
|
}
|
||||||
if existingErr == nil && !changed {
|
|
||||||
// This segment is already folded into the stored row (a periodic modem
|
|
||||||
// rescan redelivers every segment). Leave the row untouched so the
|
|
||||||
// durable id stays put and Telegram does not re-notify.
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
value.Body = mergedBody
|
|
||||||
extra = mergedExtra
|
|
||||||
if existingErr == nil {
|
if existingErr == nil {
|
||||||
|
if !changed {
|
||||||
|
if value.Read != existing.Read {
|
||||||
|
if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil {
|
||||||
|
return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err)
|
||||||
|
}
|
||||||
|
existing.Read = value.Read
|
||||||
|
}
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
// A new segment advanced the message. Replace the stale partial row so
|
// A new segment advanced the message. Replace the stale partial row so
|
||||||
// the merged row receives a fresh durable id; the Telegram id-cursor
|
// the merged row receives a fresh durable id; the Telegram id-cursor
|
||||||
// then surfaces the now-more-complete message exactly once. Carry
|
// then surfaces the now-more-complete message exactly once. Carry
|
||||||
@@ -116,6 +117,8 @@ func saveSMSMessage(
|
|||||||
value.Timestamp = existing.Timestamp
|
value.Timestamp = existing.Timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
value.Body = mergedBody
|
||||||
|
extra = mergedExtra
|
||||||
}
|
}
|
||||||
if value.Timestamp.IsZero() {
|
if value.Timestamp.IsZero() {
|
||||||
value.Timestamp = now
|
value.Timestamp = now
|
||||||
@@ -171,7 +174,10 @@ func saveSMSMessage(
|
|||||||
source = excluded.source,
|
source = excluded.source,
|
||||||
parts_total = excluded.parts_total,
|
parts_total = excluded.parts_total,
|
||||||
delivery_state = excluded.delivery_state,
|
delivery_state = excluded.delivery_state,
|
||||||
is_read = excluded.is_read,
|
is_read = CASE
|
||||||
|
WHEN sms_messages.is_read = 1 THEN 1
|
||||||
|
ELSE excluded.is_read
|
||||||
|
END,
|
||||||
extra_json = excluded.extra_json,
|
extra_json = excluded.extra_json,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`,
|
`,
|
||||||
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
|
|||||||
return affected, nil
|
return affected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
placeholders := make([]string, len(ids))
|
||||||
|
args := make([]any, 0, len(ids)+1)
|
||||||
|
args = append(args, time.Now().UTC().Unix())
|
||||||
|
for i, id := range ids {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ","))
|
||||||
|
_, err := s.db.ExecContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark SMS messages read: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListSMSContacts derives contacts and thread counters from messages. No
|
// ListSMSContacts derives contacts and thread counters from messages. No
|
||||||
// duplicated contact/thread table can drift out of sync with message history.
|
// duplicated contact/thread table can drift out of sync with message history.
|
||||||
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
|
||||||
|
|||||||
@@ -396,8 +396,8 @@ func decimalString(value string) bool {
|
|||||||
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
// ResolveCarrierProfile returns the most specific built-in match. Exact SIM
|
||||||
// attributes add specificity, so a constrained MVNO rule wins over its host
|
// attributes add specificity, so a constrained MVNO rule wins over its host
|
||||||
// PLMN without weakening the default match for unrelated subscriptions.
|
// PLMN without weakening the default match for unrelated subscriptions.
|
||||||
func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
func defaultCarrierProfile() CarrierProfile {
|
||||||
resolved := CarrierProfile{
|
return CarrierProfile{
|
||||||
ID: CarrierProfileStandard,
|
ID: CarrierProfileStandard,
|
||||||
MatchSource: "standard",
|
MatchSource: "standard",
|
||||||
IKEProposal: IKEProposalModern,
|
IKEProposal: IKEProposalModern,
|
||||||
@@ -408,6 +408,13 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
IMSDialURIScheme: "tel",
|
IMSDialURIScheme: "tel",
|
||||||
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
IMSVoiceCodecs: []string{"PCMA", "PCMU"},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := defaultCarrierProfile()
|
||||||
bestScore := -1
|
bestScore := -1
|
||||||
for _, rule := range carrierProfilesSnapshot() {
|
for _, rule := range carrierProfilesSnapshot() {
|
||||||
score, source, matched := matchCarrierProfileRule(rule, identity)
|
score, source, matched := matchCarrierProfileRule(rule, identity)
|
||||||
@@ -415,7 +422,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bestScore = score
|
bestScore = score
|
||||||
resolved = applyCarrierProfileRule(resolved, rule, source, identity)
|
resolved = applyCarrierProfileRule(defaultCarrierProfile(), rule, source, identity)
|
||||||
}
|
}
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
@@ -458,6 +465,8 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
score += 100
|
score += 100
|
||||||
sources = append(sources, "hplmn")
|
sources = append(sources, "hplmn")
|
||||||
hasHomePLMNMatch = true
|
hasHomePLMNMatch = true
|
||||||
|
} else if identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasSelectorMatch := false
|
hasSelectorMatch := false
|
||||||
@@ -487,7 +496,7 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
score += selector.weight
|
score += selector.weight
|
||||||
sources = append(sources, selector.name)
|
sources = append(sources, selector.name)
|
||||||
hasSelectorMatch = true
|
hasSelectorMatch = true
|
||||||
} else if !hasHomePLMNMatch {
|
} else if !hasHomePLMNMatch || selector.name == "gid1" || selector.name == "gid2" {
|
||||||
return 0, "", false
|
return 0, "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,6 +508,8 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
|||||||
score += 20
|
score += 20
|
||||||
sources = append(sources, "spn")
|
sources = append(sources, "spn")
|
||||||
hasSelectorMatch = true
|
hasSelectorMatch = true
|
||||||
|
} else {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hasHomePLMNMatch && !hasSelectorMatch {
|
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||||
|
|||||||
@@ -73,3 +73,63 @@ func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
|||||||
t.Fatal("standard profile should require SMS contact confirmation")
|
t.Fatal("standard profile should require SMS contact confirmation")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMVNOParentNetworkRouting(t *testing.T) {
|
||||||
|
// Giffgaff on O2 UK
|
||||||
|
giffgaff := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||||
|
})
|
||||||
|
if giffgaff.RouteMCC != "234" || giffgaff.RouteMNC != "10" {
|
||||||
|
t.Fatalf("giffgaff Route PLMN = %s-%s, want 234-10", giffgaff.RouteMCC, giffgaff.RouteMNC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VOXI on Vodafone UK
|
||||||
|
voxi := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234150000000001", HomeMCC: "234", HomeMNC: "15", SPN: "VOXI",
|
||||||
|
})
|
||||||
|
if !strings.Contains(voxi.ID, "voxi") || voxi.RouteMCC != "234" || voxi.RouteMNC != "15" {
|
||||||
|
t.Fatalf("VOXI profile = %#v", voxi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMARTY on Three UK
|
||||||
|
smarty := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234200000000001", HomeMCC: "234", HomeMNC: "20", SPN: "SMARTY",
|
||||||
|
})
|
||||||
|
if !strings.Contains(smarty.ID, "smarty") || smarty.RouteMCC != "234" || smarty.RouteMNC != "20" {
|
||||||
|
t.Fatalf("SMARTY profile = %#v", smarty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobalRoamingProviderResolution(t *testing.T) {
|
||||||
|
// Truphone / BetterRoaming global 90143
|
||||||
|
truphone := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "901430000000001", HomeMCC: "901", HomeMNC: "43",
|
||||||
|
})
|
||||||
|
if (!strings.Contains(truphone.ID, "truphone") && !strings.Contains(truphone.ID, "1global")) || truphone.EPDG != "epdg.eps.truphone.net" {
|
||||||
|
t.Fatalf("Truphone global profile = %#v", truphone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jersey Telecom 23450 (eSIM Go / 1GLOBAL / RedteaGO host)
|
||||||
|
jersey := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234500000000001", HomeMCC: "234", HomeMNC: "50",
|
||||||
|
})
|
||||||
|
if !strings.Contains(jersey.ID, "jersey-telecom") || jersey.EPDG != "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org" {
|
||||||
|
t.Fatalf("Jersey Telecom profile = %#v", jersey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCTExcelMVNOResolution(t *testing.T) {
|
||||||
|
ctexcel := ResolveCarrierProfile(SIMIdentity{
|
||||||
|
IMSI: "234330000000001",
|
||||||
|
ICCID: "8944300000000000001",
|
||||||
|
SPN: "CTExcel",
|
||||||
|
HomeMCC: "234",
|
||||||
|
HomeMNC: "33",
|
||||||
|
})
|
||||||
|
if ctexcel.ID != "ipcc-ctexcel-23433" {
|
||||||
|
t.Fatalf("CTExcel profile ID = %q, want ipcc-ctexcel-23433", ctexcel.ID)
|
||||||
|
}
|
||||||
|
if ctexcel.IMSDialURIScheme != "sip" || !ctexcel.IMSUserEqPhone {
|
||||||
|
t.Fatalf("CTExcel dial URI scheme = %q, userEqPhone = %v", ctexcel.IMSDialURIScheme, ctexcel.IMSUserEqPhone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -661,6 +661,12 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) {
|
|||||||
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
warnings.add("apn_settings_ignored", "APN settings and credentials are outside the VoCat carrier-profile importer", fullPath)
|
||||||
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"):
|
||||||
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath)
|
||||||
|
case key == "countryoforiginationformat":
|
||||||
|
warnings.add(
|
||||||
|
"country_of_origination_format_not_imported",
|
||||||
|
"CountryOfOriginationFormat was not imported because VoCat has no trusted runtime country source; a P-Access-Network-Info value must not be fabricated",
|
||||||
|
fullPath,
|
||||||
|
)
|
||||||
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
case strings.Contains(key, "emergency") || strings.Contains(key, "e911"):
|
||||||
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"IMSConfig": map[string]any{
|
"IMSConfig": map[string]any{
|
||||||
"EnableWiFiCallingWithoutEntitlement": true,
|
"EnableWiFiCallingWithoutEntitlement": true,
|
||||||
"Signaling": map[string]any{"UseIPSec": true},
|
"Signaling": map[string]any{
|
||||||
"Media": map[string]any{"SupportPCMA": false},
|
"UseIPSec": true,
|
||||||
"Emergency": map[string]any{"E911OverITechSupported": true},
|
"CountryOfOriginationFormat": "PANI",
|
||||||
|
},
|
||||||
|
"Media": map[string]any{"SupportPCMA": false},
|
||||||
|
"Emergency": map[string]any{"E911OverITechSupported": true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -79,6 +82,7 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) {
|
|||||||
"entitlement_bypass_ignored",
|
"entitlement_bypass_ignored",
|
||||||
"apn_settings_ignored",
|
"apn_settings_ignored",
|
||||||
"device_media_overrides_ignored",
|
"device_media_overrides_ignored",
|
||||||
|
"country_of_origination_format_not_imported",
|
||||||
"emergency_settings_ignored",
|
"emergency_settings_ignored",
|
||||||
} {
|
} {
|
||||||
if !hasIPCCWarning(result.Warnings, code) {
|
if !hasIPCCWarning(result.Warnings, code) {
|
||||||
|
|||||||
@@ -4054,6 +4054,19 @@
|
|||||||
"home_plmns": [
|
"home_plmns": [
|
||||||
"23450"
|
"23450"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "50"
|
||||||
|
},
|
||||||
|
"epdg": {
|
||||||
|
"hostname": "epdg.epc.mnc050.mcc234.pub.3gppnetwork.org"
|
||||||
|
},
|
||||||
|
"ike": {
|
||||||
|
"proposal": "modern"
|
||||||
|
},
|
||||||
|
"ims": {
|
||||||
|
"ipsec_encryption": "aes-cbc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -5724,13 +5737,27 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ipcc-giffgaff-23410",
|
"id": "ipcc-giffgaff-23410",
|
||||||
"match": {
|
"match_any": [
|
||||||
"home_plmns": [
|
{
|
||||||
"23410"
|
"home_plmns": [
|
||||||
],
|
"23410"
|
||||||
"gid1_prefixes": [
|
],
|
||||||
"508"
|
"gid1_prefixes": [
|
||||||
]
|
"508"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"23410"
|
||||||
|
],
|
||||||
|
"spns": [
|
||||||
|
"giffgaff"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "10"
|
||||||
},
|
},
|
||||||
"epdg": {
|
"epdg": {
|
||||||
"hostname": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org"
|
"hostname": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org"
|
||||||
@@ -5742,6 +5769,74 @@
|
|||||||
"ipsec_encryption": "aes-cbc"
|
"ipsec_encryption": "aes-cbc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "ipcc-voxi-23415",
|
||||||
|
"match_any": [
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"23415"
|
||||||
|
],
|
||||||
|
"spns": [
|
||||||
|
"VOXI"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"23415"
|
||||||
|
],
|
||||||
|
"gid1_prefixes": [
|
||||||
|
"4E"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "15"
|
||||||
|
},
|
||||||
|
"epdg": {
|
||||||
|
"hostname": "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"
|
||||||
|
},
|
||||||
|
"ike": {
|
||||||
|
"proposal": "modern"
|
||||||
|
},
|
||||||
|
"ims": {
|
||||||
|
"ipsec_encryption": "aes-cbc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ipcc-smarty-23420",
|
||||||
|
"match_any": [
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"23420"
|
||||||
|
],
|
||||||
|
"spns": [
|
||||||
|
"SMARTY"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"23420"
|
||||||
|
],
|
||||||
|
"gid1_prefixes": [
|
||||||
|
"534D41525459"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "20"
|
||||||
|
},
|
||||||
|
"epdg": {
|
||||||
|
"hostname": "epdg.epc.mnc020.mcc234.pub.3gppnetwork.org"
|
||||||
|
},
|
||||||
|
"ike": {
|
||||||
|
"proposal": "modern"
|
||||||
|
},
|
||||||
|
"ims": {
|
||||||
|
"ipsec_encryption": "aes-cbc"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ipcc-o2-23410",
|
"id": "ipcc-o2-23410",
|
||||||
"match_any": [
|
"match_any": [
|
||||||
@@ -6356,6 +6451,39 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "ipcc-ctexcel-23433",
|
||||||
|
"match": {
|
||||||
|
"home_plmns": [
|
||||||
|
"23433",
|
||||||
|
"23430"
|
||||||
|
],
|
||||||
|
"spns": [
|
||||||
|
"CTExcel",
|
||||||
|
"CTExcel UK",
|
||||||
|
"China Telecom",
|
||||||
|
"China Telecom UK"
|
||||||
|
],
|
||||||
|
"iccid_prefixes": [
|
||||||
|
"894430"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"mcc": "234",
|
||||||
|
"mnc": "33"
|
||||||
|
},
|
||||||
|
"epdg": {
|
||||||
|
"hostname": "epdg.epc.mnc033.mcc234.pub.3gppnetwork.org"
|
||||||
|
},
|
||||||
|
"ike": {
|
||||||
|
"proposal": "modern"
|
||||||
|
},
|
||||||
|
"ims": {
|
||||||
|
"dial_uri_scheme": "sip",
|
||||||
|
"user_eq_phone": true,
|
||||||
|
"ipsec_encryption": "aes-cbc"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ipcc-ee-23433",
|
"id": "ipcc-ee-23433",
|
||||||
"match": {
|
"match": {
|
||||||
@@ -9660,6 +9788,19 @@
|
|||||||
"gid1_prefixes": [
|
"gid1_prefixes": [
|
||||||
"547275554B3030656E"
|
"547275554B3030656E"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"home_plmns": [
|
||||||
|
"90143",
|
||||||
|
"90128"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spns": [
|
||||||
|
"Truphone",
|
||||||
|
"BetterRoaming",
|
||||||
|
"1GLOBAL"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"epdg": {
|
"epdg": {
|
||||||
|
|||||||
@@ -280,6 +280,239 @@ func decryptPayloads(
|
|||||||
return header, payloads, nil
|
return header, payloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultIKEFragmentSize = 1100
|
||||||
|
|
||||||
|
func encryptPayloadsFragmented(
|
||||||
|
header ikeHeader,
|
||||||
|
inner []payload,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
encryptionKey []byte,
|
||||||
|
integrityKey []byte,
|
||||||
|
maxFragmentSize int,
|
||||||
|
random io.Reader,
|
||||||
|
) ([][]byte, error) {
|
||||||
|
if random == nil {
|
||||||
|
random = rand.Reader
|
||||||
|
}
|
||||||
|
if maxFragmentSize <= 0 {
|
||||||
|
maxFragmentSize = defaultIKEFragmentSize
|
||||||
|
}
|
||||||
|
first, plaintext, err := marshalPayloadChain(inner)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ike: initialize AES: %w", err)
|
||||||
|
}
|
||||||
|
_, checksumLength, err := suite.integrityLengths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maxChunk := maxFragmentSize - ikeHeaderLength - 8 - block.BlockSize() - block.BlockSize() - checksumLength
|
||||||
|
if maxChunk < 64 {
|
||||||
|
maxChunk = 64
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks [][]byte
|
||||||
|
for len(plaintext) > 0 {
|
||||||
|
take := len(plaintext)
|
||||||
|
if take > maxChunk {
|
||||||
|
take = maxChunk
|
||||||
|
}
|
||||||
|
chunks = append(chunks, plaintext[:take])
|
||||||
|
plaintext = plaintext[take:]
|
||||||
|
}
|
||||||
|
totalFragments := uint16(len(chunks))
|
||||||
|
if totalFragments == 0 {
|
||||||
|
totalFragments = 1
|
||||||
|
chunks = [][]byte{nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
var packets [][]byte
|
||||||
|
for index, chunk := range chunks {
|
||||||
|
fragNum := uint16(index + 1)
|
||||||
|
fragNext := uint8(payloadNone)
|
||||||
|
if fragNum == 1 {
|
||||||
|
fragNext = first
|
||||||
|
}
|
||||||
|
|
||||||
|
paddingLength := block.BlockSize() - (len(chunk)+1)%block.BlockSize()
|
||||||
|
if paddingLength == block.BlockSize() {
|
||||||
|
paddingLength = 0
|
||||||
|
}
|
||||||
|
padding := make([]byte, paddingLength)
|
||||||
|
if _, err := io.ReadFull(random, padding); err != nil {
|
||||||
|
return nil, fmt.Errorf("ike: generate encrypted payload padding: %w", err)
|
||||||
|
}
|
||||||
|
paddedChunk := append(append([]byte(nil), chunk...), padding...)
|
||||||
|
paddedChunk = append(paddedChunk, byte(paddingLength))
|
||||||
|
|
||||||
|
iv := make([]byte, block.BlockSize())
|
||||||
|
if _, err := io.ReadFull(random, iv); err != nil {
|
||||||
|
return nil, fmt.Errorf("ike: generate encrypted payload IV: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext := make([]byte, len(paddedChunk))
|
||||||
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, paddedChunk)
|
||||||
|
|
||||||
|
skfLength := 4 + 4 + len(iv) + len(ciphertext) + checksumLength
|
||||||
|
if skfLength > 65535 {
|
||||||
|
return nil, errors.New("ike: encrypted fragment exceeds 65535 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make([]byte, skfLength)
|
||||||
|
body[0] = fragNext
|
||||||
|
body[1] = 0
|
||||||
|
binary.BigEndian.PutUint16(body[2:4], uint16(skfLength))
|
||||||
|
binary.BigEndian.PutUint16(body[4:6], fragNum)
|
||||||
|
binary.BigEndian.PutUint16(body[6:8], totalFragments)
|
||||||
|
copy(body[8:], iv)
|
||||||
|
copy(body[8+len(iv):], ciphertext)
|
||||||
|
|
||||||
|
fragHeader := header
|
||||||
|
fragHeader.NextPayload = payloadEncryptedFragment
|
||||||
|
packet := fragHeader.marshal(body)
|
||||||
|
checksum, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
copy(packet[len(packet)-checksumLength:], checksum)
|
||||||
|
packets = append(packets, packet)
|
||||||
|
}
|
||||||
|
return packets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptSingleFragment(
|
||||||
|
packet []byte,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
encryptionKey []byte,
|
||||||
|
integrityKey []byte,
|
||||||
|
) (ikeHeader, uint8, uint16, uint16, []byte, error) {
|
||||||
|
header, body, err := parseIKEPacket(packet)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
if header.NextPayload != payloadEncryptedFragment || len(body) < 8 {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: message is not an encrypted IKE fragment", errUnexpectedPacket)
|
||||||
|
}
|
||||||
|
skfLength := int(binary.BigEndian.Uint16(body[2:4]))
|
||||||
|
if skfLength != len(body) {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment length mismatch", errMalformedPacket)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("ike: initialize AES: %w", err)
|
||||||
|
}
|
||||||
|
_, checksumLength, err := suite.integrityLengths()
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
if len(body) < 8+block.BlockSize()+block.BlockSize()+checksumLength {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: encrypted fragment is too short", errMalformedPacket)
|
||||||
|
}
|
||||||
|
expected, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength])
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
actual := packet[len(packet)-checksumLength:]
|
||||||
|
if subtle.ConstantTimeCompare(actual, expected) != 1 {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, errIntegrityMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
fragNext := body[0]
|
||||||
|
fragNum := binary.BigEndian.Uint16(body[4:6])
|
||||||
|
totalFrags := binary.BigEndian.Uint16(body[6:8])
|
||||||
|
if fragNum == 0 || totalFrags == 0 || fragNum > totalFrags {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid fragment numbers %d/%d", errMalformedPacket, fragNum, totalFrags)
|
||||||
|
}
|
||||||
|
|
||||||
|
ivStart := 8
|
||||||
|
ciphertextStart := ivStart + block.BlockSize()
|
||||||
|
ciphertextEnd := len(body) - checksumLength
|
||||||
|
ciphertext := body[ciphertextStart:ciphertextEnd]
|
||||||
|
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: fragment ciphertext is not block aligned", errMalformedPacket)
|
||||||
|
}
|
||||||
|
plaintext := make([]byte, len(ciphertext))
|
||||||
|
cipher.NewCBCDecrypter(block, body[ivStart:ciphertextStart]).CryptBlocks(plaintext, ciphertext)
|
||||||
|
paddingLength := int(plaintext[len(plaintext)-1])
|
||||||
|
if paddingLength+1 > len(plaintext) {
|
||||||
|
return ikeHeader{}, 0, 0, 0, nil, fmt.Errorf("%w: invalid encrypted fragment padding", errMalformedPacket)
|
||||||
|
}
|
||||||
|
plaintext = plaintext[:len(plaintext)-paddingLength-1]
|
||||||
|
return header, fragNext, fragNum, totalFrags, plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptPayloadsAny(
|
||||||
|
packet []byte,
|
||||||
|
fragments [][]byte,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
encryptionKey []byte,
|
||||||
|
integrityKey []byte,
|
||||||
|
) (ikeHeader, []payload, error) {
|
||||||
|
if len(fragments) > 0 {
|
||||||
|
var (
|
||||||
|
firstHeader ikeHeader
|
||||||
|
firstNext uint8
|
||||||
|
totalExpected uint16
|
||||||
|
plaintexts = make(map[uint16][]byte)
|
||||||
|
)
|
||||||
|
for _, fragPacket := range fragments {
|
||||||
|
hdr, next, num, total, plain, err := decryptSingleFragment(fragPacket, suite, encryptionKey, integrityKey)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if totalExpected == 0 {
|
||||||
|
firstHeader = hdr
|
||||||
|
totalExpected = total
|
||||||
|
} else if total != totalExpected || hdr.MessageID != firstHeader.MessageID || hdr.Exchange != firstHeader.Exchange {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: inconsistent fragment headers", errMalformedPacket)
|
||||||
|
}
|
||||||
|
if num == 1 {
|
||||||
|
firstNext = next
|
||||||
|
}
|
||||||
|
plaintexts[num] = plain
|
||||||
|
}
|
||||||
|
if uint16(len(plaintexts)) != totalExpected {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragments: received %d of %d", errMalformedPacket, len(plaintexts), totalExpected)
|
||||||
|
}
|
||||||
|
var fullPlaintext []byte
|
||||||
|
for i := uint16(1); i <= totalExpected; i++ {
|
||||||
|
chunk, ok := plaintexts[i]
|
||||||
|
if !ok {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: missing fragment %d", errMalformedPacket, i)
|
||||||
|
}
|
||||||
|
fullPlaintext = append(fullPlaintext, chunk...)
|
||||||
|
}
|
||||||
|
payloads, err := parsePayloadChain(firstNext, fullPlaintext)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return firstHeader, payloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
header, _, err := parseIKEPacket(packet)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if header.NextPayload == payloadEncryptedFragment {
|
||||||
|
hdr, next, num, total, plain, err := decryptSingleFragment(packet, suite, encryptionKey, integrityKey)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if num != 1 || total != 1 {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: standalone fragment with total=%d", errMalformedPacket, total)
|
||||||
|
}
|
||||||
|
payloads, err := parsePayloadChain(next, plain)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return hdr, payloads, nil
|
||||||
|
}
|
||||||
|
return decryptPayloads(packet, suite, encryptionKey, integrityKey)
|
||||||
|
}
|
||||||
|
|
||||||
var modpPrimes = map[uint16]string{
|
var modpPrimes = map[uint16]string{
|
||||||
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" +
|
||||||
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
|
||||||
|
|||||||
@@ -113,3 +113,80 @@ func TestIKEKeyDerivationSeparatesDirections(t *testing.T) {
|
|||||||
t.Fatal("initiator and responder keys were not separated")
|
t.Fatal("initiator and responder keys were not separated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRFC7383FragmentationAndReassembly(t *testing.T) {
|
||||||
|
suite := legacyTestSuite()
|
||||||
|
encryptionKey := bytes.Repeat([]byte{0x11}, 16)
|
||||||
|
integrityKey := bytes.Repeat([]byte{0x22}, 20)
|
||||||
|
header := ikeHeader{
|
||||||
|
InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
|
||||||
|
ResponderSPI: [8]byte{8, 7, 6, 5, 4, 3, 2, 1},
|
||||||
|
Exchange: exchangeIKEAuth,
|
||||||
|
Flags: flagInitiator,
|
||||||
|
MessageID: 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
largeCertData := bytes.Repeat([]byte{0xAB, 0xCD, 0xEF, 0x01}, 400) // 1600 bytes
|
||||||
|
inner := []payload{
|
||||||
|
{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u', 's', 'e', 'r'}},
|
||||||
|
{Type: payloadCert, Body: largeCertData},
|
||||||
|
{Type: payloadAuth, Body: bytes.Repeat([]byte{0x55}, 64)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragment into chunks with max fragment size 600 bytes
|
||||||
|
packets, err := encryptPayloadsFragmented(
|
||||||
|
header,
|
||||||
|
inner,
|
||||||
|
suite,
|
||||||
|
encryptionKey,
|
||||||
|
integrityKey,
|
||||||
|
600,
|
||||||
|
bytes.NewReader(bytes.Repeat([]byte{0x77}, 1024)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encryptPayloadsFragmented() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(packets) < 3 {
|
||||||
|
t.Fatalf("expected at least 3 fragments for large payload, got %d", len(packets))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, pkt := range packets {
|
||||||
|
hdr, body, parseErr := parseIKEPacket(pkt)
|
||||||
|
if parseErr != nil {
|
||||||
|
t.Fatalf("fragment %d parse error: %v", i+1, parseErr)
|
||||||
|
}
|
||||||
|
if hdr.NextPayload != payloadEncryptedFragment {
|
||||||
|
t.Fatalf("fragment %d NextPayload = %d, want %d (payloadEncryptedFragment)", i+1, hdr.NextPayload, payloadEncryptedFragment)
|
||||||
|
}
|
||||||
|
if len(body) < 8 {
|
||||||
|
t.Fatalf("fragment %d body too short", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt and reassemble
|
||||||
|
decodedHeader, decoded, err := decryptPayloadsAny(nil, packets, suite, encryptionKey, integrityKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decryptPayloadsAny() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decodedHeader.MessageID != header.MessageID || len(decoded) != len(inner) {
|
||||||
|
t.Fatalf("reassembled payload mismatch: header=%#v, count=%d, want=%d", decodedHeader, len(decoded), len(inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
for index := range inner {
|
||||||
|
if decoded[index].Type != inner[index].Type || !bytes.Equal(decoded[index].Body, inner[index].Body) {
|
||||||
|
t.Fatalf("decoded payload %d = %#v, want %#v", index, decoded[index], inner[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tamper detection on second fragment
|
||||||
|
tamperedPackets := make([][]byte, len(packets))
|
||||||
|
for i := range packets {
|
||||||
|
tamperedPackets[i] = append([]byte(nil), packets[i]...)
|
||||||
|
}
|
||||||
|
tamperedPackets[1][len(tamperedPackets[1])-1] ^= 0x55
|
||||||
|
if _, _, err := decryptPayloadsAny(nil, tamperedPackets, suite, encryptionKey, integrityKey); !errors.Is(err, errIntegrityMismatch) {
|
||||||
|
t.Fatalf("tampered fragment decrypt error = %v, want errIntegrityMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,8 +264,13 @@ func permanentAKAIdentity(identity vowifi.SIMIdentity) ([]byte, error) {
|
|||||||
return nil, errors.New("ike: IMSI contains a non-digit")
|
return nil, errors.New("ike: IMSI contains a non-digit")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
mcc := strings.TrimSpace(profile.RouteMCC)
|
||||||
|
mnc := strings.TrimSpace(profile.RouteMNC)
|
||||||
|
if mcc == "" || mnc == "" {
|
||||||
|
mcc = strings.TrimSpace(identity.HomeMCC)
|
||||||
|
mnc = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
}
|
||||||
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
|
if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) {
|
||||||
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,21 +29,28 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
|||||||
resolver = net.DefaultResolver
|
resolver = net.DefaultResolver
|
||||||
}
|
}
|
||||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||||
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
hostsToTry := []string{normalized}
|
||||||
validSystemAddresses := filterValidPublicEPDGAddresses(addresses)
|
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
||||||
if systemErr == nil && len(validSystemAddresses) > 0 {
|
hostsToTry = append(hostsToTry, alt)
|
||||||
return validSystemAddresses, nil
|
}
|
||||||
|
|
||||||
|
var systemErr error
|
||||||
|
for _, targetHost := range hostsToTry {
|
||||||
|
addresses, err := resolver.LookupIPAddr(ctx, targetHost)
|
||||||
|
if err == nil {
|
||||||
|
valid := filterValidPublicEPDGAddresses(addresses)
|
||||||
|
if len(valid) > 0 {
|
||||||
|
return valid, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
systemErr = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||||
client := &http.Client{Timeout: 8 * time.Second}
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
var fallbackErr error
|
var fallbackErr error
|
||||||
|
|
||||||
hostsToTry := []string{normalized}
|
|
||||||
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
|
||||||
hostsToTry = append(hostsToTry, alt)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, targetHost := range hostsToTry {
|
for _, targetHost := range hostsToTry {
|
||||||
var fallback []net.IPAddr
|
var fallback []net.IPAddr
|
||||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
||||||
|
|||||||
+103
-32
@@ -187,6 +187,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
{Type: payloadNonce, Body: initiatorNonce},
|
{Type: payloadNonce, Body: initiatorNonce},
|
||||||
makeNotify(notifyNATSource, sourceHash),
|
makeNotify(notifyNATSource, sourceHash),
|
||||||
makeNotify(notifyNATDestination, destinationHash),
|
makeNotify(notifyNATDestination, destinationHash),
|
||||||
|
makeNotify(notifyFragmentationSupported, nil),
|
||||||
}
|
}
|
||||||
var (
|
var (
|
||||||
initRequest []byte
|
initRequest []byte
|
||||||
@@ -243,6 +244,7 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
peerSupportsFragmentation := hasNotifyType(initResponsePayloads, notifyFragmentationSupported)
|
||||||
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
saPayload, err := onePayload(initResponsePayloads, payloadSA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -345,21 +347,19 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: 1,
|
MessageID: 1,
|
||||||
}
|
}
|
||||||
authRequest, err := encryptPayloads(authHeader, firstAuthPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
_, authResponsePayloads, err := sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
authHeader,
|
||||||
authResponse, err := transport.RoundTrip(ctx, authRequest)
|
firstAuthPayloads,
|
||||||
if err != nil {
|
ikeSuite,
|
||||||
return nil, err
|
keys,
|
||||||
}
|
peerSupportsFragmentation,
|
||||||
authResponseHeader, authResponsePayloads, err := decryptAndValidate(
|
provider.config.Random,
|
||||||
authResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, 1, ikeSuite, keys,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = authResponseHeader
|
|
||||||
serverName := strings.TrimSpace(provider.config.ServerName)
|
serverName := strings.TrimSpace(provider.config.ServerName)
|
||||||
if serverName == "" {
|
if serverName == "" {
|
||||||
serverName = epdg
|
serverName = epdg
|
||||||
@@ -409,27 +409,27 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eapRequest, err := encryptPayloads(ikeHeader{
|
eapHeader := ikeHeader{
|
||||||
InitiatorSPI: initiatorSPI,
|
InitiatorSPI: initiatorSPI,
|
||||||
ResponderSPI: responseHeader.ResponderSPI,
|
ResponderSPI: responseHeader.ResponderSPI,
|
||||||
Exchange: exchangeIKEAuth,
|
Exchange: exchangeIKEAuth,
|
||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||||
return nil, notifyErr
|
return nil, notifyErr
|
||||||
} else if requested {
|
} else if requested {
|
||||||
deviceIdentityPending = true
|
deviceIdentityPending = true
|
||||||
}
|
}
|
||||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
_, currentPayloads, err = sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
eapHeader,
|
||||||
_, currentPayloads, err = decryptAndValidate(
|
requestPayloads,
|
||||||
eapResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
ikeSuite,
|
||||||
|
keys,
|
||||||
|
peerSupportsFragmentation,
|
||||||
|
provider.config.Random,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -454,22 +454,22 @@ func (provider *Provider) start(ctx context.Context, request vowifi.TunnelReques
|
|||||||
}
|
}
|
||||||
messageID++
|
messageID++
|
||||||
cleanupMessageID = messageID + 1
|
cleanupMessageID = messageID + 1
|
||||||
finalRequest, err := encryptPayloads(ikeHeader{
|
finalHeader := ikeHeader{
|
||||||
InitiatorSPI: initiatorSPI,
|
InitiatorSPI: initiatorSPI,
|
||||||
ResponderSPI: responseHeader.ResponderSPI,
|
ResponderSPI: responseHeader.ResponderSPI,
|
||||||
Exchange: exchangeIKEAuth,
|
Exchange: exchangeIKEAuth,
|
||||||
Flags: flagInitiator,
|
Flags: flagInitiator,
|
||||||
MessageID: messageID,
|
MessageID: messageID,
|
||||||
}, []payload{initiatorAUTH}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
finalResponse, err := transport.RoundTrip(ctx, finalRequest)
|
_, finalPayloads, err := sendAndReceiveIKEPayloads(
|
||||||
if err != nil {
|
ctx,
|
||||||
return nil, err
|
transport,
|
||||||
}
|
finalHeader,
|
||||||
_, finalPayloads, err := decryptAndValidate(
|
[]payload{initiatorAUTH},
|
||||||
finalResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys,
|
ikeSuite,
|
||||||
|
keys,
|
||||||
|
peerSupportsFragmentation,
|
||||||
|
provider.config.Random,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -763,7 +763,7 @@ func decryptAndValidate(
|
|||||||
suite negotiatedSuite,
|
suite negotiatedSuite,
|
||||||
keys ikeKeys,
|
keys ikeKeys,
|
||||||
) (ikeHeader, []payload, error) {
|
) (ikeHeader, []payload, error) {
|
||||||
header, payloads, err := decryptPayloads(packet, suite, keys.SKer, keys.SKar)
|
header, payloads, err := decryptPayloadsAny(packet, nil, suite, keys.SKer, keys.SKar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ikeHeader{}, nil, err
|
return ikeHeader{}, nil, err
|
||||||
}
|
}
|
||||||
@@ -778,6 +778,77 @@ func decryptAndValidate(
|
|||||||
return header, payloads, nil
|
return header, payloads, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decryptAndValidateFragments(
|
||||||
|
packets [][]byte,
|
||||||
|
initiatorSPI [8]byte,
|
||||||
|
responderSPI [8]byte,
|
||||||
|
exchange uint8,
|
||||||
|
messageID uint32,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
keys ikeKeys,
|
||||||
|
) (ikeHeader, []payload, error) {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return ikeHeader{}, nil, errors.New("ike: empty exchange response")
|
||||||
|
}
|
||||||
|
if len(packets) == 1 {
|
||||||
|
return decryptAndValidate(packets[0], initiatorSPI, responderSPI, exchange, messageID, suite, keys)
|
||||||
|
}
|
||||||
|
header, payloads, err := decryptPayloadsAny(nil, packets, suite, keys.SKer, keys.SKar)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
if header.InitiatorSPI != initiatorSPI ||
|
||||||
|
header.ResponderSPI != responderSPI ||
|
||||||
|
header.Exchange != exchange ||
|
||||||
|
header.MessageID != messageID ||
|
||||||
|
header.Flags&flagResponse == 0 ||
|
||||||
|
header.Flags&flagInitiator != 0 {
|
||||||
|
return ikeHeader{}, nil, fmt.Errorf("%w: encrypted response header does not match the request", errUnexpectedPacket)
|
||||||
|
}
|
||||||
|
return header, payloads, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendAndReceiveIKEPayloads(
|
||||||
|
ctx context.Context,
|
||||||
|
transport datagramTransport,
|
||||||
|
header ikeHeader,
|
||||||
|
payloads []payload,
|
||||||
|
suite negotiatedSuite,
|
||||||
|
keys ikeKeys,
|
||||||
|
peerSupportsFragmentation bool,
|
||||||
|
random io.Reader,
|
||||||
|
) (ikeHeader, []payload, error) {
|
||||||
|
var outboundPackets [][]byte
|
||||||
|
var err error
|
||||||
|
if peerSupportsFragmentation {
|
||||||
|
outboundPackets, err = encryptPayloadsFragmented(header, payloads, suite, keys.SKei, keys.SKai, defaultIKEFragmentSize, random)
|
||||||
|
} else {
|
||||||
|
pkt, encryptErr := encryptPayloads(header, payloads, suite, keys.SKei, keys.SKai, random)
|
||||||
|
if encryptErr != nil {
|
||||||
|
return ikeHeader{}, nil, encryptErr
|
||||||
|
}
|
||||||
|
outboundPackets = [][]byte{pkt}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
inboundPackets, err := transport.RoundTripExchange(ctx, outboundPackets)
|
||||||
|
if err != nil {
|
||||||
|
return ikeHeader{}, nil, err
|
||||||
|
}
|
||||||
|
return decryptAndValidateFragments(inboundPackets, header.InitiatorSPI, header.ResponderSPI, header.Exchange, header.MessageID, suite, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNotifyType(payloads []payload, notifyType uint16) bool {
|
||||||
|
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||||
|
kind, _, err := parseNotify(item)
|
||||||
|
if err == nil && kind == notifyType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
var errNoProposalChosen = errors.New("ike: responder reported NO_PROPOSAL_CHOSEN")
|
||||||
|
|
||||||
type invalidKEPayloadError struct {
|
type invalidKEPayloadError struct {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func (transport *firstAuthCaptureTransport) Float(context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) {
|
func (transport *firstAuthCaptureTransport) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||||
transport.calls++
|
transport.calls++
|
||||||
if len(transport.cookieChallenge) > 0 {
|
if len(transport.cookieChallenge) > 0 {
|
||||||
switch transport.calls {
|
switch transport.calls {
|
||||||
@@ -106,6 +106,17 @@ func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (transport *firstAuthCaptureTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("test: empty outbound packets")
|
||||||
|
}
|
||||||
|
resp, err := transport.RoundTrip(ctx, packets[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return [][]byte{resp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
func (transport *firstAuthCaptureTransport) answerIKECookie(packet []byte) ([]byte, error) {
|
||||||
header, _, err := parseIKEPacket(packet)
|
header, _, err := parseIKEPacket(packet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,8 +157,8 @@ func (transport *firstAuthCaptureTransport) verifyIKECookie(packet []byte) error
|
|||||||
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
return errors.New("test: first retried IKE_SA_INIT payload is not the expected COOKIE")
|
||||||
}
|
}
|
||||||
cookies := payloadsOfType(payloads, payloadNotify)
|
cookies := payloadsOfType(payloads, payloadNotify)
|
||||||
if len(cookies) != 3 {
|
if len(cookies) != 4 {
|
||||||
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 3", len(cookies))
|
return fmt.Errorf("test: retried IKE_SA_INIT has %d notify payloads, want 4", len(cookies))
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, item := range cookies {
|
for _, item := range cookies {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func newSessionRelay(
|
|||||||
keepalive time.Duration,
|
keepalive time.Duration,
|
||||||
) *sessionRelay {
|
) *sessionRelay {
|
||||||
if keepalive <= 0 {
|
if keepalive <= 0 {
|
||||||
keepalive = 20 * time.Second
|
keepalive = 15 * time.Second
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
relay := &sessionRelay{
|
relay := &sessionRelay{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ike
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -49,6 +50,16 @@ func (*fakeSessionTransport) Float(context.Context) error { return nil }
|
|||||||
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) {
|
||||||
return nil, context.DeadlineExceeded
|
return nil, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
|
func (t *fakeSessionTransport) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("empty outbound packets")
|
||||||
|
}
|
||||||
|
resp, err := t.RoundTrip(ctx, packets[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return [][]byte{resp}, nil
|
||||||
|
}
|
||||||
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
|
func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error {
|
||||||
return transport.SendSessionPacket(ctx, packet, false)
|
return transport.SendSessionPacket(ctx, packet, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type datagramTransport interface {
|
|||||||
RemoteAddr() *net.UDPAddr
|
RemoteAddr() *net.UDPAddr
|
||||||
Float(context.Context) error
|
Float(context.Context) error
|
||||||
RoundTrip(context.Context, []byte) ([]byte, error)
|
RoundTrip(context.Context, []byte) ([]byte, error)
|
||||||
|
RoundTripExchange(context.Context, [][]byte) ([][]byte, error)
|
||||||
SendESP(context.Context, []byte) error
|
SendESP(context.Context, []byte) error
|
||||||
ReceiveESP(context.Context, []byte) (int, error)
|
ReceiveESP(context.Context, []byte) (int, error)
|
||||||
SendSessionPacket(context.Context, []byte, bool) error
|
SendSessionPacket(context.Context, []byte, bool) error
|
||||||
@@ -96,6 +97,29 @@ func roundTripDatagram(
|
|||||||
read func([]byte, time.Time) (int, error),
|
read func([]byte, time.Time) (int, error),
|
||||||
packet []byte,
|
packet []byte,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
|
writeAll := func(values [][]byte) error {
|
||||||
|
if len(values) > 0 {
|
||||||
|
return write(values[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
responses, err := roundTripFragments(ctx, timeout, writeAll, read, [][]byte{packet})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return nil, errors.New("ike: empty datagram response")
|
||||||
|
}
|
||||||
|
return responses[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundTripFragments(
|
||||||
|
ctx context.Context,
|
||||||
|
timeout time.Duration,
|
||||||
|
writeAll func([][]byte) error,
|
||||||
|
read func([]byte, time.Time) (int, error),
|
||||||
|
packets [][]byte,
|
||||||
|
) ([][]byte, error) {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -110,20 +134,44 @@ func roundTripDatagram(
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := write(packet); err != nil {
|
if err := writeAll(packets); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
attemptDeadline := time.Now().Add(interval)
|
attemptDeadline := time.Now().Add(interval)
|
||||||
if deadline.Before(attemptDeadline) {
|
if deadline.Before(attemptDeadline) {
|
||||||
attemptDeadline = deadline
|
attemptDeadline = deadline
|
||||||
}
|
}
|
||||||
|
var (
|
||||||
|
totalExpected uint16
|
||||||
|
fragments = make(map[uint16][]byte)
|
||||||
|
)
|
||||||
for time.Now().Before(attemptDeadline) {
|
for time.Now().Before(attemptDeadline) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n, err := read(buffer, attemptDeadline)
|
n, err := read(buffer, attemptDeadline)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return append([]byte(nil), buffer[:n]...), nil
|
pkt := append([]byte(nil), buffer[:n]...)
|
||||||
|
header, body, parseErr := parseIKEPacket(pkt)
|
||||||
|
if parseErr == nil && header.NextPayload == payloadEncryptedFragment && len(body) >= 8 {
|
||||||
|
fragNum := binary.BigEndian.Uint16(body[4:6])
|
||||||
|
total := binary.BigEndian.Uint16(body[6:8])
|
||||||
|
if total > 1 {
|
||||||
|
if totalExpected == 0 {
|
||||||
|
totalExpected = total
|
||||||
|
}
|
||||||
|
fragments[fragNum] = pkt
|
||||||
|
if uint16(len(fragments)) == totalExpected {
|
||||||
|
res := make([][]byte, 0, totalExpected)
|
||||||
|
for i := uint16(1); i <= totalExpected; i++ {
|
||||||
|
res = append(res, fragments[i])
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [][]byte{pkt}, nil
|
||||||
}
|
}
|
||||||
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
|
if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -222,25 +270,47 @@ func (transport *directUDP) Float(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||||
|
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return nil, errors.New("ike: empty exchange response")
|
||||||
|
}
|
||||||
|
return responses[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *directUDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
defer transport.mu.Unlock()
|
defer transport.mu.Unlock()
|
||||||
if transport.conn == nil {
|
if transport.conn == nil {
|
||||||
return nil, errors.New("ike: UDP transport is closed")
|
return nil, errors.New("ike: UDP transport is closed")
|
||||||
}
|
}
|
||||||
requestHeader, _, err := parseIKEPacket(packet)
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("ike: outbound packet list is empty")
|
||||||
|
}
|
||||||
|
requestHeader, _, err := parseIKEPacket(packets[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||||
}
|
}
|
||||||
wirePacket := packet
|
var wirePackets [][]byte
|
||||||
if transport.floated {
|
for _, pkt := range packets {
|
||||||
wirePacket = append([]byte{0, 0, 0, 0}, packet...)
|
wire := pkt
|
||||||
}
|
if transport.floated {
|
||||||
write := func(value []byte) error {
|
wire = append([]byte{0, 0, 0, 0}, pkt...)
|
||||||
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
_, err := transport.conn.Write(value)
|
wirePackets = append(wirePackets, wire)
|
||||||
return err
|
}
|
||||||
|
writeAll := func(values [][]byte) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := transport.conn.Write(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||||
for {
|
for {
|
||||||
@@ -252,10 +322,6 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if transport.floated {
|
if transport.floated {
|
||||||
// IKE and ESP legitimately share UDP/4500. An ESP packet can
|
|
||||||
// arrive immediately before the IKE response that completes
|
|
||||||
// CHILD_SA setup; discard it here and keep the same absolute
|
|
||||||
// attempt deadline while waiting for marked IKE.
|
|
||||||
if !hasNonESPMarker(buffer[:n]) {
|
if !hasNonESPMarker(buffer[:n]) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -268,7 +334,7 @@ func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roundTripDatagram(ctx, transport.config.Timeout, write, read, wirePacket)
|
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, wirePackets)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error {
|
func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error {
|
||||||
@@ -561,12 +627,26 @@ func (transport *socks5UDP) Float(_ context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) {
|
||||||
|
responses, err := transport.RoundTripExchange(ctx, [][]byte{packet})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return nil, errors.New("ike: empty exchange response")
|
||||||
|
}
|
||||||
|
return responses[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (transport *socks5UDP) RoundTripExchange(ctx context.Context, packets [][]byte) ([][]byte, error) {
|
||||||
transport.mu.Lock()
|
transport.mu.Lock()
|
||||||
defer transport.mu.Unlock()
|
defer transport.mu.Unlock()
|
||||||
if transport.udp == nil {
|
if transport.udp == nil {
|
||||||
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
|
return nil, errors.New("ike: SOCKS5 UDP transport is closed")
|
||||||
}
|
}
|
||||||
requestHeader, _, err := parseIKEPacket(packet)
|
if len(packets) == 0 {
|
||||||
|
return nil, errors.New("ike: outbound packet list is empty")
|
||||||
|
}
|
||||||
|
requestHeader, _, err := parseIKEPacket(packets[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
return nil, fmt.Errorf("ike: invalid outbound packet: %w", err)
|
||||||
}
|
}
|
||||||
@@ -576,18 +656,18 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
// Once a gateway answers, keep it pinned for the lifetime of the IKE SA.
|
||||||
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
var cookieResponse []byte
|
var cookieResponse [][]byte
|
||||||
for _, candidate := range transport.remotes {
|
for _, candidate := range transport.remotes {
|
||||||
transport.remote = cloneUDPAddr(candidate)
|
transport.remote = cloneUDPAddr(candidate)
|
||||||
response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader)
|
responses, attemptErr := transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||||
if attemptErr == nil {
|
if attemptErr == nil {
|
||||||
if ikeInitResponseHasCookie(response) {
|
if len(responses) > 0 && ikeInitResponseHasCookie(responses[0]) {
|
||||||
if cookieResponse == nil {
|
if cookieResponse == nil {
|
||||||
cookieResponse = append([]byte(nil), response...)
|
cookieResponse = responses
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return response, nil
|
return responses, nil
|
||||||
}
|
}
|
||||||
lastErr = attemptErr
|
lastErr = attemptErr
|
||||||
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
if ctx.Err() != nil || !isNetworkTimeout(attemptErr) {
|
||||||
@@ -599,24 +679,32 @@ func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byt
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr)
|
||||||
}
|
}
|
||||||
return transport.roundTripLocked(ctx, packet, requestHeader)
|
return transport.roundTripFragmentsLocked(ctx, packets, requestHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte, requestHeader ikeHeader) ([]byte, error) {
|
func (transport *socks5UDP) roundTripFragmentsLocked(ctx context.Context, packets [][]byte, requestHeader ikeHeader) ([][]byte, error) {
|
||||||
wireIKE := packet
|
var datagrams [][]byte
|
||||||
if transport.floated {
|
for _, pkt := range packets {
|
||||||
wireIKE = append([]byte{0, 0, 0, 0}, packet...)
|
wireIKE := pkt
|
||||||
}
|
if transport.floated {
|
||||||
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
wireIKE = append([]byte{0, 0, 0, 0}, pkt...)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
write := func(value []byte) error {
|
|
||||||
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
_, err := transport.udp.Write(value)
|
datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE)
|
||||||
return err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
datagrams = append(datagrams, datagram)
|
||||||
|
}
|
||||||
|
writeAll := func(values [][]byte) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := transport.udp.Write(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
read := func(buffer []byte, attemptDeadline time.Time) (int, error) {
|
||||||
for {
|
for {
|
||||||
@@ -630,10 +718,6 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if transport.floated {
|
if transport.floated {
|
||||||
// The relay can deliver ESP before the marked IKE response on
|
|
||||||
// the same UDP/4500 association. Do not accept it as IKE, and
|
|
||||||
// do not abort the exchange; keep waiting within the original
|
|
||||||
// deadline.
|
|
||||||
if !hasNonESPMarker(payload) {
|
if !hasNonESPMarker(payload) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -646,7 +730,7 @@ func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte,
|
|||||||
return len(payload), nil
|
return len(payload), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return roundTripDatagram(ctx, transport.config.Timeout, write, read, datagram)
|
return roundTripFragments(ctx, transport.config.Timeout, writeAll, read, datagrams)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNetworkTimeout(err error) bool {
|
func isNetworkTimeout(err error) bool {
|
||||||
|
|||||||
+14
-12
@@ -31,9 +31,10 @@ const (
|
|||||||
payloadDelete = 42
|
payloadDelete = 42
|
||||||
payloadTSi = 44
|
payloadTSi = 44
|
||||||
payloadTSr = 45
|
payloadTSr = 45
|
||||||
payloadEncrypted = 46
|
payloadEncrypted = 46
|
||||||
payloadCP = 47
|
payloadCP = 47
|
||||||
payloadEAP = 48
|
payloadEAP = 48
|
||||||
|
payloadEncryptedFragment = 53
|
||||||
|
|
||||||
protocolIKE = 1
|
protocolIKE = 1
|
||||||
protocolESP = 3
|
protocolESP = 3
|
||||||
@@ -55,15 +56,16 @@ const (
|
|||||||
dhMODP2048 = 14
|
dhMODP2048 = 14
|
||||||
transformAttributeKeyLen = 14
|
transformAttributeKeyLen = 14
|
||||||
|
|
||||||
notifyInitialContact = 16384
|
notifyInitialContact = 16384
|
||||||
notifyMOBIKESupported = 16396
|
notifyMOBIKESupported = 16396
|
||||||
notifyNATSource = 16388
|
notifyNATSource = 16388
|
||||||
notifyNATDestination = 16389
|
notifyNATDestination = 16389
|
||||||
notifyCookie = 16390
|
notifyCookie = 16390
|
||||||
notifyEAPOnlyAuth = 16417
|
notifyEAPOnlyAuth = 16417
|
||||||
notifyDeviceIdentity = 41101
|
notifyFragmentationSupported = 16430
|
||||||
notifyInvalidKE = 17
|
notifyDeviceIdentity = 41101
|
||||||
notifyNoProposal = 14
|
notifyInvalidKE = 17
|
||||||
|
notifyNoProposal = 14
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
+109
-89
@@ -230,99 +230,109 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pcscf := provider.config.PCSCF
|
var pcscfCandidates []string
|
||||||
if pcscf == "" {
|
if provider.config.PCSCF != "" {
|
||||||
|
pcscfCandidates = []string{provider.config.PCSCF}
|
||||||
|
} else {
|
||||||
for _, candidate := range tunnel.PCSCF {
|
for _, candidate := range tunnel.PCSCF {
|
||||||
if strings.TrimSpace(candidate) != "" {
|
candidate = strings.TrimSpace(candidate)
|
||||||
pcscf = candidate
|
if candidate != "" {
|
||||||
break
|
pcscfCandidates = append(pcscfCandidates, candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if pcscf == "" {
|
if len(pcscfCandidates) == 0 {
|
||||||
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
|
return nil, errors.New("ims: tunnel did not provide a P-CSCF")
|
||||||
}
|
}
|
||||||
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
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, 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"
|
|
||||||
}
|
|
||||||
localAddress := provider.config.LocalAddress
|
|
||||||
if localAddress == "" {
|
|
||||||
if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil {
|
|
||||||
localAddress = tunnel.LocalIPv6
|
|
||||||
} else {
|
|
||||||
localAddress = tunnel.LocalIPv4
|
|
||||||
if strings.TrimSpace(localAddress) == "" {
|
|
||||||
localAddress = tunnel.LocalIPv6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0])
|
|
||||||
if localAddress == "" {
|
|
||||||
return nil, errors.New("ims: tunnel did not provide a local address")
|
|
||||||
}
|
|
||||||
if !localAddressProvenByTunnel(localAddress, tunnel) {
|
|
||||||
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
|
||||||
}
|
|
||||||
|
|
||||||
transports := []string{transport}
|
|
||||||
if provider.config.AutoTransportFallback {
|
|
||||||
alternate := "udp"
|
|
||||||
if transport == "udp" {
|
|
||||||
alternate = "tcp"
|
|
||||||
}
|
|
||||||
transports = append(transports, alternate)
|
|
||||||
}
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt, candidate := range transports {
|
for pcscfIndex, pcscf := range pcscfCandidates {
|
||||||
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port)
|
||||||
if dialErr != nil {
|
if err != nil {
|
||||||
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
lastErr = err
|
||||||
if attempt+1 < len(transports) && ctx.Err() == nil {
|
continue
|
||||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
}
|
||||||
continue
|
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, 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"
|
||||||
|
}
|
||||||
|
localAddress := provider.config.LocalAddress
|
||||||
|
if localAddress == "" {
|
||||||
|
if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil {
|
||||||
|
localAddress = tunnel.LocalIPv6
|
||||||
|
} else {
|
||||||
|
localAddress = tunnel.LocalIPv4
|
||||||
|
if strings.TrimSpace(localAddress) == "" {
|
||||||
|
localAddress = tunnel.LocalIPv6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
|
||||||
}
|
}
|
||||||
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0])
|
||||||
if sessionErr != nil {
|
if localAddress == "" {
|
||||||
_ = connection.Close()
|
return nil, errors.New("ims: tunnel did not provide a local address")
|
||||||
return nil, sessionErr
|
|
||||||
}
|
}
|
||||||
establishErr := session.establish(ctx)
|
if !localAddressProvenByTunnel(localAddress, tunnel) {
|
||||||
if establishErr == nil {
|
return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel")
|
||||||
provider.rememberTransport(request.Identity, candidate)
|
}
|
||||||
if attempt > 0 {
|
|
||||||
provider.config.Logger.Info("IMS automatic transport fallback succeeded",
|
transports := []string{transport}
|
||||||
"carrier_profile", vowifi.ResolveCarrierProfile(request.Identity).ID,
|
if provider.config.AutoTransportFallback {
|
||||||
"transport", candidate)
|
alternate := "udp"
|
||||||
|
if transport == "udp" {
|
||||||
|
alternate = "tcp"
|
||||||
}
|
}
|
||||||
return session, nil
|
transports = append(transports, alternate)
|
||||||
}
|
}
|
||||||
sipResponseObserved := session.evidence.LastSIPCode != 0
|
for attempt, candidate := range transports {
|
||||||
session.abort()
|
connection, dialErr := dialSIP(ctx, candidate, localAddress, 0, endpoint.address())
|
||||||
lastErr = establishErr
|
if dialErr != nil {
|
||||||
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
lastErr = fmt.Errorf("ims: connect to P-CSCF over %s: %w", candidate, dialErr)
|
||||||
return nil, lastErr
|
if attempt+1 < len(transports) && ctx.Err() == nil {
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], lastErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
session, sessionErr := newSession(provider, request, identities, endpoint, candidate, connection)
|
||||||
|
if sessionErr != nil {
|
||||||
|
_ = connection.Close()
|
||||||
|
lastErr = sessionErr
|
||||||
|
break
|
||||||
|
}
|
||||||
|
establishErr := session.establish(ctx)
|
||||||
|
if establishErr == nil {
|
||||||
|
provider.rememberTransport(request.Identity, candidate)
|
||||||
|
if attempt > 0 || pcscfIndex > 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()
|
||||||
|
lastErr = establishErr
|
||||||
|
if sipResponseObserved || attempt+1 >= len(transports) || ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
provider.logTransportFallback(request.Identity, candidate, transports[attempt+1], establishErr)
|
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
@@ -384,8 +394,13 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
if !digitsBetween(imsi, 5, 16) {
|
if !digitsBetween(imsi, 5, 16) {
|
||||||
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
|
return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid")
|
||||||
}
|
}
|
||||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
profile := vowifi.ResolveCarrierProfile(identity)
|
||||||
mnc := strings.TrimSpace(identity.HomeMNC)
|
mcc := strings.TrimSpace(profile.RouteMCC)
|
||||||
|
mnc := strings.TrimSpace(profile.RouteMNC)
|
||||||
|
if mcc == "" || mnc == "" {
|
||||||
|
mcc = strings.TrimSpace(identity.HomeMCC)
|
||||||
|
mnc = strings.TrimSpace(identity.HomeMNC)
|
||||||
|
}
|
||||||
if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) {
|
if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) {
|
||||||
return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid")
|
return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid")
|
||||||
}
|
}
|
||||||
@@ -395,7 +410,7 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet,
|
|||||||
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc)
|
||||||
privateDomain := domain
|
privateDomain := domain
|
||||||
publicDomain := domain
|
publicDomain := domain
|
||||||
if vowifi.ResolveCarrierProfile(identity).IMSIdentityProfile == vowifi.IMSProfileATT {
|
if profile.IMSIdentityProfile == vowifi.IMSProfileATT {
|
||||||
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
// AT&T provisions the IMPI and IMPU in its ISIM domains rather than
|
||||||
// the generic 3GPP PLMN IMS domain.
|
// the generic 3GPP PLMN IMS domain.
|
||||||
domain = "one.att.net"
|
domain = "one.att.net"
|
||||||
@@ -976,19 +991,17 @@ func (session *Session) buildRegister(
|
|||||||
}
|
}
|
||||||
lines = append(lines, "User-Agent: "+userAgent)
|
lines = append(lines, "User-Agent: "+userAgent)
|
||||||
|
|
||||||
defaultPANI := "IEEE-802.11;i-wlan-node-id=000000000000;network-provided"
|
|
||||||
pani := defaultPANI
|
|
||||||
if registerOptions.PAccessNetworkInfo != nil {
|
|
||||||
pani = *registerOptions.PAccessNetworkInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
if registerOptions.PPreferredIdentity {
|
if registerOptions.PPreferredIdentity {
|
||||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" {
|
||||||
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
|
lines = append(lines, `P-Visited-Network-ID: "`+value+`"`)
|
||||||
}
|
}
|
||||||
if pani != "" {
|
// PANI carries access/location information and must not be fabricated.
|
||||||
|
// In particular, "network-provided" identifies a value inserted by a
|
||||||
|
// trusted network proxy, not one generated by this UE. Send the header
|
||||||
|
// only when a carrier profile explicitly supplies a reviewed value.
|
||||||
|
if pani := optionalRegisterHeader(registerOptions.PAccessNetworkInfo); pani != "" {
|
||||||
lines = append(lines, "P-Access-Network-Info: "+pani)
|
lines = append(lines, "P-Access-Network-Info: "+pani)
|
||||||
}
|
}
|
||||||
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" {
|
||||||
@@ -1032,6 +1045,13 @@ func (session *Session) buildRegister(
|
|||||||
return []byte(strings.Join(lines, "\r\n")), nil
|
return []byte(strings.Join(lines, "\r\n")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalRegisterHeader(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*value)
|
||||||
|
}
|
||||||
|
|
||||||
func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string {
|
func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string {
|
||||||
base := fmt.Sprintf("<sip:%s@%s;transport=%s>", session.identity.user, contactAddress, session.transport)
|
base := fmt.Sprintf("<sip:%s@%s;transport=%s>", session.identity.user, contactAddress, session.transport)
|
||||||
instanceID := session.instanceID
|
instanceID := session.instanceID
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
for _, forbidden := range []string{
|
for _, forbidden := range []string{
|
||||||
"p-visited-network-id",
|
"p-visited-network-id",
|
||||||
"p-preferred-identity",
|
"p-preferred-identity",
|
||||||
|
"p-access-network-info",
|
||||||
} {
|
} {
|
||||||
if headers[forbidden] != "" {
|
if headers[forbidden] != "" {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
@@ -386,9 +387,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if headers["p-access-network-info"] != "IEEE-802.11;i-wlan-node-id=000000000000;network-provided" {
|
|
||||||
return fmt.Errorf("REGISTER P-Access-Network-Info = %q", headers["p-access-network-info"])
|
|
||||||
}
|
|
||||||
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
if !strings.Contains(headers["allow"], "MESSAGE") ||
|
||||||
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
!strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") {
|
||||||
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"])
|
||||||
@@ -497,6 +495,26 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOptionalRegisterHeaderRequiresExplicitNonemptyValue(t *testing.T) {
|
||||||
|
explicit := " IEEE-802.11;i-wlan-node-id=aabbccddeeff "
|
||||||
|
empty := " "
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value *string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "unspecified", value: nil, want: ""},
|
||||||
|
{name: "explicit omission", value: &empty, want: ""},
|
||||||
|
{name: "explicit value", value: &explicit, want: "IEEE-802.11;i-wlan-node-id=aabbccddeeff"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := optionalRegisterHeader(test.value); got != test.want {
|
||||||
|
t.Fatalf("optionalRegisterHeader() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||||
var callID string
|
var callID string
|
||||||
for step := 0; step < 3; step++ {
|
for step := 0; step < 3; step++ {
|
||||||
|
|||||||
@@ -71,7 +71,16 @@ func (media *rtpMedia) ready() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (media *rtpMedia) offerSDP(local net.IP) []byte {
|
func (media *rtpMedia) offerSDP(local net.IP) []byte {
|
||||||
return media.buildSDP(local, "8 0", nil)
|
return media.buildSDP(local, "8 0 104 102 100", []string{
|
||||||
|
"a=rtpmap:8 PCMA/8000",
|
||||||
|
"a=rtpmap:0 PCMU/8000",
|
||||||
|
"a=rtpmap:104 AMR-WB/16000",
|
||||||
|
"a=fmtp:104 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:102 AMR/8000",
|
||||||
|
"a=fmtp:102 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:100 telephone-event/8000",
|
||||||
|
"a=fmtp:100 0-15",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
||||||
@@ -81,8 +90,12 @@ func (media *rtpMedia) answerSDP(local net.IP) []byte {
|
|||||||
if codec == "" {
|
if codec == "" {
|
||||||
return media.offerSDP(local)
|
return media.offerSDP(local)
|
||||||
}
|
}
|
||||||
|
rate := 8000
|
||||||
|
if codec == "AMR-WB" {
|
||||||
|
rate = 16000
|
||||||
|
}
|
||||||
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
|
return media.buildSDP(local, strconv.Itoa(int(payload)), []string{
|
||||||
fmt.Sprintf("a=rtpmap:%d %s/8000", payload, codec),
|
fmt.Sprintf("a=rtpmap:%d %s/%d", payload, codec, rate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +123,16 @@ func (media *rtpMedia) buildSDP(local net.IP, formats string, attributes []strin
|
|||||||
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
|
fmt.Sprintf("m=audio %d RTP/AVP %s", port, formats),
|
||||||
}
|
}
|
||||||
if attributes == nil {
|
if attributes == nil {
|
||||||
lines = append(lines, "a=rtpmap:8 PCMA/8000", "a=rtpmap:0 PCMU/8000")
|
lines = append(lines,
|
||||||
|
"a=rtpmap:8 PCMA/8000",
|
||||||
|
"a=rtpmap:0 PCMU/8000",
|
||||||
|
"a=rtpmap:104 AMR-WB/16000",
|
||||||
|
"a=fmtp:104 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:102 AMR/8000",
|
||||||
|
"a=fmtp:102 mode-change-capability=2;max-red=220",
|
||||||
|
"a=rtpmap:100 telephone-event/8000",
|
||||||
|
"a=fmtp:100 0-15",
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, attributes...)
|
lines = append(lines, attributes...)
|
||||||
}
|
}
|
||||||
@@ -137,15 +159,24 @@ func (media *rtpMedia) configureRemote(body []byte) error {
|
|||||||
name = "PCMU"
|
name = "PCMU"
|
||||||
case 8:
|
case 8:
|
||||||
name = "PCMA"
|
name = "PCMA"
|
||||||
|
case 100:
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
name = fmt.Sprintf("PAYLOAD-%d", parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if name == "PCMA" || name == "PCMU" {
|
if name != "TELEPHONE-EVENT" {
|
||||||
codec, payload = name, byte(parsed)
|
codec, payload = name, byte(parsed)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if codec == "" && len(formats) > 0 {
|
||||||
|
if parsed, parseErr := strconv.Atoi(formats[0]); parseErr == nil {
|
||||||
|
codec, payload = fmt.Sprintf("PAYLOAD-%d", parsed), byte(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
if codec == "" {
|
if codec == "" {
|
||||||
return errors.New("ims: remote endpoint did not accept PCMA or PCMU audio")
|
return errors.New("ims: remote SDP has no usable audio format")
|
||||||
}
|
}
|
||||||
media.mu.Lock()
|
media.mu.Lock()
|
||||||
media.remote = &net.UDPAddr{IP: address, Port: port}
|
media.remote = &net.UDPAddr{IP: address, Port: port}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20",
|
DeviceID: "ec20",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
ProfileName: "Vodafone UK",
|
ProfileName: "Vodafone UK",
|
||||||
UpstreamProxyID: "clash",
|
UpstreamProxyID: "clash",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -46,7 +46,7 @@ func TestProxyResolverUsesICCIDProfileBinding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(
|
route, err := (ProxyResolver{Store: database}).Resolve(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234", HomeMNC: "15"},
|
vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234", HomeMNC: "15"},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -67,7 +67,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
|
|||||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
|
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ID: "proxy", Name: "Proxy", Addr: "127.0.0.1:1080", Enabled: true}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "A", UpstreamProxyID: "proxy"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
|
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{DeviceID: "ec20", ICCID: "89104100000028106378"})
|
||||||
@@ -137,12 +137,12 @@ func TestProxyResolverICCIDBindingWithDisabledProxyFailsClosed(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "Manual", UpstreamProxyID: "disabled",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
_, err := (ProxyResolver{Store: database}).Resolve(ctx, vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
t.Fatal("disabled explicit ICCID binding unexpectedly fell back to another route")
|
||||||
@@ -169,7 +169,7 @@ func TestProxyResolverMaterializesCountryRuleAsICCIDBinding(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
request := vowifi.ProxyRequest{
|
request := vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
}
|
}
|
||||||
resolver := ProxyResolver{Store: database}
|
resolver := ProxyResolver{Store: database}
|
||||||
route, err := resolver.Resolve(ctx, request)
|
route, err := resolver.Resolve(ctx, request)
|
||||||
@@ -214,7 +214,7 @@ func TestInsertDeviceProxyBindingIfAbsentDoesNotReplaceExplicitBinding(t *testin
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
iccid := "89441000400128014257"
|
iccid := "8944100000000000001"
|
||||||
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(ctx, store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
DeviceID: "ec20", ICCID: iccid, ProfileName: "Manual", UpstreamProxyID: "explicit",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -257,12 +257,12 @@ func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
DeviceID: "ec20", ICCID: "8944100000000000001", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
||||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
DeviceID: "ec20", ICCID: "8944100000000000001", HomeMCC: "234",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -400,10 +400,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if err := projector.Save(context.Background(), vowifi.State{
|
if err := projector.Save(context.Background(), vowifi.State{
|
||||||
DeviceID: "ec20",
|
DeviceID: "ec20",
|
||||||
ICCID: "89441000400128014257",
|
ICCID: "8944100000000000001",
|
||||||
IMSI: "234159608751160",
|
IMSI: "234150000000001",
|
||||||
Phase: vowifi.PhaseStopping,
|
Phase: vowifi.PhaseStopping,
|
||||||
PhoneNumber: "+447386083638",
|
PhoneNumber: "+447700900123",
|
||||||
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
|
PhoneNumberSource: vowifi.PhoneSourcePAssociatedURI,
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -413,10 +413,10 @@ func TestStateProjectorDoesNotAttachOldSessionNumberToNewLiveSIM(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if runtime.ICCID != "89441000400128014257" || runtime.IMSI != "234159608751160" {
|
if runtime.ICCID != "8944100000000000001" || runtime.IMSI != "234150000000001" {
|
||||||
t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI)
|
t.Fatalf("runtime identity = %q/%q", runtime.ICCID, runtime.IMSI)
|
||||||
}
|
}
|
||||||
if runtime.LocalPhone != "+447386083638" {
|
if runtime.LocalPhone != "+447700900123" {
|
||||||
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
|
t.Fatalf("runtime phone = %q", runtime.LocalPhone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,237 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
import { PlugConnectedRegular } from "@fluentui/react-icons";
|
||||||
import { useI18n, tf } from "../../lib/i18n";
|
import { useI18n, tf } from "../../lib/i18n";
|
||||||
import { cx } from "../../lib/utils";
|
import { cx } from "../../lib/utils";
|
||||||
|
|
||||||
// 模块在线率分四档:100% 绿,80-99% 黄,50-79% 橙,低于 50% 红。
|
interface DayUptime {
|
||||||
type RateLevel = "green" | "yellow" | "orange" | "red";
|
dateKey: string; // YYYY-MM-DD
|
||||||
|
date: Date;
|
||||||
function rateLevel(percent: number): RateLevel {
|
isToday: boolean;
|
||||||
if (percent >= 100) return "green";
|
daysAgo: number;
|
||||||
if (percent >= 80) return "yellow";
|
uptimePercent: number; // 0 - 100
|
||||||
if (percent >= 50) return "orange";
|
status: "online" | "degraded" | "down" | "none";
|
||||||
return "red";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_STYLES: Record<RateLevel, { text: string; dot: string; labelKey: string }> = {
|
const STORAGE_KEY = "vocat_uptime_history_14d";
|
||||||
green: { text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", labelKey: "优秀" },
|
|
||||||
yellow: { text: "text-yellow-600 dark:text-yellow-400", dot: "bg-yellow-500", labelKey: "良好" },
|
function get14DaysSlots(currentOnline: number, currentTotal: number): DayUptime[] {
|
||||||
orange: { text: "text-orange-600 dark:text-orange-400", dot: "bg-orange-500", labelKey: "一般" },
|
let savedMap: Record<string, number> = {};
|
||||||
red: { text: "text-red-600 dark:text-red-400", dot: "bg-red-500", labelKey: "较差" },
|
try {
|
||||||
};
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) savedMap = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const slots: DayUptime[] = [];
|
||||||
|
|
||||||
|
for (let i = 13; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
const dateKey = `${y}-${m}-${day}`;
|
||||||
|
const isToday = i === 0;
|
||||||
|
|
||||||
|
let percent = 100;
|
||||||
|
if (isToday) {
|
||||||
|
if (currentTotal === 0) {
|
||||||
|
percent = -1;
|
||||||
|
} else {
|
||||||
|
percent = Math.round((currentOnline / currentTotal) * 100);
|
||||||
|
}
|
||||||
|
if (percent >= 0) {
|
||||||
|
savedMap[dateKey] = percent;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dateKey in savedMap) {
|
||||||
|
percent = savedMap[dateKey];
|
||||||
|
} else {
|
||||||
|
percent = currentTotal > 0 ? 100 : -1;
|
||||||
|
if (percent >= 0) savedMap[dateKey] = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: DayUptime["status"] = "online";
|
||||||
|
if (percent < 0) status = "none";
|
||||||
|
else if (percent >= 99) status = "online";
|
||||||
|
else if (percent >= 50) status = "degraded";
|
||||||
|
else status = "down";
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
dateKey,
|
||||||
|
date: d,
|
||||||
|
isToday,
|
||||||
|
daysAgo: i,
|
||||||
|
uptimePercent: percent < 0 ? 0 : percent,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedMap));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
// 模块在线率卡:汇总全部已添加且可识别的模块,大字号百分比按四档着色。
|
|
||||||
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
export function OnlineRateCard({ online, total }: { online: number; total: number }) {
|
||||||
const { t } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const percent = total > 0 ? Math.round((online / total) * 100) : null;
|
const [hoveredDay, setHoveredDay] = useState<DayUptime | null>(null);
|
||||||
const level = percent === null ? null : rateLevel(percent);
|
const [slots, setSlots] = useState<DayUptime[]>(() => get14DaysSlots(online, total));
|
||||||
const styles = level ? LEVEL_STYLES[level] : null;
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSlots(get14DaysSlots(online, total));
|
||||||
|
}, [online, total]);
|
||||||
|
|
||||||
|
const currentPercent = total > 0 ? Math.round((online / total) * 100) : null;
|
||||||
|
const overallAvg =
|
||||||
|
slots.filter((s) => s.status !== "none").length > 0
|
||||||
|
? Math.round(
|
||||||
|
slots.filter((s) => s.status !== "none").reduce((acc, s) => acc + s.uptimePercent, 0) /
|
||||||
|
slots.filter((s) => s.status !== "none").length,
|
||||||
|
)
|
||||||
|
: currentPercent;
|
||||||
|
|
||||||
|
const formatDateLabel = (d: Date) => {
|
||||||
|
if (lang === "zh") {
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-panel p-4">
|
<div className="ui-panel relative flex flex-col justify-between p-4 transition-all">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
{/* Header */}
|
||||||
<PlugConnectedRegular className="h-4 w-4 text-sky-500" />
|
<div>
|
||||||
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
|
||||||
{percent === null ? (
|
<PlugConnectedRegular className="h-3.5 w-3.5" />
|
||||||
<div className="text-4xl font-extrabold text-gray-300 dark:text-gray-600">--%</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-1.5">
|
||||||
<div className={cx("text-5xl font-extrabold tabular-nums leading-none", styles!.text)}>
|
<h3 className="text-sm font-bold text-gray-800 dark:text-gray-100">{t("模块在线率")}</h3>
|
||||||
{percent}
|
<span className="rounded px-1.5 py-0.2 text-[10px] font-semibold bg-gray-100 text-gray-600 dark:bg-white/10 dark:text-gray-300">
|
||||||
<span className="text-2xl">%</span>
|
14d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="flex items-baseline gap-1">
|
||||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
{overallAvg === null ? (
|
||||||
{styles ? (
|
<span className="text-xl font-extrabold text-gray-400">--%</span>
|
||||||
<span className="flex items-center gap-1">
|
) : (
|
||||||
<span className={cx("inline-block h-1.5 w-1.5 rounded-full", styles.dot)} />
|
<span
|
||||||
{t(styles.labelKey)}
|
className={cx(
|
||||||
|
"text-xl font-extrabold tabular-nums tracking-tight",
|
||||||
|
overallAvg >= 99
|
||||||
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
|
: overallAvg >= 80
|
||||||
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
|
: "text-red-600 dark:text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{overallAvg}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle count */}
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"inline-block h-1.5 w-1.5 rounded-full",
|
||||||
|
online > 0 ? "bg-emerald-500 animate-pulse" : "bg-gray-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{tf("{online}/{total} 台在线", { online, total })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
|
{currentPercent !== null && currentPercent >= 99 ? t("运行优秀") : t("正常监控")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
<span className="tabular-nums">{tf("{online}/{total} 台在线", { online, total })}</span>
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime Kuma 14-day Heartbeat Bars */}
|
||||||
|
<div className="my-2.5">
|
||||||
|
<div className="flex items-center gap-1 sm:gap-1.5 h-8 w-full">
|
||||||
|
{slots.map((slot) => {
|
||||||
|
let barBg = "bg-gray-200 dark:bg-white/10";
|
||||||
|
if (slot.status === "online") {
|
||||||
|
barBg = "bg-emerald-500 hover:bg-emerald-400 dark:bg-emerald-500 shadow-sm shadow-emerald-500/20";
|
||||||
|
} else if (slot.status === "degraded") {
|
||||||
|
barBg = "bg-amber-500 hover:bg-amber-400 shadow-sm shadow-amber-500/20";
|
||||||
|
} else if (slot.status === "down") {
|
||||||
|
barBg = "bg-rose-500 hover:bg-rose-400 shadow-sm shadow-rose-500/20";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot.dateKey}
|
||||||
|
onMouseEnter={() => setHoveredDay(slot)}
|
||||||
|
onMouseLeave={() => setHoveredDay(null)}
|
||||||
|
className="group/bar relative flex-1 h-full flex items-end cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"w-full rounded-sm transition-all duration-150 group-hover/bar:scale-y-110",
|
||||||
|
slot.isToday ? "h-full ring-1 ring-emerald-400/40" : "h-full",
|
||||||
|
barBg,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Tooltip on Hover */}
|
||||||
|
{hoveredDay?.dateKey === slot.dateKey && (
|
||||||
|
<div className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-30 whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-[11px] font-medium text-white shadow-xl dark:bg-gray-800 border border-white/10">
|
||||||
|
<div className="font-bold flex items-center gap-1.5">
|
||||||
|
<span>{formatDateLabel(slot.date)}</span>
|
||||||
|
{slot.isToday ? (
|
||||||
|
<span className="rounded bg-emerald-500/30 px-1 text-[9px] text-emerald-300 font-normal">
|
||||||
|
{t("今天")}
|
||||||
|
</span>
|
||||||
|
) : slot.daysAgo === 1 ? (
|
||||||
|
<span className="text-[10px] text-gray-400 font-normal">
|
||||||
|
{t("昨天")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-gray-400 font-normal">
|
||||||
|
{tf("{days}天前", { days: slot.daysAgo })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-gray-300">
|
||||||
|
<span>
|
||||||
|
{slot.status === "online"
|
||||||
|
? `🟢 ${slot.uptimePercent}% ${t("正常在线")}`
|
||||||
|
: slot.status === "degraded"
|
||||||
|
? `🟡 ${slot.uptimePercent}% ${t("部分离线")}`
|
||||||
|
: slot.status === "down"
|
||||||
|
? `🔴 0% ${t("完全离线")}`
|
||||||
|
: `⚪ ${t("暂无数据")}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Tooltip triangle */}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900 dark:border-t-gray-800" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend / Range labels */}
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[10px] font-medium text-gray-400 dark:text-gray-500">
|
||||||
|
<span>{t("14天前")}</span>
|
||||||
|
<span className="opacity-75">{t("持续监测中")}</span>
|
||||||
|
<span>{t("今天")}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1175,4 +1175,31 @@ export const EN_DICT: Record<string, string> = {
|
|||||||
"绑定到该代理的国家规则将自动删除,相关国家会恢复直连。":
|
"绑定到该代理的国家规则将自动删除,相关国家会恢复直连。":
|
||||||
"Country rules bound to this proxy will be deleted, and those countries will revert to a direct connection.",
|
"Country rules bound to this proxy will be deleted, and those countries will revert to a direct connection.",
|
||||||
"{encoding} · 预计 {parts} 段 · {length} 字": "{encoding} · ~{parts} seg · {length} chars",
|
"{encoding} · 预计 {parts} 段 · {length} 字": "{encoding} · ~{parts} seg · {length} chars",
|
||||||
|
|
||||||
|
// Uptime & Monitoring translations
|
||||||
|
"运行优秀": "Excellent",
|
||||||
|
"正常监控": "Optimal",
|
||||||
|
"14天前": "14d ago",
|
||||||
|
"持续监测中": "Monitored",
|
||||||
|
"今天": "Today",
|
||||||
|
"昨天": "Yesterday",
|
||||||
|
"天前": "days ago",
|
||||||
|
"{days}天前": "{days}d ago",
|
||||||
|
"正常在线": "Online",
|
||||||
|
"部分离线": "Degraded",
|
||||||
|
"完全离线": "Offline",
|
||||||
|
|
||||||
|
// Additional missing system strings
|
||||||
|
"端口": "Port",
|
||||||
|
"错误详情": "Error Details",
|
||||||
|
"正在搜索网络": "Searching network",
|
||||||
|
"SM-DP+ 的公开 Profile 库存已耗尽,请稍后重试或更换服务。":
|
||||||
|
"The public profile inventory on the SM-DP+ is exhausted. Please try again later or use a different service.",
|
||||||
|
"此 SM-DP+ 的证书链不受当前 eUICC 信任;该卡不能使用此测试服务器。":
|
||||||
|
"The SM-DP+ certificate chain is not trusted by this eUICC; this card cannot use this test server.",
|
||||||
|
"激活码已被使用、已过期或被 SM-DP+ 拒绝,请更换新的 Matching ID。":
|
||||||
|
"The activation code has already been used, expired, or was rejected by SM-DP+. Please use a new Matching ID.",
|
||||||
|
"已发现该模组,但未找到 AT 串口:通常是 option 驱动未认该 PID 或模组处于 MBIM/RNDIS 组态。可 ":
|
||||||
|
"Modem detected, but no AT serial port found: option driver may not recognize this PID or modem is in MBIM/RNDIS mode. You can ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+121
-139
@@ -314,7 +314,7 @@ export default function SmsPage() {
|
|||||||
const selectContact = useCallback(
|
const selectContact = useCallback(
|
||||||
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
|
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
|
||||||
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
|
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
|
||||||
if (!key || (keyRef.current === key && messagesRef.current.length > 0)) return;
|
if (!key) return;
|
||||||
setKey(key);
|
setKey(key);
|
||||||
if (syncRoute) syncQuery(deviceRef.current, key);
|
if (syncRoute) syncQuery(deviceRef.current, key);
|
||||||
const thread = contactsRef.current.find((t) => t.key === key) || null;
|
const thread = contactsRef.current.find((t) => t.key === key) || null;
|
||||||
@@ -338,8 +338,8 @@ export default function SmsPage() {
|
|||||||
contactsList: SmsThread[],
|
contactsList: SmsThread[],
|
||||||
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
|
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const { syncRoute = false, silent = false, scrollToBottom = false } = opts;
|
const { silent = false, scrollToBottom = false } = opts;
|
||||||
const active = contactsList.find((t) => t.key === keyRef.current) || null;
|
const active = (keyRef.current && contactsList.find((t) => t.key === keyRef.current)) || null;
|
||||||
if (active) {
|
if (active) {
|
||||||
const ok = await loadThreadFor(active, device, silent);
|
const ok = await loadThreadFor(active, device, silent);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -350,16 +350,8 @@ export default function SmsPage() {
|
|||||||
}
|
}
|
||||||
setMessagesState([]);
|
setMessagesState([]);
|
||||||
setHasMoreState(false);
|
setHasMoreState(false);
|
||||||
if (keyRef.current) {
|
|
||||||
setKey("");
|
|
||||||
if (syncRoute) syncQuery(device, "");
|
|
||||||
}
|
|
||||||
const filtered = filterThreads(contactsList, searchRef.current);
|
|
||||||
if (!isMobileRef.current && filtered.length > 0) {
|
|
||||||
await selectContact(filtered[0].key, { syncRoute, silent, scrollToBottom });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[loadThreadFor, selectContact, syncQuery, scrollToBottomNow],
|
[loadThreadFor, scrollToBottomNow],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearSelection = useCallback(
|
const clearSelection = useCallback(
|
||||||
@@ -479,7 +471,7 @@ export default function SmsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [composer, devices, refreshCurrent, scrollToBottomNow]);
|
}, [composer, devices, refreshCurrent, scrollToBottomNow, t]);
|
||||||
|
|
||||||
const openNewSms = useCallback(() => {
|
const openNewSms = useCallback(() => {
|
||||||
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
|
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
|
||||||
@@ -506,7 +498,7 @@ export default function SmsPage() {
|
|||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshCurrent],
|
[refreshCurrent, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteMessageAction = useCallback(
|
const deleteMessageAction = useCallback(
|
||||||
@@ -530,7 +522,7 @@ export default function SmsPage() {
|
|||||||
setDeletingMessageId(null);
|
setDeletingMessageId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingMessageId, refreshCurrent, clearSelection],
|
[deletingMessageId, refreshCurrent, clearSelection, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteThreadAction = useCallback(
|
const deleteThreadAction = useCallback(
|
||||||
@@ -560,7 +552,7 @@ export default function SmsPage() {
|
|||||||
setDeletingThreadKey(null);
|
setDeletingThreadKey(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deletingThreadKey, clearSelection, loadContacts],
|
[deletingThreadKey, clearSelection, loadContacts, lang],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeActionSheet = useCallback(() => {
|
const closeActionSheet = useCallback(() => {
|
||||||
@@ -622,16 +614,6 @@ export default function SmsPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const prevIsMobile = useRef(isMobile);
|
|
||||||
useEffect(() => {
|
|
||||||
const was = prevIsMobile.current;
|
|
||||||
prevIsMobile.current = isMobile;
|
|
||||||
if (was && !isMobile && !keyRef.current) {
|
|
||||||
const filtered = filterThreads(contactsRef.current, searchRef.current);
|
|
||||||
if (filtered.length > 0) void selectContact(filtered[0].key, { syncRoute: true, scrollToBottom: false });
|
|
||||||
}
|
|
||||||
}, [isMobile, selectContact]);
|
|
||||||
|
|
||||||
useEffect(() => () => clearLongPress(), [clearLongPress]);
|
useEffect(() => () => clearLongPress(), [clearLongPress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -670,121 +652,121 @@ export default function SmsPage() {
|
|||||||
onRetry={refreshAll}
|
onRetry={refreshAll}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative flex-1 overflow-hidden ui-card">
|
<div className="relative flex-1 overflow-hidden ui-card">
|
||||||
{contactsLoading && contacts.length === 0 ? (
|
{contactsLoading && contacts.length === 0 ? (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
|
||||||
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
|
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="sms-main-layout">
|
|
||||||
{isDesktop ? (
|
|
||||||
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
|
|
||||||
<div className="border-b border-gray-100 p-4 dark:border-white/10">
|
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 overflow-auto p-3">
|
|
||||||
{deviceFilters.map((d) => (
|
|
||||||
<button
|
|
||||||
key={d.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void selectDevice(d.id)}
|
|
||||||
className={cx(
|
|
||||||
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
|
|
||||||
selectedDevice === d.id
|
|
||||||
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
|
|
||||||
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
|
|
||||||
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{d.id !== "all" ? (
|
) : null}
|
||||||
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
|
<div className="sms-main-layout">
|
||||||
|
{isDesktop ? (
|
||||||
|
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
|
||||||
|
<div className="border-b border-gray-100 p-4 dark:border-white/10">
|
||||||
|
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 overflow-auto p-3">
|
||||||
|
{deviceFilters.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void selectDevice(d.id)}
|
||||||
|
className={cx(
|
||||||
|
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
|
||||||
|
selectedDevice === d.id
|
||||||
|
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
|
||||||
|
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
|
||||||
|
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
|
||||||
|
</div>
|
||||||
|
{d.id !== "all" ? (
|
||||||
|
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
{showContactColumn ? (
|
||||||
))}
|
<ContactList
|
||||||
</div>
|
isMobile={isMobile}
|
||||||
</div>
|
isDesktop={isDesktop}
|
||||||
) : null}
|
selectedDevice={selectedDevice}
|
||||||
{showContactColumn ? (
|
deviceOptions={deviceSelectOptions}
|
||||||
<ContactList
|
onSelectDevice={(id) => void selectDevice(id)}
|
||||||
isMobile={isMobile}
|
searchQuery={searchQuery}
|
||||||
isDesktop={isDesktop}
|
onSearchChange={setSearch}
|
||||||
selectedDevice={selectedDevice}
|
loading={contactsLoading}
|
||||||
deviceOptions={deviceSelectOptions}
|
contacts={filteredContacts}
|
||||||
onSelectDevice={(id) => void selectDevice(id)}
|
activeKey={selectedKey}
|
||||||
searchQuery={searchQuery}
|
isUnread={isUnread}
|
||||||
onSearchChange={setSearch}
|
deletingKey={deletingThreadKey}
|
||||||
loading={contactsLoading}
|
canHover={canHover}
|
||||||
contacts={filteredContacts}
|
onSelect={(key) => void selectContact(key)}
|
||||||
activeKey={selectedKey}
|
onDelete={(t) => void deleteThreadAction(t)}
|
||||||
isUnread={isUnread}
|
onRowPointerDown={onThreadPointerDown}
|
||||||
deletingKey={deletingThreadKey}
|
onRowPointerMove={moveLongPress}
|
||||||
canHover={canHover}
|
onRowPointerEnd={clearLongPress}
|
||||||
onSelect={(key) => void selectContact(key)}
|
/>
|
||||||
onDelete={(t) => void deleteThreadAction(t)}
|
) : null}
|
||||||
onRowPointerDown={onThreadPointerDown}
|
{showDetailColumn ? (
|
||||||
onRowPointerMove={moveLongPress}
|
<ThreadPanel
|
||||||
onRowPointerEnd={clearLongPress}
|
isMobile={isMobile}
|
||||||
/>
|
isDesktop={isDesktop}
|
||||||
) : null}
|
selectedDevice={selectedDevice}
|
||||||
{showDetailColumn ? (
|
activeThread={activeThread}
|
||||||
<ThreadPanel
|
canLoadMore={!!activeThread && hasMore}
|
||||||
isMobile={isMobile}
|
loadingMore={loadingMore}
|
||||||
isDesktop={isDesktop}
|
groups={groups}
|
||||||
selectedDevice={selectedDevice}
|
deletingMessageId={deletingMessageId}
|
||||||
activeThread={activeThread}
|
canHover={canHover}
|
||||||
canLoadMore={!!activeThread && hasMore}
|
composer={composer}
|
||||||
loadingMore={loadingMore}
|
composerInfo={composerInfo}
|
||||||
groups={groups}
|
composerLength={composerLength}
|
||||||
deletingMessageId={deletingMessageId}
|
sending={sending}
|
||||||
canHover={canHover}
|
detailRef={detailRef}
|
||||||
composer={composer}
|
composerRef={composerRef}
|
||||||
composerInfo={composerInfo}
|
onBack={onBack}
|
||||||
composerLength={composerLength}
|
onScrollToBottom={scrollToBottomNow}
|
||||||
sending={sending}
|
onLoadMore={() => void loadMore()}
|
||||||
detailRef={detailRef}
|
onDeleteMessage={(m) => void deleteMessageAction(m)}
|
||||||
composerRef={composerRef}
|
onComposerChange={setComposer}
|
||||||
onBack={onBack}
|
onSend={() => void sendReply()}
|
||||||
onScrollToBottom={scrollToBottomNow}
|
onDetailScroll={onDetailScroll}
|
||||||
onLoadMore={() => void loadMore()}
|
onMsgPointerDown={onMsgPointerDown}
|
||||||
onDeleteMessage={(m) => void deleteMessageAction(m)}
|
onMsgPointerMove={moveLongPress}
|
||||||
onComposerChange={setComposer}
|
onMsgPointerEnd={clearLongPress}
|
||||||
onSend={() => void sendReply()}
|
/>
|
||||||
onDetailScroll={onDetailScroll}
|
) : null}
|
||||||
onMsgPointerDown={onMsgPointerDown}
|
</div>
|
||||||
onMsgPointerMove={moveLongPress}
|
</div>
|
||||||
onMsgPointerEnd={clearLongPress}
|
{actionSheetOpen && isMobile && actionTarget ? (
|
||||||
/>
|
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
|
||||||
) : null}
|
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
</div>
|
<div className="sms-action-sheet-title">{t("操作")}</div>
|
||||||
</div>
|
<Button
|
||||||
{actionSheetOpen && isMobile && actionTarget ? (
|
className="sms-danger-ghost-btn !w-full !justify-center"
|
||||||
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
|
icon={<DeleteRegular />}
|
||||||
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
|
onClick={() => void confirmSheetAction()}
|
||||||
<div className="sms-action-sheet-title">{t("操作")}</div>
|
>
|
||||||
<Button
|
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
|
||||||
className="sms-danger-ghost-btn !w-full !justify-center"
|
</Button>
|
||||||
icon={<DeleteRegular />}
|
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
|
||||||
onClick={() => void confirmSheetAction()}
|
{t("取消")}
|
||||||
>
|
</Button>
|
||||||
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
|
) : null}
|
||||||
{t("取消")}
|
<NewSmsModal
|
||||||
</Button>
|
open={newSmsOpen}
|
||||||
</div>
|
devices={devices}
|
||||||
</div>
|
defaultDeviceId={newSmsDevice}
|
||||||
) : null}
|
sending={sending}
|
||||||
<NewSmsModal
|
onClose={() => setNewSmsOpen(false)}
|
||||||
open={newSmsOpen}
|
onSend={sendNewSms}
|
||||||
devices={devices}
|
/>
|
||||||
defaultDeviceId={newSmsDevice}
|
|
||||||
sending={sending}
|
|
||||||
onClose={() => setNewSmsOpen(false)}
|
|
||||||
onSend={sendNewSms}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const {
|
|||||||
} = await import(moduleURL);
|
} = await import(moduleURL);
|
||||||
|
|
||||||
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
test("uses the current physical SIM when the device has no eSIM profiles", () => {
|
||||||
const iccid = "89441000400128014257";
|
const iccid = "8944100000000000001";
|
||||||
|
|
||||||
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
|
assert.deepEqual(buildAutomaticTaskProfileOptions([], iccid, "Current SIM"), [
|
||||||
{
|
{
|
||||||
@@ -30,7 +30,7 @@ test("uses the current physical SIM when the device has no eSIM profiles", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
test("does not duplicate the current SIM when it is already in the eSIM inventory", () => {
|
||||||
const iccid = "89441000400128014257";
|
const iccid = "8944100000000000001";
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildAutomaticTaskProfileOptions(
|
buildAutomaticTaskProfileOptions(
|
||||||
@@ -49,7 +49,7 @@ test("does not duplicate the current SIM when it is already in the eSIM inventor
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
|
test("does not replace a saved profile when a failed inventory only exposes the current SIM", () => {
|
||||||
const currentICCID = "89441000400128014257";
|
const currentICCID = "8944100000000000001";
|
||||||
const savedICCID = "89104100000028106378";
|
const savedICCID = "89104100000028106378";
|
||||||
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
const options = buildAutomaticTaskProfileOptions([], currentICCID, "Current SIM");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user