Compare commits

..
6 Commits
36 changed files with 1537 additions and 474 deletions
+12
View File
@@ -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
} }
+4 -4
View File
@@ -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)
} }
+18 -18
View File
@@ -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)
+5 -5
View File
@@ -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)
+27 -4
View File
@@ -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 {
+1 -1
View File
@@ -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,
+5 -5
View File
@@ -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) {
+5 -5
View File
@@ -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())
} }
+10 -4
View File
@@ -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)
+2 -2
View File
@@ -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
} }
+3 -3
View File
@@ -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" {
+19 -5
View File
@@ -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
View File
@@ -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) {
+15 -4
View File
@@ -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 {
+60
View File
@@ -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)
}
}
+6
View File
@@ -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)
} }
+7 -3
View File
@@ -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) {
+148 -7
View File
@@ -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": {
+233
View File
@@ -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" +
+77
View File
@@ -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)
}
}
+7 -2
View File
@@ -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")
} }
+16 -9
View File
@@ -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
View File
@@ -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 {
+14 -3
View File
@@ -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 {
+1 -1
View File
@@ -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{
+11
View File
@@ -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)
} }
+127 -43
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21 -3
View File
@@ -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++ {
+36 -5
View File
@@ -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}
+14 -14
View File
@@ -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)
} }
} }
+222 -40
View File
@@ -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>
); );
+27
View File
@@ -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
View File
@@ -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>
); );
} }
+3 -3
View File
@@ -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");