Compare commits

...
6 Commits
29 changed files with 13106 additions and 577 deletions
@@ -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
+3 -2
View File
@@ -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 `qmi-network` to manage packet-data sessions. The one-click installer installs
and verifies the corresponding utilities automatically. For manual deployment, and verifies the corresponding utilities automatically. For manual deployment,
Debian/Ubuntu uses `apt install libqmi-utils`; Arch Linux uses 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 `vocat doctor --repair-dji-qmi` checks for `qmicli` before changing any USB
driver binding or asserting DTR. If the utility is unavailable, the command driver binding or asserting DTR. If the utility is unavailable, the command
@@ -369,7 +370,7 @@ cd web && npm run build
## Thanks ## Thanks
- [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers - [Nodeseek.com](https://www.nodeseek.com) — A community dedicated to servers
- [Linux.do](https://linux.do) — An inspiring tech community - [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 ## Buy me a coffee
+160
View File
@@ -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
View File
@@ -237,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig) go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
} }
var onIncomingCall func(context.Context, ims.ReceivedCall) error
vowifiManager, err := configureVoWiFiRuntime( vowifiManager, err := configureVoWiFiRuntime(
startupContext, startupContext,
logger, logger,
database, database,
deviceManager, deviceManager,
cardReaders, cardReaders,
func(ctx context.Context, call ims.ReceivedCall) error {
if onIncomingCall != nil {
return onIncomingCall(ctx, call)
}
return nil
},
) )
if err != nil { if err != nil {
return fmt.Errorf("configure VoWiFi runtime: %w", err) return fmt.Errorf("configure VoWiFi runtime: %w", err)
@@ -276,10 +284,24 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
if err != nil { if err != nil {
return err 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.StartLogRetentionLoop(pollContext, time.Minute)
go handler.StartSMSSyncLoop(pollContext, 15*time.Second) go handler.StartSMSSyncLoop(pollContext, 15*time.Second)
handler.StartTelegramBot(pollContext) handler.StartTelegramBot(pollContext)
handler.StartSMSNotificationDispatchers(pollContext) handler.StartSMSNotificationDispatchers(pollContext)
go handler.StartCellularCallMonitor(pollContext)
handler.StartAutomaticTasks(pollContext) handler.StartAutomaticTasks(pollContext)
serverConfig := func(handler http.Handler) *http.Server { serverConfig := func(handler http.Handler) *http.Server {
@@ -575,6 +597,7 @@ func configureVoWiFiRuntime(
database *store.Store, database *store.Store,
deviceManager *device.Manager, deviceManager *device.Manager,
cardReaders *pcsc.Service, cardReaders *pcsc.Service,
onIncomingCall func(context.Context, ims.ReceivedCall) error,
) (*vowifiruntime.Manager, error) { ) (*vowifiruntime.Manager, error) {
mapper := integration.ATMapper{ mapper := integration.ATMapper{
Store: database, Store: database,
@@ -630,7 +653,7 @@ func configureVoWiFiRuntime(
} else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 { } else if deviceConfig.DeviceType == store.DeviceTypeWiFi410 {
adapter = nativeQMIAdapter adapter = nativeQMIAdapter
} }
return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger) return newVoWiFiOrchestrator(deviceConfig, database, adapter, logger, onIncomingCall)
}, },
}) })
@@ -694,7 +717,7 @@ func protectVoWiFiStartupRadioWithRetry(
physicalID string, physicalID string,
attempts int, attempts int,
delay time.Duration, delay time.Duration,
) error { ) error {
var lastErr error var lastErr error
for attempt := 0; attempt < attempts; attempt++ { for attempt := 0; attempt < attempts; attempt++ {
flightContext, cancel := context.WithTimeout(ctx, 10*time.Second) flightContext, cancel := context.WithTimeout(ctx, 10*time.Second)
@@ -730,6 +753,7 @@ func newVoWiFiOrchestrator(
database *store.Store, database *store.Store,
adapter vowifiDeviceAdapter, adapter vowifiDeviceAdapter,
logger *slog.Logger, logger *slog.Logger,
onIncomingCall func(context.Context, ims.ReceivedCall) error,
) (*vowifi.Orchestrator, error) { ) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN apn := deviceConfig.APN
if apn == "" { if apn == "" {
@@ -748,6 +772,7 @@ func newVoWiFiOrchestrator(
// alternate transport only if no SIP response was observed. // alternate transport only if no SIP response was observed.
Transport: "tcp", Transport: "tcp",
AutoTransportFallback: true, AutoTransportFallback: true,
OnIncomingCall: onIncomingCall,
OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error { OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error {
extra, _ := json.Marshal(map[string]any{ extra, _ := json.Marshal(map[string]any{
"transport": "ims", "transport": "ims",
@@ -1328,3 +1353,13 @@ func liftCardRegionBlock(
"device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI, "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 ""
}
+1 -1
View File
@@ -192,7 +192,7 @@ VoCat 会继续在添加设备窗口显示该硬件,并明确提示缺少服
VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理 VoCat 使用 `qmicli` 验证 QMI 控制通道是否就绪,并使用 `qmi-network` 管理
分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时, 分组数据会话。一键安装脚本会自动安装并验证对应工具。手动部署时,
Debian/Ubuntu 使用 `apt install libqmi-utils`Arch Linux 使用 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 之前检查 `vocat doctor --repair-dji-qmi` 会在修改 USB 驱动绑定或触发 DTR 之前检查
`qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。 `qmicli`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
+102
View File
@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "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...)) 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) { func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing") physical := discoverUSBSmartCardReaders(backend.sysRoot, "pcsc_driver_missing")
client, err := backend.dial(ctx) 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 err != nil {
if len(physical) > 0 { if len(physical) > 0 {
for index := range physical { for index := range physical {
@@ -61,6 +160,9 @@ func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(physical) > 0 && len(states) < len(physical) {
states = backend.waitForPCSCReaders(ctx, client, physical, states)
}
readers := make([]Reader, 0, len(states)) readers := make([]Reader, 0, len(states))
for _, state := range states { for _, state := range states {
reader := Reader{ reader := Reader{
+19 -3
View File
@@ -67,17 +67,33 @@ func mergePCSCAndUSBReaders(readers, physical []Reader) []Reader {
readers[0] = enrichPCSCReader(readers[0], physical[0]) readers[0] = enrichPCSCReader(readers[0], physical[0])
return readers return readers
} }
seen := make(map[string]bool, len(readers)) matchedPhysical := make(map[string]bool, len(physical))
for i := range readers { for i := range readers {
seen[readers[i].USBPath] = true
for _, usbReader := range physical { for _, usbReader := range physical {
if readers[i].USBPath == usbReader.USBPath { if readers[i].USBPath == usbReader.USBPath {
readers[i] = enrichPCSCReader(readers[i], usbReader) 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 { for _, usbReader := range physical {
if !seen[usbReader.USBPath] { if !matchedPhysical[usbReader.USBPath] {
readers = append(readers, usbReader) readers = append(readers, usbReader)
} }
} }
+21
View File
@@ -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) { func writeUSBTestFile(t *testing.T, path, value string) {
t.Helper() t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+343
View File
@@ -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",
})
}
}
}
}
+108
View File
@@ -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)
}
}
+291 -20
View File
@@ -411,7 +411,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
continue continue
} }
bestScore = score bestScore = score
resolved = applyCarrierProfileRule(resolved, rule, source) resolved = applyCarrierProfileRule(resolved, rule, source, identity)
} }
return resolved return resolved
} }
@@ -441,16 +441,18 @@ func matchCarrierProfileRule(rule carrierProfileRule, identity SIMIdentity) (int
func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) { func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int, string, bool) {
score := 0 score := 0
sources := make([]string, 0, 6) sources := make([]string, 0, 6)
hasHomePLMNMatch := false
if len(match.HomePLMNs) > 0 { if len(match.HomePLMNs) > 0 {
wanted := canonicalPLMN(identity.HomeMCC, identity.HomeMNC) 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 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 { for _, selector := range []struct {
name string name string
weight int weight int
@@ -467,27 +469,32 @@ func matchCarrierProfile(match carrierProfileMatch, identity SIMIdentity) (int,
continue continue
} }
actual := strings.TrimSpace(selector.actual) 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) prefix = strings.TrimSpace(prefix)
if selector.foldCase { if selector.foldCase {
return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix)) return strings.HasPrefix(strings.ToLower(actual), strings.ToLower(prefix))
} }
return strings.HasPrefix(actual, prefix) return strings.HasPrefix(actual, prefix)
}) { }) {
score += selector.weight
sources = append(sources, selector.name)
hasSelectorMatch = true
} else if !hasHomePLMNMatch {
return 0, "", false return 0, "", false
} }
score += selector.weight
sources = append(sources, selector.name)
} }
if len(match.SPNs) > 0 { if len(match.SPNs) > 0 {
spn := strings.TrimSpace(identity.SPN) 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 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 return score, strings.Join(sources, "+"), score > 0
} }
@@ -501,11 +508,43 @@ func matchesAny(values []string, match func(string) bool) bool {
return false 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.ID = rule.ID
base.MatchSource = source base.MatchSource = source
base.RouteMCC = strings.TrimSpace(rule.Route.MCC) base.RouteMCC = strings.TrimSpace(rule.Route.MCC)
base.RouteMNC = strings.TrimSpace(rule.Route.MNC) 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)) base.EPDG = strings.ToLower(strings.TrimSpace(rule.EPDG.Hostname))
if value := strings.TrimSpace(rule.IKE.Proposal); value != "" { if value := strings.TrimSpace(rule.IKE.Proposal); value != "" {
base.IKEProposal = value base.IKEProposal = value
@@ -647,19 +686,154 @@ func IsATT310280(identity SIMIdentity) bool {
} }
func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { 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 return identity
} }
profile := ResolveCarrierProfile(identity)
switch { if strings.TrimSpace(identity.ICCID) != "" {
case profile.EPDG != "": if mcc, mnc, ok := HomePLMNFromICCID(identity.ICCID); ok {
identity.EPDG = profile.EPDG imsiCountry := countryCodeForMCC(identity.HomeMCC)
case profile.RouteMCC != "": iccidCountry := countryCodeForMCC(mcc)
identity.EPDG = standardEPDGHostname(profile.RouteMCC, profile.RouteMNC) 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 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 // EPDGDNSClientSubnet returns a deliberately scoped EDNS client subnet for an
// ePDG whose authoritative DNS only exposes addresses to home-country // ePDG whose authoritative DNS only exposes addresses to home-country
// resolvers. An empty result means ordinary system DNS remains authoritative. // 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 "" return ""
} }
+26 -174
View File
@@ -5,59 +5,6 @@ import (
"testing" "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) { func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{ profile := ResolveCarrierProfile(SIMIdentity{
IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01", IMSI: "999010000000001", HomeMCC: "999", HomeMNC: "01",
@@ -72,139 +19,44 @@ func TestResolveCarrierProfileUsesStandardDefault(t *testing.T) {
} }
func TestResolveCarrierProfilePrefersConstrainedMVNO(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{ profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8944160000000000001", IMSI: "204047000000001", IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF",
HomeMCC: "204", HomeMNC: "04", SPN: "Lebara",
}) })
if profile.ID != "xesim-lebara-vodafone-uk" || profile.RouteMCC != "234" || profile.RouteMNC != "15" { if !strings.Contains(profile.ID, "giffgaff") || profile.MatchSource != "hplmn+gid1" {
t.Fatalf("MVNO profile = %#v", profile) t.Fatalf("giffgaff profile = %#v", profile)
}
if profile.MatchSource != "hplmn+imsi+iccid" {
t.Fatalf("MVNO match source = %q", profile.MatchSource)
} }
} }
func TestResolveCarrierProfileUsesAlternativeMVNOSelectors(t *testing.T) { func TestResolveCarrierProfileATT(t *testing.T) {
tests := []struct {
name string
identity SIMIdentity
source string
}{
{
name: "Apple GID1 selector",
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", GID1: "508FFFFF"},
source: "hplmn+gid1",
},
{
name: "Android SPN selector",
identity: SIMIdentity{IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10", SPN: "GiffGaff"},
source: "hplmn+spn",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
profile := ResolveCarrierProfile(test.identity)
if profile.ID != "giffgaff-o2-uk" || profile.MatchSource != test.source {
t.Fatalf("giffgaff profile = %#v", profile)
}
if profile.SMSCenter != "+447802002606" || profile.IMSTransport != "udp" || !profile.IMSUserEqPhone {
t.Fatalf("giffgaff IMS settings = %#v", profile)
}
})
}
generic := ResolveCarrierProfile(SIMIdentity{
IMSI: "234100000000001", HomeMCC: "234", HomeMNC: "10",
})
if generic.ID != "o2-uk" || generic.SMSCenter != "+447802000332" {
t.Fatalf("generic O2 profile = %#v", generic)
}
}
func TestEEHostedProfileDoesNotClaimCTExcelBrand(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{ profile := ResolveCarrierProfile(SIMIdentity{
ICCID: "8944300000000000001", IMSI: "234336000000001", ICCID: "8901410000000000001", IMSI: "310410000000001", HomeMCC: "310", HomeMNC: "410",
HomeMCC: "234", HomeMNC: "33",
}) })
if profile.ID != "ee-uk-hosted-23433" || profile.RouteMCC != "234" || profile.RouteMNC != "30" { if !strings.Contains(profile.ID, "att") {
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" {
t.Fatalf("AT&T profile = %#v", profile) 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) { func TestResolveCarrierProfileStandardHasNoRegisterOverrides(t *testing.T) {
profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "001", HomeMNC: "01"}) profile := ResolveCarrierProfile(SIMIdentity{HomeMCC: "999", HomeMNC: "99"})
if profile.ID != CarrierProfileStandard { if profile.ID != CarrierProfileStandard {
t.Fatalf("profile = %q", profile.ID) t.Fatalf("profile = %q", profile.ID)
} }
+88 -9
View File
@@ -246,6 +246,82 @@ func InstallCarrierIPCCResult(result IPCCImportResult, dir string) (string, erro
return target, nil 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 { func carrierBundleRoots(files []*zip.File) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, file := range files { for _, file := range files {
@@ -425,11 +501,17 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
} }
switch strings.ToUpper(strings.TrimSpace(name)) { switch strings.ToUpper(strings.TrimSpace(name)) {
case "GID1": case "GID1":
match.GID1Prefixes = append(match.GID1Prefixes, trimAppleHexMask(value)) if trimmed := trimAppleHexMask(value); trimmed != "" {
match.GID1Prefixes = append(match.GID1Prefixes, trimmed)
}
case "GID2": case "GID2":
match.GID2Prefixes = append(match.GID2Prefixes, trimAppleHexMask(value)) if trimmed := trimAppleHexMask(value); trimmed != "" {
match.GID2Prefixes = append(match.GID2Prefixes, trimmed)
}
case "ICCID": 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": case "SPN":
match.SPNs = append(match.SPNs, value) match.SPNs = append(match.SPNs, value)
default: default:
@@ -437,16 +519,13 @@ func parseAppleSupportedSIM(raw string, warnings *ipccWarningSet) (carrierProfil
return carrierProfileMatch{}, false, false 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 { func trimAppleHexMask(value string) string {
value = strings.ToUpper(strings.TrimSpace(value)) value = strings.ToUpper(strings.TrimSpace(value))
trimmed := strings.TrimRight(value, "F") return strings.TrimRight(value, "F")
if trimmed == "" {
return value
}
return trimmed
} }
func collectMatchingICCIDPrefixes(plists []ipccPlist) []string { func collectMatchingICCIDPrefixes(plists []ipccPlist) []string {
File diff suppressed because it is too large Load Diff
+68 -2
View File
@@ -30,6 +30,7 @@ const (
usimAIDPrefix = "A0000000871002" usimAIDPrefix = "A0000000871002"
isimAIDPrefix = "A0000000871004" isimAIDPrefix = "A0000000871004"
efADDecimal = 28589 // 0x6FAD efADDecimal = 28589 // 0x6FAD
efEHPLMNDecimal = 28441 // 0x6F19 (3GPP TS 31.102 EF_EHPLMN)
channelCleanupTimeout = 3 * time.Second channelCleanupTimeout = 3 * time.Second
) )
@@ -239,14 +240,79 @@ func (adapter *EC20Adapter) readHomePLMN(
return mcc, mnc, nil return mcc, mnc, nil
} }
} }
// Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The // Exact assigned HPLMN prefixes are data, not an MNC-length heuristic.
// target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed.
if mcc, mnc, ok := assignedHomePLMN(imsi); ok { if mcc, mnc, ok := assignedHomePLMN(imsi); ok {
return mcc, mnc, nil 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 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) { func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) {
assignments := []struct { assignments := []struct {
prefix string prefix string
+50 -29
View File
@@ -28,48 +28,67 @@ func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]ne
if resolver == nil { if resolver == nil {
resolver = net.DefaultResolver 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), ".")) normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
subnet := vowifi.EPDGDNSClientSubnet(normalized) addresses, systemErr := resolver.LookupIPAddr(ctx, host)
if subnet == "" { validSystemAddresses := filterValidPublicEPDGAddresses(addresses)
if systemErr != nil { if systemErr == nil && len(validSystemAddresses) > 0 {
return nil, systemErr return validSystemAddresses, nil
}
return nil, errors.New("ePDG did not resolve to an IP address")
} }
subnet := vowifi.EPDGDNSClientSubnet(normalized)
client := &http.Client{Timeout: 8 * time.Second} client := &http.Client{Timeout: 8 * time.Second}
var fallbackErr error 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 hostsToTry := []string{normalized}
// address records. Stay inside the runtime's two-minute setup window and if alt := alternate3GPPHostname(normalized); alt != "" && alt != normalized {
// wait through one complete negative-cache TTL so a single reconnect is hostsToTry = append(hostsToTry, alt)
// sufficient; users should not have to click Reconnect repeatedly. }
const fallbackAttempts = 13
for attempt := 0; attempt < fallbackAttempts; attempt++ { for _, targetHost := range hostsToTry {
var fallback []net.IPAddr 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 { if fallbackErr == nil && len(fallback) > 0 {
return fallback, nil return fallback, nil
} }
if attempt+1 < fallbackAttempts {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
} }
if systemErr == nil { 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) 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( func resolveEPDGWithECS(
ctx context.Context, ctx context.Context,
client *http.Client, client *http.Client,
@@ -85,7 +104,9 @@ func resolveEPDGWithECS(
query := parsed.Query() query := parsed.Query()
query.Set("name", strings.TrimSpace(host)) query.Set("name", strings.TrimSpace(host))
query.Set("type", "A") 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() parsed.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
@@ -116,7 +137,7 @@ func resolveEPDGWithECS(
continue continue
} }
ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), ".")) ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), "."))
if ip == nil { if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
continue continue
} }
duplicate := false duplicate := false
+1 -59
View File
@@ -18,25 +18,6 @@ var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed")
type constantReader struct{ value byte } 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) { func TestLegacyProposalFallbackIsLimitedToNegotiationFailures(t *testing.T) {
for _, err := range []error{ for _, err := range []error{
errNoProposalChosen, errNoProposalChosen,
@@ -208,7 +189,7 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1]) group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
wantGroup := transport.wantGroup wantGroup := transport.wantGroup
if wantGroup == 0 { if wantGroup == 0 {
wantGroup = dhMODP1024 wantGroup = dhMODP2048
} }
wantKELength := 128 wantKELength := 128
if wantGroup == dhMODP2048 { 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 _ io.Reader = constantReader{}
var _ datagramTransport = (*firstAuthCaptureTransport)(nil) var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
-12
View File
@@ -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) { func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
epdg := payload{ epdg := payload{
Type: payloadIDr, Type: payloadIDr,
+19
View File
@@ -368,6 +368,25 @@ func (session *Session) handleCallRequest(request *sipRequest, respond func([]by
session.callMu.Lock() session.callMu.Lock()
session.calls[callID] = call session.calls[callID] = call
session.callMu.Unlock() 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) response, err := buildSIPResponseWithBody(request, 180, session.fromTag, nil)
if err == nil { if err == nil {
_ = respond(response) _ = respond(response)
+50 -3
View File
@@ -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) { func TestRejectedOutgoingCallRetainsSIPReason(t *testing.T) {
session := &Session{calls: make(map[string]*imsCall)} session := &Session{calls: make(map[string]*imsCall)}
call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}} call := &imsCall{public: vowifi.Call{ID: "rejected", State: "dialing"}}
@@ -176,13 +223,13 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
wire := <-wireResult wire := <-wireResult
for _, expected := range []string{ for _, expected := range []string{
"INVITE sip:888@ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n", "INVITE tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org SIP/2.0\r\n",
"To: <sip:888@ims.mnc033.mcc234.3gppnetwork.org>\r\n", "To: <tel:888;phone-context=ims.mnc033.mcc234.3gppnetwork.org>\r\n",
"From: <sip:[email protected]>;tag=local-tag\r\n", "From: <sip:[email protected]>;tag=local-tag\r\n",
"P-Preferred-Identity: <tel:+447700900123>\r\n", "P-Preferred-Identity: <tel:+447700900123>\r\n",
"P-Preferred-Service: " + mmtelServiceURN + "\r\n", "P-Preferred-Service: " + mmtelServiceURN + "\r\n",
`Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\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", "User-Agent: VoCat Test\r\n",
"Accept: application/sdp\r\n", "Accept: application/sdp\r\n",
} { } {
+12
View File
@@ -70,11 +70,23 @@ type Config struct {
// IMS (3GPP TS 24.390). Returning an error is logged but does not affect // 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. // the 200 OK already sent, because USSI has no RP-ACK transport.
OnUSSD func(context.Context, ReceivedUSSD) error 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 // Logger receives structured IMS runtime diagnostics. Inbound SMS logs do
// not include message text or raw protocol payloads. // not include message text or raw protocol payloads.
Logger *slog.Logger 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 // Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER
// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or // transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or
// runtime dependency outside the Go standard library. // runtime dependency outside the Go standard library.
+5 -137
View File
@@ -111,14 +111,14 @@ func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) {
func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) { func TestCarrierProfileSuppliesTransportWithoutCodeMap(t *testing.T) {
t.Parallel() t.Parallel()
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"} identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "udp" { if got := transportForIdentity(Config{Transport: "tcp"}, identity); got != "tcp" {
t.Fatalf("O2 UK profile transport = %q, want udp", got) t.Fatalf("standard transport = %q, want tcp", got)
} }
if got := transportForIdentity(Config{ if got := transportForIdentity(Config{
Transport: "udp", TransportByPLMN: map[string]string{"23410": "tcp"}, Transport: "udp", TransportByPLMN: map[string]string{"99999": "tcp"},
}, identity); got != "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 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 { func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
var callID string var callID string
for step := 0; step < 3; step++ { for step := 0; step < 3; step++ {
+6 -16
View File
@@ -38,28 +38,18 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
} }
} }
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) { func TestSecurityProposalDefaultUsesAESCBC(t *testing.T) {
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"} identity := vowifi.SIMIdentity{HomeMCC: "999", HomeMNC: "99"}
if got := securityEncryptionForIdentity(identity); got != "null" { if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
t.Fatalf("O2 security encryption = %q, want null", got) t.Fatalf("standard security encryption = %q, want aes-cbc", got)
} }
proposal := securityProposal{ proposal := securityProposal{
spiClient: 1001, spiServer: 1002, spiClient: 1001, spiServer: 1002,
portClient: 40666, portServer: 55610, portClient: 40666, portServer: 55610,
encryption: securityEncryptionForIdentity(identity), 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 { 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("O2 Security-Client = %q, want %q", got, want) t.Fatalf("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)
} }
} }
+3
View File
@@ -1063,6 +1063,9 @@ func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitReq
} }
func smsCenterForIdentity(config Config, identity vowifi.SIMIdentity) string { 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) plmn := strings.TrimSpace(identity.HomeMCC) + strings.TrimSpace(identity.HomeMNC)
if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" { if configured := strings.TrimSpace(config.SMSCenterByPLMN[plmn]); configured != "" {
return configured return configured
+17 -15
View File
@@ -233,18 +233,19 @@ func TestSMSCenterForIdentityUsesExactPLMN(t *testing.T) {
} }
func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) { func TestSMSCenterForIdentityFallsBackToCarrierProfile(t *testing.T) {
for _, test := range []struct { // Explicit SIM SMSC always takes precedence
mnc string explicit := smsCenterForIdentity(Config{}, vowifi.SIMIdentity{
want string HomeMCC: "234", HomeMNC: "15", SMSC: "+447785016005",
}{ })
{mnc: "10", want: "+447802000332"}, if explicit != "+447785016005" {
{mnc: "15", want: "+447785016005"}, t.Fatalf("explicit SMSC = %q, want +447785016005", explicit)
{mnc: "30", want: ""}, }
} {
identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: test.mnc} // Profile fallback when identity has no SMSC
if got := smsCenterForIdentity(Config{}, identity); got != test.want { identity := vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}
t.Errorf("profile SMSC for 234/%s = %q, want %q", test.mnc, got, test.want) 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{ session := &Session{
provider: &Provider{config: Config{Logger: slog.Default()}}, provider: &Provider{config: Config{Logger: slog.Default()}},
request: vowifi.IMSRequest{ request: vowifi.IMSRequest{
Identity: vowifi.SIMIdentity{HomeMCC: "515", HomeMNC: "66"}, Identity: vowifi.SIMIdentity{HomeMCC: "001", HomeMNC: "01"},
}, },
smsContactConfirmed: true,
evidence: vowifi.IMSEvidence{ evidence: vowifi.IMSEvidence{
Registered: true, Registered: true,
RegistrationState: "registered", RegistrationState: "registered",
@@ -779,7 +781,7 @@ func TestSessionAllowsSMSWithoutContactConfirmationWhenProfilePermits(t *testing
evidence, err := session.EnableSMS(context.Background()) evidence, err := session.EnableSMS(context.Background())
if err != nil || !evidence.Ready { 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)
} }
} }
+3 -3
View File
@@ -120,13 +120,13 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) {
name: "three digit MNC is preserved", name: "three digit MNC is preserved",
identity: SIMIdentity{ identity: SIMIdentity{
ICCID: "one", ICCID: "one",
HomeMCC: "310", HomeMCC: "999",
HomeMNC: "260", 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{ identity: SIMIdentity{
ICCID: "8901000000000000001", ICCID: "8901000000000000001",
IMSI: "310280000000001", IMSI: "310280000000001",
+23 -7
View File
@@ -203,9 +203,14 @@ install_qmi_support() {
if is_openwrt && command -v opkg >/dev/null 2>&1; then if is_openwrt && command -v opkg >/dev/null 2>&1; then
opkg update >/dev/null 2>&1 || true opkg update >/dev/null 2>&1 || true
if opkg_has_package libqmi; then local pkgs=""
opkg install libqmi >/dev/null 2>&1 || true 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 fi
# shellcheck disable=SC2086
opkg install $pkgs >/dev/null 2>&1 || true
elif command -v apt-get >/dev/null 2>&1; then elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq || true apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true DEBIAN_FRONTEND=noninteractive apt-get install -y libqmi-utils || true
@@ -236,6 +241,7 @@ install_pcsc_support() {
local packages="" local packages=""
opkg_has_package pcscd && packages="$packages pcscd" opkg_has_package pcscd && packages="$packages pcscd"
opkg_has_package ccid && packages="$packages ccid" opkg_has_package ccid && packages="$packages ccid"
opkg_has_package libccid && packages="$packages libccid"
if [ -n "$packages" ]; then if [ -n "$packages" ]; then
# shellcheck disable=SC2086 # shellcheck disable=SC2086
opkg install $packages >/dev/null 2>&1 && installed=1 || true 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." [ "$actual" = "$expected" ] || die "SHA-256 校验失败。" "SHA-256 verification failed."
chmod 0755 "${VOCAT_TMP}/vocat" chmod 0755 "${VOCAT_TMP}/vocat"
"${VOCAT_TMP}/vocat" version >/dev/null 2>&1 || die \ "${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." "The downloaded binary cannot run on this host; the installed version was not changed."
} }
@@ -392,8 +398,18 @@ INITIAL_ADMIN_PASSWORD=""
bootstrap_admin() { bootstrap_admin() {
local candidate="${1:-$BINARY_PATH}" local candidate="${1:-$BINARY_PATH}"
local secret result local secret result
secret=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') if command -v od >/dev/null 2>&1; then
[ -n "$secret" ] || die "Failed to generate a random secret." "Failed to generate a random secret." 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) || \ result=$(printf '%s\n' "$secret" | "$candidate" bootstrap-admin --database /opt/vocat/data/vocat.db --username admin) || \
die \ die \
"待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \ "待安装版本无法读取或升级现有数据库;当前程序尚未被替换,请检查数据库与版本兼容性。" \
@@ -505,7 +521,7 @@ write_service() {
write_openwrt_init write_openwrt_init
return return
fi fi
die "Unsupported service manager." "Neither systemd nor OpenWrt procd was detected." die "不支持的服务管理器。" "Neither systemd nor OpenWrt procd was detected."
} }
enable_and_start() { enable_and_start() {
@@ -547,7 +563,7 @@ enable_and_start() {
cp -a "${BINARY_PATH}.bak" "$BINARY_PATH" cp -a "${BINARY_PATH}.bak" "$BINARY_PATH"
"$OPENWRT_INIT_PATH" restart || true "$OPENWRT_INIT_PATH" restart || true
fi 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 fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable vocat systemctl enable vocat
@@ -98,7 +98,12 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
</div> </div>
) : null} ) : null}
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace /> <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?.carrierProfileFrom || "standard"} monospace />
<FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} /> <FieldRow label={t("最后原因")} value={rt?.lastReason || "--"} />
<FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable /> <FieldRow label={t("错误分类")} value={rt?.lastErrorClass || "--"} monospace copyable />
+1
View File
@@ -948,6 +948,7 @@ export const EN_DICT: Record<string, string> = {
"通知重试发送失败": "Notification resend failed", "通知重试发送失败": "Notification resend failed",
"通知重试发送成功": "Notification resent", "通知重试发送成功": "Notification resent",
"配置存储在数据库中,部分字段可能需要重启生效": "Configuration is stored in the database; some fields may require a restart to take effect", "配置存储在数据库中,部分字段可能需要重启生效": "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", "配置已保存,但部分变更需要重启服务后生效": "Configuration saved, but some changes require a service restart",
"采样中断": "Sampling interrupted", "采样中断": "Sampling interrupted",
"重启中": "Rebooting", "重启中": "Rebooting",