From fdb2ac45ce855d94fcab467c83f10d0c3388dfc5 Mon Sep 17 00:00:00 2001 From: Rain Seven <128443127+RAiNY7Study@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:27:17 +0800 Subject: [PATCH] feat: import Apple carrier bundles safely (#50) * feat: import Apple carrier bundles safely * Delete docs/CARRIER_IPCC_IMPORT.md --------- Co-authored-by: Meng Meng <227010654+MengMengCode@users.noreply.github.com> --- LICENSES/go-plist-BSD-2-Clause.txt | 57 ++ README.md | 4 + cmd/vocat/carrier.go | 95 +++ cmd/vocat/carrier_test.go | 74 +++ cmd/vocat/cli.go | 6 + cmd/vocat/main.go | 9 + docs/README.zh-CN.md | 19 + go.mod | 1 + go.sum | 4 + internal/device/scan_test.go | 19 + internal/vowifi/carrier_compat.go | 281 ++++++-- internal/vowifi/carrier_compat_test.go | 47 ++ internal/vowifi/carrier_ipcc.go | 850 +++++++++++++++++++++++++ internal/vowifi/carrier_ipcc_test.go | 204 ++++++ internal/vowifi/carrier_profiles.json | 20 +- 15 files changed, 1637 insertions(+), 53 deletions(-) create mode 100644 LICENSES/go-plist-BSD-2-Clause.txt create mode 100644 cmd/vocat/carrier.go create mode 100644 cmd/vocat/carrier_test.go create mode 100644 internal/vowifi/carrier_ipcc.go create mode 100644 internal/vowifi/carrier_ipcc_test.go diff --git a/LICENSES/go-plist-BSD-2-Clause.txt b/LICENSES/go-plist-BSD-2-Clause.txt new file mode 100644 index 0000000..53abf0d --- /dev/null +++ b/LICENSES/go-plist-BSD-2-Clause.txt @@ -0,0 +1,57 @@ +Copyright (c) 2013, Dustin L. Howett. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are +those of the authors and should not be interpreted as representing official +policies, either expressed or implied, of the FreeBSD Project. + +------------------------------------------------------------------------------- +Parts of this package were made available under the license covering the Go +language and all attended core libraries. That license follows. +------------------------------------------------------------------------------- + +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Google Inc. nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index aeef525..d802369 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,10 @@ Vocat reads an optional JSON configuration file from `VOCAT_CONFIG`, then applie | `VOCAT_REPO` | `MengMengCode/VoCat` | Trusted GitHub repository used by the self-updater, in `owner/name` form. | | `GITHUB_TOKEN` | empty | Optional GitHub token for private repositories or higher API limits. | +User-supplied Apple carrier bundles can be converted into reviewable, +allow-listed carrier profiles with `vocat carrier import-ipcc`; see +[docs/CARRIER_IPCC_IMPORT.md](docs/CARRIER_IPCC_IMPORT.md). + Administrator credentials are stored only in SQLite. Initialize an empty database once with `vocat bootstrap-admin`; environment variables and JSON configuration cannot set or overwrite the administrator username or password. diff --git a/cmd/vocat/carrier.go b/cmd/vocat/carrier.go new file mode 100644 index 0000000..1e7d7dd --- /dev/null +++ b/cmd/vocat/carrier.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "path/filepath" + "strings" + + "vocat/internal/config" + "vocat/internal/vowifi" +) + +func runCarrier(args []string, stdout io.Writer) error { + if len(args) == 0 { + return errors.New("usage: vocat carrier import-ipcc [flags] FILE.ipcc") + } + switch args[0] { + case "import-ipcc": + return runCarrierImportIPCC(args[1:], stdout) + default: + return fmt.Errorf("unknown carrier subcommand %q", args[0]) + } +} + +func runCarrierImportIPCC(args []string, stdout io.Writer) error { + flags := flag.NewFlagSet("carrier import-ipcc", flag.ContinueOnError) + flags.SetOutput(io.Discard) + var bundle string + var profileID string + var profileDir string + var install bool + var documentOnly bool + flags.StringVar(&bundle, "bundle", "", "bundle name when an IPCC contains more than one carrier bundle") + flags.StringVar(&profileID, "id", "", "override the generated carrier profile ID") + flags.StringVar(&profileDir, "profile-dir", "", "installation directory (default: next to the VoCat database)") + flags.BoolVar(&install, "install", false, "atomically install the reviewed generated profile") + flags.BoolVar(&documentOnly, "document-only", false, "print only the generated carrier profile document") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 1 { + return errors.New("usage: vocat carrier import-ipcc [--bundle NAME] [--id ID] [--document-only] [--install] [--profile-dir DIR] FILE.ipcc") + } + if documentOnly && install { + return errors.New("--document-only and --install cannot be used together") + } + if strings.TrimSpace(profileDir) != "" && !install { + return errors.New("--profile-dir requires --install") + } + result, err := vowifi.ImportCarrierIPCC(flags.Arg(0), vowifi.IPCCImportOptions{ + Bundle: bundle, + ProfileID: profileID, + }) + if err != nil { + return err + } + if documentOnly { + _, err := stdout.Write(result.Document) + return err + } + + installedPath := "" + if install { + profileDir = strings.TrimSpace(profileDir) + if profileDir == "" { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load configuration for carrier profile directory: %w", err) + } + profileDir = filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d") + } + installedPath, err = vowifi.InstallCarrierIPCCResult(result, profileDir) + if err != nil { + return err + } + if absolute, absoluteErr := filepath.Abs(installedPath); absoluteErr == nil { + installedPath = absolute + } + } + output := struct { + vowifi.IPCCImportResult + InstalledPath string `json:"installed_path,omitempty"` + RestartRequired bool `json:"restart_required,omitempty"` + }{ + IPCCImportResult: result, + InstalledPath: installedPath, + RestartRequired: installedPath != "", + } + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} diff --git a/cmd/vocat/carrier_test.go b/cmd/vocat/carrier_test.go new file mode 100644 index 0000000..e9a0b9b --- /dev/null +++ b/cmd/vocat/carrier_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "howett.net/plist" +) + +func TestRunCarrierImportIPCCPreviewsAndInstallsExplicitly(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "test.ipcc") + file, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + entry, err := archive.Create("Payload/Test.bundle/carrier.plist") + if err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + if err := plist.NewEncoder(&encoded).Encode(map[string]any{ + "CarrierName": "Test Carrier", + "SupportedSIMs": []any{"99901"}, + "SupportedPLMNs": []any{"99901"}, + }); err != nil { + t.Fatal(err) + } + if _, err := entry.Write(encoded.Bytes()); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + + var preview bytes.Buffer + if err := runCarrier([]string{"import-ipcc", "--document-only", archivePath}, &preview); err != nil { + t.Fatal(err) + } + var document struct { + Version int `json:"version"` + } + if err := json.Unmarshal(preview.Bytes(), &document); err != nil || document.Version != 1 { + t.Fatalf("preview = %q, version=%d, error=%v", preview.String(), document.Version, err) + } + + installDir := t.TempDir() + var output bytes.Buffer + if err := runCarrier([]string{ + "import-ipcc", "--id", "cli-test", "--install", "--profile-dir", installDir, archivePath, + }, &output); err != nil { + t.Fatal(err) + } + var installed struct { + InstalledPath string `json:"installed_path"` + RestartRequired bool `json:"restart_required"` + } + if err := json.Unmarshal(output.Bytes(), &installed); err != nil { + t.Fatal(err) + } + if !installed.RestartRequired || filepath.Base(installed.InstalledPath) != "cli-test.json" { + t.Fatalf("install output = %s", output.String()) + } + if _, err := os.Stat(filepath.Join(installDir, "cli-test.json")); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/vocat/cli.go b/cmd/vocat/cli.go index 9667729..6d513da 100644 --- a/cmd/vocat/cli.go +++ b/cmd/vocat/cli.go @@ -24,6 +24,12 @@ Usage: vocat doctor Diagnose USB modem, AT, QMI, PC/SC and proxy UDP paths. Use --repair-dji-qmi on Linux to safely wake a factory-ID DJI/Baiwang 2ca3:4006 QMI interface without changing NV. + vocat carrier import-ipcc [flags] FILE.ipcc + Convert an Apple carrier bundle into a reviewable VoCat + profile. Preview is the default; --install writes it to + carrier-profiles.d and takes effect after restart. + Flags: --bundle NAME --id ID --document-only --install + --profile-dir DIR. vocat update Check GitHub for a newer release and self-update. Flags: --check Only report whether an update is available. diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 48b4d2d..883a1a8 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -82,6 +82,11 @@ func main() { logger.Error("doctor failed", "error", err) os.Exit(1) } + case "carrier": + if err := runCarrier(rest, os.Stdout); err != nil { + logger.Error("carrier command failed", "error", err) + os.Exit(1) + } case "menu": if err := runMenu(logger); err != nil { logger.Error("menu failed", "error", err) @@ -125,6 +130,10 @@ func run(logger *slog.Logger, logs *loghub.Hub) error { if err != nil { return fmt.Errorf("load configuration: %w", err) } + carrierProfileDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "carrier-profiles.d") + if err := vowifi.LoadCarrierProfileDirectory(carrierProfileDir); err != nil { + return fmt.Errorf("load installed carrier profiles: %w", err) + } instanceLock, err := lockServerInstance(cfg.DatabasePath) if err != nil { return err diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 1720fed..a4e9c0e 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -195,6 +195,25 @@ Vocat 先从 `VOCAT_CONFIG` 读取可选的 JSON 配置文件,再应用 `VOCAT_* 请勿将 Telegram token、SMTP 密码、Webhook 密钥、SIM 凭据或其他私密数据存放在仓库中。请通过应用设置或受保护的环境文件来配置它们。 +## Apple IPCC 运营商规则导入 + +VoCat 可以离线解析用户提供的 `.ipcc`,将 Apple 的 XML/二进制 plist +转换为可审查的运营商 Profile。默认只预览,不会修改配置: + +```bash +vocat carrier import-ipcc Carrier_iPhone.ipcc +``` + +确认警告和匹配范围后,使用 `--install` 安装;重启 VoCat 后生效: + +```bash +vocat carrier import-ipcc --install Carrier_iPhone.ipcc +``` + +导入器不会复制关闭证书验证、绕过运营商授权、APN 凭据、紧急呼叫或 +设备型号专属媒体参数。完整字段和冲突处理说明见 +[CARRIER_IPCC_IMPORT.md](CARRIER_IPCC_IMPORT.md)。 + ## Telegram 机器人 启用 Telegram 通知并配置好 Chat ID 与 Admin ID 后,机器人支持: diff --git a/go.mod b/go.mod index 0cf11fc..4648a79 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( golang.org/x/crypto v0.52.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.43.0 + howett.net/plist v1.0.1 modernc.org/sqlite v1.38.2 ) diff --git a/go.sum b/go.sum index c9ee80a..04865c1 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg= github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -49,10 +50,13 @@ golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= diff --git a/internal/device/scan_test.go b/internal/device/scan_test.go index 4a1eb1a..6f222de 100644 --- a/internal/device/scan_test.go +++ b/internal/device/scan_test.go @@ -121,3 +121,22 @@ func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) { t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok) } } + +func TestCarrierForSIMRecognizesGiffgaffWithoutRelabelingGenericO2(t *testing.T) { + for _, identity := range []CarrierIdentity{ + {IMSI: "234100000000001", GID1: "508FFFFF", MNCLength: 2}, + {IMSI: "234100000000001", SPN: "GiffGaff", MNCLength: 2}, + } { + plmn, name, country, ok := CarrierForSIM(identity) + if !ok || plmn != "23410" || name != "giffgaff" || country != "GB" { + t.Fatalf("giffgaff identity = (%q, %q, %q, %v)", plmn, name, country, ok) + } + } + + _, name, _, ok := CarrierForSIM(CarrierIdentity{ + IMSI: "234100000000001", MNCLength: 2, + }) + if !ok || name == "giffgaff" { + t.Fatalf("generic O2 SIM was mislabeled as giffgaff: (%q, %v)", name, ok) + } +} diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go index fd0ac70..beb3662 100644 --- a/internal/vowifi/carrier_compat.go +++ b/internal/vowifi/carrier_compat.go @@ -3,17 +3,24 @@ package vowifi import ( _ "embed" "encoding/json" + "errors" "fmt" + "io" + "os" + "path/filepath" + "sort" "strings" + "sync" ) const ( - CarrierProfileStandard = "standard-3gpp" - IKEProposalModern = "modern" - IKEProposalLegacy = "legacy-sha1-modp1024" - IMSProfileStandard = "standard" - IMSProfileO2Germany = "o2-germany" - IMSProfileATT = "att" + CarrierProfileSchemaVersion = 1 + CarrierProfileStandard = "standard-3gpp" + IKEProposalModern = "modern" + IKEProposalLegacy = "legacy-sha1-modp1024" + IMSProfileStandard = "standard" + IMSProfileO2Germany = "o2-germany" + IMSProfileATT = "att" ) // CarrierProfile contains only interoperability choices that cannot be @@ -46,50 +53,51 @@ type carrierProfileDocument struct { } type carrierProfileRule struct { - ID string `json:"id"` - Match carrierProfileMatch `json:"match"` - Route carrierProfileRoute `json:"route"` - EPDG carrierProfileEPDG `json:"epdg"` - IKE carrierProfileIKE `json:"ike"` - IMS carrierProfileIMS `json:"ims"` + ID string `json:"id"` + Match carrierProfileMatch `json:"match,omitzero"` + MatchAny []carrierProfileMatch `json:"match_any,omitempty"` + Route carrierProfileRoute `json:"route,omitzero"` + EPDG carrierProfileEPDG `json:"epdg,omitzero"` + IKE carrierProfileIKE `json:"ike,omitzero"` + IMS carrierProfileIMS `json:"ims,omitzero"` } type carrierProfileMatch struct { - HomePLMNs []string `json:"home_plmns"` - IMSIPrefixes []string `json:"imsi_prefixes"` - ICCIDPrefixes []string `json:"iccid_prefixes"` - SPNs []string `json:"spns"` - GID1Prefixes []string `json:"gid1_prefixes"` - GID2Prefixes []string `json:"gid2_prefixes"` + HomePLMNs []string `json:"home_plmns,omitempty"` + IMSIPrefixes []string `json:"imsi_prefixes,omitempty"` + ICCIDPrefixes []string `json:"iccid_prefixes,omitempty"` + SPNs []string `json:"spns,omitempty"` + GID1Prefixes []string `json:"gid1_prefixes,omitempty"` + GID2Prefixes []string `json:"gid2_prefixes,omitempty"` } type carrierProfileRoute struct { - MCC string `json:"mcc"` - MNC string `json:"mnc"` + MCC string `json:"mcc,omitempty"` + MNC string `json:"mnc,omitempty"` } type carrierProfileEPDG struct { - Hostname string `json:"hostname"` - DNSHosts []string `json:"dns_hosts"` - DNSClientSubnet string `json:"dns_client_subnet"` + Hostname string `json:"hostname,omitempty"` + DNSHosts []string `json:"dns_hosts,omitempty"` + DNSClientSubnet string `json:"dns_client_subnet,omitempty"` } type carrierProfileIKE struct { - Proposal string `json:"proposal"` - AdvertiseEAPOnly *bool `json:"advertise_eap_only"` + Proposal string `json:"proposal,omitempty"` + AdvertiseEAPOnly *bool `json:"advertise_eap_only,omitempty"` } type carrierProfileIMS struct { - Transport string `json:"transport"` - IdentityProfile string `json:"identity_profile"` - RegisterProfile string `json:"register_profile"` - IPSecEncryption string `json:"ipsec_encryption"` - SMSCenter string `json:"sms_center"` - PANICountry string `json:"pani_country"` - PANINode string `json:"pani_node"` - DialURIScheme string `json:"dial_uri_scheme"` - UserEqPhone *bool `json:"user_eq_phone"` - VoiceCodecs []string `json:"voice_codecs"` + Transport string `json:"transport,omitempty"` + IdentityProfile string `json:"identity_profile,omitempty"` + RegisterProfile string `json:"register_profile,omitempty"` + IPSecEncryption string `json:"ipsec_encryption,omitempty"` + SMSCenter string `json:"sms_center,omitempty"` + PANICountry string `json:"pani_country,omitempty"` + PANINode string `json:"pani_node,omitempty"` + DialURIScheme string `json:"dial_uri_scheme,omitempty"` + UserEqPhone *bool `json:"user_eq_phone,omitempty"` + VoiceCodecs []string `json:"voice_codecs,omitempty"` } //go:embed carrier_profiles.json @@ -97,42 +105,162 @@ var carrierProfilesJSON []byte var builtinCarrierProfiles = mustLoadCarrierProfiles(carrierProfilesJSON) +var externalCarrierProfiles = struct { + sync.RWMutex + rules []carrierProfileRule +}{} + func mustLoadCarrierProfiles(encoded []byte) []carrierProfileRule { - var document carrierProfileDocument - if err := json.Unmarshal(encoded, &document); err != nil { + rules, err := loadCarrierProfiles(encoded) + if err != nil { panic("vowifi: invalid embedded carrier profiles: " + err.Error()) } - if document.Version != 1 { - panic(fmt.Sprintf("vowifi: unsupported carrier profile version %d", document.Version)) + return rules +} + +func loadCarrierProfiles(encoded []byte) ([]carrierProfileRule, error) { + var document carrierProfileDocument + if err := json.Unmarshal(encoded, &document); err != nil { + return nil, err + } + if document.Version != CarrierProfileSchemaVersion { + return nil, fmt.Errorf("unsupported carrier profile version %d", document.Version) } seen := make(map[string]struct{}, len(document.Profiles)) for index := range document.Profiles { rule := &document.Profiles[index] rule.ID = strings.TrimSpace(rule.ID) if rule.ID == "" { - panic("vowifi: carrier profile ID is empty") + return nil, fmt.Errorf("carrier profile %d ID is empty", index) } if _, duplicate := seen[rule.ID]; duplicate { - panic("vowifi: duplicate carrier profile " + rule.ID) + return nil, errors.New("duplicate carrier profile " + rule.ID) } seen[rule.ID] = struct{}{} if !validCarrierProfileRule(*rule) { - panic("vowifi: invalid carrier profile " + rule.ID) + return nil, errors.New("invalid carrier profile " + rule.ID) } } - return document.Profiles + return document.Profiles, nil +} + +// LoadCarrierProfileDirectory replaces the installed profile set with all +// valid JSON documents in dir. A missing directory is an empty set. Profiles +// are sorted by filename; later profiles win only when selector specificity is +// equal, so a broad installed PLMN rule cannot hide a constrained MVNO rule. +func LoadCarrierProfileDirectory(dir string) error { + dir = strings.TrimSpace(dir) + if dir == "" { + return errors.New("carrier profile directory is empty") + } + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + externalCarrierProfiles.Lock() + externalCarrierProfiles.rules = nil + externalCarrierProfiles.Unlock() + return nil + } + if err != nil { + return fmt.Errorf("read carrier profile directory %q: %w", dir, err) + } + if len(entries) > 256 { + return fmt.Errorf("carrier profile directory %q contains %d entries; maximum is 256", dir, len(entries)) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + loaded := make([]carrierProfileRule, 0, len(entries)) + seen := make(map[string]string) + for _, entry := range entries { + if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !strings.EqualFold(filepath.Ext(entry.Name()), ".json") { + continue + } + path := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + return fmt.Errorf("stat carrier profile %q: %w", path, err) + } + if info.Size() > 1<<20 { + return fmt.Errorf("carrier profile %q exceeds 1 MiB", path) + } + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open carrier profile %q: %w", path, err) + } + encoded, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1)) + closeErr := file.Close() + if readErr != nil { + return fmt.Errorf("read carrier profile %q: %w", path, readErr) + } + if closeErr != nil { + return fmt.Errorf("close carrier profile %q: %w", path, closeErr) + } + if len(encoded) > 1<<20 { + return fmt.Errorf("carrier profile %q exceeds 1 MiB", path) + } + rules, err := loadCarrierProfiles(encoded) + if err != nil { + return fmt.Errorf("load carrier profile %q: %w", path, err) + } + for _, rule := range rules { + if previous := seen[rule.ID]; previous != "" { + return fmt.Errorf("carrier profile %q is duplicated in %q and %q", rule.ID, previous, path) + } + seen[rule.ID] = path + loaded = append(loaded, rule) + } + } + externalCarrierProfiles.Lock() + externalCarrierProfiles.rules = loaded + externalCarrierProfiles.Unlock() + return nil +} + +func carrierProfilesSnapshot() []carrierProfileRule { + externalCarrierProfiles.RLock() + defer externalCarrierProfiles.RUnlock() + result := make([]carrierProfileRule, 0, len(builtinCarrierProfiles)+len(externalCarrierProfiles.rules)) + result = append(result, builtinCarrierProfiles...) + result = append(result, externalCarrierProfiles.rules...) + return result } func validCarrierProfileRule(rule carrierProfileRule) bool { - match := rule.Match - if len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+ - len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 { + matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny)) + if !emptyCarrierProfileMatch(rule.Match) { + matches = append(matches, rule.Match) + } + matches = append(matches, rule.MatchAny...) + if len(matches) == 0 { return false } - for _, plmn := range match.HomePLMNs { - if canonicalPLMNValue(plmn) == "" { + for _, match := range matches { + if emptyCarrierProfileMatch(match) { return false } + for _, plmn := range match.HomePLMNs { + if canonicalPLMNValue(plmn) == "" { + return false + } + } + for _, prefix := range match.IMSIPrefixes { + if len(prefix) < 5 || len(prefix) > 18 || !decimalString(prefix) { + return false + } + } + for _, prefix := range match.ICCIDPrefixes { + if len(prefix) < 5 || len(prefix) > 22 || !decimalString(prefix) { + return false + } + } + for _, prefix := range append(append([]string(nil), match.GID1Prefixes...), match.GID2Prefixes...) { + if len(prefix) < 1 || len(prefix) > 64 || !hexString(prefix) { + return false + } + } + for _, spn := range match.SPNs { + if strings.TrimSpace(spn) == "" || len(spn) > 128 { + return false + } + } } if (rule.Route.MCC == "") != (rule.Route.MNC == "") || (rule.Route.MCC != "" && canonicalPLMN(rule.Route.MCC, rule.Route.MNC) == "") { @@ -167,6 +295,33 @@ func validCarrierProfileRule(rule carrierProfileRule) bool { return true } +func emptyCarrierProfileMatch(match carrierProfileMatch) bool { + return len(match.HomePLMNs)+len(match.IMSIPrefixes)+len(match.ICCIDPrefixes)+ + len(match.SPNs)+len(match.GID1Prefixes)+len(match.GID2Prefixes) == 0 +} + +func hexString(value string) bool { + for _, item := range value { + if item >= '0' && item <= '9' || item >= 'a' && item <= 'f' || item >= 'A' && item <= 'F' { + continue + } + return false + } + return value != "" +} + +func decimalString(value string) bool { + if value == "" { + return false + } + for _, item := range value { + if item < '0' || item > '9' { + return false + } + } + return true +} + // ResolveCarrierProfile returns the most specific built-in match. Exact SIM // attributes add specificity, so a constrained MVNO rule wins over its host // PLMN without weakening the default match for unrelated subscriptions. @@ -183,9 +338,9 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile { IMSVoiceCodecs: []string{"PCMA", "PCMU"}, } bestScore := -1 - for _, rule := range builtinCarrierProfiles { - score, source, matched := matchCarrierProfile(rule.Match, identity) - if !matched || score <= bestScore { + for _, rule := range carrierProfilesSnapshot() { + score, source, matched := matchCarrierProfileRule(rule, identity) + if !matched || score < bestScore { continue } bestScore = score @@ -194,6 +349,28 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile { return resolved } +// matchCarrierProfileRule evaluates each selector set as an alternative. This +// mirrors carrier-bundle and Android carrier-ID semantics: fields inside one +// selector are ANDed, while separate selector records for the same brand are +// ORed (for example, giffgaff can be identified by either GID1 or SPN). +func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int, string, bool) { + bestScore := -1 + bestSource := "" + matches := make([]carrierProfileMatch, 0, 1+len(rule.MatchAny)) + if !emptyCarrierProfileMatch(rule.Match) { + matches = append(matches, rule.Match) + } + matches = append(matches, rule.MatchAny...) + for _, match := range matches { + score, source, matched := matchCarrierProfile(match, identity) + if matched && score > bestScore { + bestScore = score + bestSource = source + } + } + return bestScore, bestSource, bestScore >= 0 +} + func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) { score := 0 sources := make([]string, 0, 6) @@ -377,7 +554,7 @@ func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { // resolvers. An empty result means ordinary system DNS remains authoritative. func EPDGDNSClientSubnet(host string) string { host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) - for _, rule := range builtinCarrierProfiles { + for _, rule := range carrierProfilesSnapshot() { for _, candidate := range rule.EPDG.DNSHosts { if host == strings.ToLower(strings.TrimSuffix(strings.TrimSpace(candidate), ".")) { return strings.TrimSpace(rule.EPDG.DNSClientSubnet) diff --git a/internal/vowifi/carrier_compat_test.go b/internal/vowifi/carrier_compat_test.go index 5866f5e..a75f0a0 100644 --- a/internal/vowifi/carrier_compat_test.go +++ b/internal/vowifi/carrier_compat_test.go @@ -81,6 +81,53 @@ func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) { } } +func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) { + tests := []struct { + name string + identity SIMIdentity + source string + }{ + { + name: "Apple GID1 selector", + identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"}, + source: "hplmn+gid1", + }, + { + name: "Android SPN selector", + identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"}, + source: "hplmn+spn", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + profile := ResolveCarrierProfile(test.identity) + if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source { + t.Fatalf("giffgaff profile = %#v", profile) + } + if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone { + t.Fatalf("giffgaff IMS settings = %#v", profile) + } + }) + } + + generic := ResolveCarrierProfile(SIMIdentity{ + IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", + }) + if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" { + t.Fatalf("generic O2 profile = %#v", generic) + } +} + +func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) { + profile := ResolveCarrierProfile(SIMIdentity{ + ICCID: "8944300000000000001", IMSI: "234336000000001", + HomeMCC: "234", HomeMNC: "33", + }) + if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" { + t.Fatalf("EE-hosted profile = %#v", profile) + } +} + func TestResolveCarrierProfileNormalizesMNCWidth(t *testing.T) { for _, mnc := range []string{"03", "003"} { profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: mnc}) diff --git a/internal/vowifi/carrier_ipcc.go b/internal/vowifi/carrier_ipcc.go new file mode 100644 index 0000000..8e5881d --- /dev/null +++ b/internal/vowifi/carrier_ipcc.go @@ -0,0 +1,850 @@ +package vowifi + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "unicode" + + "howett.net/plist" +) + +const ( + maxIPCCBytes = 32 << 20 + maxIPCCFiles = 512 + maxIPCCPlistBytes = 4 << 20 + maxIPCCPlistTotalBytes = 64 << 20 + installedProfileFileMode = 0o600 +) + +var supportedSIMPLMN = regexp.MustCompile(`^[0-9]{5,6}$`) + +// IPCCImportOptions controls deterministic bundle selection and profile ID +// generation. Bundle may be a full archive directory or the final .bundle +// name. ProfileID overrides the generated, filesystem-safe ID. +type IPCCImportOptions struct { + Bundle string + ProfileID string +} + +// IPCCImportWarning describes a value that was ambiguous, unsafe, or outside +// VoCat's portable carrier-profile schema. Such values are reported but never +// copied into the installed profile. +type IPCCImportWarning struct { + Code string `json:"code"` + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +// IPCCImportResult contains a reviewable carrier-profile document. Document +// is complete JSON and can be installed without retaining the Apple archive. +type IPCCImportResult struct { + SourceFile string `json:"source_file"` + SourceSHA256 string `json:"source_sha256"` + Bundle string `json:"bundle"` + CarrierName string `json:"carrier_name"` + ProfileID string `json:"profile_id"` + Document json.RawMessage `json:"document"` + Warnings []IPCCImportWarning `json:"warnings,omitempty"` +} + +type ipccPlist struct { + name string + root map[string]any +} + +type ipccWarningSet struct { + items []IPCCImportWarning + seen map[string]struct{} +} + +func (set *ipccWarningSet) add(code, message, plistPath string) { + if set.seen == nil { + set.seen = make(map[string]struct{}) + } + item := IPCCImportWarning{Code: code, Message: message, Path: plistPath} + // Device-family override plists often repeat the same setting. Preserve the + // first concrete path while keeping the review output compact. + key := code + "\x00" + message + if _, duplicate := set.seen[key]; duplicate { + return + } + set.seen[key] = struct{}{} + set.items = append(set.items, item) +} + +// ImportCarrierIPCC converts a local Apple .ipcc/.zip archive into one +// reviewable VoCat carrier profile. It never contacts Apple and never installs the +// result. Device-specific and security-weakening values are deliberately +// omitted with structured warnings. +func ImportCarrierIPCC(filePath string, options IPCCImportOptions) (IPCCImportResult, error) { + filePath = strings.TrimSpace(filePath) + if filePath == "" { + return IPCCImportResult{}, errors.New("IPCC path is empty") + } + info, err := os.Stat(filePath) + if err != nil { + return IPCCImportResult{}, fmt.Errorf("stat IPCC %q: %w", filePath, err) + } + if !info.Mode().IsRegular() { + return IPCCImportResult{}, fmt.Errorf("IPCC %q is not a regular file", filePath) + } + if info.Size() <= 0 || info.Size() > maxIPCCBytes { + return IPCCImportResult{}, fmt.Errorf("IPCC %q size %d is outside 1..%d bytes", filePath, info.Size(), maxIPCCBytes) + } + encoded, err := os.ReadFile(filePath) + if err != nil { + return IPCCImportResult{}, fmt.Errorf("read IPCC %q: %w", filePath, err) + } + archive, err := zip.NewReader(bytes.NewReader(encoded), int64(len(encoded))) + if err != nil { + return IPCCImportResult{}, fmt.Errorf("open IPCC %q: %w", filePath, err) + } + if len(archive.File) > maxIPCCFiles { + return IPCCImportResult{}, fmt.Errorf("IPCC contains %d files; maximum is %d", len(archive.File), maxIPCCFiles) + } + + bundleRoots := carrierBundleRoots(archive.File) + bundleRoot, err := selectCarrierBundle(bundleRoots, options.Bundle) + if err != nil { + return IPCCImportResult{}, err + } + plists, err := readCarrierBundlePlists(archive.File, bundleRoot) + if err != nil { + return IPCCImportResult{}, err + } + primary := plists[0] + warnings := &ipccWarningSet{} + carrierName := firstNonempty( + plistString(primary.root["CarrierName"]), + statusBarCarrierName(primary.root), + strings.TrimSuffix(path.Base(bundleRoot), path.Ext(bundleRoot)), + ) + + matches, plmns, err := importCarrierSelectors(primary.root, plists, warnings) + if err != nil { + return IPCCImportResult{}, fmt.Errorf("import selectors from %s: %w", primary.name, err) + } + profileID := strings.TrimSpace(options.ProfileID) + if profileID == "" { + profileID = generatedIPCCProfileID(carrierName, plmns) + } + if !validInstalledProfileID(profileID) { + return IPCCImportResult{}, fmt.Errorf("profile ID %q must match [a-z0-9][a-z0-9._-]{0,63}", profileID) + } + + rule := carrierProfileRule{ID: profileID} + if len(matches) == 1 { + rule.Match = matches[0] + } else { + rule.MatchAny = matches + } + importCarrierEPDG(&rule, plists, warnings) + importCarrierIKE(&rule, plists, warnings) + importCarrierIMS(&rule, plists, warnings) + inspectIgnoredCarrierFields(plists, warnings) + if !validCarrierProfileRule(rule) { + return IPCCImportResult{}, errors.New("converted IPCC profile is not valid") + } + + sum := sha256.Sum256(encoded) + document := struct { + Version int `json:"version"` + Metadata map[string]string `json:"metadata"` + Profiles []carrierProfileRule `json:"profiles"` + }{ + Version: CarrierProfileSchemaVersion, + Metadata: map[string]string{ + "source": "user-supplied Apple carrier bundle", + "source_sha256": hex.EncodeToString(sum[:]), + "bundle": bundleRoot, + "generated_by": "vocat carrier import-ipcc", + }, + Profiles: []carrierProfileRule{rule}, + } + documentJSON, err := json.MarshalIndent(document, "", " ") + if err != nil { + return IPCCImportResult{}, fmt.Errorf("encode imported carrier profile: %w", err) + } + return IPCCImportResult{ + SourceFile: filepath.Base(filePath), + SourceSHA256: hex.EncodeToString(sum[:]), + Bundle: bundleRoot, + CarrierName: carrierName, + ProfileID: profileID, + Document: append(documentJSON, '\n'), + Warnings: warnings.items, + }, nil +} + +// InstallCarrierIPCCResult atomically writes an already-reviewed import result +// to dir. Existing files are never replaced; importing an update therefore +// requires an explicit operator decision outside this function. +func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, error) { + if !validInstalledProfileID(result.ProfileID) { + return "", fmt.Errorf("invalid profile ID %q", result.ProfileID) + } + if len(result.Document) == 0 { + return "", errors.New("import result has no profile document") + } + if _, err := loadCarrierProfiles(result.Document); err != nil { + return "", fmt.Errorf("validate imported profile: %w", err) + } + dir = strings.TrimSpace(dir) + if dir == "" { + return "", errors.New("carrier profile directory is empty") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create carrier profile directory %q: %w", dir, err) + } + target := filepath.Join(dir, result.ProfileID+".json") + if _, err := os.Stat(target); err == nil { + return "", fmt.Errorf("carrier profile %q already exists", target) + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("stat carrier profile %q: %w", target, err) + } + temporary, err := os.CreateTemp(dir, "."+result.ProfileID+"-*.tmp") + if err != nil { + return "", fmt.Errorf("create temporary carrier profile: %w", err) + } + temporaryPath := temporary.Name() + removeTemporary := true + defer func() { + _ = temporary.Close() + if removeTemporary { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(installedProfileFileMode); err != nil { + return "", fmt.Errorf("protect temporary carrier profile: %w", err) + } + if _, err := temporary.Write(result.Document); err != nil { + return "", fmt.Errorf("write temporary carrier profile: %w", err) + } + if err := temporary.Sync(); err != nil { + return "", fmt.Errorf("sync temporary carrier profile: %w", err) + } + if err := temporary.Close(); err != nil { + return "", fmt.Errorf("close temporary carrier profile: %w", err) + } + if err := os.Rename(temporaryPath, target); err != nil { + return "", fmt.Errorf("install carrier profile %q: %w", target, err) + } + removeTemporary = false + return target, nil +} + +func carrierBundleRoots(files []*zip.File) []string { + seen := make(map[string]struct{}) + for _, file := range files { + name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/")) + if strings.Contains(strings.ToLower(name), "/signatures/") || + !strings.EqualFold(path.Base(name), "carrier.plist") { + continue + } + root := path.Dir(name) + if root == "." || root == "/" { + continue + } + seen[root] = struct{}{} + } + result := make([]string, 0, len(seen)) + for root := range seen { + result = append(result, root) + } + sort.Strings(result) + return result +} + +func selectCarrierBundle(roots []string, wanted string) (string, error) { + if len(roots) == 0 { + return "", errors.New("IPCC contains no carrier.plist bundle") + } + wanted = strings.TrimSpace(strings.ReplaceAll(wanted, "\\", "/")) + if wanted != "" { + for _, root := range roots { + base := path.Base(root) + if strings.EqualFold(root, wanted) || strings.EqualFold(base, wanted) || + strings.EqualFold(strings.TrimSuffix(base, path.Ext(base)), strings.TrimSuffix(wanted, path.Ext(wanted))) { + return root, nil + } + } + return "", fmt.Errorf("carrier bundle %q not found; choices: %s", wanted, strings.Join(roots, ", ")) + } + if len(roots) != 1 { + return "", fmt.Errorf("IPCC contains multiple carrier bundles; select one with --bundle: %s", strings.Join(roots, ", ")) + } + return roots[0], nil +} + +func readCarrierBundlePlists(files []*zip.File, root string) ([]ipccPlist, error) { + var primary *zip.File + overrides := make([]*zip.File, 0) + rootPrefix := strings.TrimSuffix(root, "/") + "/" + for _, file := range files { + name := path.Clean(strings.ReplaceAll(file.Name, "\\", "/")) + if !strings.HasPrefix(name, rootPrefix) || strings.Contains(strings.ToLower(name), "/signatures/") { + continue + } + base := path.Base(name) + switch { + case strings.EqualFold(name, rootPrefix+"carrier.plist"): + primary = file + case strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist"): + overrides = append(overrides, file) + } + } + if primary == nil { + return nil, fmt.Errorf("bundle %q has no carrier.plist", root) + } + sort.Slice(overrides, func(i, j int) bool { return overrides[i].Name < overrides[j].Name }) + selected := append([]*zip.File{primary}, overrides...) + result := make([]ipccPlist, 0, len(selected)) + var total uint64 + for _, file := range selected { + if file.UncompressedSize64 > maxIPCCPlistBytes { + return nil, fmt.Errorf("plist %q exceeds %d bytes", file.Name, maxIPCCPlistBytes) + } + total += file.UncompressedSize64 + if total > maxIPCCPlistTotalBytes { + return nil, fmt.Errorf("selected plists exceed %d uncompressed bytes", maxIPCCPlistTotalBytes) + } + root, err := decodeIPCCPlist(file) + if err != nil { + return nil, fmt.Errorf("decode plist %q: %w", file.Name, err) + } + result = append(result, ipccPlist{name: file.Name, root: root}) + } + return result, nil +} + +func decodeIPCCPlist(file *zip.File) (map[string]any, error) { + reader, err := file.Open() + if err != nil { + return nil, err + } + defer reader.Close() + encoded, err := io.ReadAll(io.LimitReader(reader, maxIPCCPlistBytes+1)) + if err != nil { + return nil, err + } + if len(encoded) > maxIPCCPlistBytes { + return nil, fmt.Errorf("plist exceeds %d bytes", maxIPCCPlistBytes) + } + decoder := plist.NewDecoder(bytes.NewReader(encoded)) + var root map[string]any + if err := decoder.Decode(&root); err != nil { + return nil, err + } + if root == nil { + return nil, errors.New("plist root is not a dictionary") + } + return root, nil +} + +func importCarrierSelectors(primary map[string]any, plists []ipccPlist, warnings *ipccWarningSet) ([]carrierProfileMatch, []string, error) { + supportedSIMs := plistStrings(primary["SupportedSIMs"]) + supportedPLMNs := normalizedPLMNs(plistStrings(primary["SupportedPLMNs"])) + plainPLMNs := make([]string, 0) + qualified := make([]carrierProfileMatch, 0) + for _, raw := range supportedSIMs { + match, constrained, valid := parseAppleSupportedSIM(raw, warnings) + if !valid { + continue + } + if constrained { + qualified = append(qualified, match) + } else { + plainPLMNs = append(plainPLMNs, match.HomePLMNs...) + } + } + + allPLMNs := normalizeIPCCStringList(append(append([]string(nil), plainPLMNs...), supportedPLMNs...), false) + matches := qualified + if len(matches) == 0 { + if len(allPLMNs) == 0 { + return nil, nil, errors.New("no supported MCC/MNC selector was found") + } + match := carrierProfileMatch{HomePLMNs: allPLMNs} + iccidPrefixes := collectMatchingICCIDPrefixes(plists) + if len(iccidPrefixes) > 0 { + match.ICCIDPrefixes = iccidPrefixes + warnings.add( + "remote_provisioning_iccid_selector", + "MatchingICCIDPrefixes was used only because the bundle has no GID/SPN selector; verify that it identifies subscriptions rather than only eSIM provisioning eligibility", + "RemoteCardProvisioningSettings.MatchingICCIDPrefixes", + ) + } else { + warnings.add( + "broad_plmn_selector", + "the generated rule matches a whole home PLMN because the bundle exposes no GID, SPN, or ICCID discriminator", + "SupportedSIMs", + ) + } + matches = []carrierProfileMatch{match} + } + matches = deduplicateCarrierMatches(matches) + if len(matches) == 0 { + return nil, nil, errors.New("all SupportedSIMs selectors were unsupported") + } + if len(allPLMNs) == 0 { + for _, match := range matches { + allPLMNs = append(allPLMNs, match.HomePLMNs...) + } + allPLMNs = normalizeIPCCStringList(allPLMNs, false) + } + return matches, allPLMNs, nil +} + +func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfileMatch, bool, bool) { + raw = strings.TrimSpace(raw) + parts := strings.Split(raw, "_") + if len(parts) == 0 || !supportedSIMPLMN.MatchString(parts[0]) || canonicalPLMNValue(parts[0]) == "" { + warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs value "+strconv.Quote(raw), "SupportedSIMs") + return carrierProfileMatch{}, false, false + } + match := carrierProfileMatch{HomePLMNs: []string{parts[0]}} + for _, qualifier := range parts[1:] { + name, value, found := strings.Cut(qualifier, "-") + value = strings.TrimSpace(value) + if !found || value == "" { + warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(qualifier), "SupportedSIMs") + return carrierProfileMatch{}, false, false + } + switch strings.ToUpper(strings.TrimSpace(name)) { + case "GID1": + match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value)) + case "GID2": + match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value)) + case "ICCID": + match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff")) + case "SPN": + match.SPNs = append(match.SPNs, value) + default: + warnings.add("unsupported_sim_selector", "unsupported Apple SupportedSIMs qualifier "+strconv.Quote(name), "SupportedSIMs") + return carrierProfileMatch{}, false, false + } + } + return match, len(parts) > 1, true +} + +func trimAppleHexMask(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + trimmed := strings.TrimRight(value, "F") + if trimmed == "" { + return value + } + return trimmed +} + +func collectMatchingICCIDPrefixes(plists []ipccPlist) []string { + values := make([]string, 0) + for _, document := range plists { + walkPlist(document.root, nil, func(path []string, value any) { + if len(path) == 0 || !strings.EqualFold(path[len(path)-1], "MatchingICCIDPrefixes") { + return + } + for _, prefix := range plistStrings(value) { + prefix = strings.TrimRight(strings.TrimSpace(prefix), "Ff") + if len(prefix) >= 5 && decimalString(prefix) { + values = append(values, prefix) + } + } + }) + } + return normalizeIPCCStringList(values, false) +} + +func importCarrierEPDG(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) { + addresses := make(map[string][]string) + for _, document := range plists { + for _, ike := range dictionariesForKey(document.root, "IKE") { + address := strings.ToLower(strings.TrimSuffix(plistString(ike.value["RemoteAddress"]), ".")) + if address == "" { + continue + } + if !validEPDGHostname(address) { + warnings.add("unsupported_epdg_address", "ignored non-ePDG IKE RemoteAddress "+strconv.Quote(address), document.name+":"+strings.Join(ike.path, ".")) + continue + } + addresses[address] = append(addresses[address], document.name) + } + } + keys := sortedMapKeys(addresses) + switch len(keys) { + case 0: + warnings.add("epdg_not_explicit", "no unambiguous ePDG RemoteAddress was found; VoCat will derive the standard 3GPP hostname from the matched PLMN", "TechSettings.IKE.RemoteAddress") + case 1: + rule.EPDG.Hostname = keys[0] + default: + warnings.add("conflicting_epdg", "device override plists disagree on ePDG RemoteAddress; no address was imported: "+strings.Join(keys, ", "), "TechSettings.IKE.RemoteAddress") + } +} + +func importCarrierIKE(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) { + groups := make(map[int]struct{}) + eapMethods := make(map[string]struct{}) + for _, document := range plists { + for _, located := range dictionariesForKey(document.root, "IKE") { + ike := located.value + for _, proposal := range plistDictionaries(ike["Proposals"]) { + if group, ok := plistInt(proposal["DHGroup"]); ok { + groups[group] = struct{}{} + } + if method := strings.ToUpper(plistString(proposal["EAPMethod"])); method != "" { + eapMethods[method] = struct{}{} + } + } + if validate, ok := plistBool(ike["ValidateRemoteCertificate"]); ok && !validate { + warnings.add("remote_certificate_bypass_ignored", "ValidateRemoteCertificate=false was not imported", document.name+":"+strings.Join(located.path, ".")+".ValidateRemoteCertificate") + } + if enabled, ok := plistBool(ike["DeadPeerDetectionEnabled"]); ok { + if !enabled { + warnings.add("disabled_dpd_ignored", "Apple disables DPD for this device family; VoCat keeps its safe liveness defaults", document.name+":"+strings.Join(located.path, ".")+".DeadPeerDetectionEnabled") + } else if _, hasInterval := ike["DeadPeerDetectionInterval"]; hasInterval { + warnings.add("dpd_override_ignored", "device-specific DPD timing was not imported; VoCat keeps its runtime defaults", document.name+":"+strings.Join(located.path, ".")) + } + } + } + } + if len(groups) > 0 { + unknown := make([]string, 0) + _, hasModern := groups[14] + _, hasLegacy := groups[2] + for group := range groups { + if group != 2 && group != 14 { + unknown = append(unknown, strconv.Itoa(group)) + } + } + sort.Strings(unknown) + switch { + case len(unknown) > 0: + warnings.add("unsupported_ike_group", "unsupported IKE DH group(s) were not imported: "+strings.Join(unknown, ", "), "TechSettings.IKE.Proposals") + case hasModern: + rule.IKE.Proposal = IKEProposalModern + case hasLegacy: + rule.IKE.Proposal = IKEProposalLegacy + } + } + for method := range eapMethods { + if method != "EAP-AKA" && method != "EAP-AKA'" { + warnings.add("unsupported_eap_method", "VoCat does not import Apple EAP method "+strconv.Quote(method), "TechSettings.IKE.Proposals.EAPMethod") + } + } +} + +func importCarrierIMS(rule *carrierProfileRule, plists []ipccPlist, warnings *ipccWarningSet) { + useIPSec := false + for _, document := range plists { + for _, signaling := range dictionariesForKey(document.root, "Signaling") { + if value, ok := plistBool(signaling.value["UseIPSec"]); ok { + if value { + useIPSec = true + } else { + warnings.add("disabled_ims_ipsec_ignored", "UseIPSec=false was not imported because VoWiFi IMS security cannot be weakened automatically", document.name+":"+strings.Join(signaling.path, ".")+".UseIPSec") + } + } + } + } + if useIPSec { + // Apple does not describe the negotiated ESP algorithm in a portable + // field. Keep VoCat's safe AES-CBC default while recording the intent. + rule.IMS.IPSecEncryption = "aes-cbc" + } +} + +func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) { + for _, document := range plists { + walkPlist(document.root, nil, func(keyPath []string, value any) { + if len(keyPath) == 0 { + return + } + key := strings.ToLower(keyPath[len(keyPath)-1]) + fullPath := document.name + ":" + strings.Join(keyPath, ".") + switch { + case key == "enablewificallingwithoutentitlement": + if enabled, ok := plistBool(value); ok && enabled { + warnings.add("entitlement_bypass_ignored", "Wi-Fi Calling entitlement bypass was not imported", fullPath) + } + case key == "apns": + 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"): + warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath) + case strings.Contains(key, "emergency") || strings.Contains(key, "e911"): + warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath) + } + }) + } +} + +type locatedDictionary struct { + path []string + value map[string]any +} + +func dictionariesForKey(root map[string]any, wanted string) []locatedDictionary { + result := make([]locatedDictionary, 0) + walkPlist(root, nil, func(keyPath []string, value any) { + if len(keyPath) == 0 || !strings.EqualFold(keyPath[len(keyPath)-1], wanted) { + return + } + if dictionary, ok := value.(map[string]any); ok { + result = append(result, locatedDictionary{path: append([]string(nil), keyPath...), value: dictionary}) + } + }) + return result +} + +func walkPlist(value any, keyPath []string, visit func([]string, any)) { + visit(keyPath, value) + switch typed := value.(type) { + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + walkPlist(typed[key], appendPath(keyPath, key), visit) + } + case []any: + for index, item := range typed { + walkPlist(item, appendPath(keyPath, strconv.Itoa(index)), visit) + } + } +} + +func appendPath(base []string, item string) []string { + result := make([]string, len(base), len(base)+1) + copy(result, base) + return append(result, item) +} + +func plistStrings(value any) []string { + switch typed := value.(type) { + case string: + if strings.TrimSpace(typed) != "" { + return []string{strings.TrimSpace(typed)} + } + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + if value := plistString(item); value != "" { + result = append(result, value) + } + } + return result + case []string: + return normalizeIPCCStringList(typed, false) + } + return nil +} + +func normalizeIPCCStringList(values []string, lower bool) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if lower { + value = strings.ToLower(value) + } + if value == "" { + continue + } + if _, duplicate := seen[value]; duplicate { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func plistDictionaries(value any) []map[string]any { + switch typed := value.(type) { + case map[string]any: + return []map[string]any{typed} + case []any: + result := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + if dictionary, ok := item.(map[string]any); ok { + result = append(result, dictionary) + } + } + return result + default: + return nil + } +} + +func plistString(value any) string { + if text, ok := value.(string); ok { + return strings.TrimSpace(text) + } + return "" +} + +func plistBool(value any) (bool, bool) { + result, ok := value.(bool) + return result, ok +} + +func plistInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int64: + return int(typed), int64(int(typed)) == typed + case uint64: + return int(typed), uint64(int(typed)) == typed + case float64: + return int(typed), float64(int(typed)) == typed + default: + return 0, false + } +} + +func normalizedPLMNs(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if supportedSIMPLMN.MatchString(value) && canonicalPLMNValue(value) != "" { + result = append(result, value) + } + } + return normalizeIPCCStringList(result, false) +} + +func deduplicateCarrierMatches(matches []carrierProfileMatch) []carrierProfileMatch { + result := make([]carrierProfileMatch, 0, len(matches)) + seen := make(map[string]struct{}) + for _, match := range matches { + encoded, _ := json.Marshal(match) + key := string(encoded) + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + result = append(result, match) + } + return result +} + +func statusBarCarrierName(root map[string]any) string { + for _, item := range plistDictionaries(root["StatusBarImages"]) { + if name := firstNonempty(plistString(item["CarrierName"]), plistString(item["StatusBarCarrierName"])); name != "" { + return name + } + } + return "" +} + +func generatedIPCCProfileID(carrierName string, plmns []string) string { + base := slugCarrierProfileID(carrierName) + if base == "" { + base = "carrier" + } + if len(plmns) > 0 { + base += "-" + plmns[0] + } + base = "ipcc-" + base + if len(base) > 64 { + base = strings.TrimRight(base[:64], "-._") + } + return base +} + +func slugCarrierProfileID(value string) string { + var result strings.Builder + separator := false + for _, item := range strings.ToLower(strings.TrimSpace(value)) { + switch { + case item >= 'a' && item <= 'z', item >= '0' && item <= '9': + if separator && result.Len() > 0 { + result.WriteByte('-') + } + result.WriteRune(item) + separator = false + case unicode.IsSpace(item), item == '-', item == '_', item == '.': + separator = true + } + } + return strings.Trim(result.String(), "-") +} + +func validInstalledProfileID(value string) bool { + if len(value) < 1 || len(value) > 64 || !asciiLowerOrDigit(rune(value[0])) { + return false + } + for _, item := range value { + if asciiLowerOrDigit(item) || item == '-' || item == '_' || item == '.' { + continue + } + return false + } + return true +} + +func asciiLowerOrDigit(item rune) bool { + return item >= 'a' && item <= 'z' || item >= '0' && item <= '9' +} + +func validEPDGHostname(value string) bool { + if len(value) < 4 || len(value) > 253 || !strings.Contains(strings.ToLower(value), "epdg") { + return false + } + for _, label := range strings.Split(value, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, item := range label { + if item >= 'a' && item <= 'z' || item >= '0' && item <= '9' || item == '-' { + continue + } + return false + } + } + return true +} + +func consensusPositiveInt(values []int) (int, bool) { + if len(values) == 0 || values[0] <= 0 { + return 0, false + } + for _, value := range values[1:] { + if value != values[0] { + return 0, false + } + } + return values[0], true +} + +func sortedMapKeys[T any](values map[string]T) []string { + result := make([]string, 0, len(values)) + for key := range values { + result = append(result, key) + } + sort.Strings(result) + return result +} + +func firstNonempty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} diff --git a/internal/vowifi/carrier_ipcc_test.go b/internal/vowifi/carrier_ipcc_test.go new file mode 100644 index 0000000..67b1a9e --- /dev/null +++ b/internal/vowifi/carrier_ipcc_test.go @@ -0,0 +1,204 @@ +package vowifi + +import ( + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "howett.net/plist" +) + +type testIPCCPlist struct { + value map[string]any + format int +} + +func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) { + archivePath := writeTestIPCC(t, map[string]testIPCCPlist{ + "Payload/O2_Giffgaff_UK.bundle/carrier.plist": { + format: plist.XMLFormat, + value: map[string]any{ + "CarrierName": "giffgaff", + "SupportedSIMs": []any{"23410_GID1-508FFFFF"}, + "SupportedPLMNs": []any{"23410"}, + "apns": []any{map[string]any{"apn": "giffgaff.com"}}, + }, + }, + "Payload/O2_Giffgaff_UK.bundle/overrides_D1.plist": { + format: plist.BinaryFormat, + value: map[string]any{ + "TechSettings": map[string]any{ + "IKE": map[string]any{ + "RemoteAddress": "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org", + "ValidateRemoteCertificate": false, + "DeadPeerDetectionEnabled": false, + "Proposals": []any{map[string]any{ + "DHGroup": 14, "EAPMethod": "EAP-AKA", + }}, + }, + }, + "IMSConfig": map[string]any{ + "EnableWiFiCallingWithoutEntitlement": true, + "Signaling": map[string]any{"UseIPSec": true}, + "Media": map[string]any{"SupportPCMA": false}, + "Emergency": map[string]any{"E911OverITechSupported": true}, + }, + }, + }, + }) + result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{}) + if err != nil { + t.Fatal(err) + } + if result.CarrierName != "giffgaff" || result.ProfileID != "ipcc-giffgaff-23410" || result.SourceSHA256 == "" { + t.Fatalf("import metadata = %#v", result) + } + var document carrierProfileDocument + if err := json.Unmarshal(result.Document, &document); err != nil { + t.Fatal(err) + } + if document.Version != CarrierProfileSchemaVersion || len(document.Profiles) != 1 { + t.Fatalf("document = %#v", document) + } + rule := document.Profiles[0] + if rule.Match.HomePLMNs[0] != "23410" || rule.Match.GID1Prefixes[0] != "508" { + t.Fatalf("converted selector = %#v", rule.Match) + } + if rule.EPDG.Hostname != "epdg.epc.mnc010.mcc234.pub.3gppnetwork.org" || rule.IKE.Proposal != IKEProposalModern { + t.Fatalf("converted IKE profile = %#v", rule) + } + if rule.IMS.IPSecEncryption != "aes-cbc" { + t.Fatalf("converted IMS profile = %#v", rule.IMS) + } + for _, code := range []string{ + "remote_certificate_bypass_ignored", + "disabled_dpd_ignored", + "entitlement_bypass_ignored", + "apn_settings_ignored", + "device_media_overrides_ignored", + "emergency_settings_ignored", + } { + if !hasIPCCWarning(result.Warnings, code) { + t.Errorf("missing warning %q: %#v", code, result.Warnings) + } + } +} + +func TestImportCarrierIPCCRejectsAmbiguousBundleAndConflictingEPDG(t *testing.T) { + archivePath := writeTestIPCC(t, map[string]testIPCCPlist{ + "Payload/One.bundle/carrier.plist": { + format: plist.XMLFormat, + value: map[string]any{"CarrierName": "One", "SupportedSIMs": []any{"99901"}}, + }, + "Payload/One.bundle/overrides_A.plist": { + format: plist.XMLFormat, + value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.one.example"}}}, + }, + "Payload/One.bundle/overrides_B.plist": { + format: plist.BinaryFormat, + value: map[string]any{"TechSettings": map[string]any{"IKE": map[string]any{"RemoteAddress": "epdg.two.example"}}}, + }, + "Payload/Two.bundle/carrier.plist": { + format: plist.BinaryFormat, + value: map[string]any{"CarrierName": "Two", "SupportedSIMs": []any{"99902"}}, + }, + }) + + if _, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{}); err == nil { + t.Fatal("multi-bundle IPCC imported without --bundle") + } + result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{Bundle: "One"}) + if err != nil { + t.Fatal(err) + } + var document carrierProfileDocument + if err := json.Unmarshal(result.Document, &document); err != nil { + t.Fatal(err) + } + if document.Profiles[0].EPDG.Hostname != "" || !hasIPCCWarning(result.Warnings, "conflicting_epdg") { + t.Fatalf("conflicting ePDG was not quarantined: %#v, %#v", document.Profiles[0], result.Warnings) + } +} + +func TestInstallCarrierIPCCResultLoadsExternalProfileAtEqualSpecificity(t *testing.T) { + archivePath := writeTestIPCC(t, map[string]testIPCCPlist{ + "Payload/Test.bundle/carrier.plist": { + format: plist.BinaryFormat, + value: map[string]any{ + "CarrierName": "Installed Test", + "SupportedSIMs": []any{"23410_GID1-508FFFFF"}, + "SupportedPLMNs": []any{"23410"}, + }, + }, + }) + result, err := ImportCarrierIPCC(archivePath, IPCCImportOptions{ProfileID: "installed-giffgaff-test"}) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + emptyDir := t.TempDir() + t.Cleanup(func() { + if err := LoadCarrierProfileDirectory(emptyDir); err != nil { + t.Errorf("clear external profiles: %v", err) + } + }) + target, err := InstallCarrierIPCCResult(result, dir) + if err != nil { + t.Fatal(err) + } + if filepath.Base(target) != "installed-giffgaff-test.json" { + t.Fatalf("installed path = %q", target) + } + if _, err := InstallCarrierIPCCResult(result, dir); err == nil { + t.Fatal("second install overwrote an existing profile") + } + if err := LoadCarrierProfileDirectory(dir); err != nil { + t.Fatal(err) + } + profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"}) + if profile.ID != "installed-giffgaff-test" { + t.Fatalf("installed equal-specificity profile did not override builtin: %#v", profile) + } +} + +func writeTestIPCC(t *testing.T, files map[string]testIPCCPlist) string { + t.Helper() + archivePath := filepath.Join(t.TempDir(), "carrier.ipcc") + file, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + for name, item := range files { + var encoded bytes.Buffer + if err := plist.NewEncoderForFormat(&encoded, item.format).Encode(item.value); err != nil { + t.Fatal(err) + } + entry, err := archive.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(encoded.Bytes()); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return archivePath +} + +func hasIPCCWarning(warnings []IPCCImportWarning, code string) bool { + for _, warning := range warnings { + if warning.Code == code { + return true + } + } + return false +} diff --git a/internal/vowifi/carrier_profiles.json b/internal/vowifi/carrier_profiles.json index 7bcfc41..ee2b1c1 100644 --- a/internal/vowifi/carrier_profiles.json +++ b/internal/vowifi/carrier_profiles.json @@ -12,7 +12,7 @@ "ike": { "proposal": "legacy-sha1-modp1024" } }, { - "id": "ctexcel-ee-uk", + "id": "ee-uk-hosted-23433", "match": { "home_plmns": ["23433"], "imsi_prefixes": ["23433"], @@ -59,6 +59,24 @@ "match": { "home_plmns": ["20404"] }, "ike": { "proposal": "legacy-sha1-modp1024" } }, + { + "id": "giffgaff-o2-uk", + "match_any": [ + { + "home_plmns": ["23410"], + "gid1_prefixes": ["508"] + }, + { + "home_plmns": ["23410"], + "spns": ["giffgaff"] + } + ], + "ims": { + "transport": "udp", + "sms_center": "+447802002606", + "user_eq_phone": true + } + }, { "id": "o2-uk", "match": { "home_plmns": ["23410"] },