mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-15 04:13:42 +08:00
feat: add home carrier information to ModemSummary interface
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = ©
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 + ")"}, " "))
|
||||
}
|
||||
|
||||
@@ -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 != "--(飞行模式)" {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user