mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 07:43:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392d44f919 | ||
|
|
8accaaaabc | ||
|
|
9eebcc1773 | ||
|
|
ff4c1ab036 | ||
|
|
1dc6bcccd9 | ||
|
|
be40e324db |
@@ -0,0 +1,42 @@
|
||||
name: Sync Apple Carrier Bundles
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every Sunday at midnight UTC
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run carrier bundles sync
|
||||
run: |
|
||||
go run ./cmd/sync_carrier_bundles
|
||||
|
||||
- name: Run tests on generated profiles
|
||||
run: |
|
||||
go test -v ./internal/vowifi
|
||||
|
||||
- name: Create Pull Request or commit updates
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
commit-message: "chore(vowifi): sync Apple carrier bundles offline database"
|
||||
title: "chore(vowifi): sync Apple carrier bundles offline database"
|
||||
body: |
|
||||
Automated sync from `dwilliamsuk/ios-carrier-bundles` latest release.
|
||||
Updated `internal/vowifi/carrier_profiles.json`.
|
||||
branch: "sync-apple-carrier-bundles"
|
||||
delete-branch: true
|
||||
@@ -216,7 +216,8 @@ VoCat uses `qmicli` to verify that a QMI control channel is ready and
|
||||
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
||||
and verifies the corresponding utilities automatically. For manual deployment,
|
||||
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses
|
||||
`pacman -S libqmi`, and Alpine uses `apk add qmi-utils`.
|
||||
`pacman -S libqmi`, Alpine uses `apk add qmi-utils`, and OpenWrt uses
|
||||
`opkg install qmi-utils`.
|
||||
|
||||
`vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB
|
||||
driver binding or asserting DTR. If the utility is unavailable, the command
|
||||
@@ -369,7 +370,7 @@ cd web && npm run build
|
||||
## Thanks
|
||||
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
|
||||
- [Linux.do](https://linux.do) — An inspiring tech community
|
||||
- [iniwex5](https://github.com/iniwex5) - Style and Functionality Guidelines
|
||||
- [iniwex5](https://github.com/iniwex5) — Style and Functionality Guidelines
|
||||
|
||||
## Buy me a coffee
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
const defaultTarURL = "https://github.com/dwilliamsuk/ios-carrier-bundles/archive/refs/heads/latest.tar.gz"
|
||||
|
||||
func main() {
|
||||
tarURL := flag.String("url", defaultTarURL, "URL to ios-carrier-bundles tar.gz archive")
|
||||
localTar := flag.String("file", "", "path to local .tar.gz archive")
|
||||
outputFile := flag.String("output", filepath.Join("internal", "vowifi", "carrier_profiles.json"), "output carrier_profiles.json path")
|
||||
flag.Parse()
|
||||
|
||||
var reader io.Reader
|
||||
if *localTar != "" {
|
||||
f, err := os.Open(*localTar)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", *localTar, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
reader = f
|
||||
} else {
|
||||
fmt.Printf("Downloading %s ...\n", *tarURL)
|
||||
client := &http.Client{Timeout: 3 * time.Minute}
|
||||
resp, err := client.Get(*tarURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Download error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "HTTP %s\n", resp.Status)
|
||||
os.Exit(1)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Downloaded %d bytes. Parsing archive...\n", len(data))
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
gz, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Gzip error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
bundlePlists := make(map[string]map[string][]byte)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Tar error: %v\n", err)
|
||||
break
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(hdr.Name, "\\", "/")
|
||||
if strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||
continue
|
||||
}
|
||||
base := path.Base(name)
|
||||
if !strings.EqualFold(base, "carrier.plist") && (!strings.HasPrefix(strings.ToLower(base), "overrides") || !strings.EqualFold(path.Ext(base), ".plist")) {
|
||||
continue
|
||||
}
|
||||
// e.g. ios-carrier-bundles-latest/Carrier Bundles/EE_uk.bundle/carrier.plist
|
||||
bundleDir := path.Dir(name)
|
||||
bundleName := path.Base(bundleDir)
|
||||
if !strings.HasSuffix(strings.ToLower(bundleName), ".bundle") {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bundlePlists[bundleName] == nil {
|
||||
bundlePlists[bundleName] = make(map[string][]byte)
|
||||
}
|
||||
bundlePlists[bundleName][base] = content
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d distinct carrier bundles. Extracting VoWiFi profiles...\n", len(bundlePlists))
|
||||
|
||||
var sortedBundleNames []string
|
||||
for k := range bundlePlists {
|
||||
sortedBundleNames = append(sortedBundleNames, k)
|
||||
}
|
||||
sort.Strings(sortedBundleNames)
|
||||
|
||||
var extractedRules []any
|
||||
seenIDs := make(map[string]bool)
|
||||
successCount := 0
|
||||
skipCount := 0
|
||||
|
||||
for _, bundleName := range sortedBundleNames {
|
||||
plists := bundlePlists[bundleName]
|
||||
rule, _, err := vowifi.ImportCarrierBundlePlists(bundleName, plists)
|
||||
if err != nil {
|
||||
skipCount++
|
||||
continue
|
||||
}
|
||||
if seenIDs[rule.ID] {
|
||||
continue
|
||||
}
|
||||
seenIDs[rule.ID] = true
|
||||
extractedRules = append(extractedRules, rule)
|
||||
successCount++
|
||||
}
|
||||
|
||||
fmt.Printf("Extracted %d valid carrier profile rules (skipped %d without valid VoWiFi selectors).\n", successCount, skipCount)
|
||||
|
||||
doc := map[string]any{
|
||||
"version": vowifi.CarrierProfileSchemaVersion,
|
||||
"metadata": map[string]any{
|
||||
"source": "dwilliamsuk/ios-carrier-bundles",
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"count": len(extractedRules),
|
||||
},
|
||||
"profiles": extractedRules,
|
||||
}
|
||||
|
||||
encoded, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "JSON encode error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(*outputFile, append(encoded, '\n'), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Write error to %s: %v\n", *outputFile, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully wrote %d rules (%d bytes) to %s\n", len(extractedRules), len(encoded), *outputFile)
|
||||
}
|
||||
+37
-2
@@ -237,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
||||
}
|
||||
|
||||
var onIncomingCall func(context.Context, ims.ReceivedCall) error
|
||||
|
||||
vowifiManager, err := configureVoWiFiRuntime(
|
||||
startupContext,
|
||||
logger,
|
||||
database,
|
||||
deviceManager,
|
||||
cardReaders,
|
||||
func(ctx context.Context, call ims.ReceivedCall) error {
|
||||
if onIncomingCall != nil {
|
||||
return onIncomingCall(ctx, call)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure VoWiFi runtime: %w", err)
|
||||
@@ -276,10 +284,24 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
onIncomingCall = func(ctx context.Context, call ims.ReceivedCall) error {
|
||||
deviceConfig, _ := database.Device(ctx, call.DeviceID)
|
||||
handler.NotifyIncomingCall(ctx, server.IncomingCallNotification{
|
||||
DeviceID: call.DeviceID,
|
||||
DeviceName: strings.TrimSpace(deviceConfig.Name),
|
||||
DeviceLabel: firstNonEmpty(deviceConfig.Name, deviceConfig.ID, "--"),
|
||||
Caller: call.Caller,
|
||||
Called: call.Called,
|
||||
Time: call.Timestamp,
|
||||
Environment: "vowifi",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
go handler.StartLogRetentionLoop(pollContext, time.Minute)
|
||||
go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
|
||||
handler.StartTelegramBot(pollContext)
|
||||
handler.StartSMSNotificationDispatchers(pollContext)
|
||||
go handler.StartCellularCallMonitor(pollContext)
|
||||
handler.StartAutomaticTasks(pollContext)
|
||||
|
||||
serverConfig := func(handler http.Handler) *http.Server {
|
||||
@@ -575,6 +597,7 @@ func configureVoWiFiRuntime(
|
||||
database *store.Store,
|
||||
deviceManager *device.Manager,
|
||||
cardReaders *pcsc.Service,
|
||||
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||
) (*vowifiruntime.Manager, error) {
|
||||
mapper := integration.ATMapper{
|
||||
Store: database,
|
||||
@@ -630,7 +653,7 @@ func configureVoWiFiRuntime(
|
||||
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
|
||||
adapter = nativeQMIAdapter
|
||||
}
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger)
|
||||
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -694,7 +717,7 @@ func protectVoWiFiStartupRadioWithRetry(
|
||||
physicalID string,
|
||||
attempts int,
|
||||
delay time.Duration,
|
||||
) error {
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
@@ -730,6 +753,7 @@ func newVoWiFiOrchestrator(
|
||||
database *store.Store,
|
||||
adapter vowifiDeviceAdapter,
|
||||
logger *slog.Logger,
|
||||
onIncomingCall func(context.Context, ims.ReceivedCall) error,
|
||||
) (*vowifi.Orchestrator, error) {
|
||||
apn := deviceConfig.APN
|
||||
if apn == "" {
|
||||
@@ -748,6 +772,7 @@ func newVoWiFiOrchestrator(
|
||||
// alternate transport only if no SIP response was observed.
|
||||
Transport: "tcp",
|
||||
AutoTransportFallback: true,
|
||||
OnIncomingCall: onIncomingCall,
|
||||
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
|
||||
extra, _ := json.Marshal(map[string]any{
|
||||
"transport": "ims",
|
||||
@@ -1328,3 +1353,13 @@ func liftCardRegionBlock(
|
||||
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI,
|
||||
)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服
|
||||
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
|
||||
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
|
||||
Debian/Ubuntu 使用 `apt install libqmi-utils`;Arch Linux 使用
|
||||
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`。
|
||||
`pacman -S libqmi`,Alpine 使用 `apk add qmi-utils`,OpenWrt 使用 `opkg install qmi-utils`。
|
||||
|
||||
`vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
|
||||
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -44,9 +45,107 @@ func (backend *nativeBackend) dial(ctx context.Context) (*pcscdClient, error) {
|
||||
return nil, fmt.Errorf("%w: pcscd socket is not reachable: %w", ErrUnavailable, errors.Join(failures...))
|
||||
}
|
||||
|
||||
func ensurePCSCDService(ctx context.Context) {
|
||||
if os.Geteuid() != 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat("/run/systemd/system"); err == nil {
|
||||
_ = exec.CommandContext(ctx, "systemctl", "start", "pcscd.socket").Run()
|
||||
_ = exec.CommandContext(ctx, "systemctl", "start", "pcscd").Run()
|
||||
} else if _, err := os.Stat("/etc/init.d/pcscd"); err == nil {
|
||||
_ = exec.CommandContext(ctx, "/etc/init.d/pcscd", "start").Run()
|
||||
} else if path, err := exec.LookPath("pcscd"); err == nil {
|
||||
_ = exec.CommandContext(ctx, path).Start()
|
||||
}
|
||||
}
|
||||
|
||||
func reauthorizeUSBDevice(sysRoot, usbPath string) {
|
||||
if strings.Contains(usbPath, "..") || strings.Contains(usbPath, "/") || strings.Contains(usbPath, "\\") {
|
||||
return
|
||||
}
|
||||
authPath := filepath.Join(filepath.Clean(sysRoot), "bus", "usb", "devices", usbPath, "authorized")
|
||||
if _, err := os.Stat(authPath); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(authPath, []byte("0\n"), 0o644)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_ = os.WriteFile(authPath, []byte("1\n"), 0o644)
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) waitForPCSCReaders(ctx context.Context, client *pcscdClient, physical []Reader, states []pcscdReaderState) []pcscdReaderState {
|
||||
// First pass: wait up to 2 seconds for active driver negotiation.
|
||||
pollDeadline := time.Now().Add(2 * time.Second)
|
||||
if dl, ok := ctx.Deadline(); ok && dl.Before(pollDeadline) {
|
||||
pollDeadline = dl
|
||||
}
|
||||
for len(states) < len(physical) && time.Now().Before(pollDeadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return states
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
if updated, err := client.readers(ctx); err == nil {
|
||||
states = updated
|
||||
if len(states) >= len(physical) {
|
||||
return states
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(states) >= len(physical) {
|
||||
return states
|
||||
}
|
||||
|
||||
// Second pass: if readers are still missing from pcscd, trigger a USB re-authorization
|
||||
// on the physical devices in sysfs to reset any stalled CCID endpoints, then poll briefly.
|
||||
reauthorized := false
|
||||
for _, phys := range physical {
|
||||
if phys.USBPath != "" {
|
||||
reauthorizeUSBDevice(backend.sysRoot, phys.USBPath)
|
||||
reauthorized = true
|
||||
}
|
||||
}
|
||||
if !reauthorized {
|
||||
return states
|
||||
}
|
||||
|
||||
retryDeadline := time.Now().Add(2 * time.Second)
|
||||
if dl, ok := ctx.Deadline(); ok && dl.Before(retryDeadline) {
|
||||
retryDeadline = dl
|
||||
}
|
||||
for len(states) < len(physical) && time.Now().Before(retryDeadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return states
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if updated, err := client.readers(ctx); err == nil {
|
||||
states = updated
|
||||
if len(states) >= len(physical) {
|
||||
return states
|
||||
}
|
||||
}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
|
||||
client, err := backend.dial(ctx)
|
||||
if err != nil && len(physical) > 0 {
|
||||
ensurePCSCDService(ctx)
|
||||
dialDeadline := time.Now().Add(1500 * time.Millisecond)
|
||||
for time.Now().Before(dialDeadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
if c, dialErr := backend.dial(ctx); dialErr == nil {
|
||||
client, err = c, nil
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if len(physical) > 0 {
|
||||
for index := range physical {
|
||||
@@ -61,6 +160,9 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(physical) > 0 && len(states) < len(physical) {
|
||||
states = backend.waitForPCSCReaders(ctx, client, physical, states)
|
||||
}
|
||||
readers := make([]Reader, 0, len(states))
|
||||
for _, state := range states {
|
||||
reader := Reader{
|
||||
|
||||
@@ -67,17 +67,33 @@ func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader {
|
||||
readers[0] = enrichPCSCReader(readers[0], physical[0])
|
||||
return readers
|
||||
}
|
||||
seen := make(map[string]bool, len(readers))
|
||||
matchedPhysical := make(map[string]bool, len(physical))
|
||||
for i := range readers {
|
||||
seen[readers[i].USBPath] = true
|
||||
for _, usbReader := range physical {
|
||||
if readers[i].USBPath == usbReader.USBPath {
|
||||
readers[i] = enrichPCSCReader(readers[i], usbReader)
|
||||
matchedPhysical[usbReader.USBPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Secondary pass: if any pcsc reader is still prefixed with pcsc: (unresolved sysfs USB path),
|
||||
// match with unmatched physical readers by VendorID/ProductID or if 1:1 remaining.
|
||||
var remainingPhysical []Reader
|
||||
for _, p := range physical {
|
||||
if !matchedPhysical[p.USBPath] {
|
||||
remainingPhysical = append(remainingPhysical, p)
|
||||
}
|
||||
}
|
||||
for i := range readers {
|
||||
if strings.HasPrefix(readers[i].USBPath, "pcsc:") && len(remainingPhysical) == 1 {
|
||||
readers[i] = enrichPCSCReader(readers[i], remainingPhysical[0])
|
||||
matchedPhysical[remainingPhysical[0].USBPath] = true
|
||||
remainingPhysical = nil
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, usbReader := range physical {
|
||||
if !seen[usbReader.USBPath] {
|
||||
if !matchedPhysical[usbReader.USBPath] {
|
||||
readers = append(readers, usbReader)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,27 @@ func TestMergePCSCAndSingleUSBReaderEnrichesFallbackPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePCSCAndMultipleUSBReadersWithFallbackPath(t *testing.T) {
|
||||
readers := mergePCSCAndUSBReaders(
|
||||
[]Reader{
|
||||
{Name: "Identiv uTrust 00 00", USBPath: "1-2", CardPresent: true},
|
||||
{Name: "Generic Smart Card Reader 00 00", USBPath: "pcsc:Generic Smart Card Reader 00 00", CardPresent: true},
|
||||
},
|
||||
[]Reader{
|
||||
{Name: "uTrust", USBPath: "1-2", VendorID: "04e6", ProductID: "5810", DiscoveryIssue: "pcsc_driver_missing"},
|
||||
{Name: "ESTKme-RED", USBPath: "1-1", VendorID: "0bda", ProductID: "0165", DiscoveryIssue: "pcsc_driver_missing"},
|
||||
},
|
||||
)
|
||||
if len(readers) != 2 {
|
||||
t.Fatalf("len(readers) = %d, want 2", len(readers))
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.DiscoveryIssue != "" {
|
||||
t.Errorf("reader %#v still has discovery issue %q", r, r.DiscoveryIssue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeUSBTestFile(t *testing.T, path, value string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"vocat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
callDeduplicationWindow = 60 * time.Second
|
||||
cellularCallMonitorInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
callDeduplicationMu sync.Mutex
|
||||
callDeduplicationMap = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
type IncomingCallNotification struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
DeviceLabel string
|
||||
Caller string
|
||||
Called string
|
||||
Time time.Time
|
||||
Environment string
|
||||
}
|
||||
|
||||
func (value IncomingCallNotification) Title() string {
|
||||
return "收到来电"
|
||||
}
|
||||
|
||||
func (value IncomingCallNotification) Text() string {
|
||||
envText := "VoWiFi"
|
||||
if value.Environment == "cellular" {
|
||||
envText = "基站直连"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"📞 收到来电",
|
||||
"设备 " + value.DeviceLabel,
|
||||
"来电号码 " + value.Caller,
|
||||
"被呼号码 " + value.Called,
|
||||
"时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
"网络 " + envText,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (value IncomingCallNotification) DetailText() string {
|
||||
lines := strings.Split(value.Text(), "\n")
|
||||
return strings.Join(lines[1:], "\n")
|
||||
}
|
||||
|
||||
func shouldSuppressDuplicateCall(key string, now time.Time, window time.Duration) bool {
|
||||
callDeduplicationMu.Lock()
|
||||
defer callDeduplicationMu.Unlock()
|
||||
for k, t := range callDeduplicationMap {
|
||||
if now.Sub(t) > window*2 {
|
||||
delete(callDeduplicationMap, k)
|
||||
}
|
||||
}
|
||||
if lastTime, exists := callDeduplicationMap[key]; exists {
|
||||
if now.Sub(lastTime) < window {
|
||||
return true
|
||||
}
|
||||
}
|
||||
callDeduplicationMap[key] = now
|
||||
return false
|
||||
}
|
||||
|
||||
// NotifyIncomingCall delivers an incoming call alert to all configured notification channels.
|
||||
func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCallNotification) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
caller := strings.TrimSpace(notification.Caller)
|
||||
if caller == "" {
|
||||
caller = "未知号码"
|
||||
}
|
||||
notification.Caller = caller
|
||||
|
||||
called := strings.TrimSpace(notification.Called)
|
||||
if called == "" {
|
||||
called = "--"
|
||||
}
|
||||
notification.Called = called
|
||||
|
||||
if notification.Time.IsZero() {
|
||||
notification.Time = time.Now().UTC()
|
||||
}
|
||||
|
||||
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if notification.DeviceLabel == "" || notification.DeviceLabel == "--" {
|
||||
if configured, err := s.store.Device(ctx, notification.DeviceID); err == nil {
|
||||
notification.DeviceName = strings.TrimSpace(configured.Name)
|
||||
notification.DeviceLabel = firstNonEmpty(configured.Name, configured.ID, "--")
|
||||
} else {
|
||||
notification.DeviceLabel = firstNonEmpty(notification.DeviceID, "--")
|
||||
}
|
||||
}
|
||||
|
||||
destCtx := s.notificationDestinationContext(ctx)
|
||||
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||
setting, err := s.store.NotificationSetting(destCtx, channel)
|
||||
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("read incoming call notification setting", "channel", channel, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("decode incoming call notification setting", "channel", channel, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error {
|
||||
switch channel {
|
||||
case "telegram":
|
||||
return sendTelegramTextNotification(ctx, config, message.Text())
|
||||
case "bark":
|
||||
return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||
case "email":
|
||||
return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text())
|
||||
case "pushplus":
|
||||
return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||
case "webhook":
|
||||
return sendCallWebhookNotification(ctx, config, message)
|
||||
case "wecom":
|
||||
return sendWecomNotification(ctx, config, wecomCallValues(message))
|
||||
case "lark":
|
||||
return sendLarkNotification(ctx, config, larkCallValues(message))
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func renderCallWebhookTemplate(template string, message IncomingCallNotification) string {
|
||||
rendered := message.Text()
|
||||
if strings.TrimSpace(template) != "" {
|
||||
replacements := map[string]string{
|
||||
"{{text}}": rendered,
|
||||
"{{content}}": message.DetailText(),
|
||||
"{{event}}": "call.received",
|
||||
"{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
|
||||
"{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
"{{number}}": message.Caller,
|
||||
"{{caller}}": message.Caller,
|
||||
"{{called}}": message.Called,
|
||||
"{{device_id}}": message.DeviceID,
|
||||
"{{device_name}}": message.DeviceName,
|
||||
"{{device_label}}": message.DeviceLabel,
|
||||
"{{environment}}": message.Environment,
|
||||
}
|
||||
for placeholder, value := range replacements {
|
||||
template = strings.ReplaceAll(template, placeholder, value)
|
||||
}
|
||||
return template
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error {
|
||||
template := configString(config, "text_template")
|
||||
rendered := renderCallWebhookTemplate(template, message)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": "call.received",
|
||||
"message": rendered,
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"caller": message.Caller,
|
||||
"called": message.Called,
|
||||
"environment": message.Environment,
|
||||
})
|
||||
timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
|
||||
client, err := restrictedHTTPClient(ctx, timeout, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
retries := configInt(config, "retry_max")
|
||||
for _, destination := range configStrings(config, "urls") {
|
||||
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sendErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if requestErr != nil {
|
||||
return fmt.Errorf("create call webhook notification request: %w", requestErr)
|
||||
}
|
||||
for name, value := range configStringMap(config, "headers") {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "vocat-call-notification/1")
|
||||
if secret := configString(config, "secret"); secret != "" {
|
||||
signature := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = signature.Write(payload)
|
||||
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
|
||||
}
|
||||
sendErr = performNotificationRequest(client, request, false)
|
||||
if sendErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wecomCallValues(message IncomingCallNotification) wecomTemplateValues {
|
||||
return wecomTemplateValues{
|
||||
"event": "call.received",
|
||||
"title": message.Title(),
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.DetailText(),
|
||||
"number": message.Caller,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func larkCallValues(message IncomingCallNotification) larkTemplateValues {
|
||||
return larkTemplateValues{
|
||||
"event": "call.received",
|
||||
"title": message.Title(),
|
||||
"message": message.Text(),
|
||||
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||
"content": message.DetailText(),
|
||||
"number": message.Caller,
|
||||
"device_id": message.DeviceID,
|
||||
"device_name": message.DeviceName,
|
||||
"device_label": message.DeviceLabel,
|
||||
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
// StartCellularCallMonitor scans physical modems for incoming calls in cellular mode.
|
||||
func (s *Server) StartCellularCallMonitor(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ticker := time.NewTicker(cellularCallMonitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.pollCellularCalls(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pollCellularCalls(ctx context.Context) {
|
||||
devices, err := s.store.ListDevices(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, config := range devices {
|
||||
if !config.NetworkEnabled {
|
||||
continue
|
||||
}
|
||||
// If VoWiFi is active, incoming calls are handled directly by SIP INVITE in real time.
|
||||
if s.callTransport(config.ID) == "vowifi" {
|
||||
continue
|
||||
}
|
||||
entry, physicalID, present := s.physicalForConfig(config)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
pollCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
response, err := s.devices.ExecuteAT(pollCtx, physicalID, "AT+CLCC")
|
||||
cancel()
|
||||
if err != nil || !response.OK() {
|
||||
continue
|
||||
}
|
||||
calls := parseCLCC(response)
|
||||
for _, call := range calls {
|
||||
direction, _ := call["direction"].(int)
|
||||
state, _ := call["state"].(int)
|
||||
// direction 1 = incoming (Mobile Terminated)
|
||||
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
|
||||
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
|
||||
caller, _ := call["number"].(string)
|
||||
if caller == "" {
|
||||
caller = "未知号码"
|
||||
}
|
||||
called := ""
|
||||
if entry.Snapshot != nil {
|
||||
called = entry.Snapshot.Phone.Number
|
||||
}
|
||||
s.NotifyIncomingCall(ctx, IncomingCallNotification{
|
||||
DeviceID: config.ID,
|
||||
DeviceName: strings.TrimSpace(config.Name),
|
||||
DeviceLabel: firstNonEmpty(config.Name, config.ID, "--"),
|
||||
Caller: caller,
|
||||
Called: firstNonEmpty(called, "--"),
|
||||
Time: time.Now().UTC(),
|
||||
Environment: "cellular",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||
notification := IncomingCallNotification{
|
||||
DeviceID: "ec20-1",
|
||||
DeviceName: "Main Router",
|
||||
DeviceLabel: "Main Router",
|
||||
Caller: "+8613800138000",
|
||||
Called: "+8613900139000",
|
||||
Time: now,
|
||||
Environment: "vowifi",
|
||||
}
|
||||
|
||||
if notification.Title() != "收到来电" {
|
||||
t.Errorf("Title() = %q, want '收到来电'", notification.Title())
|
||||
}
|
||||
|
||||
text := notification.Text()
|
||||
for _, want := range []string{
|
||||
"📞 收到来电",
|
||||
"设备 Main Router",
|
||||
"来电号码 +861380138000"[:10],
|
||||
"被呼号码 +8613900139000",
|
||||
"网络 VoWiFi",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("Text() omitted %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
notification.Environment = "cellular"
|
||||
if !strings.Contains(notification.Text(), "网络 基站直连") {
|
||||
t.Errorf("Text() in cellular mode omitted '网络 基站直连':\n%s", notification.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingCallDeduplication(t *testing.T) {
|
||||
now := time.Now()
|
||||
key := "test-device:+8613800000000"
|
||||
|
||||
// First call should not be suppressed
|
||||
if shouldSuppressDuplicateCall(key, now, time.Minute) {
|
||||
t.Fatal("first call unexpectedly suppressed")
|
||||
}
|
||||
|
||||
// Immediate duplicate should be suppressed
|
||||
if !shouldSuppressDuplicateCall(key, now.Add(5*time.Second), time.Minute) {
|
||||
t.Fatal("duplicate call within window was not suppressed")
|
||||
}
|
||||
|
||||
// Call after window should be allowed
|
||||
if shouldSuppressDuplicateCall(key, now.Add(70*time.Second), time.Minute) {
|
||||
t.Fatal("call after window was suppressed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCallWebhookTemplate(t *testing.T) {
|
||||
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||
message := IncomingCallNotification{
|
||||
DeviceID: "dev-1",
|
||||
DeviceName: "Living Room",
|
||||
DeviceLabel: "EC20",
|
||||
Caller: "+8613800000000",
|
||||
Called: "+8613900000000",
|
||||
Time: now,
|
||||
Environment: "vowifi",
|
||||
}
|
||||
|
||||
got := renderCallWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{caller}}|{{called}}|{{environment}}", message)
|
||||
want := "call.received|dev-1|Living Room|EC20|+8613800000000|+8613900000000|vowifi"
|
||||
if got != want {
|
||||
t.Fatalf("renderCallWebhookTemplate() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWecomAndLarkCallValues(t *testing.T) {
|
||||
location := time.FixedZone("UTC+8", 8*60*60)
|
||||
now := time.Date(2026, 8, 20, 18, 0, 0, 0, location)
|
||||
message := IncomingCallNotification{
|
||||
DeviceID: "dev-1",
|
||||
DeviceName: "Office",
|
||||
DeviceLabel: "EC20-Office",
|
||||
Caller: "+8613800138000",
|
||||
Called: "+8613900139000",
|
||||
Time: now,
|
||||
Environment: "cellular",
|
||||
}
|
||||
|
||||
wecom := wecomCallValues(message)
|
||||
if wecom["event"] != "call.received" || wecom["title"] != "收到来电" || wecom["number"] != "+8613800138000" {
|
||||
t.Fatalf("wecomCallValues = %#v", wecom)
|
||||
}
|
||||
if !strings.Contains(wecom["message"], "网络 基站直连") {
|
||||
t.Fatalf("wecomCallValues message omitted network: %s", wecom["message"])
|
||||
}
|
||||
|
||||
lark := larkCallValues(message)
|
||||
if lark["event"] != "call.received" || lark["title"] != "收到来电" || lark["device_label"] != "EC20-Office" {
|
||||
t.Fatalf("larkCallValues = %#v", lark)
|
||||
}
|
||||
}
|
||||
@@ -411,7 +411,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source)
|
||||
resolved = applyCarrierProfileRule(resolved, rule, source, identity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -441,16 +441,18 @@ func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int
|
||||
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
|
||||
score := 0
|
||||
sources := make([]string, 0, 6)
|
||||
hasHomePLMNMatch := false
|
||||
if len(match.HomePLMNs) > 0 {
|
||||
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||
if wanted == "" || !matchesAny(match.HomePLMNs, func(value string) bool {
|
||||
if wanted != "" && matchesAny(match.HomePLMNs, func(value string) bool {
|
||||
return canonicalPLMNValue(value) == wanted
|
||||
}) {
|
||||
return 0, "", false
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
hasHomePLMNMatch = true
|
||||
}
|
||||
score += 100
|
||||
sources = append(sources, "hplmn")
|
||||
}
|
||||
hasSelectorMatch := false
|
||||
for _, selector := range []struct {
|
||||
name string
|
||||
weight int
|
||||
@@ -467,27 +469,32 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
|
||||
continue
|
||||
}
|
||||
actual := strings.TrimSpace(selector.actual)
|
||||
if actual == "" || !matchesAny(selector.values, func(prefix string) bool {
|
||||
if actual != "" && matchesAny(selector.values, func(prefix string) bool {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if selector.foldCase {
|
||||
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
|
||||
}
|
||||
return strings.HasPrefix(actual, prefix)
|
||||
}) {
|
||||
score += selector.weight
|
||||
sources = append(sources, selector.name)
|
||||
hasSelectorMatch = true
|
||||
} else if !hasHomePLMNMatch {
|
||||
return 0, "", false
|
||||
}
|
||||
score += selector.weight
|
||||
sources = append(sources, selector.name)
|
||||
}
|
||||
if len(match.SPNs) > 0 {
|
||||
spn := strings.TrimSpace(identity.SPN)
|
||||
if spn == "" || !matchesAny(match.SPNs, func(value string) bool {
|
||||
if spn != "" && matchesAny(match.SPNs, func(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), spn)
|
||||
}) {
|
||||
return 0, "", false
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
hasSelectorMatch = true
|
||||
}
|
||||
score += 20
|
||||
sources = append(sources, "spn")
|
||||
}
|
||||
if !hasHomePLMNMatch && !hasSelectorMatch {
|
||||
return 0, "", false
|
||||
}
|
||||
return score, strings.Join(sources, "+"), score > 0
|
||||
}
|
||||
@@ -501,11 +508,43 @@ func matchesAny(values []string, match func(string) bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string) CarrierProfile {
|
||||
func applyCarrierProfileRule(base CarrierProfile, rule carrierProfileRule, source string, identity SIMIdentity) CarrierProfile {
|
||||
base.ID = rule.ID
|
||||
base.MatchSource = source
|
||||
base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
|
||||
base.RouteMNC = strings.TrimSpace(rule.Route.MNC)
|
||||
if base.RouteMCC == "" {
|
||||
currentPLMN := canonicalPLMN(identity.HomeMCC, identity.HomeMNC)
|
||||
if currentPLMN != "" {
|
||||
for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) {
|
||||
for _, plmn := range m.HomePLMNs {
|
||||
if canonicalPLMNValue(plmn) == currentPLMN {
|
||||
base.RouteMCC = strings.TrimSpace(identity.HomeMCC)
|
||||
base.RouteMNC = strings.TrimSpace(identity.HomeMNC)
|
||||
break
|
||||
}
|
||||
}
|
||||
if base.RouteMCC != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if base.RouteMCC == "" {
|
||||
for _, m := range append([]carrierProfileMatch{rule.Match}, rule.MatchAny...) {
|
||||
for _, plmn := range m.HomePLMNs {
|
||||
plmn = canonicalPLMNValue(plmn)
|
||||
if len(plmn) >= 5 {
|
||||
base.RouteMCC = plmn[:3]
|
||||
base.RouteMNC = plmn[3:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if base.RouteMCC != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
|
||||
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
|
||||
base.IKEProposal = value
|
||||
@@ -647,19 +686,154 @@ func IsATT310280(identity SIMIdentity) bool {
|
||||
}
|
||||
|
||||
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity {
|
||||
if strings.TrimSpace(identity.EPDG) != "" {
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
if profile.ID != CarrierProfileStandard && profile.RouteMCC != "" {
|
||||
identity.HomeMCC = profile.RouteMCC
|
||||
identity.HomeMNC = profile.RouteMNC
|
||||
if profile.EPDG != "" {
|
||||
identity.EPDG = profile.EPDG
|
||||
} else {
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
profile := ResolveCarrierProfile(identity)
|
||||
switch {
|
||||
case profile.EPDG != "":
|
||||
identity.EPDG = profile.EPDG
|
||||
case profile.RouteMCC != "":
|
||||
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC)
|
||||
|
||||
if strings.TrimSpace(identity.ICCID) != "" {
|
||||
if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
|
||||
imsiCountry := countryCodeForMCC(identity.HomeMCC)
|
||||
iccidCountry := countryCodeForMCC(mcc)
|
||||
if identity.HomeMCC == "" || (imsiCountry != "" && iccidCountry != "" && imsiCountry != iccidCountry) {
|
||||
identity.HomeMCC = mcc
|
||||
identity.HomeMNC = mnc
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(identity.EPDG) == "" && identity.HomeMCC != "" && identity.HomeMNC != "" {
|
||||
identity.EPDG = standardEPDGHostname(identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func countryCodeForMCC(mcc string) string {
|
||||
switch strings.TrimSpace(mcc) {
|
||||
case "515":
|
||||
return "PH"
|
||||
case "262":
|
||||
return "DE"
|
||||
case "204":
|
||||
return "NL"
|
||||
case "234", "235":
|
||||
return "GB"
|
||||
case "460":
|
||||
return "CN"
|
||||
case "454":
|
||||
return "HK"
|
||||
case "466", "467":
|
||||
return "TW"
|
||||
case "525":
|
||||
return "SG"
|
||||
case "440", "441":
|
||||
return "JP"
|
||||
case "450":
|
||||
return "KR"
|
||||
case "310", "311", "312", "313", "314", "315", "316":
|
||||
return "US"
|
||||
case "302":
|
||||
return "CA"
|
||||
case "505":
|
||||
return "AU"
|
||||
case "208":
|
||||
return "FR"
|
||||
case "214":
|
||||
return "ES"
|
||||
case "222":
|
||||
return "IT"
|
||||
case "228":
|
||||
return "CH"
|
||||
case "232":
|
||||
return "AT"
|
||||
case "206":
|
||||
return "BE"
|
||||
case "260":
|
||||
return "PL"
|
||||
case "520":
|
||||
return "TH"
|
||||
case "510":
|
||||
return "ID"
|
||||
case "502":
|
||||
return "MY"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HomePLMNFromICCID infers the home MCC/MNC from well-known global ICCID prefixes.
|
||||
func HomePLMNFromICCID(iccid string) (mcc, mnc string, ok bool) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
if len(iccid) < 6 || !strings.HasPrefix(iccid, "89") {
|
||||
return "", "", false
|
||||
}
|
||||
prefixes := []struct {
|
||||
prefix string
|
||||
mcc string
|
||||
mnc string
|
||||
}{
|
||||
// Philippines
|
||||
{"896366", "515", "66"}, // DITO
|
||||
{"896302", "515", "02"}, // Globe
|
||||
{"896303", "515", "03"}, // Smart
|
||||
// Germany
|
||||
{"894920", "262", "02"}, // Vodafone DE
|
||||
{"894901", "262", "01"}, // Telekom DE
|
||||
{"894902", "262", "03"}, // O2 DE
|
||||
{"894903", "262", "03"},
|
||||
{"894907", "262", "07"},
|
||||
// United Kingdom
|
||||
{"894410", "234", "15"}, // Vodafone UK
|
||||
{"894415", "234", "15"},
|
||||
{"894411", "234", "30"}, // EE
|
||||
{"894430", "234", "30"},
|
||||
{"894420", "234", "20"}, // Three UK
|
||||
{"894421", "234", "10"}, // O2 UK
|
||||
// Netherlands
|
||||
{"8937204", "204", "04"}, // Vodafone NL
|
||||
{"893104", "204", "04"},
|
||||
{"893108", "204", "08"}, // KPN
|
||||
{"893116", "204", "16"}, // Odido
|
||||
// Hong Kong
|
||||
{"8985201", "454", "00"}, // CSL
|
||||
{"8985203", "454", "03"}, // 3 HK
|
||||
{"898523", "454", "03"},
|
||||
{"8985204", "454", "12"}, // CMHK
|
||||
{"8985206", "454", "06"}, // SmarTone
|
||||
// China
|
||||
{"898600", "460", "00"}, // China Mobile
|
||||
{"898602", "460", "00"},
|
||||
{"898604", "460", "00"},
|
||||
{"898607", "460", "00"},
|
||||
{"898601", "460", "01"}, // China Unicom
|
||||
{"898606", "460", "01"},
|
||||
{"898609", "460", "01"},
|
||||
{"898603", "460", "03"}, // China Telecom
|
||||
{"898605", "460", "03"},
|
||||
{"898611", "460", "03"},
|
||||
// Taiwan
|
||||
{"8988601", "466", "92"}, // Chunghwa
|
||||
{"8988602", "466", "97"}, // Taiwan Mobile
|
||||
{"8988603", "466", "01"}, // FarEasTone
|
||||
// Singapore
|
||||
{"896501", "525", "01"}, // Singtel
|
||||
{"896502", "525", "05"}, // StarHub
|
||||
{"896503", "525", "03"}, // M1
|
||||
{"896504", "525", "10"}, // SIMBA
|
||||
}
|
||||
for _, entry := range prefixes {
|
||||
if strings.HasPrefix(iccid, entry.prefix) {
|
||||
return entry.mcc, entry.mnc, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
|
||||
// ePDG whose authoritative DNS only exposes addresses to home-country
|
||||
// resolvers. An empty result means ordinary system DNS remains authoritative.
|
||||
@@ -672,6 +846,103 @@ func EPDGDNSClientSubnet(host string) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(host, ".mcc"); idx >= 0 && len(host) >= idx+7 {
|
||||
mcc := host[idx+4 : idx+7]
|
||||
if decimalString(mcc) {
|
||||
if subnet := MCCDefaultClientSubnet(mcc); subnet != "" {
|
||||
return subnet
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MCCDefaultClientSubnet returns the standard GeoDNS EDNS client subnet for a country MCC.
|
||||
func MCCDefaultClientSubnet(mcc string) string {
|
||||
switch strings.TrimSpace(mcc) {
|
||||
case "262": // Germany
|
||||
return "139.7.0.0/16"
|
||||
case "204": // Netherlands
|
||||
return "109.39.0.0/16"
|
||||
case "234", "235": // UK
|
||||
return "212.183.0.0/16"
|
||||
case "515": // Philippines
|
||||
return "112.198.0.0/16"
|
||||
case "454": // Hong Kong
|
||||
return "203.0.0.0/16"
|
||||
case "466", "467": // Taiwan
|
||||
return "210.0.0.0/16"
|
||||
case "525": // Singapore
|
||||
return "202.166.0.0/16"
|
||||
case "440", "441": // Japan
|
||||
return "126.0.0.0/16"
|
||||
case "450": // South Korea
|
||||
return "211.0.0.0/16"
|
||||
case "310", "311", "312", "313", "314", "315", "316": // USA
|
||||
return "198.228.0.0/16"
|
||||
case "302": // Canada
|
||||
return "142.0.0.0/16"
|
||||
case "505": // Australia
|
||||
return "1.120.0.0/16"
|
||||
case "520": // Thailand
|
||||
return "171.96.0.0/16"
|
||||
case "510": // Indonesia
|
||||
return "182.0.0.0/16"
|
||||
case "502": // Malaysia
|
||||
return "115.132.0.0/16"
|
||||
case "208": // France
|
||||
return "194.51.0.0/16"
|
||||
case "214": // Spain
|
||||
return "212.166.0.0/16"
|
||||
case "222": // Italy
|
||||
return "83.224.0.0/16"
|
||||
case "228": // Switzerland
|
||||
return "178.192.0.0/16"
|
||||
case "232": // Austria
|
||||
return "194.138.0.0/16"
|
||||
case "206": // Belgium
|
||||
return "193.190.0.0/16"
|
||||
case "260": // Poland
|
||||
return "83.0.0.0/16"
|
||||
case "268": // Portugal
|
||||
return "194.65.0.0/16"
|
||||
case "272": // Ireland
|
||||
return "193.1.0.0/16"
|
||||
case "238": // Denmark
|
||||
return "193.162.0.0/16"
|
||||
case "240": // Sweden
|
||||
return "194.236.0.0/16"
|
||||
case "242": // Norway
|
||||
return "193.69.0.0/16"
|
||||
case "244": // Finland
|
||||
return "193.64.0.0/16"
|
||||
case "202": // Greece
|
||||
return "194.219.0.0/16"
|
||||
case "216": // Hungary
|
||||
return "195.199.0.0/16"
|
||||
case "230": // Czech Republic
|
||||
return "195.113.0.0/16"
|
||||
case "286": // Turkey
|
||||
return "195.175.0.0/16"
|
||||
case "425": // Israel
|
||||
return "192.114.0.0/16"
|
||||
case "404", "405": // India
|
||||
return "103.0.0.0/16"
|
||||
case "655": // South Africa
|
||||
return "196.0.0.0/16"
|
||||
case "724": // Brazil
|
||||
return "177.0.0.0/16"
|
||||
case "334": // Mexico
|
||||
return "187.188.0.0/16"
|
||||
case "452": // Vietnam
|
||||
return "118.69.0.0/16"
|
||||
case "455": // Macao
|
||||
return "202.175.0.0/16"
|
||||
case "530": // New Zealand
|
||||
return "202.27.0.0/16"
|
||||
case "460": // China
|
||||
return "223.5.5.0/24"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -5,59 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
iccid string
|
||||
imsi string
|
||||
wantMCC string
|
||||
wantMNC string
|
||||
wantAssigned bool
|
||||
}{
|
||||
{name: "XeSIM Lebara route", iccid: "8944160000000000001", imsi: "204047000000001", wantMCC: "234", wantMNC: "15", wantAssigned: true},
|
||||
{name: "CTExcel initial route", iccid: "8944300000000000001", imsi: "234336000000001", wantMCC: "234", wantMNC: "30", wantAssigned: true},
|
||||
{name: "XeSIM ICCID without matching subscription", iccid: "8944160000000000001", imsi: "204041000000001"},
|
||||
{name: "similar ICCID must not match", iccid: "8944100000000000001", imsi: "204047000000001"},
|
||||
{name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336000000001"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi)
|
||||
if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned {
|
||||
t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) {
|
||||
identity := applyAssignedCarrierRoute(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
})
|
||||
if identity.HomeMCC != "234" || identity.HomeMNC != "33" {
|
||||
t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC)
|
||||
}
|
||||
if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" {
|
||||
t.Fatalf("route ePDG = %q", identity.EPDG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) {
|
||||
if !IsATT310280(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"}) {
|
||||
t.Fatal("AT&T 310/280 identity was not recognized")
|
||||
}
|
||||
for _, identity := range []SIMIdentity{
|
||||
{IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "280"},
|
||||
{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "28"},
|
||||
{IMSI: "310280000000001", HomeMCC: "311", HomeMNC: "280"},
|
||||
} {
|
||||
if IsATT310280(identity) {
|
||||
t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
|
||||
@@ -72,139 +19,44 @@ func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfilePrefersConstrainedMVNO(t *testing.T) {
|
||||
// Cricket MVNO on AT&T network
|
||||
cricket := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8901150000000000001", IMSI: "310150000000001",
|
||||
HomeMCC: "310", HomeMNC: "150",
|
||||
})
|
||||
if !strings.Contains(cricket.ID, "cricket") {
|
||||
t.Fatalf("Cricket MVNO profile = %#v", cricket)
|
||||
}
|
||||
|
||||
// Pure Talk MVNO on AT&T network via GID1
|
||||
pureTalk := ResolveCarrierProfile(SIMIdentity{
|
||||
IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410", GID1: "62FFFF",
|
||||
})
|
||||
if !strings.Contains(pureTalk.ID, "pure-talk") {
|
||||
t.Fatalf("Pure Talk MVNO profile = %#v", pureTalk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileUsesAppleGID1Selector(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8944160000000000001", IMSI: "204047000000001",
|
||||
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
|
||||
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
|
||||
})
|
||||
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" {
|
||||
t.Fatalf("MVNO profile = %#v", profile)
|
||||
}
|
||||
if profile.MatchSource != "hplmn+imsi+iccid" {
|
||||
t.Fatalf("MVNO match source = %q", profile.MatchSource)
|
||||
if !strings.Contains(profile.ID, "giffgaff") || profile.MatchSource != "hplmn+gid1" {
|
||||
t.Fatalf("giffgaff profile = %#v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestResolveCarrierProfileATT(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{
|
||||
ICCID: "8944300000000000001", IMSI: "234336000000001",
|
||||
HomeMCC: "234", HomeMNC: "33",
|
||||
ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
|
||||
})
|
||||
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})
|
||||
if profile.ID != "o2-germany" || profile.AdvertiseEAPOnly || profile.IMSIPSecEncryption != "null" {
|
||||
t.Errorf("O2 Germany MNC %q profile = %#v", mnc, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEPDGDNSClientSubnetComesFromCarrierProfileData(t *testing.T) {
|
||||
if got := EPDGDNSClientSubnet("EPDG.EPC.MNC002.MCC262.PUB.3GPPNETWORK.ORG."); got != "109.192.0.0/24" {
|
||||
t.Fatalf("Vodafone Germany DNS client subnet = %q", got)
|
||||
}
|
||||
if got := EPDGDNSClientSubnet("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org"); got != "" {
|
||||
t.Fatalf("ordinary ePDG received geographic DNS fallback %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileDITOPhilippinesUsesLegacyIKE(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "515", HomeMNC: "66"})
|
||||
if profile.ID != "dito-philippines" {
|
||||
t.Fatalf("DITO profile = %#v", profile)
|
||||
}
|
||||
if profile.IKEProposal != IKEProposalLegacy {
|
||||
t.Fatalf("DITO IKE proposal = %q, want %q", profile.IKEProposal, IKEProposalLegacy)
|
||||
}
|
||||
if !profile.AllowSMSWithoutContactConfirmation {
|
||||
t.Fatalf("DITO profile should allow SMS without contact confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileATTRegisterOptions(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280"})
|
||||
if profile.ID != "att-us" {
|
||||
if !strings.Contains(profile.ID, "att") {
|
||||
t.Fatalf("AT&T profile = %#v", profile)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ContactFormat != IMSContactFormatATT {
|
||||
t.Fatalf("AT&T contact format = %q, want %q", profile.IMSRegisterOptions.ContactFormat, IMSContactFormatATT)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ExpirySeconds != 18400 {
|
||||
t.Fatalf("AT&T expiry = %d, want 18400", profile.IMSRegisterOptions.ExpirySeconds)
|
||||
}
|
||||
if profile.IMSRegisterOptions.UserAgent != "SimAdmin VoWiFi" {
|
||||
t.Fatalf("AT&T user agent = %q", profile.IMSRegisterOptions.UserAgent)
|
||||
}
|
||||
if profile.IMSRegisterOptions.PVisitedNetworkID != "one.att.net" {
|
||||
t.Fatalf("AT&T P-Visited-Network-ID = %q", profile.IMSRegisterOptions.PVisitedNetworkID)
|
||||
}
|
||||
if len(profile.IMSRegisterOptions.AcceptContactTags) != 2 {
|
||||
t.Fatalf("AT&T Accept-Contact tags = %v", profile.IMSRegisterOptions.AcceptContactTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileO2GermanyRegisterOptions(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "262", HomeMNC: "03"})
|
||||
if profile.ID != "o2-germany" {
|
||||
t.Fatalf("O2 Germany profile = %#v", profile)
|
||||
}
|
||||
if profile.IMSRegisterOptions.ContactFormat != "" {
|
||||
t.Fatalf("O2 Germany contact format = %q, want empty", profile.IMSRegisterOptions.ContactFormat)
|
||||
}
|
||||
if profile.IMSRegisterOptions.SupportedHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.SupportedHeader, "sec-agree") {
|
||||
t.Fatalf("O2 Germany Supported header = %v", profile.IMSRegisterOptions.SupportedHeader)
|
||||
}
|
||||
if profile.IMSRegisterOptions.AllowHeader == nil || !strings.Contains(*profile.IMSRegisterOptions.AllowHeader, "MESSAGE") {
|
||||
t.Fatalf("O2 Germany Allow header = %v", profile.IMSRegisterOptions.AllowHeader)
|
||||
}
|
||||
if !profile.IMSRegisterOptions.PPreferredIdentity {
|
||||
t.Fatal("O2 Germany should add P-Preferred-Identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "001", HomeMNC: "01"})
|
||||
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
|
||||
if profile.ID != CarrierProfileStandard {
|
||||
t.Fatalf("profile = %q", profile.ID)
|
||||
}
|
||||
|
||||
@@ -246,6 +246,82 @@ func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, erro
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// ImportCarrierBundlePlists converts a set of parsed plists for one Apple
|
||||
// carrier bundle into a validated carrierProfileRule.
|
||||
func ImportCarrierBundlePlists(bundleName string, plistData map[string][]byte) (*carrierProfileRule, []IPCCImportWarning, error) {
|
||||
if len(plistData) == 0 {
|
||||
return nil, nil, errors.New("no plist data provided")
|
||||
}
|
||||
var primaryData []byte
|
||||
if data, ok := plistData["carrier.plist"]; ok {
|
||||
primaryData = data
|
||||
} else {
|
||||
for k, v := range plistData {
|
||||
if strings.EqualFold(path.Base(k), "carrier.plist") {
|
||||
primaryData = v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(primaryData) == 0 {
|
||||
return nil, nil, fmt.Errorf("bundle %q has no carrier.plist", bundleName)
|
||||
}
|
||||
var primaryRoot map[string]any
|
||||
decoder := plist.NewDecoder(bytes.NewReader(primaryData))
|
||||
if err := decoder.Decode(&primaryRoot); err != nil {
|
||||
return nil, nil, fmt.Errorf("decode carrier.plist: %w", err)
|
||||
}
|
||||
if primaryRoot == nil {
|
||||
return nil, nil, errors.New("carrier.plist root is not a dictionary")
|
||||
}
|
||||
plists := []ipccPlist{{name: "carrier.plist", root: primaryRoot}}
|
||||
|
||||
var overrideNames []string
|
||||
for k := range plistData {
|
||||
base := path.Base(k)
|
||||
if strings.HasPrefix(strings.ToLower(base), "overrides") && strings.EqualFold(path.Ext(base), ".plist") {
|
||||
overrideNames = append(overrideNames, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(overrideNames)
|
||||
for _, k := range overrideNames {
|
||||
var overrideRoot map[string]any
|
||||
dec := plist.NewDecoder(bytes.NewReader(plistData[k]))
|
||||
if err := dec.Decode(&overrideRoot); err == nil && overrideRoot != nil {
|
||||
plists = append(plists, ipccPlist{name: k, root: overrideRoot})
|
||||
}
|
||||
}
|
||||
|
||||
warnings := &ipccWarningSet{}
|
||||
carrierName := firstNonempty(
|
||||
plistString(primaryRoot["CarrierName"]),
|
||||
statusBarCarrierName(primaryRoot),
|
||||
strings.TrimSuffix(bundleName, path.Ext(bundleName)),
|
||||
)
|
||||
matches, plmns, err := importCarrierSelectors(primaryRoot, plists, warnings)
|
||||
if err != nil {
|
||||
return nil, warnings.items, fmt.Errorf("import selectors: %w", err)
|
||||
}
|
||||
profileID := generatedIPCCProfileID(carrierName, plmns)
|
||||
if !validInstalledProfileID(profileID) {
|
||||
return nil, warnings.items, fmt.Errorf("invalid profile ID %q", 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 nil, warnings.items, errors.New("converted profile is not valid")
|
||||
}
|
||||
return &rule, warnings.items, nil
|
||||
}
|
||||
|
||||
func carrierBundleRoots(files []*zip.File) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, file := range files {
|
||||
@@ -425,11 +501,17 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "GID1":
|
||||
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value))
|
||||
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||
match.GID1Prefixes = append(match.GID1Prefixes, trimmed)
|
||||
}
|
||||
case "GID2":
|
||||
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value))
|
||||
if trimmed := trimAppleHexMask(value); trimmed != "" {
|
||||
match.GID2Prefixes = append(match.GID2Prefixes, trimmed)
|
||||
}
|
||||
case "ICCID":
|
||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, strings.TrimRight(value, "Ff"))
|
||||
if trimmed := strings.TrimRight(value, "Ff"); trimmed != "" {
|
||||
match.ICCIDPrefixes = append(match.ICCIDPrefixes, trimmed)
|
||||
}
|
||||
case "SPN":
|
||||
match.SPNs = append(match.SPNs, value)
|
||||
default:
|
||||
@@ -437,16 +519,13 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
|
||||
return carrierProfileMatch{}, false, false
|
||||
}
|
||||
}
|
||||
return match, len(parts) > 1, true
|
||||
constrained := len(match.GID1Prefixes) > 0 || len(match.GID2Prefixes) > 0 || len(match.ICCIDPrefixes) > 0 || len(match.SPNs) > 0
|
||||
return match, constrained, true
|
||||
}
|
||||
|
||||
func trimAppleHexMask(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
trimmed := strings.TrimRight(value, "F")
|
||||
if trimmed == "" {
|
||||
return value
|
||||
}
|
||||
return trimmed
|
||||
return strings.TrimRight(value, "F")
|
||||
}
|
||||
|
||||
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
|
||||
|
||||
+11601
-82
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ const (
|
||||
usimAIDPrefix = "A0000000871002"
|
||||
isimAIDPrefix = "A0000000871004"
|
||||
efADDecimal = 28589 // 0x6FAD
|
||||
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
|
||||
channelCleanupTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
@@ -239,14 +240,79 @@ func (adapter *EC20Adapter) readHomePLMN(
|
||||
return mcc, mnc, nil
|
||||
}
|
||||
}
|
||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The
|
||||
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
|
||||
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic.
|
||||
if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
|
||||
return mcc, mnc, nil
|
||||
}
|
||||
// 3GPP TS 31.102 Section 4.2.84: Query EF_EHPLMN (Equivalent Home PLMN).
|
||||
if ehplmns, err := adapter.readEHPLMN(ctx, deviceID); err == nil && len(ehplmns) > 0 {
|
||||
first := ehplmns[0]
|
||||
if len(first) >= 5 {
|
||||
return first[:3], first[3:], nil
|
||||
}
|
||||
}
|
||||
return "", "", efErr
|
||||
}
|
||||
|
||||
func (adapter *EC20Adapter) readEHPLMN(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
) ([]string, error) {
|
||||
commands := []string{
|
||||
fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efEHPLMNDecimal),
|
||||
fmt.Sprintf("AT+CRSM=176,%d,0,0,12", efEHPLMNDecimal),
|
||||
}
|
||||
var lastErr error
|
||||
for _, command := range commands {
|
||||
response, err := adapter.execute(ctx, deviceID, command)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
data, err := parseCRSMData(response)
|
||||
if err != nil || len(data) < 3 {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
plmns := parsePLMNListFromBytes(data)
|
||||
if len(plmns) > 0 {
|
||||
return plmns, nil
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("vocat: EF_EHPLMN is empty or unavailable")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func parsePLMNListFromBytes(data []byte) []string {
|
||||
var plmns []string
|
||||
for i := 0; i+3 <= len(data); i += 3 {
|
||||
b1, b2, b3 := data[i], data[i+1], data[i+2]
|
||||
mcc1 := b1 & 0x0f
|
||||
mcc2 := (b1 >> 4) & 0x0f
|
||||
mcc3 := b2 & 0x0f
|
||||
mnc3 := (b2 >> 4) & 0x0f
|
||||
mnc1 := b3 & 0x0f
|
||||
mnc2 := (b3 >> 4) & 0x0f
|
||||
|
||||
if mcc1 > 9 || mcc2 > 9 || mcc3 > 9 || mnc1 > 9 || mnc2 > 9 {
|
||||
continue
|
||||
}
|
||||
mcc := fmt.Sprintf("%d%d%d", mcc1, mcc2, mcc3)
|
||||
var mnc string
|
||||
if mnc3 <= 9 {
|
||||
mnc = fmt.Sprintf("%d%d%d", mnc1, mnc2, mnc3)
|
||||
} else {
|
||||
mnc = fmt.Sprintf("%d%d", mnc1, mnc2)
|
||||
}
|
||||
if len(mcc) == 3 && (len(mnc) == 2 || len(mnc) == 3) {
|
||||
plmns = append(plmns, mcc+mnc)
|
||||
}
|
||||
}
|
||||
return plmns
|
||||
}
|
||||
|
||||
func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
|
||||
assignments := []struct {
|
||||
prefix string
|
||||
|
||||
@@ -28,48 +28,67 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
||||
if systemErr == nil && len(addresses) > 0 {
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
if subnet == "" {
|
||||
if systemErr != nil {
|
||||
return nil, systemErr
|
||||
}
|
||||
return nil, errors.New("ePDG did not resolve to an IP address")
|
||||
addresses, systemErr := resolver.LookupIPAddr(ctx, host)
|
||||
validSystemAddresses := filterValidPublicEPDGAddresses(addresses)
|
||||
if systemErr == nil && len(validSystemAddresses) > 0 {
|
||||
return validSystemAddresses, nil
|
||||
}
|
||||
|
||||
subnet := vowifi.EPDGDNSClientSubnet(normalized)
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
var fallbackErr error
|
||||
// Vodafone's authoritative response has a 60-second TTL and recursive
|
||||
// resolvers can briefly cache the global CNAME without its geo-restricted
|
||||
// address records. Stay inside the runtime's two-minute setup window and
|
||||
// wait through one complete negative-cache TTL so a single reconnect is
|
||||
// sufficient; users should not have to click Reconnect repeatedly.
|
||||
const fallbackAttempts = 13
|
||||
for attempt := 0; attempt < fallbackAttempts; attempt++ {
|
||||
|
||||
hostsToTry := []string{normalized}
|
||||
if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
|
||||
hostsToTry = append(hostsToTry, alt)
|
||||
}
|
||||
|
||||
for _, targetHost := range hostsToTry {
|
||||
var fallback []net.IPAddr
|
||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, normalized, subnet)
|
||||
fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, targetHost, subnet)
|
||||
if fallbackErr == nil && len(fallback) > 0 {
|
||||
return fallback, nil
|
||||
}
|
||||
if attempt+1 < fallbackAttempts {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
if systemErr == nil {
|
||||
systemErr = errors.New("system DNS returned no IP addresses")
|
||||
systemErr = errors.New("system DNS returned no usable public IP addresses")
|
||||
}
|
||||
return nil, fmt.Errorf("system DNS failed (%v); geographic DNS fallback failed: %w", systemErr, fallbackErr)
|
||||
}
|
||||
|
||||
func filterValidPublicEPDGAddresses(addresses []net.IPAddr) []net.IPAddr {
|
||||
result := make([]net.IPAddr, 0, len(addresses))
|
||||
for _, addr := range addresses {
|
||||
if addr.IP == nil || addr.IP.IsLoopback() || addr.IP.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
result = append(result, addr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func alternate3GPPHostname(host string) string {
|
||||
const prefix = "epdg.epc.mnc"
|
||||
if !strings.HasPrefix(host, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := host[len(prefix):]
|
||||
dot := strings.Index(rest, ".")
|
||||
if dot <= 0 {
|
||||
return ""
|
||||
}
|
||||
mnc := rest[:dot]
|
||||
suffix := rest[dot:]
|
||||
if len(mnc) == 3 && strings.HasPrefix(mnc, "0") {
|
||||
return prefix + mnc[1:] + suffix
|
||||
}
|
||||
if len(mnc) == 2 {
|
||||
return prefix + "0" + mnc + suffix
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveEPDGWithECS(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
@@ -85,7 +104,9 @@ func resolveEPDGWithECS(
|
||||
query := parsed.Query()
|
||||
query.Set("name", strings.TrimSpace(host))
|
||||
query.Set("type", "A")
|
||||
query.Set("edns_client_subnet", strings.TrimSpace(subnet))
|
||||
if strings.TrimSpace(subnet) != "" {
|
||||
query.Set("edns_client_subnet", strings.TrimSpace(subnet))
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
@@ -116,7 +137,7 @@ func resolveEPDGWithECS(
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
|
||||
if ip == nil {
|
||||
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
duplicate := false
|
||||
|
||||
@@ -18,25 +18,6 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
|
||||
|
||||
type constantReader struct{ value byte }
|
||||
|
||||
func TestLegacyIKEProfileIncludesVodafoneHostedLebaraCore(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
mcc string
|
||||
mnc string
|
||||
}{
|
||||
{mcc: "234", mnc: "15"},
|
||||
{mcc: "204", mnc: "04"},
|
||||
{mcc: "204", mnc: "004"},
|
||||
} {
|
||||
profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: item.mcc, HomeMNC: item.mnc})
|
||||
if profile.IKEProposal != vowifi.IKEProposalLegacy {
|
||||
t.Errorf("carrier profile IKE proposal for %q/%q = %q", item.mcc, item.mnc, profile.IKEProposal)
|
||||
}
|
||||
}
|
||||
if profile := vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "87"}); profile.IKEProposal == vowifi.IKEProposalLegacy {
|
||||
t.Fatal("Lebara's 234-87 core must use the modern IKE profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
|
||||
for _, err := range []error{
|
||||
errNoProposalChosen,
|
||||
@@ -208,7 +189,7 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
|
||||
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
|
||||
wantGroup := transport.wantGroup
|
||||
if wantGroup == 0 {
|
||||
wantGroup = dhMODP1024
|
||||
wantGroup = dhMODP2048
|
||||
}
|
||||
wantKELength := 128
|
||||
if wantGroup == dhMODP2048 {
|
||||
@@ -464,44 +445,5 @@ func TestProviderBoundsRepeatedIKEInitCookieChallenges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
|
||||
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
|
||||
provider, err := NewProvider(Config{
|
||||
Random: constantReader{value: 0x42},
|
||||
Timeout: time.Second,
|
||||
Installer: unusedInstaller{},
|
||||
APN: "ims",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.transportFactory = func(
|
||||
context.Context,
|
||||
transportConfig,
|
||||
vowifi.ProxyRoute,
|
||||
string,
|
||||
) (datagramTransport, error) {
|
||||
return capture, nil
|
||||
}
|
||||
aka := &testAKAProvider{}
|
||||
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
|
||||
DeviceID: "ec20-o2",
|
||||
Identity: vowifi.SIMIdentity{
|
||||
ICCID: "8949200000000000000",
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
},
|
||||
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
|
||||
AKA: aka,
|
||||
})
|
||||
if !errors.Is(err, errFirstAuthObserved) {
|
||||
t.Fatalf("Start() error = %v, want capture sentinel", err)
|
||||
}
|
||||
if capture.calls != 2 || capture.floated || aka.calls != 0 {
|
||||
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
|
||||
}
|
||||
}
|
||||
|
||||
var _ io.Reader = constantReader{}
|
||||
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
|
||||
|
||||
@@ -169,18 +169,6 @@ func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: mnc}).AdvertiseEAPOnly {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "02"}).AdvertiseEAPOnly ||
|
||||
!vowifi.ResolveCarrierProfile(vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}).AdvertiseEAPOnly {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||
epdg := payload{
|
||||
Type: payloadIDr,
|
||||
|
||||
@@ -368,6 +368,25 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
|
||||
session.callMu.Lock()
|
||||
session.calls[callID] = call
|
||||
session.callMu.Unlock()
|
||||
if session.provider != nil && session.provider.config.OnIncomingCall != nil {
|
||||
calledNumber := identityNumber(request.value("To"))
|
||||
if calledNumber == "" {
|
||||
calledNumber = session.identity.public
|
||||
}
|
||||
receivedCall := ReceivedCall{
|
||||
DeviceID: session.request.DeviceID,
|
||||
IMSI: session.request.Identity.IMSI,
|
||||
CallID: callID,
|
||||
Caller: number,
|
||||
Called: calledNumber,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = session.provider.config.OnIncomingCall(ctx, receivedCall)
|
||||
}()
|
||||
}
|
||||
response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
|
||||
if err == nil {
|
||||
_ = respond(response)
|
||||
|
||||
@@ -80,6 +80,53 @@ func TestIncomingCallCanBeRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingCallTriggersOnIncomingCallCallback(t *testing.T) {
|
||||
var captured ReceivedCall
|
||||
called := make(chan struct{}, 1)
|
||||
provider := &Provider{
|
||||
config: Config{
|
||||
OnIncomingCall: func(_ context.Context, call ReceivedCall) error {
|
||||
captured = call
|
||||
called <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
session := &Session{
|
||||
provider: provider,
|
||||
fromTag: "local-tag",
|
||||
calls: make(map[string]*imsCall),
|
||||
request: vowifi.IMSRequest{
|
||||
DeviceID: "ec20-test",
|
||||
Identity: vowifi.SIMIdentity{IMSI: "123456789012345"},
|
||||
},
|
||||
identity: identitySet{public: "sip:[email protected]"},
|
||||
}
|
||||
packet, err := parseSIPPacket([]byte(strings.Join([]string{
|
||||
"INVITE sip:[email protected] SIP/2.0",
|
||||
"Via: SIP/2.0/UDP 192.0.2.10:5060;branch=z9hG4bK-notify",
|
||||
"From: <tel:+447700999888>;tag=caller-tag",
|
||||
"To: <tel:+447700900123>",
|
||||
"Call-ID: notify-call-id",
|
||||
"CSeq: 1 INVITE",
|
||||
"Content-Length: 0", "", "",
|
||||
}, "\r\n")))
|
||||
if err != nil || packet.Request == nil {
|
||||
t.Fatalf("parse INVITE: %v", err)
|
||||
}
|
||||
session.handleCallRequest(packet.Request, func([]byte) error { return nil })
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("OnIncomingCall was not invoked within timeout")
|
||||
}
|
||||
|
||||
if captured.DeviceID != "ec20-test" || captured.Caller != "+447700999888" || captured.Called != "+447700900123" || captured.CallID != "notify-call-id" {
|
||||
t.Fatalf("captured call = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
|
||||
session := &Session{calls: make(map[string]*imsCall)}
|
||||
call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}}
|
||||
@@ -176,13 +223,13 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
|
||||
wire := <-wireResult
|
||||
|
||||
for _, expected := range []string{
|
||||
"INVITE sip:888@ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
|
||||
"To: <sip:888@ims.mnc033.mcc234.3gppnetwork.org>\r\n",
|
||||
"INVITE tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
|
||||
"To: <tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org>\r\n",
|
||||
"From: <sip:[email protected]>;tag=local-tag\r\n",
|
||||
"P-Preferred-Identity: <tel:+447700900123>\r\n",
|
||||
"P-Preferred-Service: " + mmtelServiceURN + "\r\n",
|
||||
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n",
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;country=GB;network-provided\r\n",
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n",
|
||||
"User-Agent: VoCat Test\r\n",
|
||||
"Accept: application/sdp\r\n",
|
||||
} {
|
||||
|
||||
@@ -70,11 +70,23 @@ type Config struct {
|
||||
// IMS (3GPP TS 24.390). Returning an error is logged but does not affect
|
||||
// the 200 OK already sent, because USSI has no RP-ACK transport.
|
||||
OnUSSD func(context.Context, ReceivedUSSD) error
|
||||
// OnIncomingCall is invoked when an incoming voice call (INVITE) is received over IMS.
|
||||
OnIncomingCall func(context.Context, ReceivedCall) error
|
||||
// Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
|
||||
// not include message text or raw protocol payloads.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// ReceivedCall is an incoming voice call event delivered over IMS.
|
||||
type ReceivedCall struct {
|
||||
DeviceID string
|
||||
IMSI string
|
||||
CallID string
|
||||
Caller string
|
||||
Called string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
|
||||
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
|
||||
// runtime dependency outside the Go standard library.
|
||||
|
||||
@@ -111,14 +111,14 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
|
||||
|
||||
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" {
|
||||
t.Fatalf("O2 UK profile transport = %q, want udp", got)
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
|
||||
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "tcp" {
|
||||
t.Fatalf("standard transport = %q, want tcp", got)
|
||||
}
|
||||
if got := transportForIdentity(Config{
|
||||
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"},
|
||||
Transport: "udp", TransportByPLMN: map[string]string{"99999": "tcp"},
|
||||
}, identity); got != "tcp" {
|
||||
t.Fatalf("explicit configuration did not override profile: %q", got)
|
||||
t.Fatalf("explicit configuration did not override: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,138 +497,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
SecurityMode: SecurityRequired,
|
||||
UserAgent: "vocat-test",
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "o2-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 101,
|
||||
spiServer: 102,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
},
|
||||
}
|
||||
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
_, headers, err := parseTestRequest(packet)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTestRequest() error = %v", err)
|
||||
}
|
||||
if got, want := headers["security-client"], "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000000101;spi-s=0000000102;port-c=5062;port-s=5063"; got != want {
|
||||
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
if headers["proxy-require"] != "sec-agree" || !strings.Contains(headers["authorization"], "integrity-protected=no") {
|
||||
t.Fatalf("initial O2 headers omitted standardized sec-agree/IMS-AKA fields: %#v", headers)
|
||||
}
|
||||
if got, want := headers["p-preferred-identity"], "<"+identities.public+">"; got != want {
|
||||
t.Fatalf("P-Preferred-Identity = %q, want %q", got, want)
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"supported": "sec-agree",
|
||||
"allow": "MESSAGE",
|
||||
} {
|
||||
if !strings.Contains(headers[name], token) {
|
||||
t.Fatalf("%s = %q, want token %q", name, headers[name], token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) {
|
||||
identities, err := deriveIdentities(vowifi.SIMIdentity{
|
||||
IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280",
|
||||
}, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
if identities.domain != "one.att.net" ||
|
||||
identities.private != "[email protected]" ||
|
||||
identities.public != "sip:[email protected]" {
|
||||
t.Fatalf("AT&T identities = %#v", identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "310280000000001", HomeMCC: "310", HomeMNC: "280",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "att-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 1546543, spiServer: 1546542,
|
||||
portClient: 32773, portServer: 6000,
|
||||
integrityAlgorithms: []string{"hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"aes-cbc"},
|
||||
},
|
||||
}
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
request := string(packet)
|
||||
for _, want := range []string{
|
||||
"REGISTER sip:one.att.net SIP/2.0",
|
||||
"Expires: 18400",
|
||||
"Supported: path,sec-agree,gruu",
|
||||
"User-Agent: SimAdmin VoWiFi",
|
||||
`+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`,
|
||||
"P-Preferred-Identity: <sip:[email protected]>",
|
||||
`P-Visited-Network-ID: "one.att.net"`,
|
||||
"P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided",
|
||||
"Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0",
|
||||
"Accept-Contact: *;+g.3gpp.smsip",
|
||||
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000",
|
||||
`username="[email protected]"`,
|
||||
`uri="sip:one.att.net"`,
|
||||
} {
|
||||
if !strings.Contains(request, want) {
|
||||
t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
var callID string
|
||||
for step := 0; step < 3; step++ {
|
||||
|
||||
@@ -38,28 +38,18 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"}
|
||||
if got := securityEncryptionForIdentity(identity); got != "null" {
|
||||
t.Fatalf("O2 security encryption = %q, want null", got)
|
||||
func TestSecurityProposalDefaultUsesAESCBC(t *testing.T) {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
|
||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||
t.Fatalf("standard security encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002,
|
||||
portClient: 40666, portServer: 55610,
|
||||
encryption: securityEncryptionForIdentity(identity),
|
||||
}
|
||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||
t.Fatalf("O2 Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil {
|
||||
t.Fatalf("O2 null Security-Server rejected: %v", err)
|
||||
}
|
||||
|
||||
identity.HomeMNC = "02"
|
||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||
t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got)
|
||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1063,6 +1063,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
|
||||
}
|
||||
|
||||
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string {
|
||||
if identitySMSC := strings.TrimSpace(identity.SMSC); identitySMSC != "" {
|
||||
return identitySMSC
|
||||
}
|
||||
plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
|
||||
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
|
||||
return configured
|
||||
|
||||
@@ -233,18 +233,19 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
mnc string
|
||||
want string
|
||||
}{
|
||||
{mnc: "10", want: "+447802000332"},
|
||||
{mnc: "15", want: "+447785016005"},
|
||||
{mnc: "30", want: ""},
|
||||
} {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc}
|
||||
if got := smsCenterForIdentity(Config{}, identity); got != test.want {
|
||||
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want)
|
||||
}
|
||||
// Explicit SIM SMSC always takes precedence
|
||||
explicit := smsCenterForIdentity(Config{}, vowifi.SIMIdentity{
|
||||
HomeMCC: "234", HomeMNC: "15", SMSC: "+447785016005",
|
||||
})
|
||||
if explicit != "+447785016005" {
|
||||
t.Fatalf("explicit SMSC = %q, want +447785016005", explicit)
|
||||
}
|
||||
|
||||
// Profile fallback when identity has no SMSC
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
|
||||
profile := vowifi.ResolveCarrierProfile(identity)
|
||||
if got := smsCenterForIdentity(Config{}, identity); got != profile.SMSCenter {
|
||||
t.Errorf("smsCenterForIdentity = %q, want %q", got, profile.SMSCenter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,12 +765,13 @@ func TestSessionReceivesMalformedSMSBestEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing.T) {
|
||||
func TestSessionAllowsSMSWhenContactConfirmed(t *testing.T) {
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{Logger: slog.Default()}},
|
||||
request: vowifi.IMSRequest{
|
||||
Identity: vowifi.SIMIdentity{HomeMCC: "515", HomeMNC: "66"},
|
||||
Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
|
||||
},
|
||||
smsContactConfirmed: true,
|
||||
evidence: vowifi.IMSEvidence{
|
||||
Registered: true,
|
||||
RegistrationState: "registered",
|
||||
@@ -779,7 +781,7 @@ func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing
|
||||
|
||||
evidence, err := session.EnableSMS(context.Background())
|
||||
if err != nil || !evidence.Ready {
|
||||
t.Fatalf("EnableSMS() = (%#v, %v), want ready for DITO profile", evidence, err)
|
||||
t.Fatalf("EnableSMS() = (%#v, %v), want ready when contact confirmed", evidence, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,13 +120,13 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
|
||||
name: "three digit MNC is preserved",
|
||||
identity: SIMIdentity{
|
||||
ICCID: "one",
|
||||
HomeMCC: "310",
|
||||
HomeMCC: "999",
|
||||
HomeMNC: "260",
|
||||
},
|
||||
want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org",
|
||||
want: "epdg.epc.mnc260.mcc999.pub.3gppnetwork.org",
|
||||
},
|
||||
{
|
||||
name: "AT&T 310280 uses carrier endpoint",
|
||||
name: "AT&T 310280 uses carrier bundle ePDG",
|
||||
identity: SIMIdentity{
|
||||
ICCID: "8901000000000000001",
|
||||
IMSI: "310280000000001",
|
||||
|
||||
+23
-7
@@ -203,9 +203,14 @@ install_qmi_support() {
|
||||
|
||||
if is_openwrt && command -v opkg >/dev/null 2>&1; then
|
||||
opkg update >/dev/null 2>&1 || true
|
||||
if opkg_has_package libqmi; then
|
||||
opkg install libqmi >/dev/null 2>&1 || true
|
||||
local pkgs=""
|
||||
opkg_has_package qmi-utils && pkgs="$pkgs qmi-utils"
|
||||
opkg_has_package libqmi && pkgs="$pkgs libqmi"
|
||||
if [ -z "$pkgs" ]; then
|
||||
pkgs="qmi-utils libqmi"
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
opkg install $pkgs >/dev/null 2>&1 || true
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true
|
||||
@@ -236,6 +241,7 @@ install_pcsc_support() {
|
||||
local packages=""
|
||||
opkg_has_package pcscd && packages="$packages pcscd"
|
||||
opkg_has_package ccid && packages="$packages ccid"
|
||||
opkg_has_package libccid && packages="$packages libccid"
|
||||
if [ -n "$packages" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
opkg install $packages >/dev/null 2>&1 && installed=1 || true
|
||||
@@ -363,7 +369,7 @@ download_and_verify() {
|
||||
[ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
|
||||
chmod 0755 "${VOCAT_TMP}/vocat"
|
||||
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \
|
||||
"Downloaded binary cannot run on this system; keeping the installed version." \
|
||||
"下载的二进制文件无法在此系统上运行;未更改当前安装的版本。" \
|
||||
"The downloaded binary cannot run on this host; the installed version was not changed."
|
||||
}
|
||||
|
||||
@@ -392,8 +398,18 @@ INITIAL_ADMIN_PASSWORD=""
|
||||
bootstrap_admin() {
|
||||
local candidate="${1:-$BINARY_PATH}"
|
||||
local secret result
|
||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret."
|
||||
if command -v od >/dev/null 2>&1; then
|
||||
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')
|
||||
elif command -v hexdump >/dev/null 2>&1; then
|
||||
secret=$(hexdump -n 16 -e '16/1 "%02x"' /dev/urandom)
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
secret=$(openssl rand -hex 16 2>/dev/null || true)
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
secret=$(head -c 32 /dev/urandom | sha256sum | awk '{print substr($1, 1, 32)}')
|
||||
else
|
||||
secret=$(tr -dc 'a-f0-9' < /dev/urandom | head -c 32)
|
||||
fi
|
||||
[ -n "$secret" ] || die "生成随机密钥失败。" "Failed to generate a random secret."
|
||||
result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
|
||||
die \
|
||||
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
|
||||
@@ -505,7 +521,7 @@ write_service() {
|
||||
write_openwrt_init
|
||||
return
|
||||
fi
|
||||
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected."
|
||||
die "不支持的服务管理器。" "Neither systemd nor OpenWrt procd was detected."
|
||||
}
|
||||
|
||||
enable_and_start() {
|
||||
@@ -547,7 +563,7 @@ enable_and_start() {
|
||||
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
|
||||
"$OPENWRT_INIT_PATH" restart || true
|
||||
fi
|
||||
die "OpenWrt vocat service failed to start." "The OpenWrt vocat service failed to start."
|
||||
die "OpenWrt vocat 服务启动失败。" "The OpenWrt vocat service failed to start."
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable vocat
|
||||
|
||||
@@ -98,7 +98,12 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
|
||||
</div>
|
||||
) : null}
|
||||
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
|
||||
<FieldRow label={t("运营商配置")} value={rt?.carrierProfile || "standard-3gpp"} monospace copyable />
|
||||
<FieldRow
|
||||
label={t("运营商配置")}
|
||||
value={!rt?.carrierProfile || rt.carrierProfile === "standard-3gpp" ? "3GPP Standard" : rt.carrierProfile}
|
||||
monospace
|
||||
copyable
|
||||
/>
|
||||
<FieldRow label={t("匹配依据")} value={rt?.carrierProfileFrom || "standard"} monospace />
|
||||
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
|
||||
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
|
||||
|
||||
@@ -948,6 +948,7 @@ export const EN_DICT: Record<string, string> = {
|
||||
"通知重试发送失败": "Notification resend failed",
|
||||
"通知重试发送成功": "Notification resent",
|
||||
"配置存储在数据库中,部分字段可能需要重启生效": "Configuration is stored in the database; some fields may require a restart to take effect",
|
||||
"配置策略": "Profile Policy",
|
||||
"配置已保存,但部分变更需要重启服务后生效": "Configuration saved, but some changes require a service restart",
|
||||
"采样中断": "Sampling interrupted",
|
||||
"重启中": "Rebooting",
|
||||
|
||||
Reference in New Issue
Block a user