mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-17 21:33:43 +08:00
feat: import Apple carrier bundles safely (#50)
* feat: import Apple carrier bundles safely * Delete docs/CARRIER_IPCC_IMPORT.md --------- Co-authored-by: Meng Meng <[email protected]>
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 后,机器人支持:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"] },
|
||||
|
||||
Reference in New Issue
Block a user