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
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
+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)
}
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 ""
}
+1 -1
View File
@@ -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`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
+102
View File
@@ -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{
+19 -3
View File
@@ -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)
}
}
+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) {
t.Helper()
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
}
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 ""
}
+26 -174
View File
@@ -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)
}
+88 -9
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+68 -2
View File
@@ -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
+50 -29
View File
@@ -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
+1 -59
View File
@@ -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)
-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) {
epdg := payload{
Type: payloadIDr,
+19
View File
@@ -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)
+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) {
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",
} {
+12
View File
@@ -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.
+5 -137
View File
@@ -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++ {
+6 -16
View File
@@ -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)
}
}
+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 {
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
+17 -15
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
View File
@@ -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 />
+1
View File
@@ -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",