feat: add home carrier information to ModemSummary interface

This commit is contained in:
MengMengCode
2026-08-14 00:22:39 +08:00
parent ba28c7f79a
commit bd36cdb131
27 changed files with 5888 additions and 132 deletions
+153 -1
View File
@@ -14,7 +14,41 @@ import (
var carrierDatabaseJSON []byte
type carrierDatabase struct {
Carriers map[string][]string `json:"c"`
Carriers map[string][]string `json:"c"`
Countries map[string]string `json:"i"`
Rules []carrierRule `json:"r"`
}
type carrierRule struct {
Name string `json:"n"`
PLMNs []string `json:"m"`
IMSIPatterns []string `json:"x"`
SPNs []string `json:"s"`
GID1Prefixes []string `json:"g1"`
GID2Prefixes []string `json:"g2"`
ICCIDPrefixes []string `json:"i"`
}
// CarrierIdentity contains the SIM-issued values used by Android's carrier
// resolver. MNC length comes from EF_AD; GID values come from EF_GID1/2.
type CarrierIdentity struct {
IMSI string
ICCID string
SPN string
GID1 string
GID2 string
MNCLength int
}
// CountryForMCC returns the ISO alpha-2 country/territory code associated with
// a three-digit mobile country code in the embedded Android carrier database.
func CountryForMCC(mcc string) (string, bool) {
mcc = strings.TrimSpace(mcc)
if len(mcc) != 3 {
return "", false
}
country := strings.ToUpper(strings.TrimSpace(globalCarrierDatabase.Countries[mcc]))
return country, len(country) == 2
}
var globalCarrierDatabase = func() carrierDatabase {
@@ -64,3 +98,121 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
}
return "", "", "", false
}
// CarrierForSIM applies the constrained Android carrier-ID rules before the
// MCC/MNC fallback. This is important for MVNO and travel eSIM profiles where
// several customer-facing carriers authenticate through the same home PLMN.
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
imsi := strings.TrimSpace(identity.IMSI)
if !decimalDigits(imsi, 5, 20) {
return "", "", "", false
}
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
bestScore := -1
for _, rule := range globalCarrierDatabase.Rules {
matchedPLMN := firstMatchingValue(rule.PLMNs, func(value string) bool {
return containsString(plmns, value)
})
if matchedPLMN == "" {
continue
}
score := 1 << 8
if len(rule.IMSIPatterns) > 0 {
if firstMatchingValue(rule.IMSIPatterns, func(pattern string) bool { return imsiPatternMatch(imsi, pattern) }) == "" {
continue
}
score += 1 << 7
}
if len(rule.ICCIDPrefixes) > 0 {
if firstMatchingValue(rule.ICCIDPrefixes, func(prefix string) bool { return strings.HasPrefix(identity.ICCID, prefix) }) == "" {
continue
}
score += 1 << 6
}
if len(rule.GID1Prefixes) > 0 {
if firstMatchingValue(rule.GID1Prefixes, func(prefix string) bool { return prefixFold(identity.GID1, prefix) }) == "" {
continue
}
score += 1 << 5
}
if len(rule.GID2Prefixes) > 0 {
if firstMatchingValue(rule.GID2Prefixes, func(prefix string) bool { return prefixFold(identity.GID2, prefix) }) == "" {
continue
}
score += 1 << 4
}
if len(rule.SPNs) > 0 {
if !containsFold(rule.SPNs, identity.SPN) {
continue
}
score += 1 << 1
}
if score > bestScore {
bestScore = score
plmn = matchedPLMN
name = strings.TrimSpace(rule.Name)
}
}
if bestScore >= 0 && name != "" {
countryCode, _ = CountryForMCC(plmn[:3])
return plmn, name, countryCode, true
}
return CarrierForIMSI(imsi)
}
func carrierPLMNCandidates(imsi string, mncLength int) []string {
if (mncLength == 2 || mncLength == 3) && len(imsi) >= 3+mncLength {
return []string{imsi[:3+mncLength]}
}
result := make([]string, 0, 2)
for _, length := range []int{6, 5} {
if len(imsi) >= length {
result = append(result, imsi[:length])
}
}
return result
}
func firstMatchingValue(values []string, match func(string) bool) string {
for _, value := range values {
if match(value) {
return value
}
}
return ""
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func containsFold(values []string, wanted string) bool {
for _, value := range values {
if strings.EqualFold(value, wanted) {
return true
}
}
return false
}
func prefixFold(value, prefix string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), strings.ToLower(strings.TrimSpace(prefix)))
}
func imsiPatternMatch(imsi, pattern string) bool {
pattern = strings.TrimSpace(pattern)
if len(imsi) < len(pattern) {
return false
}
for index, value := range pattern {
if value != 'x' && value != 'X' && byte(value) != imsi[index] {
return false
}
}
return true
}
+9 -1
View File
@@ -411,7 +411,14 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
previousICCID := state.lastICCID
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
var previousSnapshot *Snapshot
manager.mu.RLock()
if state.snapshot != nil {
copy := *state.snapshot
previousSnapshot = &copy
}
manager.mu.RUnlock()
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, previousSnapshot, client)
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
}
@@ -447,6 +454,7 @@ func (manager *Manager) refreshCardReader(ctx context.Context, id string, state
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.MNCLength = card.Identity.MNCLength
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
+14 -1
View File
@@ -64,6 +64,12 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
{command: "AT+CIMI", response: okResponse("460001234567890")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020002FFFF"`)},
{command: "AT+CRSM=176,28478,0,0,2", response: okResponse(`+CRSM: 144,0,"0102"`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
{
command: `AT+QENG="servingcell"`,
@@ -112,7 +118,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
}
if snapshot.IMEI != "867123456789012" ||
snapshot.ICCID != "8986001234567890123" ||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" ||
snapshot.MNCLength != 2 || snapshot.GID1 != "0102" || snapshot.GID2 != "" {
t.Fatalf("subscriber identifiers = %#v", snapshot)
}
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
@@ -198,6 +205,12 @@ func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
{command: "AT+CFUN=4", response: okResponse()},
{command: "AT+CIMI", response: okResponse("234150000000002")},
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28478,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
File diff suppressed because one or more lines are too long
+12 -2
View File
@@ -22,6 +22,13 @@ var BlockedMCCs = map[string]string{
// code. The MCC is the leading three digits and the MNC the following two or
// three. Empty strings are returned for an unusable IMSI.
func CardMCCMNC(imsi string) (mcc string, mnc string) {
return CardMCCMNCWithLength(imsi, 0)
}
// CardMCCMNCWithLength uses the MNC length advertised by EF_AD when available.
// Without it the historical three-digit behavior is retained for callers that
// have only an IMSI.
func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
digits := strings.TrimSpace(imsi)
if len(digits) < 5 ||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
@@ -29,8 +36,11 @@ func CardMCCMNC(imsi string) (mcc string, mnc string) {
}
mcc = digits[:3]
mnc = digits[3:]
if len(mnc) > 3 {
mnc = mnc[:3]
if mncLength != 2 && mncLength != 3 {
mncLength = 3
}
if len(mnc) > mncLength {
mnc = mnc[:mncLength]
}
return mcc, mnc
}
+3
View File
@@ -23,6 +23,9 @@ func TestCardMCCMNC(t *testing.T) {
if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" {
t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc)
}
if mcc, mnc := CardMCCMNCWithLength("454006395879502", 2); mcc != "454" || mnc != "00" {
t.Fatalf("CardMCCMNCWithLength = (%q, %q), want (454, 00)", mcc, mnc)
}
for _, bad := range []string{"", "4600", "4600X1234"} {
if mcc, _ := CardMCCMNC(bad); mcc != "" {
t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc)
+37
View File
@@ -55,6 +55,25 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
}
}
func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
tests := map[string]string{
"234": "GB",
"262": "DE",
"310": "US",
"460": "CN",
}
for mcc, want := range tests {
if got, ok := CountryForMCC(mcc); !ok || got != want {
t.Errorf("CountryForMCC(%q) = (%q, %v), want %q", mcc, got, ok, want)
}
}
for _, invalid := range []string{"", "23", "999", "abcd"} {
if got, ok := CountryForMCC(invalid); ok || got != "" {
t.Errorf("CountryForMCC(%q) = (%q, %v), want unknown", invalid, got, ok)
}
}
}
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
tests := []struct {
imsi string
@@ -64,6 +83,7 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
}
for _, item := range tests {
@@ -73,3 +93,20 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
}
}
}
func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
plmn, name, country, ok := CarrierForSIM(CarrierIdentity{
IMSI: "454006395879502", ICCID: "89852350126077295027",
SPN: "Saily", GID1: "536E617065", GID2: "536E617065000012", MNCLength: 2,
})
if !ok || plmn != "45400" || name != "Webbing" || country != "HK" {
t.Fatalf("CarrierForSIM exact rule = (%q, %q, %q, %v)", plmn, name, country, ok)
}
plmn, name, country, ok = CarrierForSIM(CarrierIdentity{
IMSI: "454006395879502", SPN: "Saily", MNCLength: 2,
})
if !ok || plmn != "45400" || name != "1O1O / csl / Club Sim" || country != "HK" {
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
}
}
+105
View File
@@ -21,6 +21,7 @@ func (manager *Manager) readSnapshot(
candidate modem.Candidate,
backend string,
previousICCID string,
previousSnapshot *Snapshot,
client modem.Client,
) (Snapshot, error) {
snapshot := Snapshot{
@@ -81,6 +82,25 @@ func (manager *Manager) readSnapshot(
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
snapshot.SPN = parseSPN(response)
}
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
snapshot.MNCLength = previousSnapshot.MNCLength
snapshot.GID1 = previousSnapshot.GID1
snapshot.GID2 = previousSnapshot.GID2
snapshot.IdentityFilesRead = true
} else {
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
// Read these files once per inserted ICCID and cache even an empty result;
// repeatedly probing unsupported EFs would add avoidable modem traffic.
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
snapshot.MNCLength = length
}
}
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
snapshot.IdentityFilesRead = true
}
if response, ok := optional("AT+CSQ"); ok {
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
}
@@ -164,6 +184,91 @@ func (manager *Manager) readSnapshot(
return snapshot, nil
}
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
if err != nil {
return nil
}
size := transparentSIMFileSize(crsmPayload(response))
if size <= 0 || size > 64 {
return nil
}
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
if err != nil {
return nil
}
return crsmPayload(response)
}
func transparentSIMFileSize(payload []byte) int {
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
// template and walk its immediate TLVs.
content := payload
if len(content) >= 2 && content[0] == 0x62 {
length, header, ok := berLength(content[1:])
if !ok || 1+header+length > len(content) {
return 0
}
content = content[1+header : 1+header+length]
}
for offset := 0; offset+2 <= len(content); {
tag := content[offset]
length, header, ok := berLength(content[offset+1:])
start := offset + 1 + header
end := start + length
if !ok || end > len(content) {
break
}
if tag == 0x80 && (length == 1 || length == 2) {
size := 0
for _, value := range content[start:end] {
size = size<<8 | int(value)
}
return size
}
offset = end
}
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
if len(payload) >= 4 && payload[0] != 0x62 {
return int(payload[2])<<8 | int(payload[3])
}
return 0
}
func berLength(value []byte) (length, header int, ok bool) {
if len(value) == 0 {
return 0, 0, false
}
if value[0] < 0x80 {
return int(value[0]), 1, true
}
count := int(value[0] & 0x7f)
if count == 0 || count > 2 || len(value) < count+1 {
return 0, 0, false
}
for _, item := range value[1 : count+1] {
length = length<<8 | int(item)
}
return length, count + 1, true
}
func encodeSIMGroupID(value []byte) string {
if len(value) == 0 {
return ""
}
allPadding := true
for _, item := range value {
if item != 0xff {
allPadding = false
break
}
}
if allPadding {
return ""
}
return strings.ToUpper(hex.EncodeToString(value))
}
func parseSPN(response modem.Response) string {
value := valueAfterPrefix(response, "+CRSM:")
fields := csvValues(value)
+4
View File
@@ -105,6 +105,10 @@ type Snapshot struct {
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
SPN string `json:"spn,omitempty"`
MNCLength int `json:"mncLength,omitempty"`
GID1 string `json:"gid1,omitempty"`
GID2 string `json:"gid2,omitempty"`
IdentityFilesRead bool `json:"-"`
OperatingMode int `json:"operatingMode"`
ModeKnown bool `json:"modeKnown"`
FlightMode bool `json:"flightMode"`
@@ -31,6 +31,7 @@ type automaticTaskNotification struct {
}
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
ctx = s.notificationDestinationContext(ctx)
deviceLabel := task.DeviceID
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
+64 -54
View File
@@ -1904,66 +1904,76 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
if snapshot == nil {
return map[string]any{
"operator": "",
"native_mcc": "",
"native_mnc": "",
"native_spn": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
"card_country": "",
"service_blocked": false,
"blocked_reason": "",
"network_mode": "",
"radio_band": "",
"radio_channel": 0,
"signal_dbm": 0,
"signal_sinr": 0,
"imei": "",
"iccid": "",
"reg_status": 0,
"reg_status_text": "not refreshed",
"sim_inserted": false,
"phone_number": phone,
"phone_number_source": phoneSource,
"model": "",
"operator": "",
"native_mcc": "",
"native_mnc": "",
"native_spn": "",
"operator_country_code": "",
"card_mcc": "",
"card_mnc": "",
"card_country": "",
"home_carrier_name": "",
"home_carrier_plmn": "",
"home_carrier_country_code": "",
"service_blocked": false,
"blocked_reason": "",
"network_mode": "",
"radio_band": "",
"radio_channel": 0,
"signal_dbm": 0,
"signal_sinr": 0,
"imei": "",
"iccid": "",
"reg_status": 0,
"reg_status_text": "not refreshed",
"sim_inserted": false,
"phone_number": phone,
"phone_number_source": phoneSource,
"model": "",
}
}
mcc, mnc := splitPLMN(snapshot.OperatorCode)
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
cardMCC, cardMNC := device.CardMCCMNCWithLength(snapshot.IMSI, snapshot.MNCLength)
homePLMN, homeCarrier, homeCountry, _ := device.CarrierForSIM(device.CarrierIdentity{
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
})
blockedReason := device.RegionBlockReason(snapshot.IMSI)
return map[string]any{
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"native_spn": snapshot.SPN,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
"card_country": countryNameForMCC(cardMCC),
"service_blocked": blockedReason != "",
"blocked_reason": blockedReason,
"network_mode": snapshot.AccessTech,
"network_duplex": "",
"radio_band": snapshot.Band,
"radio_channel": parseDecimal(snapshot.Channel),
"signal_dbm": pointerInt(snapshot.RSSIDBm),
"signal_rsrp": pointerInt(snapshot.RSRP),
"signal_rsrq": pointerInt(snapshot.RSRQ),
"signal_sinr": pointerInt(snapshot.SINR),
"imei": snapshot.IMEI,
"iccid": snapshot.ICCID,
"imsi": snapshot.IMSI,
"firmware": snapshot.Firmware,
"model": snapshot.Model,
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
"operator": snapshot.OperatorName,
"native_mcc": mcc,
"native_mnc": mnc,
"native_spn": snapshot.SPN,
"operator_country_code": operatorCountryCode,
"card_mcc": cardMCC,
"card_mnc": cardMNC,
"card_country": countryNameForMCC(cardMCC),
"home_carrier_name": homeCarrier,
"home_carrier_plmn": homePLMN,
"home_carrier_country_code": homeCountry,
"service_blocked": blockedReason != "",
"blocked_reason": blockedReason,
"network_mode": snapshot.AccessTech,
"network_duplex": "",
"radio_band": snapshot.Band,
"radio_channel": parseDecimal(snapshot.Channel),
"signal_dbm": pointerInt(snapshot.RSSIDBm),
"signal_rsrp": pointerInt(snapshot.RSRP),
"signal_rsrq": pointerInt(snapshot.RSRQ),
"signal_sinr": pointerInt(snapshot.SINR),
"imei": snapshot.IMEI,
"iccid": snapshot.ICCID,
"imsi": snapshot.IMSI,
"firmware": snapshot.Firmware,
"model": snapshot.Model,
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
}
}
+41 -7
View File
@@ -429,17 +429,18 @@ func (s *Server) handleNotificationTest(
return
}
notificationContext := s.notificationDestinationContext(r.Context())
switch channel {
case "webhook":
err = sendWebhookNotificationTest(r.Context(), resolved)
err = sendWebhookNotificationTest(notificationContext, resolved)
case "telegram":
err = sendTelegramNotificationTest(r.Context(), resolved)
err = sendTelegramNotificationTest(notificationContext, resolved)
case "email":
err = sendEmailNotificationTest(r.Context(), resolved)
err = sendEmailNotificationTest(notificationContext, resolved)
case "bark":
err = sendBarkNotificationTest(r.Context(), resolved)
err = sendBarkNotificationTest(notificationContext, resolved)
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
err = sendWecomNotificationTest(notificationContext, resolved)
}
if err != nil {
redacted := store.RedactText(err.Error(), provider)
@@ -1073,6 +1074,39 @@ func dialRestricted(
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
}
type notificationAllowedNetworksKey struct{}
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
access := s.currentAccessConfig()
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
}
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
address = address.Unmap()
// Even an administrator-provided exception must never turn a notification
// endpoint into a loopback or cloud-metadata request. Private/LAN and
// benchmark ranges may be explicitly allowed for local push gateways and
// DNS Fake-IP deployments, but these process-local destinations stay closed.
if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() ||
address.IsMulticast() || address.IsLinkLocalUnicast() ||
address == netip.MustParseAddr("100.100.100.200") {
return false
}
if publicNotificationAddress(address) {
return true
}
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
for _, prefix := range prefixes {
if prefix.Contains(address) {
return true
}
}
return false
}
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
if normalized == "" || normalized == "localhost" ||
@@ -1084,7 +1118,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
}
if literal, err := netip.ParseAddr(normalized); err == nil {
literal = literal.Unmap()
if !publicNotificationAddress(literal) {
if !notificationAddressAllowed(ctx, literal) {
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
}
return []netip.Addr{literal}, nil
@@ -1099,7 +1133,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
result := make([]netip.Addr, 0, len(addresses))
for _, address := range addresses {
address = address.Unmap()
if !publicNotificationAddress(address) {
if !notificationAddressAllowed(ctx, address) {
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
}
result = append(result, address)
+21
View File
@@ -789,6 +789,27 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) {
); err == nil {
t.Fatal("metadata IP was not blocked")
}
allowedContext := context.WithValue(
context.Background(),
notificationAllowedNetworksKey{},
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
)
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err)
}
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
t.Fatal("unlisted metadata IP was allowed")
}
wideAllowedContext := context.WithValue(
context.Background(),
notificationAllowedNetworksKey{},
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
)
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
t.Fatalf("non-overridable destination %s was allowed", address)
}
}
}
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
+1 -1
View File
@@ -105,7 +105,7 @@ func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string)
} else {
for _, message := range messages {
notification := s.newSMSNotification(ctx, message)
if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
if sendErr := sendSMSNotification(s.notificationDestinationContext(ctx), channel, config, notification); sendErr != nil {
if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
s.logSMSNotificationError(channel, sendErr)
lastError, lastErrorAt = sendErr.Error(), time.Now()
+22 -12
View File
@@ -850,7 +850,7 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun
"ICCID"+firstNonEmpty(snapshot.ICCID, "--"),
"IMSI"+firstNonEmpty(snapshot.IMSI, "--"),
"号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot),
"原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN),
"原运营商:"+telegramSnapshotHomeCarrier(snapshot),
"当前网络:"+telegramCurrentNetwork(snapshot),
"蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
)
@@ -929,20 +929,30 @@ func usableTelegramPhoneNumber(value string) bool {
}
func telegramHomeCarrier(imsi string, spn ...string) string {
plmn, name, country, ok := device.CarrierForIMSI(imsi)
if !ok {
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
return strings.TrimSpace(spn[0])
}
identity := device.CarrierIdentity{IMSI: imsi}
if len(spn) > 0 {
identity.SPN = spn[0]
}
return telegramResolvedHomeCarrier(identity)
}
func telegramSnapshotHomeCarrier(snapshot *device.Snapshot) string {
if snapshot == nil {
return "--"
}
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
brand := strings.TrimSpace(spn[0])
brandCountry := country
if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") {
brandCountry = "GB"
return telegramResolvedHomeCarrier(device.CarrierIdentity{
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
})
}
func telegramResolvedHomeCarrier(identity device.CarrierIdentity) string {
plmn, name, country, ok := device.CarrierForSIM(identity)
if !ok {
if strings.TrimSpace(identity.SPN) != "" {
return strings.TrimSpace(identity.SPN)
}
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ""}, " "))
return "--"
}
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " "))
}
+5 -2
View File
@@ -127,8 +127,11 @@ func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
t.Fatalf("home carrier = %q", got)
}
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
t.Fatalf("branded foreign-core 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") {
t.Fatalf("profile brand overrode home carrier = %q", got)
}
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
t.Fatalf("unknown home carrier did not fall back to SPN: %q", got)
}
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
+24 -8
View File
@@ -28,21 +28,37 @@ func (resolver ProxyResolver) Resolve(
}
deviceID := strings.TrimSpace(request.DeviceID)
iccid := strings.TrimSpace(request.ICCID)
if deviceID == "" || iccid == "" {
if deviceID == "" {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
var upstreamID string
if iccid != "" {
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
if err == nil {
upstreamID = binding.UpstreamProxyID
} else if !errors.Is(err, store.ErrNotFound) {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
}
}
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
if upstreamID == "" {
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
if !found {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
}
if ruleErr != nil {
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
}
upstreamID = rule.UpstreamProxyID
}
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
if err != nil {
return vowifi.ProxyRoute{}, fmt.Errorf(
"load upstream proxy %q for device %s: %w",
binding.UpstreamProxyID,
upstreamID,
deviceID,
err,
)
+37 -3
View File
@@ -79,7 +79,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
}
}
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
database := testStore(t)
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
ID: "legacy", Name: "Legacy", Addr: "127.0.0.1:1080", Enabled: true,
@@ -98,8 +98,42 @@ func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if route.Mode != vowifi.ProxyModeDirect {
t.Fatalf("route = %#v, want direct", route)
if route.Mode != vowifi.ProxyModeSOCKS5 || route.ID != "legacy" {
t.Fatalf("route = %#v, want MCC country fallback", route)
}
}
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
database := testStore(t)
for _, proxy := range []store.UpstreamProxy{
{ID: "profile", Name: "Profile", Addr: "127.0.0.1:1080", Enabled: true},
{ID: "country", Name: "Country", Addr: "127.0.0.1:1081", Enabled: true},
} {
if err := database.UpsertUpstreamProxy(context.Background(), proxy); err != nil {
t.Fatal(err)
}
}
if err := database.UpsertCountryRule(context.Background(), store.CountryRule{
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "country", Enabled: true,
}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
t.Fatal(err)
}
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
}); err != nil {
t.Fatal(err)
}
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
})
if err != nil {
t.Fatal(err)
}
if route.ID != "profile" {
t.Fatalf("route = %#v, want ICCID binding", route)
}
}
+63 -8
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
"""Refresh VoCat's offline carrier table from Android's carrier database.
The AOSP carrier ID table is maintained for Android's own carrier recognition.
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
The compact ``c`` map remains the MCC/MNC-only fallback. The ``r`` list keeps
Android's SIM-identity rules (IMSI, ICCID, SPN and GID) so travel eSIMs and
MVNOs sharing an MNO's PLMN can be identified without hard-coded exceptions.
"""
from __future__ import annotations
@@ -125,10 +125,61 @@ def aosp_carriers(text: str) -> dict[str, str]:
return carriers
RULE_FIELDS = {
"mccmnc_tuple": "m",
"imsi_prefix_xpattern": "x",
"spn": "s",
"gid1": "g1",
"gid2": "g2",
"iccid_prefix": "i",
}
def textproto_strings(block: str, field: str) -> list[str]:
return [
json.loads(value)
for value in re.findall(
rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")",
block,
re.M,
)
]
def aosp_identity_rules(text: str) -> list[dict[str, object]]:
"""Return rules VoCat can evaluate using values read directly from a SIM.
Android combines different fields with AND and repeated values of one field
with OR. Rules that also require APN, PNN/PLMN or carrier certificates are
omitted until those inputs are available; treating an unavailable input as
a wildcard would incorrectly identify subscriptions.
"""
supported = set(RULE_FIELDS)
rules: list[dict[str, object]] = []
for carrier in braced_blocks(text, "carrier_id"):
name = textproto_string(carrier, "carrier_name").strip()
if not name:
continue
for attribute in braced_blocks(carrier, "carrier_attribute"):
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
if not fields.issubset(supported) or fields == {"mccmnc_tuple"}:
continue
rule: dict[str, object] = {"n": name}
for source_field, output_field in RULE_FIELDS.items():
values = textproto_strings(attribute, source_field)
if values:
rule[output_field] = values
if rule.get("m"):
rules.append(rule)
return rules
def main() -> None:
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
source = base64.b64decode(response.read()).decode("utf-8")
names = aosp_carriers(source)
identity_rules = aosp_identity_rules(source)
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
countries: dict[str, str] = table["i"]
countries.update({str(mcc): "us" for mcc in range(310, 317)})
@@ -149,19 +200,23 @@ def main() -> None:
"c": dict(sorted(carriers.items())),
"i": dict(sorted(countries.items())),
"t": sorted(set(table["t"])),
"r": identity_rules,
"meta": {
"source": "Android Open Source Project carrier_list.textpb",
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
"aosp_version": version_match.group(1) if version_match else "unknown",
"aosp_generic_records": len(names),
"aosp_identity_rules": len(identity_rules),
},
}
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
frontend_encoded = json.dumps(output, ensure_ascii=False, indent=4) + "\n"
backend_encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
FRONTEND_TABLE.write_text(frontend_encoded, encoding="utf-8", newline="\n")
BACKEND_TABLE.write_text(backend_encoded, encoding="utf-8", newline="\n")
print(
f"updated {len(carriers)} PLMN records "
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
f"({len(names)} generic records, {len(identity_rules)} identity rules, "
f"version {output['meta']['aosp_version']})"
)
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
import { useShowSensitive } from "./shared";
import type { DeviceDetail } from "./types";
import { useI18n } from "../../lib/i18n";
import { carrierBrandIso } from "../../lib/carrier";
import { carrierIso } from "../../lib/carrier";
import { CountryFlag } from "../CountryFlag";
export interface OverviewSimPanelProps {
@@ -22,7 +22,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber
const sensitive = !showSensitive;
const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
const carrierCountryCode = String(modem?.homeCarrierCountryCode ?? "").trim() || carrierIso(modem?.imsi);
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
const backendLabel =
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
+10 -12
View File
@@ -259,20 +259,18 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
const spn = String(modem?.nativeSpn ?? "").trim();
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
const plmn = plmnOf(modem);
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
// resolved from IMSI instead, so GigSky on 222-01 renders as
// "GigSky (22201)" even while roaming.
if (spn) {
const home = lookupCarrier(modem?.imsi);
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
}
if (name) return withPlmn(name, plmn);
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
// "Original Carrier" means the IMSI home/authentication network. EF_SPN is
// only a profile-supplied display brand (travel eSIMs and MVNOs may put their
// storefront name there), so it must not override a known home PLMN.
const resolvedName = String(modem?.homeCarrierName ?? "").trim();
const resolvedPLMN = String(modem?.homeCarrierPlmn ?? "").trim();
if (resolvedName) return withPlmn(resolvedName, resolvedPLMN);
const carrier = lookupCarrier(modem?.imsi);
if (carrier) return withPlmn(carrier.name, carrier.mcc + carrier.mnc);
// If the bundled carrier database cannot resolve the home PLMN, retain the
// card's own labels as graceful, data-driven fallbacks.
if (spn) return withPlmn(spn, cardPlmnOf(modem));
if (name) return withPlmn(name, plmn);
if (plmn) return plmn;
const cardPlmn = cardPlmnOf(modem);
if (cardPlmn) return cardPlmn;
@@ -21,6 +21,10 @@ function profileLabel(profile: { name?: string; serviceProviderName?: string; ic
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
}
function currentDeviceICCID(device: DeviceListItem) {
return String(device.modem?.iccid || device.vowifiRuntime?.iccid || "").trim();
}
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
const { t } = useI18n();
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
@@ -30,7 +34,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const proxyName = proxy?.name || proxy?.id || "";
const deviceKey = devices
.map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`)
.map((device) => `${device.id}:${currentDeviceICCID(device)}`)
.sort()
.join("|");
const current = useMemo(
@@ -53,7 +57,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
let active = true;
setLoadingProfiles(true);
Promise.allSettled(devices.map(async (device) => {
const currentICCID = String(device.modem?.iccid || "").trim();
const currentICCID = currentDeviceICCID(device);
let installed: ProfileProxyCandidate[] = [];
try {
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
@@ -101,7 +101,7 @@ export function NetworkAccessCard({
</Button>
</div>
<p className="text-[10px] text-gray-400">
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。")}
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。")}
</p>
{cidrs.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/30 py-2 text-center text-xs text-gray-400 dark:border-white/10 dark:bg-white/5">
-10
View File
@@ -47,13 +47,3 @@ export function carrierIso(imsi?: string): string {
if (hit) return hit.iso;
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
}
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
// customer-facing country is GB even though AKA must continue using 204-04.
export function carrierBrandIso(spn?: string, imsi?: string): string {
const brand = String(spn ?? "").trim().toLowerCase();
const digits = imsiDigits(imsi);
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
return carrierIso(imsi);
}
+2 -2
View File
@@ -352,8 +352,8 @@ export const EN_DICT: Record<string, string> = {
"Opening to the public internet greatly expands the attack surface. Use a strong password and switch back to Internal Only as soon as possible.",
: "Additional Allowed Ranges",
: "Add Range",
"在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。":
"CIDRs or single IPs always allowed in addition to the built-in internal ranges (e.g. 203.0.113.0/24).",
"在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。":
"CIDRs or single IPs always allowed in addition to the built-in internal ranges; notification delivery may also access these destinations (e.g. 198.18.0.0/15).",
: "No additional allowed ranges",
: "Trust Proxy Headers",
"仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。":
+5247 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -64,6 +64,9 @@ export interface ModemSummary {
cardMcc?: string;
cardMnc?: string;
cardCountry?: string;
homeCarrierName?: string;
homeCarrierPlmn?: string;
homeCarrierCountryCode?: string;
serviceBlocked?: boolean;
blockedReason?: string;
networkMode: string;