mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-22 07:43:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392d44f919 | ||
|
|
8accaaaabc | ||
|
|
9eebcc1773 | ||
|
|
ff4c1ab036 | ||
|
|
1dc6bcccd9 | ||
|
|
be40e324db |
@@ -0,0 +1,42 @@
|
|||||||
|
name: Sync Apple Carrier Bundles
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every Sunday at midnight UTC
|
||||||
|
- cron: '0 0 * * 0'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
|
||||||
|
- name: Run carrier bundles sync
|
||||||
|
run: |
|
||||||
|
go run ./cmd/sync_carrier_bundles
|
||||||
|
|
||||||
|
- name: Run tests on generated profiles
|
||||||
|
run: |
|
||||||
|
go test -v ./internal/vowifi
|
||||||
|
|
||||||
|
- name: Create Pull Request or commit updates
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
commit-message: "chore(vowifi): sync Apple carrier bundles offline database"
|
||||||
|
title: "chore(vowifi): sync Apple carrier bundles offline database"
|
||||||
|
body: |
|
||||||
|
Automated sync from `dwilliamsuk/ios-carrier-bundles` latest release.
|
||||||
|
Updated `internal/vowifi/carrier_profiles.json`.
|
||||||
|
branch: "sync-apple-carrier-bundles"
|
||||||
|
delete-branch: true
|
||||||
@@ -216,7 +216,8 @@ VoCat uses `qmicli` to verify that a QMI control channel is ready and
|
|||||||
`qmi-network` to manage packet-data sessions. The one-click installer installs
|
`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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/vowifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultTarURL = "https://github.com/dwilliamsuk/ios-carrier-bundles/archive/refs/heads/latest.tar.gz"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tarURL := flag.String("url", defaultTarURL, "URL to ios-carrier-bundles tar.gz archive")
|
||||||
|
localTar := flag.String("file", "", "path to local .tar.gz archive")
|
||||||
|
outputFile := flag.String("output", filepath.Join("internal", "vowifi", "carrier_profiles.json"), "output carrier_profiles.json path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var reader io.Reader
|
||||||
|
if *localTar != "" {
|
||||||
|
f, err := os.Open(*localTar)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", *localTar, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
reader = f
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Downloading %s ...\n", *tarURL)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Minute}
|
||||||
|
resp, err := client.Get(*tarURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Download error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
fmt.Fprintf(os.Stderr, "HTTP %s\n", resp.Status)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Downloaded %d bytes. Parsing archive...\n", len(data))
|
||||||
|
reader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(reader)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Gzip error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
bundlePlists := make(map[string]map[string][]byte)
|
||||||
|
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Tar error: %v\n", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if hdr.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.ReplaceAll(hdr.Name, "\\", "/")
|
||||||
|
if strings.Contains(strings.ToLower(name), "/signatures/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := path.Base(name)
|
||||||
|
if !strings.EqualFold(base, "carrier.plist") && (!strings.HasPrefix(strings.ToLower(base), "overrides") || !strings.EqualFold(path.Ext(base), ".plist")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// e.g. ios-carrier-bundles-latest/Carrier Bundles/EE_uk.bundle/carrier.plist
|
||||||
|
bundleDir := path.Dir(name)
|
||||||
|
bundleName := path.Base(bundleDir)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(bundleName), ".bundle") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bundlePlists[bundleName] == nil {
|
||||||
|
bundlePlists[bundleName] = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
bundlePlists[bundleName][base] = content
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d distinct carrier bundles. Extracting VoWiFi profiles...\n", len(bundlePlists))
|
||||||
|
|
||||||
|
var sortedBundleNames []string
|
||||||
|
for k := range bundlePlists {
|
||||||
|
sortedBundleNames = append(sortedBundleNames, k)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedBundleNames)
|
||||||
|
|
||||||
|
var extractedRules []any
|
||||||
|
seenIDs := make(map[string]bool)
|
||||||
|
successCount := 0
|
||||||
|
skipCount := 0
|
||||||
|
|
||||||
|
for _, bundleName := range sortedBundleNames {
|
||||||
|
plists := bundlePlists[bundleName]
|
||||||
|
rule, _, err := vowifi.ImportCarrierBundlePlists(bundleName, plists)
|
||||||
|
if err != nil {
|
||||||
|
skipCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seenIDs[rule.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIDs[rule.ID] = true
|
||||||
|
extractedRules = append(extractedRules, rule)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Extracted %d valid carrier profile rules (skipped %d without valid VoWiFi selectors).\n", successCount, skipCount)
|
||||||
|
|
||||||
|
doc := map[string]any{
|
||||||
|
"version": vowifi.CarrierProfileSchemaVersion,
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"source": "dwilliamsuk/ios-carrier-bundles",
|
||||||
|
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"count": len(extractedRules),
|
||||||
|
},
|
||||||
|
"profiles": extractedRules,
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "JSON encode error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(*outputFile, append(encoded, '\n'), 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Write error to %s: %v\n", *outputFile, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully wrote %d rules (%d bytes) to %s\n", len(extractedRules), len(encoded), *outputFile)
|
||||||
|
}
|
||||||
+37
-2
@@ -237,12 +237,20 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
|
|||||||
go watchDeveloperDisable(pollContext, logger, database, deviceManager, exportProxyManager, legacyExportProxyConfig)
|
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 ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`。如果工具不可用,命令会给出安装提示并停止,保持设备当前状态不变。
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vocat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
callDeduplicationWindow = 60 * time.Second
|
||||||
|
cellularCallMonitorInterval = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
callDeduplicationMu sync.Mutex
|
||||||
|
callDeduplicationMap = make(map[string]time.Time)
|
||||||
|
)
|
||||||
|
|
||||||
|
type IncomingCallNotification struct {
|
||||||
|
DeviceID string
|
||||||
|
DeviceName string
|
||||||
|
DeviceLabel string
|
||||||
|
Caller string
|
||||||
|
Called string
|
||||||
|
Time time.Time
|
||||||
|
Environment string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) Title() string {
|
||||||
|
return "收到来电"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) Text() string {
|
||||||
|
envText := "VoWiFi"
|
||||||
|
if value.Environment == "cellular" {
|
||||||
|
envText = "基站直连"
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
"📞 收到来电",
|
||||||
|
"设备 " + value.DeviceLabel,
|
||||||
|
"来电号码 " + value.Caller,
|
||||||
|
"被呼号码 " + value.Called,
|
||||||
|
"时间 " + value.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
"网络 " + envText,
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value IncomingCallNotification) DetailText() string {
|
||||||
|
lines := strings.Split(value.Text(), "\n")
|
||||||
|
return strings.Join(lines[1:], "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSuppressDuplicateCall(key string, now time.Time, window time.Duration) bool {
|
||||||
|
callDeduplicationMu.Lock()
|
||||||
|
defer callDeduplicationMu.Unlock()
|
||||||
|
for k, t := range callDeduplicationMap {
|
||||||
|
if now.Sub(t) > window*2 {
|
||||||
|
delete(callDeduplicationMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastTime, exists := callDeduplicationMap[key]; exists {
|
||||||
|
if now.Sub(lastTime) < window {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callDeduplicationMap[key] = now
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyIncomingCall delivers an incoming call alert to all configured notification channels.
|
||||||
|
func (s *Server) NotifyIncomingCall(ctx context.Context, notification IncomingCallNotification) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
caller := strings.TrimSpace(notification.Caller)
|
||||||
|
if caller == "" {
|
||||||
|
caller = "未知号码"
|
||||||
|
}
|
||||||
|
notification.Caller = caller
|
||||||
|
|
||||||
|
called := strings.TrimSpace(notification.Called)
|
||||||
|
if called == "" {
|
||||||
|
called = "--"
|
||||||
|
}
|
||||||
|
notification.Called = called
|
||||||
|
|
||||||
|
if notification.Time.IsZero() {
|
||||||
|
notification.Time = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
dedupKey := fmt.Sprintf("%s:%s", notification.DeviceID, notification.Caller)
|
||||||
|
if shouldSuppressDuplicateCall(dedupKey, notification.Time, callDeduplicationWindow) {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Debug("suppressed duplicate incoming call notification", "device_id", notification.DeviceID, "caller", notification.Caller)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if notification.DeviceLabel == "" || notification.DeviceLabel == "--" {
|
||||||
|
if configured, err := s.store.Device(ctx, notification.DeviceID); err == nil {
|
||||||
|
notification.DeviceName = strings.TrimSpace(configured.Name)
|
||||||
|
notification.DeviceLabel = firstNonEmpty(configured.Name, configured.ID, "--")
|
||||||
|
} else {
|
||||||
|
notification.DeviceLabel = firstNonEmpty(notification.DeviceID, "--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destCtx := s.notificationDestinationContext(ctx)
|
||||||
|
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
|
||||||
|
setting, err := s.store.NotificationSetting(destCtx, channel)
|
||||||
|
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("read incoming call notification setting", "channel", channel, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var config map[string]any
|
||||||
|
if err := json.Unmarshal(setting.Config, &config); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("decode incoming call notification setting", "channel", channel, "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sendCallNotification(destCtx, channel, config, notification); err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Warn("send incoming call notification", "channel", channel, "device_id", notification.DeviceID, "caller", notification.Caller, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCallNotification(ctx context.Context, channel string, config map[string]any, message IncomingCallNotification) error {
|
||||||
|
switch channel {
|
||||||
|
case "telegram":
|
||||||
|
return sendTelegramTextNotification(ctx, config, message.Text())
|
||||||
|
case "bark":
|
||||||
|
return sendBarkTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||||
|
case "email":
|
||||||
|
return sendEmailTextNotification(ctx, config, message.Title()+" - "+message.DeviceLabel, message.Text())
|
||||||
|
case "pushplus":
|
||||||
|
return sendPushplusTextNotification(ctx, config, message.Title(), message.DetailText())
|
||||||
|
case "webhook":
|
||||||
|
return sendCallWebhookNotification(ctx, config, message)
|
||||||
|
case "wecom":
|
||||||
|
return sendWecomNotification(ctx, config, wecomCallValues(message))
|
||||||
|
case "lark":
|
||||||
|
return sendLarkNotification(ctx, config, larkCallValues(message))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported notification channel %q", channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCallWebhookTemplate(template string, message IncomingCallNotification) string {
|
||||||
|
rendered := message.Text()
|
||||||
|
if strings.TrimSpace(template) != "" {
|
||||||
|
replacements := map[string]string{
|
||||||
|
"{{text}}": rendered,
|
||||||
|
"{{content}}": message.DetailText(),
|
||||||
|
"{{event}}": "call.received",
|
||||||
|
"{{timestamp}}": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"{{time}}": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
"{{number}}": message.Caller,
|
||||||
|
"{{caller}}": message.Caller,
|
||||||
|
"{{called}}": message.Called,
|
||||||
|
"{{device_id}}": message.DeviceID,
|
||||||
|
"{{device_name}}": message.DeviceName,
|
||||||
|
"{{device_label}}": message.DeviceLabel,
|
||||||
|
"{{environment}}": message.Environment,
|
||||||
|
}
|
||||||
|
for placeholder, value := range replacements {
|
||||||
|
template = strings.ReplaceAll(template, placeholder, value)
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendCallWebhookNotification(ctx context.Context, config map[string]any, message IncomingCallNotification) error {
|
||||||
|
template := configString(config, "text_template")
|
||||||
|
rendered := renderCallWebhookTemplate(template, message)
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"event": "call.received",
|
||||||
|
"message": rendered,
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"caller": message.Caller,
|
||||||
|
"called": message.Called,
|
||||||
|
"environment": message.Environment,
|
||||||
|
})
|
||||||
|
timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
|
||||||
|
client, err := restrictedHTTPClient(ctx, timeout, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
retries := configInt(config, "retry_max")
|
||||||
|
for _, destination := range configStrings(config, "urls") {
|
||||||
|
parsed, err := validateOutboundURL(ctx, destination, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var sendErr error
|
||||||
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
|
request, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||||
|
if requestErr != nil {
|
||||||
|
return fmt.Errorf("create call webhook notification request: %w", requestErr)
|
||||||
|
}
|
||||||
|
for name, value := range configStringMap(config, "headers") {
|
||||||
|
request.Header.Set(name, value)
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("User-Agent", "vocat-call-notification/1")
|
||||||
|
if secret := configString(config, "secret"); secret != "" {
|
||||||
|
signature := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = signature.Write(payload)
|
||||||
|
request.Header.Set("X-vocat-Signature", "sha256="+hex.EncodeToString(signature.Sum(nil)))
|
||||||
|
}
|
||||||
|
sendErr = performNotificationRequest(client, request, false)
|
||||||
|
if sendErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sendErr != nil {
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func wecomCallValues(message IncomingCallNotification) wecomTemplateValues {
|
||||||
|
return wecomTemplateValues{
|
||||||
|
"event": "call.received",
|
||||||
|
"title": message.Title(),
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.DetailText(),
|
||||||
|
"number": message.Caller,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func larkCallValues(message IncomingCallNotification) larkTemplateValues {
|
||||||
|
return larkTemplateValues{
|
||||||
|
"event": "call.received",
|
||||||
|
"title": message.Title(),
|
||||||
|
"message": message.Text(),
|
||||||
|
"timestamp": message.Time.UTC().Format(time.RFC3339),
|
||||||
|
"content": message.DetailText(),
|
||||||
|
"number": message.Caller,
|
||||||
|
"device_id": message.DeviceID,
|
||||||
|
"device_name": message.DeviceName,
|
||||||
|
"device_label": message.DeviceLabel,
|
||||||
|
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCellularCallMonitor scans physical modems for incoming calls in cellular mode.
|
||||||
|
func (s *Server) StartCellularCallMonitor(ctx context.Context) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(cellularCallMonitorInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.pollCellularCalls(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pollCellularCalls(ctx context.Context) {
|
||||||
|
devices, err := s.store.ListDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, config := range devices {
|
||||||
|
if !config.NetworkEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// If VoWiFi is active, incoming calls are handled directly by SIP INVITE in real time.
|
||||||
|
if s.callTransport(config.ID) == "vowifi" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, physicalID, present := s.physicalForConfig(config)
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pollCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
response, err := s.devices.ExecuteAT(pollCtx, physicalID, "AT+CLCC")
|
||||||
|
cancel()
|
||||||
|
if err != nil || !response.OK() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
calls := parseCLCC(response)
|
||||||
|
for _, call := range calls {
|
||||||
|
direction, _ := call["direction"].(int)
|
||||||
|
state, _ := call["state"].(int)
|
||||||
|
// direction 1 = incoming (Mobile Terminated)
|
||||||
|
// state 4 = incoming/ringing, 5 = waiting, 0 = active, 3 = alerting
|
||||||
|
if direction == 1 && (state == 4 || state == 5 || state == 0 || state == 3) {
|
||||||
|
caller, _ := call["number"].(string)
|
||||||
|
if caller == "" {
|
||||||
|
caller = "未知号码"
|
||||||
|
}
|
||||||
|
called := ""
|
||||||
|
if entry.Snapshot != nil {
|
||||||
|
called = entry.Snapshot.Phone.Number
|
||||||
|
}
|
||||||
|
s.NotifyIncomingCall(ctx, IncomingCallNotification{
|
||||||
|
DeviceID: config.ID,
|
||||||
|
DeviceName: strings.TrimSpace(config.Name),
|
||||||
|
DeviceLabel: firstNonEmpty(config.Name, config.ID, "--"),
|
||||||
|
Caller: caller,
|
||||||
|
Called: firstNonEmpty(called, "--"),
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
Environment: "cellular",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIncomingCallNotificationTextFormatting(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||||
|
notification := IncomingCallNotification{
|
||||||
|
DeviceID: "ec20-1",
|
||||||
|
DeviceName: "Main Router",
|
||||||
|
DeviceLabel: "Main Router",
|
||||||
|
Caller: "+8613800138000",
|
||||||
|
Called: "+8613900139000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "vowifi",
|
||||||
|
}
|
||||||
|
|
||||||
|
if notification.Title() != "收到来电" {
|
||||||
|
t.Errorf("Title() = %q, want '收到来电'", notification.Title())
|
||||||
|
}
|
||||||
|
|
||||||
|
text := notification.Text()
|
||||||
|
for _, want := range []string{
|
||||||
|
"📞 收到来电",
|
||||||
|
"设备 Main Router",
|
||||||
|
"来电号码 +861380138000"[:10],
|
||||||
|
"被呼号码 +8613900139000",
|
||||||
|
"网络 VoWiFi",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("Text() omitted %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.Environment = "cellular"
|
||||||
|
if !strings.Contains(notification.Text(), "网络 基站直连") {
|
||||||
|
t.Errorf("Text() in cellular mode omitted '网络 基站直连':\n%s", notification.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncomingCallDeduplication(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
key := "test-device:+8613800000000"
|
||||||
|
|
||||||
|
// First call should not be suppressed
|
||||||
|
if shouldSuppressDuplicateCall(key, now, time.Minute) {
|
||||||
|
t.Fatal("first call unexpectedly suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate duplicate should be suppressed
|
||||||
|
if !shouldSuppressDuplicateCall(key, now.Add(5*time.Second), time.Minute) {
|
||||||
|
t.Fatal("duplicate call within window was not suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call after window should be allowed
|
||||||
|
if shouldSuppressDuplicateCall(key, now.Add(70*time.Second), time.Minute) {
|
||||||
|
t.Fatal("call after window was suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCallWebhookTemplate(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 20, 10, 30, 0, 0, time.UTC)
|
||||||
|
message := IncomingCallNotification{
|
||||||
|
DeviceID: "dev-1",
|
||||||
|
DeviceName: "Living Room",
|
||||||
|
DeviceLabel: "EC20",
|
||||||
|
Caller: "+8613800000000",
|
||||||
|
Called: "+8613900000000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "vowifi",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := renderCallWebhookTemplate("{{event}}|{{device_id}}|{{device_name}}|{{device_label}}|{{caller}}|{{called}}|{{environment}}", message)
|
||||||
|
want := "call.received|dev-1|Living Room|EC20|+8613800000000|+8613900000000|vowifi"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("renderCallWebhookTemplate() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWecomAndLarkCallValues(t *testing.T) {
|
||||||
|
location := time.FixedZone("UTC+8", 8*60*60)
|
||||||
|
now := time.Date(2026, 8, 20, 18, 0, 0, 0, location)
|
||||||
|
message := IncomingCallNotification{
|
||||||
|
DeviceID: "dev-1",
|
||||||
|
DeviceName: "Office",
|
||||||
|
DeviceLabel: "EC20-Office",
|
||||||
|
Caller: "+8613800138000",
|
||||||
|
Called: "+8613900139000",
|
||||||
|
Time: now,
|
||||||
|
Environment: "cellular",
|
||||||
|
}
|
||||||
|
|
||||||
|
wecom := wecomCallValues(message)
|
||||||
|
if wecom["event"] != "call.received" || wecom["title"] != "收到来电" || wecom["number"] != "+8613800138000" {
|
||||||
|
t.Fatalf("wecomCallValues = %#v", wecom)
|
||||||
|
}
|
||||||
|
if !strings.Contains(wecom["message"], "网络 基站直连") {
|
||||||
|
t.Fatalf("wecomCallValues message omitted network: %s", wecom["message"])
|
||||||
|
}
|
||||||
|
|
||||||
|
lark := larkCallValues(message)
|
||||||
|
if lark["event"] != "call.received" || lark["title"] != "收到来电" || lark["device_label"] != "EC20-Office" {
|
||||||
|
t.Fatalf("larkCallValues = %#v", lark)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -411,7 +411,7 @@ func ResolveCarrierProfile(identity SIMIdentity) CarrierProfile {
|
|||||||
continue
|
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 ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+11601
-82
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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++ {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user