Compare commits

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